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 {