Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
39 changes: 38 additions & 1 deletion CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,17 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- No test may ever report to the production trace service, whichever
Spring contexts a future test happens to start. -->
<systemPropertyVariables>
<usage.reporting.enabled>false</usage.reporting.enabled>
</systemPropertyVariables>
</configuration>
</plugin>
</plugins>
</build>
</project>
6 changes: 5 additions & 1 deletion src/main/java/com/github/backup/BackupCommandLineRunner.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,6 +51,7 @@ public void run(String... args) throws Exception {
}

System.out.println("\nBackup completed!");
usageReporting.backupCompleted();
}

private void runInteractiveMode() {
Expand Down Expand Up @@ -90,6 +93,7 @@ private void runInteractiveMode() {
} catch (Exception e) {
System.err.println("Error backing up " + userOrOrg + ": " + e.getMessage());
}
usageReporting.backupCompleted();
}
break;

Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/github/backup/ScheduledBackupService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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()
Expand Down Expand Up @@ -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();
}
}
148 changes: 148 additions & 0 deletions src/main/java/com/github/backup/UsageReportingService.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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());
}
}
}
Loading
Loading