diff --git a/.env.example b/.env.example index e9da726..1355593 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,9 @@ SCHEDULED_USERS=octocat # Leave empty to use the default of 86400000 (24 hours) # Example: BACKUP_INTERVAL_MS=3600000 (1 hour) BACKUP_INTERVAL_MS= + +# Usage reporting: gh-backup sends a 'startup' event (program name and version) +# and a 'backup-completed' event (nothing else) to the maintainers' trace +# service. Nothing about the users or repositories being backed up is sent. +# Set to false to turn it off. See CONFIG.md. +USAGE_REPORTING_ENABLED=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eebcab..e14a55d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Added +- Usage reporting to the maintainers' trace service: a `startup` event (program name and version only) once per process and a `backup-completed` event (nothing else) when a backup run finishes, sent from a background thread that never delays a backup or holds up exit by more than the client's 5-second timeout. Nothing about the users, organizations or repositories being backed up is sent. On by default; a one-line notice is logged the first time it runs on a machine (recorded in `~/.config/gh-backup/usage-reporting-notice-shown`), and it is turned off with `-Dusage.reporting.enabled=false` or `USAGE_REPORTING_ENABLED=false` (`usage.reporting.endpoint` and `usage.reporting.key` are documented in `CONFIG.md`; the Docker daemon takes `USAGE_REPORTING_ENABLED` from `.env`) +- `TraceClient`, the [trace-client-java](https://github.com/Stephenson-Software/trace-client-java) client, vendored unmodified apart from its package line as `com.github.backup.trace.TraceClient` together with its tests - `BACKUP_INTERVAL_MS` environment variable for the Docker daemon image, mapped by `docker-entrypoint.sh` to `-Dbackup.scheduled.interval.ms`, so the backup interval can be configured from `.env`/`docker-compose.yml` without overriding the entrypoint - `docker-entrypoint-test.sh`, a shell test that runs `docker-entrypoint.sh` against a stub `java` and asserts the argument list built for each combination of `BACKUP_DIRECTORY`, `SCHEDULED_USERS` and `BACKUP_INTERVAL_MS`, including empty values and values containing spaces; it runs in the `docker-build` CI job, so entrypoint changes are no longer merged unexecuted diff --git a/CONFIG.md b/CONFIG.md index 435c696..4a63c0c 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -125,6 +125,41 @@ java -Dlogging.level.root=INFO -jar target/gh-backup-2.0.0-SNAPSHOT-8-8-2026.jar --- +## usage.reporting.enabled + +**Type:** boolean +**Default:** `true` +**Description:** Whether gh-backup reports that it was used to the maintainers' trace service. When on, two events are sent, both from a background thread that never blocks or delays a backup: `startup` once per process, tagged with the program version only, and `backup-completed` when a backup run finishes, with no tags at all. Nothing about the users, organizations or repositories being backed up is sent, and no usernames, hostnames, IP addresses or paths. The first time reporting runs on a machine, one `INFO` line saying so is logged, and a marker file (`~/.config/gh-backup/usage-reporting-notice-shown`) keeps it from repeating; delete that file to see the notice again. gh-backup has no settings file of its own, which is why the marker lives there. Setting the property to `false` sends nothing, logs no notice and writes no marker. As with every other property, it can also be set in an `application.properties` next to the JAR, and it is read from a `USAGE_REPORTING_ENABLED` environment variable, which is how the Docker image is configured. + +**Override at runtime:** +```bash +java -Dusage.reporting.enabled=false -jar target/gh-backup-2.0.0-SNAPSHOT-8-8-2026.jar octocat +``` + +or: +```bash +export USAGE_REPORTING_ENABLED=false +java -jar target/gh-backup-2.0.0-SNAPSHOT-8-8-2026.jar octocat +``` + +--- + +## usage.reporting.endpoint + +**Type:** string (URL) +**Default:** `https://trace.danielstephenson.dev` +**Description:** The trace server usage events are sent to. Only worth changing to point a development build at a local stub. + +--- + +## usage.reporting.key + +**Type:** string +**Default:** the key issued to gh-backup (bundled in `application.properties`) +**Description:** The write-only key gh-backup authenticates to the trace server with. It can only record usage events; it cannot read anything. Leaving it empty disables reporting. + +--- + ## GITHUB_TOKEN (environment variable) **Type:** string @@ -148,12 +183,14 @@ When the image built from the included `Dockerfile` is used, the container alway | `BACKUP_INTERVAL_MS` | `backup.scheduled.interval.ms` | *(empty)* | Delay between scheduled backups, in milliseconds. Passed to the application only when non-empty; when empty, the default of `86400000` (24 hours) from `application-daemon.properties` applies. | | `BACKUP_DIRECTORY` | `backup.directory` | `/backups` | Backup directory inside the container. | | `GITHUB_TOKEN` | *(read directly by the application)* | *(empty)* | GitHub personal access token, as described above. | +| `USAGE_REPORTING_ENABLED` | `usage.reporting.enabled` *(read directly by the application)* | `true` | Set to `false` to stop the daemon reporting `startup` and `backup-completed` events to the trace service, as described above. | -Of these, `GITHUB_TOKEN`, `SCHEDULED_USERS` and `BACKUP_INTERVAL_MS` are wired through to a `.env` file by the included `docker-compose.yml` (see `.env.example`); that file pins `BACKUP_DIRECTORY` to `/backups` and mounts the host's `./backups` directory there so backups persist outside the container. +Of these, `GITHUB_TOKEN`, `SCHEDULED_USERS`, `BACKUP_INTERVAL_MS` and `USAGE_REPORTING_ENABLED` are wired through to a `.env` file by the included `docker-compose.yml` (see `.env.example`); that file pins `BACKUP_DIRECTORY` to `/backups` and mounts the host's `./backups` directory there so backups persist outside the container. **Example `.env`:** ``` GITHUB_TOKEN=ghp_yourTokenHere SCHEDULED_USERS=octocat,github BACKUP_INTERVAL_MS=3600000 +USAGE_REPORTING_ENABLED=true ``` diff --git a/README.md b/README.md index 16d962c..a83435d 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ gh-backup is a Spring Boot tool for backing up public GitHub repositories for sp - [Commands Reference](COMMANDS.md) – Complete list of all CLI commands and options - [Configuration Guide](CONFIG.md) – Detailed configuration options +### Usage reporting + +gh-backup reports that it was used to the maintainers' [trace](https://github.com/Stephenson-Software/trace) service: a `startup` event carrying the program name and version, and a `backup-completed` event carrying nothing else. Nothing about the users, organizations or repositories being backed up is sent. It is on by default, a one-line notice is logged the first time it runs, and it is turned off with `-Dusage.reporting.enabled=false` (or `USAGE_REPORTING_ENABLED=false` in the environment). See [`usage.reporting.enabled`](CONFIG.md#usagereportingenabled) in the Configuration Guide. + ## Support You can find the support Discord server [here](https://discord.gg/xXtuAQ2). diff --git a/docker-compose.yml b/docker-compose.yml index aad9e48..330e2b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,11 @@ services: # Optional: Custom backup directory inside container - BACKUP_DIRECTORY=/backups + + # Usage reporting to the maintainers' trace service (startup and + # backup-completed events, nothing about what is backed up). + # Set USAGE_REPORTING_ENABLED=false to turn it off; see CONFIG.md + - USAGE_REPORTING_ENABLED=${USAGE_REPORTING_ENABLED:-true} volumes: # Mount a local directory to persist backups - ./backups:/backups diff --git a/pom.xml b/pom.xml index 18910a7..b16578e 100644 --- a/pom.xml +++ b/pom.xml @@ -65,6 +65,17 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-surefire-plugin + + + + false + + + diff --git a/src/main/java/com/github/backup/BackupCommandLineRunner.java b/src/main/java/com/github/backup/BackupCommandLineRunner.java index 2d8c1ea..e7d9be0 100644 --- a/src/main/java/com/github/backup/BackupCommandLineRunner.java +++ b/src/main/java/com/github/backup/BackupCommandLineRunner.java @@ -12,9 +12,11 @@ public class BackupCommandLineRunner implements CommandLineRunner { private final BackupService backupService; + private final UsageReportingService usageReporting; - public BackupCommandLineRunner(BackupService backupService) { + public BackupCommandLineRunner(BackupService backupService, UsageReportingService usageReporting) { this.backupService = backupService; + this.usageReporting = usageReporting; } @Override @@ -49,6 +51,7 @@ public void run(String... args) throws Exception { } System.out.println("\nBackup completed!"); + usageReporting.backupCompleted(); } private void runInteractiveMode() { @@ -90,6 +93,7 @@ private void runInteractiveMode() { } catch (Exception e) { System.err.println("Error backing up " + userOrOrg + ": " + e.getMessage()); } + usageReporting.backupCompleted(); } break; diff --git a/src/main/java/com/github/backup/ScheduledBackupService.java b/src/main/java/com/github/backup/ScheduledBackupService.java index 2d2d403..c4ecf2c 100644 --- a/src/main/java/com/github/backup/ScheduledBackupService.java +++ b/src/main/java/com/github/backup/ScheduledBackupService.java @@ -26,14 +26,17 @@ public class ScheduledBackupService { private static final int SEPARATOR_LENGTH = 80; private final BackupService backupService; + private final UsageReportingService usageReporting; private final List 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}: + * + *

+ * + *

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());