From 319cb2bc230f639d995f51d16b8d1c04a33b11be Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Tue, 29 Sep 2026 21:23:55 -0600 Subject: [PATCH 1/2] Require the program version and tag every event with it; 0.4.0 builder(baseUrl, application, version) now takes the program's own version, required (blank or over 255 characters throws). Every event the client sends carries it as the tag `version` -- command events and any other, not only a hand-tagged startup -- so each event can be tied to a release. An event's own `version` tag wins; the program version is placed above server-wide tags, so a server-wide `version` never replaces it. The caller's tag map is never modified. Breaking: the two-argument builder is gone. Upgrading is one argument (in a Bukkit plugin, getDescription().getVersion()). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AuTszT2gqv7nLVYrtKD4ji --- README.md | 26 ++- pom.xml | 2 +- .../stephenson/trace/TraceClient.java | 55 +++++- .../stephenson/trace/TraceClientTest.java | 165 ++++++++++++------ 4 files changed, 179 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 260a61a..d378571 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ whole library is one file, `TraceClient.java`, and the integration on the program side is meant to stay one call. ```java -TraceClient trace = TraceClient.builder("https://trace.danielstephenson.dev", "MyPlugin") +TraceClient trace = TraceClient.builder("https://trace.danielstephenson.dev", "MyPlugin", + getDescription().getVersion()) .key(config.getString("usage-reporting.key")) .enabled(config.getBoolean("usage-reporting.enabled", true)) .serverWideConfig(getDataFolder().getParentFile()) // plugins/ -- Bukkit plugins only @@ -33,6 +34,19 @@ trace.report("command", 1.0, Collections.singletonMap("name", "home")); trace.close(); ``` +## Every event carries the program's version + +The third argument to `builder` is the program's own version, and it is +required: a blank one, or one over 255 characters, throws +`IllegalArgumentException`. Every event the client sends — `startup`, +`command`, anything else — carries it as the tag `version`, so every event +can be tied to a release, not just `startup`. An event that passes its own +`version` tag keeps it. There is no need to tag `startup` by hand any more. + +Before 0.4.0, `builder` took two arguments and only events tagged by hand +carried a version. Upgrading is one argument: in a Bukkit plugin, +`getDescription().getVersion()`. + ## What `report` promises | Property | Meaning | @@ -79,8 +93,8 @@ tags: Values may be double-quoted, single-quoted or bare; blank lines and `#` comments inside the block are skipped. The block ends at the next line that is not indented, or at the end of the file. -- An event's own tag always wins: a server-wide `version` never overwrites the - `version` a plugin sends. +- An event's own tag always wins, and the program's version is the event's + own: a server-wide `version` never overwrites it. - Entries the trace server would reject are dropped one by one, never the whole report: keys must match `[A-Za-z0-9][A-Za-z0-9_.-]*`, keys and values are at most 255 characters, and server-wide tags stop being added once an @@ -113,7 +127,7 @@ plugins already vendor bStats' `Metrics.java`. com.github.Stephenson-Software trace-client-java - 0.3.0 + 0.4.0 ``` @@ -124,10 +138,10 @@ Shade it into a plugin jar; it is one class. `POST {baseUrl}/api/metrics` with `Authorization: Bearer ` and a body of ```json -{"application":"MyPlugin","name":"command","value":1.0,"tags":{"name":"home"}} +{"application":"MyPlugin","name":"command","value":1.0,"tags":{"name":"home","version":"1.4.0"}} ``` -`value` and `tags` are omitted when not given. The server assigns the +`value` is omitted when not given; `tags` always holds at least `version`. The server assigns the timestamp. A `201` is success; anything else is logged at `FINE` and dropped. ## Keys diff --git a/pom.xml b/pom.xml index d486415..1db6945 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ software.stephenson trace-client - 0.3.0 + 0.4.0 jar trace-client diff --git a/src/main/java/software/stephenson/trace/TraceClient.java b/src/main/java/software/stephenson/trace/TraceClient.java index d8c517f..3278209 100644 --- a/src/main/java/software/stephenson/trace/TraceClient.java +++ b/src/main/java/software/stephenson/trace/TraceClient.java @@ -72,12 +72,18 @@ * event's own tag wins over a server-wide one of the same name. See * {@link Builder#serverWideConfig(File)}. * + *

Every event carries the program's own version as the tag + * {@code version} -- the third argument to {@link #builder}, required, so a + * {@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. + * *

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

{@code
- * TraceClient trace = TraceClient.builder("https://trace.example.org", "MyPlugin")
+ * TraceClient trace = TraceClient.builder("https://trace.example.org", "MyPlugin",
+ *                 getDescription().getVersion())
  *         .key(config.getString("usage-reporting.key"))
  *         .enabled(config.getBoolean("usage-reporting.enabled", true))
  *         .serverWideConfig(getDataFolder().getParentFile()) // plugins/
@@ -100,7 +106,7 @@
 public final class TraceClient {
 
     /** This client's version, as sent in the User-Agent. */
-    public static final String VERSION = "0.3.0";
+    public static final String VERSION = "0.4.0";
 
     /** How many reports may wait to be sent before new ones are dropped. */
     public static final int QUEUE_CAPACITY = 256;
@@ -158,6 +164,7 @@ public final class TraceClient {
     private final String endpoint;
     private final String key;
     private final String application;
+    private final String version;
     private final Logger logger;
     private final String disabledReason; // null when enabled
     private final Map serverWideTags; // never null; read once, at build()
@@ -167,6 +174,7 @@ private TraceClient(Builder builder) {
         this.endpoint = builder.baseUrl.replaceAll("/+$", "") + "/api/metrics";
         this.key = builder.key;
         this.application = builder.application;
+        this.version = builder.version;
         this.logger = builder.logger;
         ServerWideConfig serverWide = builder.pluginsDirectory == null || environmentDisables()
                 ? ServerWideConfig.NONE
@@ -191,15 +199,18 @@ private TraceClient(Builder builder) {
 
     /**
      * Starts describing a client for the program named {@code application},
-     * reporting to the trace server at {@code baseUrl}.
+     * at {@code version}, reporting to the trace server at {@code baseUrl}.
+     * The version is sent as the tag {@code version} on every event; a blank
+     * one, or one longer than {@value #MAX_TAG_LENGTH} characters, is an
+     * {@link IllegalArgumentException}.
      */
-    public static Builder builder(String baseUrl, String application) {
-        return new Builder(baseUrl, application);
+    public static Builder builder(String baseUrl, String application, String version) {
+        return new Builder(baseUrl, application, version);
     }
 
     /** 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();
+        return new Builder("http://disabled.invalid", "disabled", "disabled").enabled(false).build();
     }
 
     /** Whether {@link #report} will actually send anything. */
@@ -460,6 +471,25 @@ static Map withServerWideTags(Map tags, Map withVersion(Map tags, String version) {
+        Map merged = new LinkedHashMap<>();
+        if (tags != null) {
+            for (Map.Entry tag : new LinkedHashMap<>(tags).entrySet()) {
+                if (tag.getKey() != null && tag.getValue() != null) {
+                    merged.put(tag.getKey(), tag.getValue());
+                }
+            }
+        }
+        if (!merged.containsKey("version")) {
+            merged.put("version", version);
+        }
+        return merged;
+    }
+
     /** Reports that {@code name} happened. */
     public void report(String name) {
         report(name, null, null);
@@ -473,7 +503,8 @@ 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, withServerWideTags(tags, serverWideTags));
+        final String body = json(application, name, value,
+                withServerWideTags(withVersion(tags, version), serverWideTags));
         executor.execute(() -> send(body));
     }
 
@@ -606,20 +637,28 @@ static String quote(String text) {
     public static final class Builder {
         private final String baseUrl;
         private final String application;
+        private final String version;
         private String key;
         private boolean enabled = true;
         private File pluginsDirectory;
         private Logger logger;
 
-        private Builder(String baseUrl, String application) {
+        private Builder(String baseUrl, String application, String version) {
             if (baseUrl == null || baseUrl.trim().isEmpty()) {
                 throw new IllegalArgumentException("baseUrl is required");
             }
             if (application == null || application.trim().isEmpty()) {
                 throw new IllegalArgumentException("application is required");
             }
+            if (version == null || version.trim().isEmpty()) {
+                throw new IllegalArgumentException("version is required");
+            }
+            if (version.trim().length() > MAX_TAG_LENGTH) {
+                throw new IllegalArgumentException("version is longer than " + MAX_TAG_LENGTH + " characters");
+            }
             this.baseUrl = baseUrl.trim();
             this.application = application.trim();
+            this.version = version.trim();
         }
 
         /** The program's write key. Without one the client is a no-op. */
diff --git a/src/test/java/software/stephenson/trace/TraceClientTest.java b/src/test/java/software/stephenson/trace/TraceClientTest.java
index e5f4db8..05866af 100644
--- a/src/test/java/software/stephenson/trace/TraceClientTest.java
+++ b/src/test/java/software/stephenson/trace/TraceClientTest.java
@@ -108,7 +108,7 @@ private String baseUrl() {
     @Test
     void report_postsTheEventToTheMetricsEndpointWithTheKey() throws Exception {
         // Arrange
-        TraceClient client = TraceClient.builder(baseUrl() + "/", "MyPlugin").key("k-123").build();
+        TraceClient client = TraceClient.builder(baseUrl() + "/", "MyPlugin", "1.2.3").key("k-123").build();
 
         // Act
         client.report("startup");
@@ -120,14 +120,14 @@ void report_postsTheEventToTheMetricsEndpointWithTheKey() throws Exception {
         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);
+        assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\",\"tags\":{\"version\":\"1.2.3\"}}", request.body);
         client.close();
     }
 
     @Test
     void report_carriesValueAndTagsWhenGiven() throws Exception {
         // Arrange
-        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k").build();
+        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").build();
         Map tags = new LinkedHashMap<>();
         tags.put("command", "home");
         tags.put("world", "the \"end\"");
@@ -139,7 +139,7 @@ void report_carriesValueAndTagsWhenGiven() throws Exception {
         assertTrue(arrived.await(5, TimeUnit.SECONDS));
         assertEquals(
                 "{\"application\":\"MyPlugin\",\"name\":\"command\",\"value\":2.5,"
-                        + "\"tags\":{\"command\":\"home\",\"world\":\"the \\\"end\\\"\"}}",
+                        + "\"tags\":{\"command\":\"home\",\"world\":\"the \\\"end\\\"\",\"version\":\"1.2.3\"}}",
                 received.get(0).body);
         client.close();
     }
@@ -162,7 +162,7 @@ void report_returnsBeforeTheServerAnswers() throws Exception {
             exchange.close();
         });
         slow.start();
-        TraceClient client = TraceClient.builder("http://127.0.0.1:" + slow.getAddress().getPort(), "MyPlugin")
+        TraceClient client = TraceClient.builder("http://127.0.0.1:" + slow.getAddress().getPort(), "MyPlugin", "1.2.3")
                 .key("k").build();
 
         // Act
@@ -188,7 +188,7 @@ void report_doesNotThrowWhenNothingIsListening() throws Exception {
         Logger logger = Logger.getLogger("TraceClientTest.dead");
         logger.setLevel(Level.ALL);
         logger.addHandler(log);
-        TraceClient client = TraceClient.builder("http://127.0.0.1:" + deadPort, "MyPlugin")
+        TraceClient client = TraceClient.builder("http://127.0.0.1:" + deadPort, "MyPlugin", "1.2.3")
                 .key("k").logger(logger).build();
 
         // Act
@@ -209,7 +209,7 @@ void report_doesNotThrowWhenTheServerRejectsTheKey() throws Exception {
         Logger logger = Logger.getLogger("TraceClientTest.rejected");
         logger.setLevel(Level.ALL);
         logger.addHandler(log);
-        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("revoked").logger(logger).build();
+        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("revoked").logger(logger).build();
 
         // Act
         assertDoesNotThrow(() -> client.report("startup"));
@@ -223,9 +223,9 @@ void report_doesNotThrowWhenTheServerRejectsTheKey() throws Exception {
     @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 byFlag = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").enabled(false).build();
+        TraceClient byMissingKey = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").build();
+        TraceClient byBlankKey = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("  ").build();
         TraceClient explicit = TraceClient.disabled();
 
         // Act
@@ -243,7 +243,7 @@ void disabledClient_sendsNothing() throws Exception {
     @Test
     void report_ignoresABlankName() throws Exception {
         // Arrange
-        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k").build();
+        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").build();
 
         // Act
         client.report(null);
@@ -257,10 +257,66 @@ void report_ignoresABlankName() throws Exception {
 
     @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", ""));
+        assertThrows(IllegalArgumentException.class, () -> TraceClient.builder(null, "MyPlugin", "1.2.3"));
+        assertThrows(IllegalArgumentException.class, () -> TraceClient.builder(" ", "MyPlugin", "1.2.3"));
+        assertThrows(IllegalArgumentException.class, () -> TraceClient.builder("http://x", null, "1.2.3"));
+        assertThrows(IllegalArgumentException.class, () -> TraceClient.builder("http://x", "", "1.2.3"));
+    }
+
+    @Test
+    void builder_rejectsAMissingOrOverlongVersion() {
+        assertThrows(IllegalArgumentException.class, () -> TraceClient.builder("http://x", "MyPlugin", null));
+        assertThrows(IllegalArgumentException.class, () -> TraceClient.builder("http://x", "MyPlugin", "  "));
+        StringBuilder overlong = new StringBuilder();
+        for (int i = 0; i <= TraceClient.MAX_TAG_LENGTH; i++) {
+            overlong.append('9');
+        }
+        assertThrows(IllegalArgumentException.class, () -> TraceClient.builder("http://x", "MyPlugin", overlong.toString()));
+    }
+
+    @Test
+    void report_tagsACommandWithTheProgramVersionTrimmed() throws Exception {
+        // Arrange
+        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin", " 2.0.0-SNAPSHOT ").key("k").build();
+
+        // Act
+        client.report("command", null, Collections.singletonMap("name", "home"));
+
+        // Assert
+        assertTrue(arrived.await(5, TimeUnit.SECONDS));
+        assertEquals("{\"application\":\"MyPlugin\",\"name\":\"command\","
+                + "\"tags\":{\"name\":\"home\",\"version\":\"2.0.0-SNAPSHOT\"}}", received.get(0).body);
+        client.close();
+    }
+
+    @Test
+    void report_anEventsOwnVersionTagWinsOverTheProgramVersion() throws Exception {
+        // Arrange
+        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").build();
+        Map tags = new LinkedHashMap<>();
+        tags.put("version", "9.9.9");
+
+        // Act
+        client.report("startup", null, tags);
+
+        // Assert
+        assertTrue(arrived.await(5, TimeUnit.SECONDS));
+        assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\",\"tags\":{\"version\":\"9.9.9\"}}",
+                received.get(0).body);
+        assertEquals(Collections.singletonMap("version", "9.9.9"), tags, "the caller's map is not modified");
+        client.close();
+    }
+
+    @Test
+    void withVersion_neverModifiesTheCallersMap() {
+        Map tags = new LinkedHashMap<>();
+        tags.put("name", "home");
+
+        Map merged = TraceClient.withVersion(tags, "1.2.3");
+
+        assertEquals(Collections.singletonMap("name", "home"), tags);
+        assertEquals("1.2.3", merged.get("version"));
+        assertEquals("1.2.3", TraceClient.withVersion(null, "1.2.3").get("version"));
     }
 
     @Test
@@ -353,7 +409,7 @@ void queue_isBoundedAndDropsRatherThanGrows() throws Exception {
             exchange.close();
         });
         slow.start();
-        TraceClient client = TraceClient.builder("http://127.0.0.1:" + slow.getAddress().getPort(), "MyPlugin")
+        TraceClient client = TraceClient.builder("http://127.0.0.1:" + slow.getAddress().getPort(), "MyPlugin", "1.2.3")
                 .key("k").build();
         int flood = TraceClient.QUEUE_CAPACITY * 3;
 
@@ -388,7 +444,7 @@ void close_sendsWhatWasJustQueuedBeforeStopping() throws Exception {
         // races the sender thread and is lost a good fraction of the time; 30
         // back-to-back report()+close() pairs make that fraction visible.
         for (int i = 0; i < 30; i++) {
-            TraceClient client = TraceClient.builder(baseUrl(), "MyCli").key("k").build();
+            TraceClient client = TraceClient.builder(baseUrl(), "MyCli", "1.2.3").key("k").build();
             client.report("startup", null, Collections.singletonMap("run", String.valueOf(i)));
             client.close();
         }
@@ -405,7 +461,7 @@ void close_stillReturnsWithinTheTimeoutWhenTheServerHangs() throws Exception {
             exchange.close();
         });
         slow.start();
-        TraceClient client = TraceClient.builder("http://127.0.0.1:" + slow.getAddress().getPort(), "MyCli").key("k").build();
+        TraceClient client = TraceClient.builder("http://127.0.0.1:" + slow.getAddress().getPort(), "MyCli", "1.2.3").key("k").build();
         client.report("startup");
 
         long before = System.nanoTime();
@@ -420,7 +476,7 @@ void close_stillReturnsWithinTheTimeoutWhenTheServerHangs() throws Exception {
     @Test
     void close_isSafeToCallTwice() throws Exception {
         // Arrange
-        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k").build();
+        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").build();
         client.report("startup");
 
         // Act
@@ -434,7 +490,7 @@ void close_isSafeToCallTwice() throws Exception {
     @Test
     void report_afterCloseIsDroppedWithoutThrowing() throws Exception {
         // Arrange
-        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k").build();
+        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").build();
         client.close();
 
         // Act
@@ -448,7 +504,7 @@ void report_afterCloseIsDroppedWithoutThrowing() throws Exception {
     @Test
     void report_sendsAUserAgentNamingTheClientAndTheApplication() throws Exception {
         // Arrange
-        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k").build();
+        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").build();
 
         // Act
         client.report("startup");
@@ -472,7 +528,7 @@ void report_logsASuccessStatusOtherThan201() throws Exception {
         Logger logger = Logger.getLogger("TraceClientTest.status200");
         logger.setLevel(Level.ALL);
         logger.addHandler(log);
-        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k").logger(logger).build();
+        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").logger(logger).build();
 
         // Act
         assertDoesNotThrow(() -> client.report("startup"));
@@ -488,7 +544,7 @@ void report_logsASuccessStatusOtherThan201() throws Exception {
     @Test
     void builder_trimsTheBaseUrlAndApplicationAndDropsEveryTrailingSlash() throws Exception {
         // Arrange
-        TraceClient client = TraceClient.builder("  " + baseUrl() + "///  ", "  MyPlugin  ").key("k").build();
+        TraceClient client = TraceClient.builder("  " + baseUrl() + "///  ", "  MyPlugin  ", "1.2.3").key("k").build();
 
         // Act
         client.report("startup");
@@ -496,16 +552,16 @@ void builder_trimsTheBaseUrlAndApplicationAndDropsEveryTrailingSlash() throws Ex
         // Assert
         assertTrue(arrived.await(5, TimeUnit.SECONDS));
         assertEquals("/api/metrics", received.get(0).path);
-        assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\"}", received.get(0).body);
+        assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\",\"tags\":{\"version\":\"1.2.3\"}}", received.get(0).body);
         client.close();
     }
 
     @Test
     void disabledClient_saysWhy() {
-        assertEquals(TraceClient.REASON_CONFIG, TraceClient.builder(baseUrl(), "MyPlugin").key("k").enabled(false).build().disabledReason());
-        assertEquals(TraceClient.REASON_NO_KEY, TraceClient.builder(baseUrl(), "MyPlugin").build().disabledReason());
-        assertEquals(TraceClient.REASON_NO_KEY, TraceClient.builder(baseUrl(), "MyPlugin").key("  ").build().disabledReason());
-        assertNull(TraceClient.builder(baseUrl(), "MyPlugin").key("k").build().disabledReason(), "an enabled client has no reason");
+        assertEquals(TraceClient.REASON_CONFIG, TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").enabled(false).build().disabledReason());
+        assertEquals(TraceClient.REASON_NO_KEY, TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").build().disabledReason());
+        assertEquals(TraceClient.REASON_NO_KEY, TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("  ").build().disabledReason());
+        assertNull(TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").build().disabledReason(), "an enabled client has no reason");
     }
 
     @Test
@@ -516,7 +572,7 @@ void serverWideConfig_isCreatedWithTheExactContentWhenMissing(@TempDir Path plug
         assertFalse(Files.exists(file));
 
         // Act
-        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k")
+        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k")
                 .serverWideConfig(pluginsDirectory).build();
 
         // Assert
@@ -548,7 +604,7 @@ void serverWideConfig_enabledFalseDisablesWithTheServerWideReason(@TempDir Path
         Files.write(file, operatorsFile.getBytes(StandardCharsets.UTF_8));
 
         // Act
-        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k").enabled(true)
+        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").enabled(true)
                 .serverWideConfig(plugins.toFile()).build();
         client.report("startup");
         client.close();
@@ -568,16 +624,16 @@ void serverWideConfig_acceptsEverySpellingOfOff(@TempDir Path plugins) throws Ex
         for (String off : new String[] {"false", "no", "0", "off", "OFF", "No"}) {
             Files.write(file, ("enabled: " + off + "\n").getBytes(StandardCharsets.UTF_8));
             assertEquals(TraceClient.REASON_SERVER_WIDE,
-                    TraceClient.builder(baseUrl(), "MyPlugin").key("k").serverWideConfig(plugins.toFile()).build().disabledReason(),
+                    TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").serverWideConfig(plugins.toFile()).build().disabledReason(),
                     "enabled: " + off + " should disable");
         }
         for (String on : new String[] {"true", "yes", "1", "on", "anything-else"}) {
             Files.write(file, ("enabled: " + on + "\n").getBytes(StandardCharsets.UTF_8));
-            assertNull(TraceClient.builder(baseUrl(), "MyPlugin").key("k").serverWideConfig(plugins.toFile()).build().disabledReason(),
+            assertNull(TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").serverWideConfig(plugins.toFile()).build().disabledReason(),
                     "enabled: " + on + " should not disable");
         }
         Files.write(file, "# nothing here\n".getBytes(StandardCharsets.UTF_8));
-        assertNull(TraceClient.builder(baseUrl(), "MyPlugin").key("k").serverWideConfig(plugins.toFile()).build().disabledReason(),
+        assertNull(TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").serverWideConfig(plugins.toFile()).build().disabledReason(),
                 "a file without an enabled: line means enabled");
     }
 
@@ -586,13 +642,13 @@ void environment_disablesAndWinsOverTheServerWideFile(@TempDir Path plugins) thr
         // Arrange
         // The file says on; the environment says off. The environment wins,
         // and is the reason given.
-        TraceClient.builder(baseUrl(), "MyPlugin").key("k").serverWideConfig(plugins.toFile()).build().close();
+        TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").serverWideConfig(plugins.toFile()).build().close();
         assertTrue(Files.readAllLines(plugins.resolve("trace").resolve("config.yml"), StandardCharsets.UTF_8).contains("enabled: true"));
 
         for (String off : new String[] {"off", "OFF", "false", "0", "no", " No "}) {
             environment.clear();
             environment.put("TRACE_USAGE_REPORTING", off);
-            TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k").serverWideConfig(plugins.toFile()).build();
+            TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").serverWideConfig(plugins.toFile()).build();
             assertFalse(client.isEnabled(), "TRACE_USAGE_REPORTING=" + off + " should disable");
             assertEquals("environment", client.disabledReason());
             client.report("startup");
@@ -601,7 +657,7 @@ void environment_disablesAndWinsOverTheServerWideFile(@TempDir Path plugins) thr
         for (String yes : new String[] {"1", "true", "TRUE", "yes"}) {
             environment.clear();
             environment.put("DO_NOT_TRACK", yes);
-            TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k").serverWideConfig(plugins.toFile()).build();
+            TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").serverWideConfig(plugins.toFile()).build();
             assertFalse(client.isEnabled(), "DO_NOT_TRACK=" + yes + " should disable");
             assertEquals("environment", client.disabledReason());
             client.report("startup");
@@ -613,7 +669,7 @@ void environment_disablesAndWinsOverTheServerWideFile(@TempDir Path plugins) thr
         environment.clear();
         environment.put("TRACE_USAGE_REPORTING", "on");
         environment.put("DO_NOT_TRACK", "0");
-        assertNull(TraceClient.builder(baseUrl(), "MyPlugin").key("k").serverWideConfig(plugins.toFile()).build().disabledReason());
+        assertNull(TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").serverWideConfig(plugins.toFile()).build().disabledReason());
     }
 
     @Test
@@ -627,16 +683,16 @@ void disabledReason_followsThePrecedenceEnvironmentThenServerWideThenConfigThenK
 
         // Act + Assert: peel the reasons off one at a time, in order.
         assertEquals("environment",
-                TraceClient.builder(baseUrl(), "MyPlugin").enabled(false).serverWideConfig(pluginsDirectory).build().disabledReason());
+                TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").enabled(false).serverWideConfig(pluginsDirectory).build().disabledReason());
         environment.clear();
         assertEquals("server-wide config: plugins/trace/config.yml",
-                TraceClient.builder(baseUrl(), "MyPlugin").enabled(false).serverWideConfig(pluginsDirectory).build().disabledReason());
+                TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").enabled(false).serverWideConfig(pluginsDirectory).build().disabledReason());
         Files.write(file, "enabled: true\n".getBytes(StandardCharsets.UTF_8));
         assertEquals("config.yml",
-                TraceClient.builder(baseUrl(), "MyPlugin").enabled(false).serverWideConfig(pluginsDirectory).build().disabledReason());
+                TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").enabled(false).serverWideConfig(pluginsDirectory).build().disabledReason());
         assertEquals("no key",
-                TraceClient.builder(baseUrl(), "MyPlugin").enabled(true).serverWideConfig(pluginsDirectory).build().disabledReason());
-        TraceClient enabled = TraceClient.builder(baseUrl(), "MyPlugin").key("k").enabled(true).serverWideConfig(pluginsDirectory).build();
+                TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").enabled(true).serverWideConfig(pluginsDirectory).build().disabledReason());
+        TraceClient enabled = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").enabled(true).serverWideConfig(pluginsDirectory).build();
         assertNull(enabled.disabledReason());
         assertTrue(enabled.isEnabled());
         enabled.close();
@@ -660,7 +716,7 @@ void serverWideTags_areMergedIntoEveryEvent(@TempDir Path plugins) throws Except
     void serverWideTags_areAddedToAnEventWithNoTagsOfItsOwn(@TempDir Path plugins) throws Exception {
         writeServerWideConfig(plugins, "tags:\n  ci: \"true\"\n");
 
-        assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\",\"tags\":{\"ci\":\"true\"}}",
+        assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\",\"tags\":{\"version\":\"1.2.3\",\"ci\":\"true\"}}",
                 reportedBody(plugins, "startup", null));
     }
 
@@ -792,15 +848,16 @@ void serverWideTags_areCappedSoTheEventStaysWithinTheServersTagLimit(@TempDir Pa
 
         // Assert
         int pairs = body.split("\":\"v\"", -1).length - 1;
-        assertEquals(TraceClient.MAX_TAGS, pairs, body);
-        assertTrue(body.contains("\"e29\":\"v\"") && body.contains("\"s1\":\"v\"") && !body.contains("\"s2\""), body);
+        assertEquals(TraceClient.MAX_TAGS - 1, pairs, "30 event tags + version + 1 server-wide: " + body);
+        assertTrue(body.contains("\"e29\":\"v\"") && body.contains("\"version\":\"1.2.3\"")
+                && body.contains("\"s0\":\"v\"") && !body.contains("\"s1\""), body);
     }
 
     @Test
     void serverWideTags_doNotResurrectADisabledClient(@TempDir Path plugins) throws Exception {
         writeServerWideConfig(plugins, "enabled: false\ntags:\n  ci: \"true\"\n");
 
-        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k").serverWideConfig(plugins.toFile()).build();
+        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").serverWideConfig(plugins.toFile()).build();
         client.report("startup");
         client.close();
 
@@ -812,16 +869,16 @@ void serverWideTags_doNotResurrectADisabledClient(@TempDir Path plugins) throws
     @Test
     void serverWideTags_areNoneWithoutAServerWideConfigOrAFileThatHasNone(@TempDir Path plugins) throws Exception {
         // No serverWideConfig(...) at all.
-        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k").build();
+        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").build();
         client.report("startup");
         assertTrue(arrived.await(5, TimeUnit.SECONDS));
         client.close();
-        assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\"}", received.get(0).body);
+        assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\",\"tags\":{\"version\":\"1.2.3\"}}", received.get(0).body);
 
         // A file with only the switch in it.
         arrived = new CountDownLatch(1);
         writeServerWideConfig(plugins, "enabled: true\n");
-        assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\"}", reportedBody(plugins, "startup", null));
+        assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\",\"tags\":{\"version\":\"1.2.3\"}}", reportedBody(plugins, "startup", null));
     }
 
     @Test
@@ -831,7 +888,7 @@ void serverWideTags_aFreshlyCreatedFileHasNoActiveTags(@TempDir Path plugins) th
 
         // ... and nothing in it is live: the example is commented out.
         assertTrue(Files.exists(plugins.resolve("trace").resolve("config.yml")));
-        assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\"}", body);
+        assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\",\"tags\":{\"version\":\"1.2.3\"}}", body);
         TraceClient.ServerWideConfig config = TraceClient.parseServerWideConfig(
                 Arrays.asList(TraceClient.SERVER_WIDE_CONFIG_CONTENT.split("\n")));
         assertTrue(config.tags.isEmpty());
@@ -853,7 +910,7 @@ void serverWideTags_malformedFileNeverThrowsAndReportingStillWorks(@TempDir Path
             arrived = new CountDownLatch(1);
             writeServerWideConfig(plugins, content);
             String body = assertDoesNotThrow(() -> reportedBody(plugins, "startup", null), content);
-            assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\"}", body, content);
+            assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\",\"tags\":{\"version\":\"1.2.3\"}}", body, content);
         }
 
         // Bytes that are not UTF-8 at all.
@@ -861,7 +918,7 @@ void serverWideTags_malformedFileNeverThrowsAndReportingStillWorks(@TempDir Path
         arrived = new CountDownLatch(1);
         Path file = plugins.resolve("trace").resolve("config.yml");
         Files.write(file, new byte[] {'t', 'a', 'g', 's', ':', '\n', ' ', ' ', 'c', 'i', ':', ' ', (byte) 0xC3, (byte) 0x28, '\n'});
-        assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\"}", reportedBody(plugins, "startup", null),
+        assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\",\"tags\":{\"version\":\"1.2.3\"}}", reportedBody(plugins, "startup", null),
                 "a file that is not UTF-8 counts as enabled with no tags");
     }
 
@@ -886,7 +943,7 @@ void serverWideConfig_ioFailureIsLoggedFineAndTreatedAsEnabled(@TempDir Path scr
         logger.addHandler(log);
 
         // Act
-        TraceClient client = assertDoesNotThrow(() -> TraceClient.builder(baseUrl(), "MyPlugin").key("k")
+        TraceClient client = assertDoesNotThrow(() -> TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k")
                 .serverWideConfig(notADirectory.toFile()).logger(logger).build());
 
         // Assert
@@ -909,7 +966,7 @@ private static Path writeServerWideConfig(Path plugins, String content) throws j
 
     /** Builds a client over {@code plugins}, reports one event, and returns the body the server got. */
     private String reportedBody(Path plugins, String name, Map tags) throws Exception {
-        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k").serverWideConfig(plugins.toFile()).build();
+        TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin", "1.2.3").key("k").serverWideConfig(plugins.toFile()).build();
         client.report(name, null, tags);
         assertTrue(arrived.await(5, TimeUnit.SECONDS), "the report should reach the server");
         client.close();

From c6d4a8a9b37b4b97b4d37d3a65c9e9248b0a49a8 Mon Sep 17 00:00:00 2001
From: Daniel McCoy Stephenson 
Date: Tue, 29 Sep 2026 21:29:13 -0600
Subject: [PATCH 2/2] Header comment: 0.4.0

Co-Authored-By: Claude Opus 5.5 
Claude-Session: https://claude.ai/code/session_01AuTszT2gqv7nLVYrtKD4ji
---
 src/main/java/software/stephenson/trace/TraceClient.java | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/main/java/software/stephenson/trace/TraceClient.java b/src/main/java/software/stephenson/trace/TraceClient.java
index 3278209..42eaec4 100644
--- a/src/main/java/software/stephenson/trace/TraceClient.java
+++ b/src/main/java/software/stephenson/trace/TraceClient.java
@@ -1,5 +1,5 @@
 /*
- * trace-client 0.3.0 -- https://github.com/Stephenson-Software/trace-client-java
+ * trace-client 0.4.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.