diff --git a/README.md b/README.md index c535621..f424e8c 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,8 @@ export -s impact ## Usage reporting Usage reporting is on by default: Parpt reports that it was used to the maintainers' [trace](https://github.com/Stephenson-Software/trace) service at `https://trace.danielstephenson.dev`, sending a `startup` event carrying its name and version once per run, and a `project-created` event carrying only the version when a project is saved. Nothing about your projects is sent: no project names, descriptions, scores or files, and no usernames, hostnames, IP addresses, paths or anything typed at the prompt. A one-line notice is printed the first time it runs on a machine (recorded in `~/.config/parpt/usage-reporting-notice-shown`). +Every event also carries a random installation ID (the tag `install`), so installations can be counted rather than events. It is a random UUID written the first time reporting runs to `~/.config/parpt/trace-install-id`, next to the notice marker, and reused after that; it identifies no person, account or address. Delete the file to get a new one, or set `TRACE_INSTALL_ID` in the environment to pin one. Every opt-out below also stops it: when reporting is off, no ID is made up and the file is neither read nor written. + To turn it off, any one of these is enough: - `java -Dusage-reporting.enabled=false -jar build/libs/Parpt-*.jar` diff --git a/src/main/java/com/preponderous/parpt/trace/TraceClient.java b/src/main/java/com/preponderous/parpt/trace/TraceClient.java index db35263..61d8bae 100644 --- a/src/main/java/com/preponderous/parpt/trace/TraceClient.java +++ b/src/main/java/com/preponderous/parpt/trace/TraceClient.java @@ -1,5 +1,5 @@ /* - * trace-client 0.4.0 -- https://github.com/Stephenson-Software/trace-client-java + * trace-client 0.6.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. @@ -16,11 +16,14 @@ import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -77,6 +80,21 @@ * {@code command} event can be tied to a release as well as a * {@code startup} one. An event's own {@code version} tag wins over it. * + *
Every event also carries a random per-installation ID as the tag + * {@code install}, so the trace server can count distinct servers rather + * than raw events. On a Spigot server it is the {@code server-id:} line of + * {@code plugins/trace/config.yml}, generated with + * {@link UUID#randomUUID()} and appended to that file the first time an + * enabled client finds none -- the same idea as bStats' {@code serverUuid}. + * Anything else may pass a file to keep it in with + * {@link Builder#installIdFile(File)}, or the ID itself with + * {@link Builder#installId(String)}. When more than one is given, the + * explicit {@code installId} wins, then {@code installIdFile}, then the + * server-wide {@code server-id:}; with none of them, no {@code install} tag + * is sent. The ID is random: it names no person, account or address. A + * disabled client never generates, reads or writes one. An event's own + * {@code install} tag wins over it. + * *
A disabled client is a no-op that costs nothing. Programs that run on
* other people's machines should expose their own switch in their
* configuration and say on startup whether reporting is on.
@@ -106,7 +124,7 @@
public final class TraceClient {
/** This client's version, as sent in the User-Agent. */
- public static final String VERSION = "0.4.0";
+ public static final String VERSION = "0.6.0";
/** How many reports may wait to be sent before new ones are dropped. */
public static final int QUEUE_CAPACITY = 256;
@@ -131,6 +149,16 @@ public final class TraceClient {
/** The server-wide switch, relative to the plugins directory. */
static final String SERVER_WIDE_CONFIG_PATH = "trace" + File.separator + "config.yml";
+ /**
+ * Explains the {@code server-id:} line. Part of a freshly created file,
+ * and written above the line when it is appended to an existing one.
+ */
+ static final String SERVER_ID_COMMENT =
+ "#\n"
+ + "# server-id: a random ID made on first run and sent as the tag \"install\", so\n"
+ + "# trace can count servers, not events. It identifies no person and no IP\n"
+ + "# address. Delete the server-id line to get a new one.\n";
+
/** Exactly what a missing server-wide switch file is created with. */
static final String SERVER_WIDE_CONFIG_CONTENT =
"# Server-wide switch for usage reporting by plugins that report to trace\n"
@@ -144,10 +172,18 @@ public final class TraceClient {
+ "# own tag of the same name wins. On a test or CI server, uncomment the two\n"
+ "# lines below so its events are left out of real-installation figures.\n"
+ "# tags:\n"
- + "# ci: \"true\"\n";
+ + "# ci: \"true\"\n"
+ + SERVER_ID_COMMENT;
+
+ /** The tag every event carries the installation's ID as. */
+ static final String INSTALL_TAG = "install";
+
+ /** What {@link #installIdFromFile(File)} accepts as an ID on a line of its file. */
+ private static final Pattern INSTALL_ID_LINE = Pattern.compile("[A-Za-z0-9_.\\-]{1,255}");
private static final Pattern ENABLED_LINE = Pattern.compile("^\\s*enabled\\s*:\\s*(\\S+)");
private static final Pattern TAGS_LINE = Pattern.compile("^tags\\s*:\\s*(#.*)?$");
+ private static final Pattern SERVER_ID_LINE = Pattern.compile("^server-id\\s*:(.*)$");
// What the trace server accepts in a report's tags (MetricDto): at most
// MAX_TAGS pairs, keys not blank, keys and values at most MAX_TAG_LENGTH
@@ -168,6 +204,7 @@ public final class TraceClient {
private final Logger logger;
private final String disabledReason; // null when enabled
private final Map Never throws: if the file exists but cannot be read, or cannot be
+ * written, a fresh random ID is returned for this process only, and an
+ * unreadable file is left as it is.
+ *
+ * Called directly, this writes whatever the opt-outs say. Pass
+ * the file to {@link Builder#installIdFile(File)} instead to keep the
+ * guarantee that a disabled client writes nothing.
+ */
+ public static String installIdFromFile(File file) {
+ return loadOrCreateInstallId(file, null);
+ }
+
+ private static String loadOrCreateInstallId(File file, Logger logger) {
+ String fresh = UUID.randomUUID().toString();
+ if (file == null || file.getPath().trim().isEmpty()) {
+ return fresh;
+ }
+ Path path;
+ try {
+ // Inside the try: toPath() throws InvalidPathException for a path
+ // the file system cannot represent.
+ path = file.toPath();
+ for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) {
+ String candidate = line.trim();
+ if (INSTALL_ID_LINE.matcher(candidate).matches()) {
+ return candidate;
+ }
+ }
+ } catch (NoSuchFileException missing) {
+ path = file.toPath(); // it converted, or there would be no NoSuchFileException
+ } catch (IOException | RuntimeException failure) {
+ // There but unreadable (a directory, no permission, not UTF-8):
+ // never overwrite it.
+ log(logger, "could not read install ID file " + file + ", using an in-memory one: " + failure);
+ return fresh;
+ }
+ try {
+ Path parent = path.toAbsolutePath().getParent();
+ if (parent != null) {
+ Files.createDirectories(parent);
+ }
+ Files.write(path, (fresh + "\n").getBytes(StandardCharsets.UTF_8));
+ } catch (IOException | RuntimeException failure) {
+ log(logger, "could not write install ID file " + file + ", using an in-memory one: " + failure);
+ }
+ return fresh;
+ }
+
private static String disabledReason(Builder builder, ServerWideConfig serverWide) {
if (environmentDisables()) {
return REASON_ENVIRONMENT;
@@ -264,16 +422,31 @@ private static boolean isYes(String value) {
return v.equals("1") || v.equals("true") || v.equals("yes");
}
- /** What {@code plugins/trace/config.yml} says: the switch and the server-wide tags. */
+ /** What {@code plugins/trace/config.yml} says: the switch, the server-wide tags and the server-id. */
static final class ServerWideConfig {
- static final ServerWideConfig NONE = new ServerWideConfig(false, Collections. Both are read once, here. Optional; programs that are not
@@ -697,6 +914,48 @@ public Builder serverWideConfig(File pluginsDirectory) {
return this;
}
+ /**
+ * The installation's ID, for programs that are not Spigot plugins:
+ * sent as the tag {@code install} on every event, so the trace
+ * server can count installations. It should be random -- e.g. a
+ * {@link UUID#randomUUID()} the program stores in its own
+ * configuration -- and never derived from a person, account or
+ * address. Takes precedence over {@link #installIdFile(File)} and the
+ * server-wide {@code server-id:}. Trimmed; {@code null} or blank means
+ * none. Without it, {@link #installIdFile(File)} or
+ * {@link #serverWideConfig(File)}, no {@code install} tag is sent.
+ *
+ * @throws IllegalArgumentException when longer than {@value TraceClient#MAX_TAG_LENGTH} characters
+ */
+ public Builder installId(String installId) {
+ String trimmed = installId == null ? null : installId.trim();
+ if (trimmed != null && trimmed.length() > MAX_TAG_LENGTH) {
+ throw new IllegalArgumentException("installId is longer than " + MAX_TAG_LENGTH + " characters");
+ }
+ this.installId = trimmed == null || trimmed.isEmpty() ? null : trimmed;
+ return this;
+ }
+
+ /**
+ * A file to keep the installation's ID in, for programs that are not
+ * Spigot plugins -- e.g. {@code Precedence: an explicit {@link #installId(String)} wins over this,
+ * and this wins over the server-wide {@code server-id:} of
+ * {@link #serverWideConfig(File)} (whose file then gains no
+ * {@code server-id:} line). {@code null} means none.
+ */
+ public Builder installIdFile(File installIdFile) {
+ this.installIdFile = installIdFile;
+ return this;
+ }
+
/** Where dropped reports are mentioned, at {@link Level#FINE}. Optional. */
public Builder logger(Logger logger) {
this.logger = logger;
diff --git a/src/main/java/com/preponderous/parpt/trace/TraceInstallId.java b/src/main/java/com/preponderous/parpt/trace/TraceInstallId.java
new file mode 100644
index 0000000..1663d9b
--- /dev/null
+++ b/src/main/java/com/preponderous/parpt/trace/TraceInstallId.java
@@ -0,0 +1,83 @@
+package com.preponderous.parpt.trace;
+
+import java.io.File;
+import java.util.Locale;
+import java.util.function.Function;
+
+/**
+ * Where this program's random trace installation ID comes from (the tag
+ * {@code install}, so trace can count installations rather than events).
+ *
+ * Both values are only handed to the trace client's builder
+ * ({@code installId(...)} and {@code installIdFile(...)}); the client resolves
+ * them after its own opt-out checks, so a disabled client never reads or
+ * writes the file. Nothing here touches the disk.
+ *
+ * Two events are sent, both off the calling thread through the vendored {@link TraceClient}:
* {@code startup} once per process and {@code project-created} when a project is saved, each
- * tagged with the program version only. Nothing about the
+ * tagged with the program version and a random installation ID only. Nothing about the
* projects themselves is sent: no names, descriptions, scores, paths or hostnames.
*
* Reporting is on by default and switched off with {@code usage-reporting.enabled=false}
@@ -63,11 +64,20 @@ public UsageReporter(
UsageReporter(String enabled, String endpoint, String key, String version, Path noticeMarker) {
this.client = buildClient(enabled, endpoint, key,
- version == null || version.isBlank() ? UNKNOWN_VERSION : version);
+ version == null || version.isBlank() ? UNKNOWN_VERSION : version, installIdFile(noticeMarker));
this.noticeMarker = noticeMarker;
}
- private static TraceClient buildClient(String enabled, String endpoint, String key, String version) {
+ /**
+ * The installation ID file, {@value TraceInstallId#FILE_NAME}, next to the notice marker in
+ * Parpt's own directory ({@code ~/.config/parpt/}); null if there is no usable home.
+ */
+ static File installIdFile(Path noticeMarker) {
+ return noticeMarker == null ? null : noticeMarker.resolveSibling(TraceInstallId.FILE_NAME).toFile();
+ }
+
+ private static TraceClient buildClient(String enabled, String endpoint, String key, String version,
+ File installIdFile) {
// A blank value (an empty environment variable, say) means "default", i.e. on.
boolean on = enabled == null || enabled.isBlank() || !"false".equalsIgnoreCase(enabled.trim());
try {
@@ -76,6 +86,8 @@ private static TraceClient buildClient(String enabled, String endpoint, String k
return TraceClient.builder(endpoint, APPLICATION, version)
.key(key)
.enabled(on)
+ .installId(TraceInstallId.fromEnvironment())
+ .installIdFile(installIdFile)
.logger(java.util.logging.Logger.getLogger(UsageReporter.class.getName()))
.build();
} catch (RuntimeException badConfiguration) {
@@ -137,9 +149,9 @@ public void close() {
/** The text shown once, the first time reporting runs on a machine. */
static String firstRunNotice() {
- return "Usage reporting is on: Parpt sends its name and version with a startup event and a "
- + "project-created event (nothing else) to https://trace.danielstephenson.dev - nothing "
- + "about your projects or this machine. Turn it off with -Dusage-reporting.enabled=false, "
+ return "Usage reporting is on: Parpt sends its name, version and a random installation ID with a "
+ + "startup event and a project-created event (nothing else) to https://trace.danielstephenson.dev"
+ + " - nothing about you or your projects. Turn it off with -Dusage-reporting.enabled=false, "
+ "USAGE_REPORTING_ENABLED=false or TRACE_USAGE_REPORTING=off. Details: " + DETAILS_URL;
}
diff --git a/src/test/java/com/preponderous/parpt/trace/TraceInstallIdTest.java b/src/test/java/com/preponderous/parpt/trace/TraceInstallIdTest.java
new file mode 100644
index 0000000..d805c48
--- /dev/null
+++ b/src/test/java/com/preponderous/parpt/trace/TraceInstallIdTest.java
@@ -0,0 +1,48 @@
+package com.preponderous.parpt.trace;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+class TraceInstallIdTest {
+
+ @Test
+ void environmentPinsTheIdTrimmedAndBlankOrOverlongMeansNone() {
+ Map
* enabled: true
* tags:
* ci: "true"
+ * server-id: 0f8b6c1e-...
*
*
*
+ *
+ */
+public final class TraceInstallId {
+
+ /** Environment variable that pins the installation ID. */
+ public static final String ENV = "TRACE_INSTALL_ID";
+
+ /** Name of the file the ID is kept in. */
+ public static final String FILE_NAME = "trace-install-id";
+
+ private static final int MAX_LENGTH = 255;
+
+ private TraceInstallId() {
+ }
+
+ /** {@value #ENV}, trimmed, or {@code null} when unset, blank or too long to send. */
+ public static String fromEnvironment() {
+ return fromEnvironment(System::getenv);
+ }
+
+ static String fromEnvironment(Function