diff --git a/README.md b/README.md
index ed158d5..54f5fcb 100644
--- a/README.md
+++ b/README.md
@@ -89,9 +89,26 @@ There is no way to send events without it short of turning reporting off;
that is deliberate, so "how many servers" is a number that can be trusted.
**Programs that are not plugins.** Without `serverWideConfig(...)`, no ID is
-made up and no hidden file is written anywhere. A program that wants to be
-counted passes one it stores itself, e.g. a UUID it generated on first run
-and keeps in its own config:
+made up and no hidden file is written anywhere unless the program asks for
+one. Since 0.6.0 a program can name a file to keep the ID in — the program
+chooses it; there is no default location:
+
+```java
+TraceClient trace = TraceClient.builder(url, "mycli", version).key(key)
+ .enabled(settings.usageReportingEnabled())
+ .installIdFile(new File(dataDir, "trace-install-id")) // e.g. ~/.local/share/mycli/
+ .build();
+```
+
+The first time an *enabled* client starts, it writes a new random UUID to
+that file (creating parent directories) and reuses it on every later run.
+The first line that is an ID (`[A-Za-z0-9_.-]`, at most 255 characters) is
+the one used. If the file cannot be read or written, a fresh ID is used in
+memory for that run only — `build()` never throws over it, and a file that
+exists but cannot be read is never overwritten. Delete the file to reset it,
+or put your own value on its first line.
+
+A program that already keeps its own settings can pass the ID instead:
```java
TraceClient.builder(url, "MyCli", version).key(key)
@@ -99,9 +116,18 @@ TraceClient.builder(url, "MyCli", version).key(key)
.build();
```
-An explicit `installId(...)` wins over `server-id:`. An event that passes its
-own `install` tag keeps it. `trace.installId()` returns the ID in use (`null`
-when disabled or when there is none), so a program can print it.
+**Precedence.** An explicit `installId(...)` wins over `installIdFile(...)`,
+which wins over the server-wide `server-id:` (the server-wide file then gains
+no `server-id:` line; its `enabled:` switch and `tags:` still apply). An
+event that passes its own `install` tag keeps it. `trace.installId()` returns
+the ID in use (`null` when disabled or when there is none), so a program can
+print it.
+
+`TraceClient.installIdFromFile(file)` is the same load-or-create step on its
+own, for a program that wants the ID for something else. **Called directly,
+it writes the file whatever the opt-outs say** — pass the file to
+`installIdFile(...)` to keep the guarantee that a disabled client never
+generates, reads or writes an ID.
## What `report` promises
@@ -183,7 +209,7 @@ plugins already vendor bStats' `Metrics.java`.
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 @@ -119,7 +124,7 @@ public final class TraceClient { /** This client's version, as sent in the User-Agent. */ - public static final String VERSION = "0.5.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; @@ -173,6 +178,9 @@ public final class TraceClient { /** 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*:(.*)$"); @@ -263,16 +271,18 @@ public String disabledReason() { /** * The random per-installation ID every event carries as the tag * {@code install}, or {@code null} when the client is disabled or has - * none (no {@link Builder#serverWideConfig(File)} and no - * {@link Builder#installId(String)}). + * none (no {@link Builder#installId(String)}, no + * {@link Builder#installIdFile(File)} and no + * {@link Builder#serverWideConfig(File)}). */ public String installId() { return installId; } /** - * The installation's ID: the builder's, else the server-wide file's - * {@code server-id:}, else a new random one appended to that file. If the + * The installation's ID: the builder's, else the one kept in the + * builder's install ID file, else the server-wide file's + * {@code server-id:}, else a new random one appended to that file. If a * file could not be read or written, the new ID lives in memory for this * process only. Never throws. */ @@ -280,6 +290,9 @@ private String resolveInstallId(Builder builder, ServerWideConfig serverWide) { if (builder.installId != null) { return builder.installId; } + if (builder.installIdFile != null) { + return loadOrCreateInstallId(builder.installIdFile, logger); + } if (builder.pluginsDirectory == null) { return null; } @@ -316,6 +329,63 @@ private String resolveInstallId(Builder builder, ServerWideConfig serverWide) { } } + /** + * The installation's ID kept in {@code file}, which the program chooses + * -- there is no default location. The first line that is an ID + * ({@code [A-Za-z0-9_.-]}, at most {@value #MAX_TAG_LENGTH} characters, + * surrounding whitespace ignored) is returned. If the file does not exist + * or holds no such line, a new random {@link UUID#randomUUID()} is written + * to it (parent directories created) and returned. Delete the file to get + * a new one. + * + *
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;
@@ -714,6 +784,10 @@ private static void drain(InputStream stream) throws IOException {
}
private void log(String message) {
+ log(logger, message);
+ }
+
+ private static void log(Logger logger, String message) {
if (logger != null) {
logger.log(Level.FINE, "[trace] " + message);
}
@@ -778,6 +852,7 @@ public static final class Builder {
private boolean enabled = true;
private File pluginsDirectory;
private String installId;
+ private File installIdFile;
private Logger logger;
private Builder(String baseUrl, String application, String version) {
@@ -845,8 +920,9 @@ public Builder serverWideConfig(File pluginsDirectory) {
* 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 the server-wide {@code server-id:}.
- * Trimmed; {@code null} or blank means none. Without it, and without
+ * 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
@@ -860,6 +936,26 @@ public Builder installId(String installId) {
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/test/java/software/stephenson/trace/TraceClientTest.java b/src/test/java/software/stephenson/trace/TraceClientTest.java
index 4203cec..9b30939 100644
--- a/src/test/java/software/stephenson/trace/TraceClientTest.java
+++ b/src/test/java/software/stephenson/trace/TraceClientTest.java
@@ -1172,6 +1172,163 @@ void installId_anEventsOwnInstallTagWins(@TempDir Path plugins) throws Exception
reportedBody(plugins, "startup", null));
}
+ @Test
+ void installIdFile_isCreatedOnceWithParentDirectoriesAndReused(@TempDir Path scratch) throws Exception {
+ // Arrange
+ Path file = scratch.resolve("nested").resolve("deeper").resolve("trace-install-id");
+
+ // Act
+ TraceClient first = TraceClient.builder(baseUrl(), "MyCli", "1.2.3").key("k").installIdFile(file.toFile()).build();
+ first.close();
+ TraceClient second = TraceClient.builder(baseUrl(), "MyCli", "1.2.3").key("k").installIdFile(file.toFile()).build();
+ second.report("startup");
+
+ // Assert
+ assertTrue(arrived.await(5, TimeUnit.SECONDS));
+ second.close();
+ String id = first.installId();
+ assertTrue(id.matches(UUID_PATTERN), id);
+ assertEquals(id + "\n", new String(Files.readAllBytes(file), StandardCharsets.UTF_8), "parent directories are created");
+ assertEquals(id, second.installId(), "the next run reuses it");
+ assertEquals("{\"application\":\"MyCli\",\"name\":\"startup\",\"tags\":{\"version\":\"1.2.3\",\"install\":\"" + id + "\"}}",
+ received.get(0).body);
+ }
+
+ @Test
+ void installIdFromFile_readsTheFirstValidLineAndNeverRewritesTheFile(@TempDir Path scratch) throws Exception {
+ Path file = scratch.resolve("trace-install-id");
+ String content = "\n# not an id\n my-own.id_1 \nsecond-id\n";
+ Files.write(file, content.getBytes(StandardCharsets.UTF_8));
+
+ assertEquals("my-own.id_1", TraceClient.installIdFromFile(file.toFile()));
+ assertEquals(content, new String(Files.readAllBytes(file), StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void installIdFromFile_replacesAFileWithNoValidLine(@TempDir Path scratch) throws Exception {
+ Path file = scratch.resolve("trace-install-id");
+ StringBuilder overlong = new StringBuilder();
+ for (int i = 0; i <= TraceClient.MAX_TAG_LENGTH; i++) {
+ overlong.append('x');
+ }
+ Files.write(file, ("not an id\n" + overlong + "\n").getBytes(StandardCharsets.UTF_8));
+
+ String made = TraceClient.installIdFromFile(file.toFile());
+
+ assertTrue(made.matches(UUID_PATTERN), made);
+ assertEquals(made, TraceClient.installIdFromFile(file.toFile()));
+ assertEquals(made + "\n", new String(Files.readAllBytes(file), StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void installIdFile_unwritableYieldsAnInMemoryIdWithoutThrowing(@TempDir Path scratch) throws Exception {
+ // Arrange: a path under a regular file cannot be created, even as root.
+ Path blocker = scratch.resolve("a-file");
+ Files.write(blocker, "x".getBytes(StandardCharsets.UTF_8));
+ Path file = blocker.resolve("trace-install-id");
+ Logger logger = Logger.getAnonymousLogger();
+ logger.setUseParentHandlers(false);
+ logger.setLevel(Level.FINE);
+ RecordingHandler handler = new RecordingHandler();
+ logger.addHandler(handler);
+
+ // Act
+ TraceClient client = assertDoesNotThrow(() -> TraceClient.builder(baseUrl(), "MyCli", "1.2.3").key("k")
+ .installIdFile(file.toFile()).logger(logger).build());
+ client.report("startup");
+
+ // Assert
+ assertTrue(arrived.await(5, TimeUnit.SECONDS));
+ client.close();
+ assertTrue(client.isEnabled());
+ assertTrue(client.installId().matches(UUID_PATTERN), client.installId());
+ assertFalse(Files.exists(file));
+ assertEquals("x", new String(Files.readAllBytes(blocker), StandardCharsets.UTF_8));
+ assertNotEquals(client.installId(), TraceClient.installIdFromFile(file.toFile()), "in memory: a new one each process");
+ assertTrue(received.get(0).body.contains("\"install\":\"" + client.installId() + "\""), received.get(0).body);
+ assertTrue(handler.records.stream().anyMatch(r -> r.getLevel() == Level.FINE && r.getMessage().contains("install ID file")),
+ "the fallback is logged at FINE");
+ }
+
+ @Test
+ void installIdFromFile_leavesAnUnreadableFileAlone(@TempDir Path scratch) throws Exception {
+ // A directory cannot be read as a file.
+ Path directory = Files.createDirectory(scratch.resolve("dir"));
+
+ String made = TraceClient.installIdFromFile(directory.toFile());
+
+ assertTrue(made.matches(UUID_PATTERN), made);
+ assertTrue(Files.isDirectory(directory));
+ try (java.util.stream.Stream