From 34c6679e09437a25c7a550d7b0d18370ee623d15 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sun, 27 Sep 2026 18:36:06 -0600 Subject: [PATCH] Add server-wide tags from plugins/trace/config.yml; 0.3.0 A `tags:` block in the server-wide config is merged into every event every plugin on the server reports, so a test or CI server can mark itself with `ci: "true"`. The event's own tag wins on a conflict; entries the trace server would reject are dropped and the merge is capped at MAX_TAGS; malformed content never throws or disables reporting. Read once at build(), alongside `enabled:`, which still wins. A freshly created file carries the example commented out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016Qot8ZBn7NPiiqmjAWpGY5 --- README.md | 35 ++- pom.xml | 2 +- .../stephenson/trace/TraceClient.java | 259 +++++++++++++++-- .../stephenson/trace/TraceClientTest.java | 262 +++++++++++++++++- 4 files changed, 529 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index de4eb6a..260a61a 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ last word. `build()` checks these in order; the first match wins and is what | Switch | `disabledReason()` | |---|---| | Environment: `TRACE_USAGE_REPORTING=off` (or `false`, `0`, `no`) or `DO_NOT_TRACK=1` (or `true`, `yes`), case-insensitive. Always checked. | `environment` | -| Server-wide, when `serverWideConfig(pluginsDirectory)` was given: `enabled: false` in `plugins/trace/config.yml`. `build()` creates the file with `enabled: true` if it is missing and never rewrites it afterwards; it is read with a line regex, no YAML library. An IO failure is logged at `FINE` and counts as enabled. | `server-wide config: plugins/trace/config.yml` | +| Server-wide, when `serverWideConfig(pluginsDirectory)` was given: `enabled: false` in `plugins/trace/config.yml`. `build()` creates the file with `enabled: true` (and a commented-out [`tags:`](#server-wide-tags) example) if it is missing and never rewrites it afterwards; it is read with a line regex, no YAML library. An IO failure is logged at `FINE` and counts as enabled. | `server-wide config: plugins/trace/config.yml` | | The program's own setting: `enabled(false)`. | `config.yml` | | No key, or a blank one. | `no key` | @@ -62,6 +62,37 @@ startup, whether reporting is on and how to turn it off — see the example above and the [usage reporting](https://github.com/Stephenson-Software/trace#usage-reporting) page for the wording the fleet uses. +## Server-wide tags + +The same `plugins/trace/config.yml` can carry a `tags:` block. Every event +every plugin on that server reports gets these tags added — the way a test or +CI server marks itself so its events are left out of real-installation +figures (the trace server's public numbers exclude `ci`, `service` and `page`): + +```yaml +enabled: true +tags: + ci: "true" +``` + +- `tags:` starts at column 0 and is followed by indented `key: value` lines. + 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. +- 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 + event carries 32 tags in total. Anything the line reader does not + understand (flow maps, lists, block scalars, a quote never closed) is + dropped the same way; a malformed file never throws and never turns + reporting off. +- The tags are read once, in `build()`, together with `enabled:`. + `enabled: false` still wins — a disabled client sends nothing, tags or not. +- A file created by `build()` has the example above commented out, so nothing + is added until the operator uncomments it. + ## Getting it **Copy the file.** `src/main/java/software/stephenson/trace/TraceClient.java` @@ -82,7 +113,7 @@ plugins already vendor bStats' `Metrics.java`. com.github.Stephenson-Software trace-client-java - 0.2.0 + 0.3.0 ``` diff --git a/pom.xml b/pom.xml index 9f6d79a..d486415 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ software.stephenson trace-client - 0.2.0 + 0.3.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 4bb3085..d8c517f 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.2.0 -- https://github.com/Stephenson-Software/trace-client-java + * trace-client 0.3.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. @@ -66,6 +66,12 @@ *
  • no key -- reason {@code no key}.
  • * * + *

    The same server-wide file can also carry a {@code tags:} block, merged + * into every event every plugin on the server reports -- {@code ci: "true"} + * on a test server keeps its events out of real-installation figures. An + * event's own tag wins over a server-wide one of the same name. See + * {@link Builder#serverWideConfig(File)}. + * *

    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. @@ -93,6 +99,9 @@ */ public final class TraceClient { + /** This client's version, as sent in the User-Agent. */ + public static final String VERSION = "0.3.0"; + /** How many reports may wait to be sent before new ones are dropped. */ public static final int QUEUE_CAPACITY = 256; @@ -123,9 +132,24 @@ public final class TraceClient { + "# Set enabled to false and every such plugin on this server stops reporting,\n" + "# regardless of its own usage-reporting.enabled setting. Plugins never turn\n" + "# this back on.\n" - + "enabled: true\n"; + + "enabled: true\n" + + "#\n" + + "# Tags added to every event such plugins on this server report. A plugin's\n" + + "# 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"; private static final Pattern ENABLED_LINE = Pattern.compile("^\\s*enabled\\s*:\\s*(\\S+)"); + private static final Pattern TAGS_LINE = Pattern.compile("^tags\\s*:\\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 + // characters. Server-wide tags are held to that and to a stricter key + // alphabet, so a typo in the file can never turn every report into a 400. + static final int MAX_TAGS = 32; + static final int MAX_TAG_LENGTH = 255; + private static final Pattern TAG_KEY = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_.\\-]*"); // Where environment variables come from. A seam rather than System.getenv // directly, so tests can point it at a map; nothing else should touch it. @@ -136,6 +160,7 @@ public final class TraceClient { private final String application; private final Logger logger; private final String disabledReason; // null when enabled + private final Map serverWideTags; // never null; read once, at build() private final ThreadPoolExecutor executor; // null when disabled private TraceClient(Builder builder) { @@ -143,7 +168,11 @@ private TraceClient(Builder builder) { this.key = builder.key; this.application = builder.application; this.logger = builder.logger; - this.disabledReason = disabledReason(builder); + ServerWideConfig serverWide = builder.pluginsDirectory == null || environmentDisables() + ? ServerWideConfig.NONE + : readServerWideConfig(builder.pluginsDirectory); + this.disabledReason = disabledReason(builder, serverWide); + this.serverWideTags = serverWide.tags; if (disabledReason == null) { this.executor = new ThreadPoolExecutor( 1, 1, 30, TimeUnit.SECONDS, @@ -188,11 +217,11 @@ public String disabledReason() { return disabledReason; } - private String disabledReason(Builder builder) { + private static String disabledReason(Builder builder, ServerWideConfig serverWide) { if (environmentDisables()) { return REASON_ENVIRONMENT; } - if (builder.pluginsDirectory != null && serverWideConfigDisables(builder.pluginsDirectory)) { + if (serverWide.disables) { return REASON_SERVER_WIDE; } if (!builder.enabled) { @@ -224,33 +253,211 @@ 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. */ + static final class ServerWideConfig { + static final ServerWideConfig NONE = new ServerWideConfig(false, Collections.emptyMap()); + + final boolean disables; + final Map tags; + + ServerWideConfig(boolean disables, Map tags) { + this.disables = disables; + this.tags = tags; + } + } + /** * Ensures {@code /trace/config.yml} exists and reads its - * {@code enabled:} line. No YAML library: the file is ours, one key deep, - * and a line regex is enough. Anything going wrong on disk is logged at - * FINE and counts as enabled -- a read-only plugins directory must not - * silently switch reporting off, nor stop the host program. + * {@code enabled:} line and {@code tags:} block. No YAML library: the file + * is ours, shallow, and a line scan is enough. Anything going wrong on + * disk is logged at FINE and counts as enabled with no tags -- a + * read-only plugins directory must not silently switch reporting off, + * nor stop the host program. */ - private boolean serverWideConfigDisables(File pluginsDirectory) { + private ServerWideConfig readServerWideConfig(File pluginsDirectory) { Path file = new File(pluginsDirectory, SERVER_WIDE_CONFIG_PATH).toPath(); try { if (!Files.exists(file)) { Files.createDirectories(file.getParent()); Files.write(file, SERVER_WIDE_CONFIG_CONTENT.getBytes(StandardCharsets.UTF_8)); - return false; // just written with enabled: true + // just written: enabled: true, and the tags example commented out + } + return parseServerWideConfig(Files.readAllLines(file, StandardCharsets.UTF_8)); + } catch (IOException | RuntimeException failure) { + log("could not read server-wide config " + file + ": " + failure); + return ServerWideConfig.NONE; + } + } + + /** + * Reads the switch and the tags from the lines of the server-wide file. + * The first {@code enabled:} line outside a {@code tags:} block is the + * switch. A {@code tags:} line at column 0 opens a block of indented + * {@code key: value} lines, which ends at the next non-blank line that is + * not indented; blank and {@code #} lines inside it are skipped, as are + * 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. + */ + static ServerWideConfig parseServerWideConfig(List lines) { + Boolean disables = null; + Map tags = new LinkedHashMap<>(); + boolean inTags = false; + int entryIndent = -1; + for (String line : lines) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + int indent = 0; + while (indent < line.length() && (line.charAt(indent) == ' ' || line.charAt(indent) == '\t')) { + indent++; } - List lines = Files.readAllLines(file, StandardCharsets.UTF_8); - for (String line : lines) { + if (inTags) { + if (indent > 0) { + if (entryIndent < 0) { + entryIndent = indent; + } + if (indent == entryIndent) { + addServerWideTag(tags, trimmed); + } + continue; + } + inTags = false; + } + if (TAGS_LINE.matcher(line).matches()) { + inTags = true; + entryIndent = -1; + continue; + } + if (disables == null) { Matcher matcher = ENABLED_LINE.matcher(line); if (matcher.find()) { - return isOff(matcher.group(1)); + disables = isOff(matcher.group(1)); } } - return false; // no enabled: line at all - } catch (IOException | RuntimeException failure) { - log("could not read server-wide config " + file + ": " + failure); - return false; } + return new ServerWideConfig(disables != null && disables, + tags.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(tags)); + } + + private static void addServerWideTag(Map tags, String entry) { + if (tags.size() >= MAX_TAGS) { + return; + } + int colon = entry.indexOf(':'); + if (colon <= 0) { + return; + } + String key = unquote(entry.substring(0, colon).trim()); + String rest = entry.substring(colon + 1); + if (key == null || !rest.isEmpty() && rest.charAt(0) != ' ' && rest.charAt(0) != '\t') { + return; // "a:b" is a string in YAML, not a pair + } + String value = scalar(rest.trim()); + if (value == null + || key.length() > MAX_TAG_LENGTH || !TAG_KEY.matcher(key).matches() + || value.length() > MAX_TAG_LENGTH) { + return; + } + if (!tags.containsKey(key)) { + tags.put(key, value); + } + } + + /** A key, quoted or not; null when the quoting is broken. */ + private static String unquote(String key) { + if (key.startsWith("\"") || key.startsWith("'")) { + return key.length() >= 2 && key.charAt(key.length() - 1) == key.charAt(0) + ? key.substring(1, key.length() - 1) + : null; + } + return key; + } + + /** + * A YAML scalar value, with any trailing comment removed; null for an + * empty (YAML null) value, a broken quote, or anything that is not a + * plain one-line scalar. + */ + private static String scalar(String text) { + if (text.isEmpty() || text.startsWith("#")) { + return null; + } + char first = text.charAt(0); + if (first == '"' || first == '\'') { + StringBuilder out = new StringBuilder(); + int i = 1; + for (; i < text.length(); i++) { + char c = text.charAt(i); + if (first == '"' && c == '\\' && i + 1 < text.length()) { + char next = text.charAt(++i); + switch (next) { + case 'n': out.append('\n'); break; + case 't': out.append('\t'); break; + case 'r': out.append('\r'); break; + default: out.append(next); // \" \\ \/ and anything else, literally + } + } else if (c == first) { + if (first == '\'' && i + 1 < text.length() && text.charAt(i + 1) == '\'') { + out.append('\''); + i++; + } else { + break; + } + } else { + out.append(c); + } + } + if (i >= text.length()) { + return null; // never closed + } + String after = text.substring(i + 1).trim(); + return after.isEmpty() || after.startsWith("#") ? out.toString() : null; + } + if ("[{|>&*!%@`".indexOf(first) >= 0) { + return null; // flow collections, block scalars, anchors, tags: not ours + } + int comment = -1; + for (int i = 1; i < text.length(); i++) { + if (text.charAt(i) == '#' && (text.charAt(i - 1) == ' ' || text.charAt(i - 1) == '\t')) { + comment = i; + break; + } + } + String value = (comment < 0 ? text : text.substring(0, comment)).trim(); + return value.isEmpty() ? null : value; + } + + /** + * The event's tags with the server-wide ones added: an event's own tag + * wins on a key conflict, and server-wide tags stop being added once + * {@link #MAX_TAGS} is reached, so the merge never makes a report the + * server would reject. A tag with a null key or value counts as absent, + * as it does in {@link #json}. + */ + static Map withServerWideTags(Map tags, Map serverWide) { + if (serverWide.isEmpty()) { + return tags; + } + 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()); + } + } + } + for (Map.Entry tag : serverWide.entrySet()) { + if (merged.size() >= MAX_TAGS) { + break; + } + if (!merged.containsKey(tag.getKey())) { + merged.put(tag.getKey(), tag.getValue()); + } + } + return merged; } /** Reports that {@code name} happened. */ @@ -266,7 +473,7 @@ 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, tags); + final String body = json(application, name, value, withServerWideTags(tags, serverWideTags)); executor.execute(() -> send(body)); } @@ -303,7 +510,7 @@ private void send(String body) { connection.setRequestMethod("POST"); connection.setRequestProperty("Content-Type", "application/json; charset=utf-8"); connection.setRequestProperty("Authorization", "Bearer " + key); - connection.setRequestProperty("User-Agent", "trace-client/0.2.0 (" + application + ")"); + connection.setRequestProperty("User-Agent", "trace-client/" + VERSION + " (" + application + ")"); connection.setDoOutput(true); byte[] bytes = body.getBytes(StandardCharsets.UTF_8); connection.setFixedLengthStreamingMode(bytes.length); @@ -434,7 +641,17 @@ public Builder enabled(boolean enabled) { * {@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. Optional; programs that are not plugins leave it unset. + * exists. Its optional {@code tags:} block is added to every event + * this client reports, below the event's own tags: + * + *

    +         * enabled: true
    +         * tags:
    +         *   ci: "true"
    +         * 
    + * + *

    Both are read once, here. Optional; programs that are not + * plugins leave it unset. */ public Builder serverWideConfig(File pluginsDirectory) { this.pluginsDirectory = pluginsDirectory; diff --git a/src/test/java/software/stephenson/trace/TraceClientTest.java b/src/test/java/software/stephenson/trace/TraceClientTest.java index a16313f..e5f4db8 100644 --- a/src/test/java/software/stephenson/trace/TraceClientTest.java +++ b/src/test/java/software/stephenson/trace/TraceClientTest.java @@ -13,6 +13,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; @@ -525,7 +526,13 @@ void serverWideConfig_isCreatedWithTheExactContentWhenMissing(@TempDir Path plug + "# Set enabled to false and every such plugin on this server stops reporting,\n" + "# regardless of its own usage-reporting.enabled setting. Plugins never turn\n" + "# this back on.\n" - + "enabled: true\n"; + + "enabled: true\n" + + "#\n" + + "# Tags added to every event such plugins on this server report. A plugin's\n" + + "# 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"; assertEquals(expected, new String(Files.readAllBytes(file), StandardCharsets.UTF_8)); assertTrue(client.isEnabled(), "a freshly created switch file means enabled"); assertNull(client.disabledReason()); @@ -580,7 +587,7 @@ void environment_disablesAndWinsOverTheServerWideFile(@TempDir Path plugins) thr // 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(); - assertEquals("enabled: true\n", lastLine(plugins.resolve("trace").resolve("config.yml"))); + 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(); @@ -635,6 +642,236 @@ void disabledReason_followsThePrecedenceEnvironmentThenServerWideThenConfigThenK enabled.close(); } + @Test + void serverWideTags_areMergedIntoEveryEvent(@TempDir Path plugins) throws Exception { + // Arrange + writeServerWideConfig(plugins, "enabled: true\ntags:\n ci: \"true\"\n"); + Map tags = new LinkedHashMap<>(); + tags.put("version", "1.2.3"); + + // Act + String body = reportedBody(plugins, "startup", tags); + + // Assert + assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\",\"tags\":{\"version\":\"1.2.3\",\"ci\":\"true\"}}", body); + } + + @Test + void serverWideTags_areAddedToAnEventWithNoTagsOfItsOwn(@TempDir Path plugins) throws Exception { + writeServerWideConfig(plugins, "tags:\n ci: \"true\"\n"); + + assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\",\"tags\":{\"ci\":\"true\"}}", + reportedBody(plugins, "startup", null)); + } + + @Test + void serverWideTags_neverOverwriteTheEventsOwnTag(@TempDir Path plugins) throws Exception { + // Arrange + writeServerWideConfig(plugins, "tags:\n version: \"9.9.9\"\n name: overwritten\n ci: true\n"); + Map tags = new LinkedHashMap<>(); + tags.put("version", "1.2.3"); + tags.put("name", "home"); + + // Act + String body = reportedBody(plugins, "command", tags); + + // Assert + assertEquals("{\"application\":\"MyPlugin\",\"name\":\"command\",\"tags\":" + + "{\"version\":\"1.2.3\",\"name\":\"home\",\"ci\":\"true\"}}", body); + } + + @Test + void serverWideTags_acceptDoubleQuotedSingleQuotedAndBareValues() { + Map tags = tagsOf("tags:\n" + + " a: \"double \\\"quoted\\\" # not a comment\"\n" + + " b: 'single ''quoted'''\n" + + " c: bare value # a comment\n" + + " d: true\n" + + " e: \"\"\n" + + " 'f': \"quoted key\"\n" + + " g: \"x\" # comment after a quote\n"); + Map expected = new LinkedHashMap<>(); + expected.put("a", "double \"quoted\" # not a comment"); + expected.put("b", "single 'quoted'"); + expected.put("c", "bare value"); + expected.put("d", "true"); + expected.put("e", ""); + expected.put("f", "quoted key"); + expected.put("g", "x"); + assertEquals(expected, tags); + } + + @Test + void serverWideTags_skipBlankAndCommentLinesInsideTheBlock() { + Map tags = tagsOf("tags: # server-wide\n" + + "\n" + + " # the CI marker\n" + + "# a comment at column 0 does not end the block either\n" + + " ci: \"true\"\n" + + " \n" + + " env: staging\n"); + Map expected = new LinkedHashMap<>(); + expected.put("ci", "true"); + expected.put("env", "staging"); + assertEquals(expected, tags); + } + + @Test + void serverWideTags_blockEndsAtTheNextUnindentedLineOrEndOfFile() { + // Ends at the next key. + TraceClient.ServerWideConfig config = TraceClient.parseServerWideConfig(Arrays.asList( + "tags:", " ci: \"true\"", "enabled: false", " stray: x")); + assertEquals(Collections.singletonMap("ci", "true"), config.tags); + assertTrue(config.disables, "the enabled: line after the block is still the switch"); + + // Ends at end of file, with no trailing newline. + assertEquals(Collections.singletonMap("ci", "true"), tagsOf("enabled: true\ntags:\n ci: \"true\"")); + + // An enabled: entry inside the block is a tag, not the switch. + config = TraceClient.parseServerWideConfig(Arrays.asList("tags:", " enabled: \"false\"")); + assertFalse(config.disables); + assertEquals(Collections.singletonMap("enabled", "false"), config.tags); + + // Deeper-indented lines are not entries of this block. + assertEquals(Collections.singletonMap("ci", "true"), tagsOf("tags:\n ci: \"true\"\n nested: x\n")); + + // "tags:" must be at column 0, and an empty block is no tags. + assertTrue(tagsOf("other:\n tags:\n ci: \"true\"\n").isEmpty()); + assertTrue(tagsOf("tags:\nenabled: true\n").isEmpty()); + } + + @Test + void serverWideTags_dropEntriesTheServerWouldRejectOrThatAreNotUnderstood() { + StringBuilder longText = new StringBuilder(); + for (int i = 0; i < TraceClient.MAX_TAG_LENGTH + 1; i++) { + longText.append('x'); + } + String exactlyMax = longText.substring(1); + Map tags = tagsOf("tags:\n" + + " ci: \"true\"\n" + + " " + longText + ": key-too-long\n" + + " long: \"" + longText + "\"\n" + + " max: " + exactlyMax + "\n" + + " \"has space\": x\n" + + " \"\": blank-key\n" + + " -dash-first: x\n" + + " empty:\n" + + " comment-only: # nothing\n" + + " a:b\n" + + " no colon at all\n" + + " broken: \"never closed\n" + + " trailing: \"x\" junk\n" + + " list: [1, 2]\n" + + " map: {a: b}\n" + + " block: |\n" + + " ok.key_1-2: fine\n" + + " ci: \"duplicate, first wins\"\n"); + Map expected = new LinkedHashMap<>(); + expected.put("ci", "true"); + expected.put("max", exactlyMax); + expected.put("ok.key_1-2", "fine"); + assertEquals(expected, tags); + } + + @Test + void serverWideTags_areCappedSoTheEventStaysWithinTheServersTagLimit(@TempDir Path plugins) throws Exception { + // Arrange: 40 server-wide tags, 30 event tags. + StringBuilder file = new StringBuilder("tags:\n"); + for (int i = 0; i < 40; i++) { + file.append(" s").append(i).append(": v\n"); + } + assertEquals(TraceClient.MAX_TAGS, tagsOf(file.toString()).size(), "at most MAX_TAGS are read"); + writeServerWideConfig(plugins, file.toString()); + Map tags = new LinkedHashMap<>(); + for (int i = 0; i < 30; i++) { + tags.put("e" + i, "v"); + } + + // Act + String body = reportedBody(plugins, "startup", tags); + + // 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); + } + + @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(); + client.report("startup"); + client.close(); + + assertFalse(client.isEnabled()); + assertEquals(TraceClient.REASON_SERVER_WIDE, client.disabledReason()); + assertFalse(arrived.await(300, TimeUnit.MILLISECONDS), "nothing should have been sent"); + } + + @Test + void serverWideTags_areNoneWithoutAServerWideConfigOrAFileThatHasNone(@TempDir Path plugins) throws Exception { + // No serverWideConfig(...) at all. + TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k").build(); + client.report("startup"); + assertTrue(arrived.await(5, TimeUnit.SECONDS)); + client.close(); + assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\"}", 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)); + } + + @Test + void serverWideTags_aFreshlyCreatedFileHasNoActiveTags(@TempDir Path plugins) throws Exception { + // Created by build() because it was missing ... + String body = reportedBody(plugins, "startup", null); + + // ... 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); + TraceClient.ServerWideConfig config = TraceClient.parseServerWideConfig( + Arrays.asList(TraceClient.SERVER_WIDE_CONFIG_CONTENT.split("\n"))); + assertTrue(config.tags.isEmpty()); + assertFalse(config.disables); + } + + @Test + void serverWideTags_malformedFileNeverThrowsAndReportingStillWorks(@TempDir Path plugins) throws Exception { + // Arrange: garbage of every kind. + String[] contents = { + "tags", + "tags:\n :\n ::::\n \"\n '\n \\\n\t\tci:\t\"true\n", + "tags: {ci: true}\n", + "tags:\n- ci\n- \"true\"\n", + "\u0000\u0001tags:\n \u0000: \u0001\n", + }; + for (String content : contents) { + received.clear(); + arrived = new CountDownLatch(1); + writeServerWideConfig(plugins, content); + String body = assertDoesNotThrow(() -> reportedBody(plugins, "startup", null), content); + assertEquals("{\"application\":\"MyPlugin\",\"name\":\"startup\"}", body, content); + } + + // Bytes that are not UTF-8 at all. + received.clear(); + 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), + "a file that is not UTF-8 counts as enabled with no tags"); + } + + @Test + void withServerWideTags_leavesTheEventAloneWhenThereAreNone() { + Map tags = Collections.singletonMap("name", "home"); + assertSame(tags, TraceClient.withServerWideTags(tags, Collections.emptyMap())); + assertNull(TraceClient.withServerWideTags(null, Collections.emptyMap())); + } + @Test void serverWideConfig_ioFailureIsLoggedFineAndTreatedAsEnabled(@TempDir Path scratch) throws Exception { // Arrange @@ -663,9 +900,24 @@ void serverWideConfig_ioFailureIsLoggedFineAndTreatedAsEnabled(@TempDir Path scr client.close(); } - private static String lastLine(Path file) throws java.io.IOException { - List lines = Files.readAllLines(file, StandardCharsets.UTF_8); - return lines.get(lines.size() - 1) + "\n"; + private static Path writeServerWideConfig(Path plugins, String content) throws java.io.IOException { + Path file = plugins.resolve("trace").resolve("config.yml"); + Files.createDirectories(file.getParent()); + Files.write(file, content.getBytes(StandardCharsets.UTF_8)); + return file; + } + + /** 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(); + client.report(name, null, tags); + assertTrue(arrived.await(5, TimeUnit.SECONDS), "the report should reach the server"); + client.close(); + return received.get(received.size() - 1).body; + } + + private static Map tagsOf(String content) { + return TraceClient.parseServerWideConfig(Arrays.asList(content.split("\n", -1))).tags; } private static byte[] readAll(InputStream in) throws java.io.IOException {