scheduledUsers;
private final long backupIntervalMs;
private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public ScheduledBackupService(BackupService backupService,
+ UsageReportingService usageReporting,
@Value("${backup.scheduled.users:}") String scheduledUsersConfig,
@Value("${backup.scheduled.interval.ms:86400000}") long backupIntervalMs) {
this.backupService = backupService;
+ this.usageReporting = usageReporting;
this.backupIntervalMs = backupIntervalMs;
// Parse comma-separated list of users/orgs
this.scheduledUsers = scheduledUsersConfig.isBlank()
@@ -91,5 +94,6 @@ public void runScheduledBackup() {
log.info("Next backup will run {} hours after this backup completes.", backupIntervalMs / 3600000.0);
log.info("{}", "=".repeat(SEPARATOR_LENGTH));
log.info("");
+ usageReporting.backupCompleted();
}
}
diff --git a/src/main/java/com/github/backup/UsageReportingService.java b/src/main/java/com/github/backup/UsageReportingService.java
new file mode 100644
index 0000000..3596810
--- /dev/null
+++ b/src/main/java/com/github/backup/UsageReportingService.java
@@ -0,0 +1,148 @@
+package com.github.backup;
+
+import com.github.backup.trace.TraceClient;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Collections;
+
+/**
+ * Reports that gh-backup was used, to the trace service, and never gets in the
+ * way of a backup.
+ *
+ * Two events are sent, both off the calling thread through the vendored
+ * {@link TraceClient}: {@code startup} once per process (tagged with the
+ * program version only) and {@code backup-completed} when a backup run
+ * finishes, with no tags at all. Nothing identifying is sent: no user or
+ * organization names, no repository names, no counts, no paths, no hostnames.
+ *
+ *
Reporting is on by default and switched off with
+ * {@code usage.reporting.enabled=false} (as a {@code -D} system property, in
+ * {@code application.properties}, or as {@code USAGE_REPORTING_ENABLED=false}
+ * in the environment). The first time reporting runs on a machine, one
+ * notice is logged saying so; a marker file under the user's config directory
+ * ({@code ~/.config/gh-backup/}) keeps it from being repeated. gh-backup has
+ * no settings file of its own, which is why a marker file is used.
+ *
+ *
Every path through this class is exception-safe: a bad endpoint, an
+ * unwritable home directory or an unreachable trace server leave the backup
+ * untouched.
+ */
+@Service
+public class UsageReportingService {
+
+ private static final Logger log = LoggerFactory.getLogger(UsageReportingService.class);
+
+ /** The name the program key was issued for. */
+ static final String APPLICATION = "gh-backup";
+ static final String STARTUP_EVENT = "startup";
+ static final String BACKUP_COMPLETED_EVENT = "backup-completed";
+ static final String NOTICE_MARKER_FILE = "usage-reporting-notice-shown";
+
+ private final TraceClient client;
+ private final String version;
+ private final Path noticeMarker;
+
+ @Autowired
+ public UsageReportingService(
+ @Value("${usage.reporting.enabled:true}") String enabled,
+ @Value("${usage.reporting.endpoint:https://trace.danielstephenson.dev}") String endpoint,
+ @Value("${usage.reporting.key:}") String key,
+ @Value("${spring.application.version:}") String version) {
+ this(enabled, endpoint, key, version, defaultNoticeMarker());
+ }
+
+ UsageReportingService(String enabled, String endpoint, String key, String version, Path noticeMarker) {
+ this.client = buildClient(enabled, endpoint, key);
+ this.version = version;
+ this.noticeMarker = noticeMarker;
+ }
+
+ private static TraceClient buildClient(String enabled, String endpoint, String key) {
+ // A blank value (an empty environment variable, say) means "default", i.e. on.
+ boolean on = enabled == null || enabled.isBlank() || !"false".equalsIgnoreCase(enabled.trim());
+ try {
+ return TraceClient.builder(endpoint, APPLICATION)
+ .key(key)
+ .enabled(on)
+ .logger(java.util.logging.Logger.getLogger(UsageReportingService.class.getName()))
+ .build();
+ } catch (RuntimeException badConfiguration) {
+ log.debug("Usage reporting disabled: {}", badConfiguration.getMessage());
+ return TraceClient.disabled();
+ }
+ }
+
+ /** {@code ~/.config/gh-backup/usage-reporting-notice-shown}; null if there is no usable home. */
+ static Path defaultNoticeMarker() {
+ String home = System.getProperty("user.home");
+ if (home == null || home.isBlank()) {
+ return null;
+ }
+ return Paths.get(home, ".config", APPLICATION, NOTICE_MARKER_FILE);
+ }
+
+ /** Whether events are actually sent. */
+ public boolean isEnabled() {
+ return client.isEnabled();
+ }
+
+ @PostConstruct
+ void start() {
+ if (!client.isEnabled()) {
+ return;
+ }
+ showFirstRunNoticeOnce();
+ String tagged = version == null ? "" : version.trim();
+ // An unfiltered "@project.version@" means the build did not run through Maven; send no tag then.
+ if (tagged.isEmpty() || tagged.startsWith("@")) {
+ client.report(STARTUP_EVENT);
+ } else {
+ client.report(STARTUP_EVENT, null, Collections.singletonMap("version", tagged));
+ }
+ }
+
+ /** Reports that a backup run finished. Carries nothing about what was backed up. */
+ public void backupCompleted() {
+ client.report(BACKUP_COMPLETED_EVENT);
+ }
+
+ /**
+ * Stops the sending thread, waiting briefly (at most the client's read timeout)
+ * for a report in flight, so a short CLI run does not exit before its startup
+ * event has left the machine. Bound to context shutdown, which Spring Boot's
+ * shutdown hook runs when the process ends.
+ */
+ @PreDestroy
+ public void close() {
+ client.close();
+ }
+
+ private void showFirstRunNoticeOnce() {
+ if (noticeMarker == null) {
+ return;
+ }
+ try {
+ if (Files.exists(noticeMarker)) {
+ return;
+ }
+ log.info("Usage reporting is on: gh-backup sends a startup event (program name and version only) "
+ + "and a backup-completed event (nothing else) to trace.danielstephenson.dev. "
+ + "Turn it off with -Dusage.reporting.enabled=false or USAGE_REPORTING_ENABLED=false.");
+ Files.createDirectories(noticeMarker.getParent());
+ Files.writeString(noticeMarker, "The usage-reporting notice was shown once; delete this file to see it again.\n");
+ } catch (IOException | RuntimeException cannotPersist) {
+ // The notice is shown again next run; that is the worst case, and it is harmless.
+ log.debug("Could not record that the usage-reporting notice was shown: {}", cannotPersist.getMessage());
+ }
+ }
+}
diff --git a/src/main/java/com/github/backup/trace/TraceClient.java b/src/main/java/com/github/backup/trace/TraceClient.java
new file mode 100644
index 0000000..e10c83c
--- /dev/null
+++ b/src/main/java/com/github/backup/trace/TraceClient.java
@@ -0,0 +1,297 @@
+/*
+ * trace-client 0.1.0 -- https://github.com/Stephenson-Software/trace-client-java
+ *
+ * One call to report that a program was used. Copy this file into a project as
+ * is, or depend on the artifact; either way there is nothing else to add.
+ *
+ * MIT licensed. Keep this header when vendoring so the file can be found again.
+ *
+ * Vendored into gh-backup unmodified apart from the package line.
+ */
+package com.github.backup.trace;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Reports usage events to a trace server, and never gets in the way of the
+ * program doing the reporting.
+ *
+ *
Three properties hold for every call to {@link #report}:
+ *
+ *
+ * - It returns immediately. The HTTP call happens on a single
+ * daemon thread owned by this client. A Spigot plugin can report from
+ * the server thread without a tick ever waiting on the network.
+ * - It never throws. A server that is down, slow, or rejecting the
+ * key is a dropped report, not an exception in the host program. Failures
+ * are logged at {@link Level#FINE} if a logger was given, and otherwise
+ * not at all.
+ * - It is bounded. At most {@value #QUEUE_CAPACITY} reports wait to
+ * be sent; beyond that, new reports are dropped rather than accumulated.
+ * A trace server that is unreachable for a week costs a few kilobytes,
+ * not the host's heap.
+ *
+ *
+ * Reporting is opt-out: a client built with {@link Builder#enabled(boolean)
+ * enabled(false)}, or with no key, is a no-op that costs nothing. Programs that
+ * run on other people's machines should expose that switch in their
+ * configuration.
+ *
+ *
{@code
+ * TraceClient trace = TraceClient.builder("https://trace.example.org", "MyPlugin")
+ * .key(config.getString("usage-reporting.key"))
+ * .enabled(config.getBoolean("usage-reporting.enabled", true))
+ * .logger(getLogger())
+ * .build();
+ *
+ * trace.report("startup");
+ * trace.report("command", 1.0, Collections.singletonMap("name", "home"));
+ *
+ * // on shutdown
+ * trace.close();
+ * }
+ */
+public final class TraceClient {
+
+ /** How many reports may wait to be sent before new ones are dropped. */
+ public static final int QUEUE_CAPACITY = 256;
+
+ private static final int CONNECT_TIMEOUT_MS = 5_000;
+ private static final int READ_TIMEOUT_MS = 5_000;
+
+ private final String endpoint;
+ private final String key;
+ private final String application;
+ private final Logger logger;
+ private final ThreadPoolExecutor executor; // null when disabled
+
+ private TraceClient(Builder builder) {
+ this.endpoint = builder.baseUrl.replaceAll("/+$", "") + "/api/metrics";
+ this.key = builder.key;
+ this.application = builder.application;
+ this.logger = builder.logger;
+ boolean enabled = builder.enabled && builder.key != null && !builder.key.trim().isEmpty();
+ if (enabled) {
+ this.executor = new ThreadPoolExecutor(
+ 1, 1, 30, TimeUnit.SECONDS,
+ new ArrayBlockingQueue(QUEUE_CAPACITY),
+ runnable -> {
+ Thread thread = new Thread(runnable, "trace-client/" + application);
+ thread.setDaemon(true);
+ return thread;
+ },
+ new ThreadPoolExecutor.DiscardPolicy());
+ this.executor.allowCoreThreadTimeOut(true);
+ } else {
+ this.executor = null;
+ }
+ }
+
+ /**
+ * Starts describing a client for the program named {@code application},
+ * reporting to the trace server at {@code baseUrl}.
+ */
+ public static Builder builder(String baseUrl, String application) {
+ return new Builder(baseUrl, application);
+ }
+
+ /** A client that reports nothing. Useful as a default before configuration is read. */
+ public static TraceClient disabled() {
+ return new Builder("http://disabled.invalid", "disabled").enabled(false).build();
+ }
+
+ /** Whether {@link #report} will actually send anything. */
+ public boolean isEnabled() {
+ return executor != null;
+ }
+
+ /** Reports that {@code name} happened. */
+ public void report(String name) {
+ report(name, null, null);
+ }
+
+ /**
+ * Reports that {@code name} happened, with an optional numeric value and
+ * optional string tags. Returns immediately; see the class comment.
+ */
+ public void report(String name, Double value, Map tags) {
+ if (executor == null || name == null || name.trim().isEmpty()) {
+ return;
+ }
+ final String body = json(application, name, value, tags);
+ executor.execute(() -> send(body));
+ }
+
+ /**
+ * Stops the sending thread. Reports already queued are dropped; one in
+ * flight is given a moment to finish. Safe to call more than once, and on
+ * a disabled client.
+ */
+ public void close() {
+ if (executor == null) {
+ return;
+ }
+ executor.shutdownNow();
+ try {
+ executor.awaitTermination(READ_TIMEOUT_MS, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private void send(String body) {
+ HttpURLConnection connection = null;
+ try {
+ connection = (HttpURLConnection) new URL(endpoint).openConnection();
+ connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
+ connection.setReadTimeout(READ_TIMEOUT_MS);
+ connection.setRequestMethod("POST");
+ connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
+ connection.setRequestProperty("Authorization", "Bearer " + key);
+ connection.setRequestProperty("User-Agent", "trace-client/0.1.0 (" + application + ")");
+ connection.setDoOutput(true);
+ byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+ connection.setFixedLengthStreamingMode(bytes.length);
+ try (OutputStream out = connection.getOutputStream()) {
+ out.write(bytes);
+ }
+ int status = connection.getResponseCode();
+ drain(status >= 400 ? connection.getErrorStream() : connection.getInputStream());
+ if (status != 201) {
+ log("trace server answered " + status + " for " + body);
+ }
+ } catch (IOException | RuntimeException failure) {
+ // RuntimeException too: a misconfigured URL surfaces as one, and a
+ // usage report must never be the reason a host program logs a
+ // stack trace, let alone stops.
+ log("could not deliver " + body + ": " + failure);
+ } finally {
+ if (connection != null) {
+ connection.disconnect();
+ }
+ }
+ }
+
+ private static void drain(InputStream stream) throws IOException {
+ // Reading the body to its end lets HttpURLConnection reuse the
+ // connection; the content itself is not interesting.
+ if (stream == null) {
+ return;
+ }
+ try (InputStream in = stream) {
+ byte[] buffer = new byte[512];
+ while (in.read(buffer) != -1) { /* discard */ }
+ }
+ }
+
+ private void log(String message) {
+ if (logger != null) {
+ logger.log(Level.FINE, "[trace] " + message);
+ }
+ }
+
+ // JSON is written by hand so this file has no dependencies. The shape is
+ // fixed and small -- three scalars and a flat string map -- which is all
+ // a usage report needs.
+ static String json(String application, String name, Double value, Map tags) {
+ StringBuilder out = new StringBuilder(128);
+ out.append("{\"application\":").append(quote(application));
+ out.append(",\"name\":").append(quote(name));
+ if (value != null && !value.isNaN() && !value.isInfinite()) {
+ out.append(",\"value\":").append(value);
+ }
+ Map safeTags = tags == null ? Collections.emptyMap() : new LinkedHashMap<>(tags);
+ if (!safeTags.isEmpty()) {
+ out.append(",\"tags\":{");
+ boolean first = true;
+ for (Map.Entry tag : safeTags.entrySet()) {
+ if (tag.getKey() == null || tag.getValue() == null) {
+ continue;
+ }
+ if (!first) {
+ out.append(',');
+ }
+ first = false;
+ out.append(quote(tag.getKey())).append(':').append(quote(tag.getValue()));
+ }
+ out.append('}');
+ }
+ return out.append('}').toString();
+ }
+
+ static String quote(String text) {
+ StringBuilder out = new StringBuilder(text.length() + 2).append('"');
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ switch (c) {
+ case '"': out.append("\\\""); break;
+ case '\\': out.append("\\\\"); break;
+ case '\n': out.append("\\n"); break;
+ case '\r': out.append("\\r"); break;
+ case '\t': out.append("\\t"); break;
+ default:
+ if (c < 0x20) {
+ out.append(String.format("\\u%04x", (int) c));
+ } else {
+ out.append(c);
+ }
+ }
+ }
+ return out.append('"').toString();
+ }
+
+ /** Describes a {@link TraceClient}; see {@link TraceClient#builder}. */
+ public static final class Builder {
+ private final String baseUrl;
+ private final String application;
+ private String key;
+ private boolean enabled = true;
+ private Logger logger;
+
+ private Builder(String baseUrl, String application) {
+ if (baseUrl == null || baseUrl.trim().isEmpty()) {
+ throw new IllegalArgumentException("baseUrl is required");
+ }
+ if (application == null || application.trim().isEmpty()) {
+ throw new IllegalArgumentException("application is required");
+ }
+ this.baseUrl = baseUrl.trim();
+ this.application = application.trim();
+ }
+
+ /** The program's write key. Without one the client is a no-op. */
+ public Builder key(String key) {
+ this.key = key;
+ return this;
+ }
+
+ /** The opt-out. {@code false} yields a client that reports nothing. */
+ public Builder enabled(boolean enabled) {
+ this.enabled = enabled;
+ return this;
+ }
+
+ /** Where dropped reports are mentioned, at {@link Level#FINE}. Optional. */
+ public Builder logger(Logger logger) {
+ this.logger = logger;
+ return this;
+ }
+
+ public TraceClient build() {
+ return new TraceClient(this);
+ }
+ }
+}
diff --git a/src/main/java/com/github/backup/web/BackupController.java b/src/main/java/com/github/backup/web/BackupController.java
index a65513b..0245af3 100644
--- a/src/main/java/com/github/backup/web/BackupController.java
+++ b/src/main/java/com/github/backup/web/BackupController.java
@@ -1,6 +1,7 @@
package com.github.backup.web;
import com.github.backup.BackupService;
+import com.github.backup.UsageReportingService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -21,15 +22,18 @@
public class BackupController {
private final BackupService backupService;
+ private final UsageReportingService usageReporting;
- public BackupController(BackupService backupService) {
+ public BackupController(BackupService backupService, UsageReportingService usageReporting) {
this.backupService = backupService;
+ this.usageReporting = usageReporting;
}
@PostMapping
public ResponseEntity createBackup(@Valid @RequestBody BackupRequest request) {
try {
backupService.backupUserRepositories(request.getUserOrOrg());
+ usageReporting.backupCompleted();
return ResponseEntity.ok(new BackupResponse(true, "Backup completed successfully for " + request.getUserOrOrg()));
} catch (IOException e) {
return ResponseEntity.status(500)
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 86c24d2..3d456ee 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -14,3 +14,16 @@ spring.main.web-application-type=none
# Backup mode: 'cli' for command line, 'web' for web server
backup.mode=cli
+
+# Version of this build, filled in by Maven resource filtering; sent as the
+# 'version' tag of the usage-reporting startup event.
+spring.application.version=@project.version@
+
+# Usage reporting: gh-backup sends a 'startup' event (program name and version
+# only) and a 'backup-completed' event (nothing else) to the trace service so
+# the maintainers can see the tool is being used. Nothing about the users,
+# organizations or repositories being backed up is sent. Turn it off with
+# -Dusage.reporting.enabled=false or USAGE_REPORTING_ENABLED=false.
+usage.reporting.enabled=true
+usage.reporting.endpoint=https://trace.danielstephenson.dev
+usage.reporting.key=39GTkLaxl_bSbkaAdO9reeIO4LXjeygPH70g3cn4y7M
diff --git a/src/test/java/com/github/backup/BackupCommandLineRunnerTest.java b/src/test/java/com/github/backup/BackupCommandLineRunnerTest.java
index 108c6e9..96f5d59 100644
--- a/src/test/java/com/github/backup/BackupCommandLineRunnerTest.java
+++ b/src/test/java/com/github/backup/BackupCommandLineRunnerTest.java
@@ -15,17 +15,23 @@ class BackupCommandLineRunnerTest {
@Mock
private BackupService backupService;
+ @Mock
+ private UsageReportingService usageReporting;
+
private BackupCommandLineRunner runner;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
- runner = new BackupCommandLineRunner(backupService);
+ runner = new BackupCommandLineRunner(backupService, usageReporting);
}
@Test
void testRun_NoArguments() throws Exception {
assertDoesNotThrow(() -> runner.run());
+
+ // Printing the usage text is not a backup run
+ verifyNoInteractions(usageReporting);
}
@Test
@@ -46,6 +52,9 @@ void testRun_WithMultipleUsers() throws Exception {
verify(backupService).backupUserRepositories("user1");
verify(backupService).backupUserRepositories("user2");
verify(backupService).backupUserRepositories("user3");
+
+ // One backup run, one backup-completed report, however many users it covered
+ verify(usageReporting, times(1)).backupCompleted();
}
@Test
diff --git a/src/test/java/com/github/backup/DaemonModeIntegrationTest.java b/src/test/java/com/github/backup/DaemonModeIntegrationTest.java
index 636ea6f..02092d8 100644
--- a/src/test/java/com/github/backup/DaemonModeIntegrationTest.java
+++ b/src/test/java/com/github/backup/DaemonModeIntegrationTest.java
@@ -18,7 +18,8 @@
@TestPropertySource(properties = {
"backup.mode=daemon",
"backup.scheduled.users=testuser1,testuser2",
- "backup.scheduled.interval.ms=3600000"
+ "backup.scheduled.interval.ms=3600000",
+ "usage.reporting.enabled=false" // never report to the trace service from a test
})
class DaemonModeIntegrationTest {
diff --git a/src/test/java/com/github/backup/GhBackupApplicationTests.java b/src/test/java/com/github/backup/GhBackupApplicationTests.java
index 44f6901..810f42c 100644
--- a/src/test/java/com/github/backup/GhBackupApplicationTests.java
+++ b/src/test/java/com/github/backup/GhBackupApplicationTests.java
@@ -2,8 +2,10 @@
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.TestPropertySource;
@SpringBootTest
+@TestPropertySource(properties = "usage.reporting.enabled=false") // never report to the trace service from a test
class GhBackupApplicationTests {
@Test
diff --git a/src/test/java/com/github/backup/ScheduledBackupServiceTest.java b/src/test/java/com/github/backup/ScheduledBackupServiceTest.java
index fe0154b..b6ef326 100644
--- a/src/test/java/com/github/backup/ScheduledBackupServiceTest.java
+++ b/src/test/java/com/github/backup/ScheduledBackupServiceTest.java
@@ -25,6 +25,9 @@ class ScheduledBackupServiceTest {
@Mock
private BackupService backupService;
+ @Mock
+ private UsageReportingService usageReporting;
+
private ScheduledBackupService scheduledBackupService;
private ListAppender logAppender;
private Logger logger;
@@ -49,18 +52,20 @@ void tearDown() {
@Test
void testScheduledBackupService_WithNoUsers() {
- scheduledBackupService = new ScheduledBackupService(backupService, "", DEFAULT_INTERVAL);
+ scheduledBackupService = new ScheduledBackupService(backupService, usageReporting, "", DEFAULT_INTERVAL);
// Should not throw exception with empty users list
assertDoesNotThrow(() -> scheduledBackupService.runScheduledBackup());
// Should not call backup service when no users configured
verifyNoInteractions(backupService);
+ // ...and a run that backed up nothing is not reported as a completed backup
+ verifyNoInteractions(usageReporting);
}
@Test
void testScheduledBackupService_WithSingleUser() throws IOException {
- scheduledBackupService = new ScheduledBackupService(backupService, "octocat", DEFAULT_INTERVAL);
+ scheduledBackupService = new ScheduledBackupService(backupService, usageReporting, "octocat", DEFAULT_INTERVAL);
scheduledBackupService.runScheduledBackup();
@@ -70,7 +75,7 @@ void testScheduledBackupService_WithSingleUser() throws IOException {
@Test
void testScheduledBackupService_WithMultipleUsers() throws IOException {
- scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github,spring-projects", DEFAULT_INTERVAL);
+ scheduledBackupService = new ScheduledBackupService(backupService, usageReporting, "octocat,github,spring-projects", DEFAULT_INTERVAL);
scheduledBackupService.runScheduledBackup();
@@ -78,11 +83,14 @@ void testScheduledBackupService_WithMultipleUsers() throws IOException {
verify(backupService, times(1)).backupUserRepositories("octocat");
verify(backupService, times(1)).backupUserRepositories("github");
verify(backupService, times(1)).backupUserRepositories("spring-projects");
+
+ // One scheduled run, one backup-completed report, however many users it covered
+ verify(usageReporting, times(1)).backupCompleted();
}
@Test
void testScheduledBackupService_WithWhitespace() throws IOException {
- scheduledBackupService = new ScheduledBackupService(backupService, " octocat , github , spring-projects ", DEFAULT_INTERVAL);
+ scheduledBackupService = new ScheduledBackupService(backupService, usageReporting, " octocat , github , spring-projects ", DEFAULT_INTERVAL);
scheduledBackupService.runScheduledBackup();
@@ -94,7 +102,7 @@ void testScheduledBackupService_WithWhitespace() throws IOException {
@Test
void testScheduledBackupService_HandlesException() throws IOException {
- scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github", DEFAULT_INTERVAL);
+ scheduledBackupService = new ScheduledBackupService(backupService, usageReporting, "octocat,github", DEFAULT_INTERVAL);
doThrow(new IOException("Test exception")).when(backupService).backupUserRepositories("octocat");
@@ -107,7 +115,7 @@ void testScheduledBackupService_HandlesException() throws IOException {
@Test
void testScheduledBackupService_WithEmptyStrings() throws IOException {
- scheduledBackupService = new ScheduledBackupService(backupService, "octocat,,github", DEFAULT_INTERVAL);
+ scheduledBackupService = new ScheduledBackupService(backupService, usageReporting, "octocat,,github", DEFAULT_INTERVAL);
scheduledBackupService.runScheduledBackup();
@@ -119,7 +127,7 @@ void testScheduledBackupService_WithEmptyStrings() throws IOException {
@Test
void testScheduledBackupService_WithBlankString() {
- scheduledBackupService = new ScheduledBackupService(backupService, " ", DEFAULT_INTERVAL);
+ scheduledBackupService = new ScheduledBackupService(backupService, usageReporting, " ", DEFAULT_INTERVAL);
assertDoesNotThrow(() -> scheduledBackupService.runScheduledBackup());
@@ -129,7 +137,7 @@ void testScheduledBackupService_WithBlankString() {
@Test
void testScheduledBackupService_WithCustomInterval() {
- scheduledBackupService = new ScheduledBackupService(backupService, "octocat", ONE_HOUR_INTERVAL);
+ scheduledBackupService = new ScheduledBackupService(backupService, usageReporting, "octocat", ONE_HOUR_INTERVAL);
// Should not throw exception with custom interval
assertDoesNotThrow(() -> scheduledBackupService.runScheduledBackup());
@@ -137,7 +145,7 @@ void testScheduledBackupService_WithCustomInterval() {
@Test
void testInit_WithNoUsers_LogsWarning() {
- scheduledBackupService = new ScheduledBackupService(backupService, "", DEFAULT_INTERVAL);
+ scheduledBackupService = new ScheduledBackupService(backupService, usageReporting, "", DEFAULT_INTERVAL);
scheduledBackupService.init();
// Verify warning logs are present
@@ -150,7 +158,7 @@ void testInit_WithNoUsers_LogsWarning() {
@Test
void testInit_WithUsers_LogsInfo() {
- scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github", DEFAULT_INTERVAL);
+ scheduledBackupService = new ScheduledBackupService(backupService, usageReporting, "octocat,github", DEFAULT_INTERVAL);
scheduledBackupService.init();
// Verify info logs are present
@@ -168,7 +176,7 @@ void testInit_WithUsers_LogsInfo() {
@Test
void testInit_DisplaysCorrectIntervalInHours() {
- scheduledBackupService = new ScheduledBackupService(backupService, "octocat", ONE_HOUR_INTERVAL);
+ scheduledBackupService = new ScheduledBackupService(backupService, usageReporting, "octocat", ONE_HOUR_INTERVAL);
scheduledBackupService.init();
// Verify interval is displayed correctly
@@ -180,7 +188,7 @@ void testInit_DisplaysCorrectIntervalInHours() {
@Test
void testInit_DisplaysDefault24HourInterval() {
- scheduledBackupService = new ScheduledBackupService(backupService, "octocat", DEFAULT_INTERVAL);
+ scheduledBackupService = new ScheduledBackupService(backupService, usageReporting, "octocat", DEFAULT_INTERVAL);
scheduledBackupService.init();
// Verify 24 hour interval is displayed
@@ -192,7 +200,7 @@ void testInit_DisplaysDefault24HourInterval() {
@Test
void testRunScheduledBackup_LogsStartAndComplete() throws IOException {
- scheduledBackupService = new ScheduledBackupService(backupService, "octocat", DEFAULT_INTERVAL);
+ scheduledBackupService = new ScheduledBackupService(backupService, usageReporting, "octocat", DEFAULT_INTERVAL);
scheduledBackupService.runScheduledBackup();
// Verify backup start and completion are logged
@@ -208,7 +216,7 @@ void testRunScheduledBackup_LogsStartAndComplete() throws IOException {
@Test
void testRunScheduledBackup_LogsErrorForFailedUser() throws IOException {
- scheduledBackupService = new ScheduledBackupService(backupService, "octocat,github", DEFAULT_INTERVAL);
+ scheduledBackupService = new ScheduledBackupService(backupService, usageReporting, "octocat,github", DEFAULT_INTERVAL);
doThrow(new IOException("Network error")).when(backupService).backupUserRepositories("octocat");
@@ -228,7 +236,7 @@ void testRunScheduledBackup_LogsErrorForFailedUser() throws IOException {
@Test
void testRunScheduledBackup_WithNoUsers_NoLogsGenerated() {
- scheduledBackupService = new ScheduledBackupService(backupService, "", DEFAULT_INTERVAL);
+ scheduledBackupService = new ScheduledBackupService(backupService, usageReporting, "", DEFAULT_INTERVAL);
logAppender.list.clear(); // Clear any init logs
scheduledBackupService.runScheduledBackup();
@@ -245,6 +253,7 @@ void testRunScheduledBackup_WithNoUsers_NoLogsGenerated() {
void testMultipleUserNames_ParsedCorrectly() throws IOException {
scheduledBackupService = new ScheduledBackupService(
backupService,
+ usageReporting,
"user1,user2,user3,user4,user5",
DEFAULT_INTERVAL
);
@@ -265,6 +274,7 @@ void testSpecialCharactersInUserNames() throws IOException {
// Test that usernames with hyphens and underscores work correctly
scheduledBackupService = new ScheduledBackupService(
backupService,
+ usageReporting,
"user-with-dash,user_with_underscore,user.with.dots",
DEFAULT_INTERVAL
);
@@ -279,7 +289,7 @@ void testSpecialCharactersInUserNames() throws IOException {
@Test
void testIntervalAccuracy_SmallInterval() {
long twoHours = 7200000L; // 2 hours
- scheduledBackupService = new ScheduledBackupService(backupService, "octocat", twoHours);
+ scheduledBackupService = new ScheduledBackupService(backupService, usageReporting, "octocat", twoHours);
scheduledBackupService.init();
// Verify correct hour calculation for 2 hours
@@ -292,7 +302,7 @@ void testIntervalAccuracy_SmallInterval() {
@Test
void testIntervalAccuracy_LargeInterval() {
long oneWeek = 604800000L; // 7 days = 168 hours
- scheduledBackupService = new ScheduledBackupService(backupService, "octocat", oneWeek);
+ scheduledBackupService = new ScheduledBackupService(backupService, usageReporting, "octocat", oneWeek);
scheduledBackupService.init();
// Verify correct hour calculation for 168 hours (1 week)
diff --git a/src/test/java/com/github/backup/SchedulingConfigurationTest.java b/src/test/java/com/github/backup/SchedulingConfigurationTest.java
index d7c032d..f43bc70 100644
--- a/src/test/java/com/github/backup/SchedulingConfigurationTest.java
+++ b/src/test/java/com/github/backup/SchedulingConfigurationTest.java
@@ -7,6 +7,7 @@
import org.springframework.context.ApplicationContext;
import org.springframework.scheduling.annotation.ScheduledAnnotationBeanPostProcessor;
import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.TestPropertySource;
import static org.junit.jupiter.api.Assertions.*;
@@ -18,6 +19,7 @@ class SchedulingConfigurationTest {
@Nested
@SpringBootTest
+ @TestPropertySource(properties = "usage.reporting.enabled=false") // never report to the trace service from a test
@ActiveProfiles("daemon")
class DaemonProfileTest {
@@ -46,6 +48,7 @@ void scheduledBackupServiceShouldBeLoadedInDaemonProfile() {
@Nested
@SpringBootTest
+ @TestPropertySource(properties = "usage.reporting.enabled=false") // never report to the trace service from a test
@ActiveProfiles("web")
class WebProfileTest {
@@ -62,6 +65,7 @@ void schedulingConfigurationShouldNotBeLoadedInWebProfile() {
@Nested
@SpringBootTest
+ @TestPropertySource(properties = "usage.reporting.enabled=false") // never report to the trace service from a test
class DefaultProfileTest {
@Autowired
diff --git a/src/test/java/com/github/backup/UsageReportingServiceTest.java b/src/test/java/com/github/backup/UsageReportingServiceTest.java
new file mode 100644
index 0000000..dd967f9
--- /dev/null
+++ b/src/test/java/com/github/backup/UsageReportingServiceTest.java
@@ -0,0 +1,197 @@
+package com.github.backup;
+
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.slf4j.LoggerFactory;
+
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Drives the usage-reporting wiring against a stub trace server on a loopback
+ * port (the JDK's own), so nothing here ever reaches the real service.
+ */
+class UsageReportingServiceTest {
+
+ private HttpServer server;
+ private final List bodies = new CopyOnWriteArrayList<>();
+ private final List authorizations = new CopyOnWriteArrayList<>();
+ private volatile CountDownLatch arrived = new CountDownLatch(1);
+
+ private Logger logger;
+ private ListAppender logAppender;
+
+ @TempDir
+ Path tempDir;
+
+ @BeforeEach
+ void startStubAndCaptureLog() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", exchange -> {
+ bodies.add(new String(readAll(exchange.getRequestBody()), StandardCharsets.UTF_8));
+ authorizations.add(exchange.getRequestHeaders().getFirst("Authorization"));
+ exchange.sendResponseHeaders(201, -1);
+ exchange.close();
+ arrived.countDown();
+ });
+ server.start();
+
+ logger = (Logger) LoggerFactory.getLogger(UsageReportingService.class);
+ logAppender = new ListAppender<>();
+ logAppender.start();
+ logger.addAppender(logAppender);
+ }
+
+ @AfterEach
+ void stop() {
+ server.stop(0);
+ logger.detachAppender(logAppender);
+ }
+
+ private String endpoint() {
+ return "http://127.0.0.1:" + server.getAddress().getPort();
+ }
+
+ private Path marker() {
+ return tempDir.resolve(".config").resolve("gh-backup").resolve(UsageReportingService.NOTICE_MARKER_FILE);
+ }
+
+ private long noticesLogged() {
+ return logAppender.list.stream()
+ .filter(event -> event.getFormattedMessage().startsWith("Usage reporting is on: gh-backup sends a startup event"))
+ .count();
+ }
+
+ @Test
+ void startupEventCarriesTheApplicationNameAndVersionOnly() throws Exception {
+ UsageReportingService service = new UsageReportingService("true", endpoint(), "test-key", "2.0.0-TEST", marker());
+ service.start();
+
+ assertTrue(arrived.await(5, TimeUnit.SECONDS), "startup event should arrive");
+ service.close();
+
+ assertEquals(1, bodies.size());
+ assertEquals("{\"application\":\"gh-backup\",\"name\":\"startup\",\"tags\":{\"version\":\"2.0.0-TEST\"}}", bodies.get(0));
+ assertEquals("Bearer test-key", authorizations.get(0));
+ }
+
+ @Test
+ void backupCompletedEventCarriesNothingElse() throws Exception {
+ UsageReportingService service = new UsageReportingService("true", endpoint(), "test-key", "2.0.0-TEST", marker());
+ service.start();
+ assertTrue(arrived.await(5, TimeUnit.SECONDS));
+
+ arrived = new CountDownLatch(1);
+ service.backupCompleted();
+ assertTrue(arrived.await(5, TimeUnit.SECONDS), "backup-completed event should arrive");
+ service.close();
+
+ assertEquals("{\"application\":\"gh-backup\",\"name\":\"backup-completed\"}", bodies.get(1));
+ }
+
+ @Test
+ void unfilteredVersionPlaceholderIsNotSentAsATag() throws Exception {
+ UsageReportingService service = new UsageReportingService("true", endpoint(), "test-key", "@project.version@", marker());
+ service.start();
+
+ assertTrue(arrived.await(5, TimeUnit.SECONDS));
+ service.close();
+
+ assertEquals("{\"application\":\"gh-backup\",\"name\":\"startup\"}", bodies.get(0));
+ }
+
+ @Test
+ void firstRunNoticeIsLoggedOnceAndRecordedInTheMarkerFile() throws Exception {
+ assertFalse(Files.exists(marker()));
+
+ UsageReportingService first = new UsageReportingService("true", endpoint(), "test-key", "2.0.0-TEST", marker());
+ first.start();
+ first.close();
+
+ assertEquals(1, noticesLogged(), "the notice should be logged on the first run");
+ assertTrue(Files.exists(marker()), "the marker file should record that the notice was shown");
+ String notice = logAppender.list.get(0).getFormattedMessage();
+ assertTrue(notice.contains("trace.danielstephenson.dev"), notice);
+ assertTrue(notice.contains("-Dusage.reporting.enabled=false"), notice);
+
+ UsageReportingService second = new UsageReportingService("true", endpoint(), "test-key", "2.0.0-TEST", marker());
+ second.start();
+ second.close();
+
+ assertEquals(1, noticesLogged(), "the notice should not be logged again on the second run");
+ }
+
+ @Test
+ void disabledSendsNothingAndShowsNoNotice() throws Exception {
+ UsageReportingService service = new UsageReportingService("false", endpoint(), "test-key", "2.0.0-TEST", marker());
+ service.start();
+ service.backupCompleted();
+ service.close();
+
+ assertFalse(service.isEnabled());
+ assertFalse(arrived.await(300, TimeUnit.MILLISECONDS), "nothing should be sent when disabled");
+ assertTrue(bodies.isEmpty());
+ assertEquals(0, noticesLogged());
+ assertFalse(Files.exists(marker()), "no marker should be written when reporting is off");
+ }
+
+ @Test
+ void blankEnabledValueMeansOnAndMissingKeyMeansOff() {
+ assertTrue(new UsageReportingService("", endpoint(), "test-key", "1", marker()).isEnabled(),
+ "an empty USAGE_REPORTING_ENABLED must not turn reporting off or crash");
+ assertTrue(new UsageReportingService(null, endpoint(), "test-key", "1", marker()).isEnabled());
+ assertFalse(new UsageReportingService("true", endpoint(), "", "1", marker()).isEnabled(),
+ "no key means nothing can be reported");
+ assertFalse(new UsageReportingService("FALSE", endpoint(), "test-key", "1", marker()).isEnabled());
+ }
+
+ @Test
+ void badEndpointOrUnwritableMarkerNeverThrows() {
+ UsageReportingService badEndpoint = new UsageReportingService("true", " ", "test-key", "1", marker());
+ assertFalse(badEndpoint.isEnabled());
+ assertDoesNotThrow(badEndpoint::start);
+ assertDoesNotThrow(badEndpoint::backupCompleted);
+ assertDoesNotThrow(badEndpoint::close);
+
+ // A marker whose parent is a regular file cannot be created; the notice is simply shown again next time.
+ Path notADirectory = tempDir.resolve("not-a-directory");
+ assertDoesNotThrow(() -> Files.writeString(notADirectory, "x"));
+ UsageReportingService unwritable = new UsageReportingService("true", endpoint(), "test-key", "1", notADirectory.resolve("marker"));
+ assertDoesNotThrow(unwritable::start);
+ assertDoesNotThrow(unwritable::close);
+ assertEquals(1, noticesLogged());
+ }
+
+ @Test
+ void defaultMarkerLivesUnderTheUsersConfigDirectory() {
+ Path marker = UsageReportingService.defaultNoticeMarker();
+ assertNotNull(marker);
+ assertTrue(marker.endsWith(Path.of(".config", "gh-backup", UsageReportingService.NOTICE_MARKER_FILE)), marker.toString());
+ }
+
+ private static byte[] readAll(InputStream in) throws java.io.IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[1024];
+ int read;
+ while ((read = in.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ }
+ return out.toByteArray();
+ }
+}
diff --git a/src/test/java/com/github/backup/trace/TraceClientTest.java b/src/test/java/com/github/backup/trace/TraceClientTest.java
new file mode 100644
index 0000000..dde3da3
--- /dev/null
+++ b/src/test/java/com/github/backup/trace/TraceClientTest.java
@@ -0,0 +1,328 @@
+package com.github.backup.trace;
+
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.logging.Handler;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+import java.util.logging.Logger;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Drives the client against a real HTTP server on a loopback port -- the
+ * JDK's own, so the test has no more dependencies than the client does.
+ */
+class TraceClientTest {
+
+ private HttpServer server;
+ private final List received = new CopyOnWriteArrayList<>();
+ private volatile int replyStatus = 201;
+ private volatile CountDownLatch arrived = new CountDownLatch(1);
+
+ private static final class Received {
+ final String method;
+ final String path;
+ final String authorization;
+ final String contentType;
+ final String body;
+
+ Received(String method, String path, String authorization, String contentType, String body) {
+ this.method = method;
+ this.path = path;
+ this.authorization = authorization;
+ this.contentType = contentType;
+ this.body = body;
+ }
+ }
+
+ @BeforeEach
+ void startServer() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", exchange -> {
+ byte[] body = readAll(exchange.getRequestBody());
+ received.add(new Received(
+ exchange.getRequestMethod(),
+ exchange.getRequestURI().getPath(),
+ exchange.getRequestHeaders().getFirst("Authorization"),
+ exchange.getRequestHeaders().getFirst("Content-Type"),
+ new String(body, StandardCharsets.UTF_8)));
+ exchange.sendResponseHeaders(replyStatus, -1);
+ exchange.close();
+ arrived.countDown();
+ });
+ server.start();
+ }
+
+ @AfterEach
+ void stopServer() {
+ server.stop(0);
+ }
+
+ private String baseUrl() {
+ return "http://127.0.0.1:" + server.getAddress().getPort();
+ }
+
+ @Test
+ void report_postsTheEventToTheMetricsEndpointWithTheKey() throws Exception {
+ // Arrange
+ TraceClient client = TraceClient.builder(baseUrl() + "/", "MyPlugin").key("k-123").build();
+
+ // Act
+ client.report("startup");
+
+ // Assert
+ assertTrue(arrived.await(5, TimeUnit.SECONDS), "the report should reach the server");
+ Received request = received.get(0);
+ assertEquals("POST", request.method);
+ assertEquals("/api/metrics", request.path, "a trailing slash on the base URL must not double up");
+ assertEquals("Bearer k-123", request.authorization);
+ assertTrue(request.contentType.startsWith("application/json"), request.contentType);
+ assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\"}", request.body);
+ client.close();
+ }
+
+ @Test
+ void report_carriesValueAndTagsWhenGiven() throws Exception {
+ // Arrange
+ TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k").build();
+ Map tags = new LinkedHashMap<>();
+ tags.put("command", "home");
+ tags.put("world", "the \"end\"");
+
+ // Act
+ client.report("command", 2.5, tags);
+
+ // Assert
+ assertTrue(arrived.await(5, TimeUnit.SECONDS));
+ assertEquals(
+ "{\"application\":\"MyPlugin\",\"name\":\"command\",\"value\":2.5,"
+ + "\"tags\":{\"command\":\"home\",\"world\":\"the \\\"end\\\"\"}}",
+ received.get(0).body);
+ client.close();
+ }
+
+ @Test
+ void report_returnsBeforeTheServerAnswers() throws Exception {
+ // Arrange
+ // A server that never replies. If report() waited on the network the
+ // caller -- a game server's main thread, in the case that matters --
+ // would wait with it.
+ CountDownLatch release = new CountDownLatch(1);
+ HttpServer slow = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ slow.createContext("/", exchange -> {
+ try {
+ release.await(10, TimeUnit.SECONDS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ exchange.sendResponseHeaders(201, -1);
+ exchange.close();
+ });
+ slow.start();
+ TraceClient client = TraceClient.builder("http://127.0.0.1:" + slow.getAddress().getPort(), "MyPlugin")
+ .key("k").build();
+
+ // Act
+ long before = System.nanoTime();
+ client.report("startup");
+ long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - before);
+
+ // Assert
+ assertTrue(elapsedMs < 1_000, "report() took " + elapsedMs + " ms; it must not wait on the network");
+ release.countDown();
+ client.close();
+ slow.stop(0);
+ }
+
+ @Test
+ void report_doesNotThrowWhenNothingIsListening() throws Exception {
+ // Arrange
+ // Pick a port by binding and releasing it, so nothing answers there.
+ HttpServer probe = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ int deadPort = probe.getAddress().getPort();
+ probe.stop(0);
+ RecordingHandler log = new RecordingHandler();
+ Logger logger = Logger.getLogger("TraceClientTest.dead");
+ logger.setLevel(Level.ALL);
+ logger.addHandler(log);
+ TraceClient client = TraceClient.builder("http://127.0.0.1:" + deadPort, "MyPlugin")
+ .key("k").logger(logger).build();
+
+ // Act
+ assertDoesNotThrow(() -> client.report("startup"));
+ client.close(); // waits for the in-flight attempt to fail
+
+ // Assert
+ assertTrue(log.await(5, TimeUnit.SECONDS), "the failure should be mentioned at FINE");
+ assertEquals(Level.FINE, log.records.get(0).getLevel());
+ assertTrue(log.records.get(0).getMessage().contains("could not deliver"), log.records.get(0).getMessage());
+ }
+
+ @Test
+ void report_doesNotThrowWhenTheServerRejectsTheKey() throws Exception {
+ // Arrange
+ replyStatus = 401;
+ RecordingHandler log = new RecordingHandler();
+ Logger logger = Logger.getLogger("TraceClientTest.rejected");
+ logger.setLevel(Level.ALL);
+ logger.addHandler(log);
+ TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("revoked").logger(logger).build();
+
+ // Act
+ assertDoesNotThrow(() -> client.report("startup"));
+
+ // Assert
+ assertTrue(log.await(5, TimeUnit.SECONDS));
+ assertTrue(log.records.get(0).getMessage().contains("answered 401"), log.records.get(0).getMessage());
+ client.close();
+ }
+
+ @Test
+ void disabledClient_sendsNothing() throws Exception {
+ // Arrange
+ TraceClient byFlag = TraceClient.builder(baseUrl(), "MyPlugin").key("k").enabled(false).build();
+ TraceClient byMissingKey = TraceClient.builder(baseUrl(), "MyPlugin").build();
+ TraceClient byBlankKey = TraceClient.builder(baseUrl(), "MyPlugin").key(" ").build();
+ TraceClient explicit = TraceClient.disabled();
+
+ // Act
+ for (TraceClient client : new TraceClient[] {byFlag, byMissingKey, byBlankKey, explicit}) {
+ assertFalse(client.isEnabled());
+ client.report("startup");
+ client.close();
+ }
+
+ // Assert
+ assertFalse(arrived.await(300, TimeUnit.MILLISECONDS), "nothing should have been sent");
+ assertTrue(received.isEmpty());
+ }
+
+ @Test
+ void report_ignoresABlankName() throws Exception {
+ // Arrange
+ TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k").build();
+
+ // Act
+ client.report(null);
+ client.report(" ");
+ client.close();
+
+ // Assert
+ assertFalse(arrived.await(300, TimeUnit.MILLISECONDS));
+ assertTrue(received.isEmpty());
+ }
+
+ @Test
+ void builder_rejectsAMissingBaseUrlOrApplication() {
+ assertThrows(IllegalArgumentException.class, () -> TraceClient.builder(null, "MyPlugin"));
+ assertThrows(IllegalArgumentException.class, () -> TraceClient.builder(" ", "MyPlugin"));
+ assertThrows(IllegalArgumentException.class, () -> TraceClient.builder("http://x", null));
+ assertThrows(IllegalArgumentException.class, () -> TraceClient.builder("http://x", ""));
+ }
+
+ @Test
+ void json_escapesControlCharactersAndSkipsNullTags() {
+ Map tags = new LinkedHashMap<>();
+ tags.put("ok", "line\nbreak\ttab\\slash");
+ tags.put("nullValue", null);
+ tags.put(null, "nullKey");
+
+ String json = TraceClient.json("App", "n", Double.NaN, tags);
+
+ assertEquals("{\"application\":\"App\",\"name\":\"n\",\"tags\":{\"ok\":\"line\\nbreak\\ttab\\\\slash\"}}", json,
+ "NaN is not JSON and is dropped; null keys or values are skipped");
+ assertEquals("\"\\u0001\"", TraceClient.quote("\u0001"));
+ }
+
+ @Test
+ void queue_isBoundedAndDropsRatherThanGrows() throws Exception {
+ // Arrange
+ // Hold the sending thread on the first report so everything behind it
+ // queues, overfill the queue, then let the server drain and count.
+ CountDownLatch release = new CountDownLatch(1);
+ AtomicInteger delivered = new AtomicInteger();
+ HttpServer slow = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ slow.createContext("/", exchange -> {
+ try {
+ release.await(10, TimeUnit.SECONDS);
+ } catch (InterruptedException ignored) {
+ Thread.currentThread().interrupt();
+ }
+ delivered.incrementAndGet();
+ exchange.sendResponseHeaders(201, -1);
+ exchange.close();
+ });
+ slow.start();
+ TraceClient client = TraceClient.builder("http://127.0.0.1:" + slow.getAddress().getPort(), "MyPlugin")
+ .key("k").build();
+ int flood = TraceClient.QUEUE_CAPACITY * 3;
+
+ // Act
+ for (int i = 0; i < flood; i++) {
+ client.report("flood");
+ }
+ release.countDown();
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20);
+ int seen = -1;
+ while (System.nanoTime() < deadline) {
+ Thread.sleep(200);
+ int now = delivered.get();
+ if (now == seen) {
+ break; // nothing arrived in the last 200 ms: the queue is drained
+ }
+ seen = now;
+ }
+
+ // Assert
+ assertTrue(delivered.get() >= 1, "the first report was in flight and must land");
+ assertTrue(delivered.get() <= TraceClient.QUEUE_CAPACITY + 1,
+ "delivered " + delivered.get() + " of " + flood + "; at most the in-flight one plus a full queue may survive");
+ assertTrue(delivered.get() < flood, "an unbounded queue would have delivered all " + flood);
+ client.close();
+ slow.stop(0);
+ }
+
+ private static byte[] readAll(InputStream in) throws java.io.IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[1024];
+ int n;
+ while ((n = in.read(buffer)) != -1) {
+ out.write(buffer, 0, n);
+ }
+ return out.toByteArray();
+ }
+
+ private static final class RecordingHandler extends Handler {
+ final List records = new CopyOnWriteArrayList<>();
+ private final CountDownLatch first = new CountDownLatch(1);
+
+ @Override
+ public void publish(LogRecord record) {
+ records.add(record);
+ first.countDown();
+ }
+
+ boolean await(long timeout, TimeUnit unit) throws InterruptedException {
+ return first.await(timeout, unit);
+ }
+
+ @Override public void flush() { }
+ @Override public void close() { }
+ }
+}
diff --git a/src/test/java/com/github/backup/web/BackupControllerTest.java b/src/test/java/com/github/backup/web/BackupControllerTest.java
index a066f16..7c82e9e 100644
--- a/src/test/java/com/github/backup/web/BackupControllerTest.java
+++ b/src/test/java/com/github/backup/web/BackupControllerTest.java
@@ -1,6 +1,7 @@
package com.github.backup.web;
import com.github.backup.BackupService;
+import com.github.backup.UsageReportingService;
import com.github.backup.web.BackupStatusResponse.RepositoryInfo;
import com.github.backup.web.BackupStatusResponse.UserBackupInfo;
import org.junit.jupiter.api.Test;
@@ -31,6 +32,9 @@ class BackupControllerTest {
@MockBean
private BackupService backupService;
+ @MockBean
+ private UsageReportingService usageReporting;
+
@Test
void createBackup_Success() throws Exception {
doNothing().when(backupService).backupUserRepositories(anyString());