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 serverWideTags; // never null; read once, at build() + private final String installId; // null when disabled or when there is none private final ThreadPoolExecutor executor; // null when disabled private TraceClient(Builder builder) { @@ -181,6 +218,9 @@ private TraceClient(Builder builder) { : readServerWideConfig(builder.pluginsDirectory); this.disabledReason = disabledReason(builder, serverWide); this.serverWideTags = serverWide.tags; + // After the opt-outs, never before: a disabled client neither makes + // up an ID nor writes one to disk. + this.installId = disabledReason == null ? resolveInstallId(builder, serverWide) : null; if (disabledReason == null) { this.executor = new ThreadPoolExecutor( 1, 1, 30, TimeUnit.SECONDS, @@ -228,6 +268,124 @@ public String disabledReason() { return 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#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 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. + */ + 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; + } + if (serverWide.serverId != null) { + return serverWide.serverId; + } + String fresh = UUID.randomUUID().toString(); + if (!serverWide.read) { + // A file that could not be read is not appended to: one that is + // there but unreadable would otherwise gain a line every start. + log("using an in-memory server-id for this process: server-wide config was not readable"); + return fresh; + } + File location = new File(builder.pluginsDirectory, SERVER_WIDE_CONFIG_PATH); + try { + Path file = location.toPath(); + byte[] existing = Files.readAllBytes(file); + StringBuilder block = new StringBuilder(); + if (existing.length > 0 && existing[existing.length - 1] != '\n') { + block.append('\n'); + } + if (!serverWide.created) { + block.append(SERVER_ID_COMMENT); // a fresh file already has it + } + block.append("server-id: ").append(fresh).append('\n'); + Files.write(file, block.toString().getBytes(StandardCharsets.UTF_8), StandardOpenOption.APPEND); + // Read back, so two clients racing on one file agree on the first line. + String written = parseServerWideConfig(Files.readAllLines(file, StandardCharsets.UTF_8)).serverId; + return written != null ? written : fresh; + } catch (IOException | RuntimeException failure) { + log("could not write server-id to server-wide config " + location + + ", using an in-memory one for this process: " + failure); + return fresh; + } + } + + /** + * 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; @@ -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.emptyMap()); + /** No file was read: none was asked for, or it could not be read. */ + static final ServerWideConfig NONE = new ServerWideConfig(false, Collections.emptyMap(), null); final boolean disables; final Map tags; + final String serverId; // null when the file has no usable server-id: line + final boolean read; // the file was read successfully + final boolean created; // ... and build() had just created it - ServerWideConfig(boolean disables, Map tags) { + ServerWideConfig(boolean disables, Map tags, String serverId) { + this(disables, tags, serverId, false, false); + } + + private ServerWideConfig(boolean disables, Map tags, String serverId, boolean read, boolean created) { this.disables = disables; this.tags = tags; + this.serverId = serverId; + this.read = read; + this.created = created; + } + + ServerWideConfig readFromDisk(boolean created) { + return new ServerWideConfig(disables, tags, serverId, true, created); } } @@ -286,16 +459,21 @@ static final class ServerWideConfig { * nor stop the host program. */ private ServerWideConfig readServerWideConfig(File pluginsDirectory) { - Path file = new File(pluginsDirectory, SERVER_WIDE_CONFIG_PATH).toPath(); + File location = new File(pluginsDirectory, SERVER_WIDE_CONFIG_PATH); try { + // Inside the try: toPath() throws InvalidPathException for a path + // the file system cannot represent, and build() never throws. + Path file = location.toPath(); + boolean created = false; if (!Files.exists(file)) { Files.createDirectories(file.getParent()); Files.write(file, SERVER_WIDE_CONFIG_CONTENT.getBytes(StandardCharsets.UTF_8)); // just written: enabled: true, and the tags example commented out + created = true; } - return parseServerWideConfig(Files.readAllLines(file, StandardCharsets.UTF_8)); + return parseServerWideConfig(Files.readAllLines(file, StandardCharsets.UTF_8)).readFromDisk(created); } catch (IOException | RuntimeException failure) { - log("could not read server-wide config " + file + ": " + failure); + log("could not read server-wide config " + location + ": " + failure); return ServerWideConfig.NONE; } } @@ -309,10 +487,14 @@ private ServerWideConfig readServerWideConfig(File pluginsDirectory) { * lines indented differently from its first entry. Values may be bare, * double- or single-quoted. Entries the trace server would reject -- and * anything this reader does not understand -- are dropped, one by one, - * and at most {@link #MAX_TAGS} are kept; nothing here throws. + * and at most {@link #MAX_TAGS} are kept. The first {@code server-id:} + * line at column 0, outside a {@code tags:} block, whose value is a valid + * tag value made of {@code [A-Za-z0-9_.-]} is the installation's ID; any + * other is ignored. Nothing here throws. */ static ServerWideConfig parseServerWideConfig(List lines) { Boolean disables = null; + String serverId = null; Map tags = new LinkedHashMap<>(); boolean inTags = false; int entryIndent = -1; @@ -342,6 +524,16 @@ static ServerWideConfig parseServerWideConfig(List lines) { entryIndent = -1; continue; } + if (serverId == null) { + Matcher matcher = SERVER_ID_LINE.matcher(line); + if (matcher.matches()) { + String value = scalar(matcher.group(1).trim()); + if (value != null && value.length() <= MAX_TAG_LENGTH && TAG_KEY.matcher(value).matches()) { + serverId = value; + } + continue; + } + } if (disables == null) { Matcher matcher = ENABLED_LINE.matcher(line); if (matcher.find()) { @@ -350,7 +542,8 @@ static ServerWideConfig parseServerWideConfig(List lines) { } } return new ServerWideConfig(disables != null && disables, - tags.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(tags)); + tags.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(tags), + serverId); } private static void addServerWideTag(Map tags, String entry) { @@ -490,6 +683,19 @@ static Map withVersion(Map tags, String version) return merged; } + /** + * The tags plus {@code install}, unless they already carry one, there is + * no ID, or adding it would pass {@link #MAX_TAGS}. A copy when it adds. + */ + static Map withInstall(Map tags, String installId) { + if (installId == null || tags.containsKey(INSTALL_TAG) || tags.size() >= MAX_TAGS) { + return tags; + } + Map merged = new LinkedHashMap<>(tags); + merged.put(INSTALL_TAG, installId); + return merged; + } + /** Reports that {@code name} happened. */ public void report(String name) { report(name, null, null); @@ -504,7 +710,7 @@ public void report(String name, Double value, Map tags) { return; } final String body = json(application, name, value, - withServerWideTags(withVersion(tags, version), serverWideTags)); + withServerWideTags(withInstall(withVersion(tags, version), installId), serverWideTags)); executor.execute(() -> send(body)); } @@ -578,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); } @@ -641,6 +851,8 @@ public static final class Builder { private String key; private boolean enabled = true; private File pluginsDirectory; + private String installId; + private File installIdFile; private Logger logger; private Builder(String baseUrl, String application, String version) { @@ -679,14 +891,19 @@ public Builder enabled(boolean enabled) { * in a Bukkit plugin), {@link #build()} makes sure * {@code plugins/trace/config.yml} exists -- creating it with * {@code enabled: true} if it is missing -- and honours - * {@code enabled: false} in it. The file is never rewritten once it - * exists. Its optional {@code tags:} block is added to every event - * this client reports, below the event's own tags: + * {@code enabled: false} in it. Its optional {@code tags:} block is + * added to every event this client reports, below the event's own + * tags. Its {@code server-id:} line is sent as the tag + * {@code install}; when an enabled client finds none, it appends one + * -- a random UUID under a comment saying what it is -- and otherwise + * never changes the file. If that write fails, the ID is kept in + * memory for this process only: * *

          * enabled: true
          * tags:
          *   ci: "true"
+         * server-id: 0f8b6c1e-...
          * 
* *

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 //trace-install-id}. + * {@link #build()} resolves it with {@link TraceClient#installIdFromFile(File)} + * only when the client is enabled, after every opt-out: the first + * run writes a new random UUID there (creating parent directories) and + * later runs reuse it. A file that cannot be read or written yields an + * in-memory ID for this process; an unreadable one is never + * overwritten; {@code build()} still never throws. + * + *

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. + * + *

    + *
  1. {@value #ENV}, when set and not blank, pins the ID (e.g. for a + * container or a service deployment);
  2. + *
  3. otherwise the file {@code //trace-install-id}: + * {@code $XDG_DATA_HOME} or {@code ~/.local/share} on Linux/BSD, + * {@code ~/Library/Application Support} on macOS, {@code %APPDATA%} on + * Windows. Delete it to get a new ID.
  4. + *
+ */ +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 environment) { + String value = environment.apply(ENV); + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() || trimmed.length() > MAX_LENGTH ? null : trimmed; + } + + /** {@code //trace-install-id}. */ + public static File file(String programName) { + return file(programName, System::getenv, + System.getProperty("os.name", ""), System.getProperty("user.home", "")); + } + + static File file(String programName, Function environment, String osName, String userHome) { + File program = new File(userDataDirectory(environment, osName, userHome), + programName.trim().toLowerCase(Locale.ROOT)); + return new File(program, FILE_NAME); + } + + static File userDataDirectory(Function environment, String osName, String userHome) { + String os = osName == null ? "" : osName.toLowerCase(Locale.ROOT); + if (os.startsWith("windows")) { + String appData = environment.apply("APPDATA"); + return notBlank(appData) ? new File(appData) : new File(new File(new File(userHome), "AppData"), "Roaming"); + } + if (os.contains("mac") || os.contains("darwin")) { + return new File(new File(new File(userHome), "Library"), "Application Support"); + } + String xdg = environment.apply("XDG_DATA_HOME"); + if (notBlank(xdg) && new File(xdg).isAbsolute()) { + return new File(xdg); + } + return new File(new File(new File(userHome), ".local"), "share"); + } + + private static boolean notBlank(String value) { + return value != null && !value.trim().isEmpty(); + } +} diff --git a/src/main/java/com/preponderous/parpt/trace/UsageReporter.java b/src/main/java/com/preponderous/parpt/trace/UsageReporter.java index 5622ef8..4e84271 100644 --- a/src/main/java/com/preponderous/parpt/trace/UsageReporter.java +++ b/src/main/java/com/preponderous/parpt/trace/UsageReporter.java @@ -9,6 +9,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -20,7 +21,7 @@ * *

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 env = new HashMap<>(); + assertNull(TraceInstallId.fromEnvironment(env::get)); + env.put("TRACE_INSTALL_ID", " "); + assertNull(TraceInstallId.fromEnvironment(env::get)); + env.put("TRACE_INSTALL_ID", " pinned-1 "); + assertEquals("pinned-1", TraceInstallId.fromEnvironment(env::get)); + StringBuilder overlong = new StringBuilder(); + for (int i = 0; i < 256; i++) { + overlong.append('x'); + } + env.put("TRACE_INSTALL_ID", overlong.toString()); + assertNull(TraceInstallId.fromEnvironment(env::get), "the file is used instead"); + } + + @Test + void fileLivesInThePlatformsUserDataDirectoryUnderTheLowerCasedProgramName() { + Map env = new HashMap<>(); + File home = new File("/home/someone"); + assertEquals(new File(home, ".local/share/myprogram/trace-install-id"), + TraceInstallId.file("MyProgram", env::get, "Linux", home.getPath())); + env.put("XDG_DATA_HOME", "/data"); + assertEquals(new File("/data/myprogram/trace-install-id"), + TraceInstallId.file("MyProgram", env::get, "Linux", home.getPath())); + env.put("XDG_DATA_HOME", "relative/ignored"); + assertEquals(new File(home, ".local/share/myprogram/trace-install-id"), + TraceInstallId.file("MyProgram", env::get, "FreeBSD", home.getPath())); + assertEquals(new File(home, "Library/Application Support/myprogram/trace-install-id"), + TraceInstallId.file("MyProgram", env::get, "Mac OS X", home.getPath())); + env.put("APPDATA", "C:\\Users\\someone\\AppData\\Roaming"); + assertEquals(new File(new File("C:\\Users\\someone\\AppData\\Roaming", "myprogram"), "trace-install-id"), + TraceInstallId.file("MyProgram", env::get, "Windows 11", home.getPath())); + } +} diff --git a/src/test/java/com/preponderous/parpt/trace/UsageReporterTest.java b/src/test/java/com/preponderous/parpt/trace/UsageReporterTest.java index e288f88..7f93588 100644 --- a/src/test/java/com/preponderous/parpt/trace/UsageReporterTest.java +++ b/src/test/java/com/preponderous/parpt/trace/UsageReporterTest.java @@ -74,7 +74,7 @@ void blankKeySendsNothing(@TempDir Path home) { } @Test - void sendsStartupAndProjectCreatedWithTheVersionOnly(@TempDir Path home) { + void sendsStartupAndProjectCreatedWithTheVersionOnly(@TempDir Path home) throws Exception { if (environmentOptsOut()) { return; // the environment has switched reporting off; that path is the client's to test } @@ -89,10 +89,31 @@ void sendsStartupAndProjectCreatedWithTheVersionOnly(@TempDir Path home) { assertTrue(bodies.get(0).contains("\"name\":\"startup\""), bodies.get(0)); assertTrue(bodies.get(0).contains("\"version\":\"1.2.3\""), bodies.get(0)); assertTrue(bodies.get(1).contains("\"name\":\"project-created\""), bodies.get(1)); - assertTrue(bodies.get(1).endsWith("\"tags\":{\"version\":\"1.2.3\"}}"), bodies.get(1)); + String installId = Files.readString(marker.resolveSibling("trace-install-id")).trim(); + assertTrue(bodies.get(1).endsWith("\"tags\":{\"version\":\"1.2.3\",\"install\":\"" + installId + "\"}}"), + bodies.get(1)); assertTrue(Files.exists(marker), "the first-run notice is recorded as shown"); } + @Test + void keepsTheInstallationIdNextToTheMarkerAndOnlyWhenEnabled(@TempDir Path home) throws Exception { + Path marker = home.resolve("config").resolve("marker"); + Path idFile = marker.resolveSibling("trace-install-id"); + new UsageReporter("false", endpoint, "a-key", "1.0", marker).close(); + new UsageReporter("true", endpoint, "", "1.0", marker).close(); + assertFalse(Files.exists(idFile), "a disabled client never writes the ID"); + if (environmentOptsOut()) { + return; + } + UsageReporter first = new UsageReporter("true", endpoint, "a-key", "1.0", marker); + first.close(); + String id = Files.readString(idFile).trim(); + assertTrue(id.matches("[0-9a-f-]{36}"), id); + UsageReporter second = new UsageReporter("true", endpoint, "a-key", "1.0", marker); + second.close(); + assertEquals(id, Files.readString(idFile).trim(), "a later start reuses it"); + } + @Test void noticeMentionsEveryOptOutAndTheDetailsLink() { String notice = UsageReporter.firstRunNotice();