diff --git a/README.md b/README.md
index cb3ecef..de4eb6a 100644
--- a/README.md
+++ b/README.md
@@ -11,9 +11,21 @@ program side is meant to stay one call.
TraceClient trace = TraceClient.builder("https://trace.danielstephenson.dev", "MyPlugin")
.key(config.getString("usage-reporting.key"))
.enabled(config.getBoolean("usage-reporting.enabled", true))
+ .serverWideConfig(getDataFolder().getParentFile()) // plugins/ -- Bukkit plugins only
.logger(getLogger())
.build();
+// Say so, every startup, on the program's own logger.
+if (trace.isEnabled()) {
+ getLogger().info("Usage reporting is on: MyPlugin sends its name, version and command names to "
+ + "https://trace.danielstephenson.dev - nothing about players or the server. "
+ + "Turn it off with usage-reporting.enabled: false in this plugin's config.yml, "
+ + "or for every plugin with enabled: false in plugins/trace/config.yml. "
+ + "Details: https://github.com/Stephenson-Software/trace#usage-reporting");
+} else {
+ getLogger().info("Usage reporting is off (" + trace.disabledReason() + ").");
+}
+
trace.report("startup");
trace.report("command", 1.0, Collections.singletonMap("name", "home"));
@@ -30,9 +42,25 @@ trace.close();
| **Bounded** | At most 256 reports wait to be sent; past that, new ones are dropped. A trace server that is unreachable for a week costs a few kilobytes, not your heap. |
| **`close()` drains** | Reports already queued get up to the client timeout (5 s total) to be sent before the thread stops, so a CLI that reports and exits at once does not lose its event. Still bounded: an unreachable server delays exit by at most the timeout. |
-Reporting is **opt-out**: `enabled(false)`, or no key at all, yields a client
-that does nothing and costs nothing. A program that runs on other people's
-machines should expose that switch in its configuration.
+## Opting out
+
+Reporting is **opt-out**, and the person running the program always has the
+last word. `build()` checks these in order; the first match wins and is what
+`disabledReason()` returns, verbatim, so the program can log it:
+
+| 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` |
+| The program's own setting: `enabled(false)`. | `config.yml` |
+| No key, or a blank one. | `no key` |
+
+`disabledReason()` is `null` when the client is enabled. A disabled client does
+nothing and costs nothing. A program that runs on other people's machines
+should expose its own switch in its configuration and print, on every
+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.
## Getting it
@@ -54,7 +82,7 @@ plugins already vendor bStats' `Metrics.java`.
Reporting is opt-out: a client built with {@link Builder#enabled(boolean) - * enabled(false)}, or with no key, is a no-op that costs nothing. Programs that - * run on other people's machines should expose that switch in their - * configuration. + *
Reporting is opt-out, and the person running the program always has the + * last word. {@link Builder#build()} checks, in this order, and the first + * match is what {@link #disabledReason()} reports: + * + *
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")
* .key(config.getString("usage-reporting.key"))
* .enabled(config.getBoolean("usage-reporting.enabled", true))
+ * .serverWideConfig(getDataFolder().getParentFile()) // plugins/
* .logger(getLogger())
* .build();
*
+ * if (trace.isEnabled()) {
+ * getLogger().info("Usage reporting is on: ...");
+ * } else {
+ * getLogger().info("Usage reporting is off (" + trace.disabledReason() + ").");
+ * }
+ *
* trace.report("startup");
* trace.report("command", 1.0, Collections.singletonMap("name", "home"));
*
@@ -70,10 +99,43 @@ public final class TraceClient {
private static final int CONNECT_TIMEOUT_MS = 5_000;
private static final int READ_TIMEOUT_MS = 5_000;
+ /** Reason reported when an environment variable turned reporting off. */
+ public static final String REASON_ENVIRONMENT = "environment";
+ /** Reason reported when {@code plugins/trace/config.yml} turned reporting off. */
+ public static final String REASON_SERVER_WIDE = "server-wide config: plugins/trace/config.yml";
+ /** Reason reported when the program's own setting turned reporting off. */
+ public static final String REASON_CONFIG = "config.yml";
+ /** Reason reported when no key was given. */
+ public static final String REASON_NO_KEY = "no key";
+
+ /** Environment variable that turns reporting off: {@code off}, {@code false}, {@code 0}, {@code no}. */
+ public static final String ENV_USAGE_REPORTING = "TRACE_USAGE_REPORTING";
+ /** Environment variable that turns reporting off: {@code 1}, {@code true}, {@code yes}. See https://consoledonottrack.com. */
+ public static final String ENV_DO_NOT_TRACK = "DO_NOT_TRACK";
+
+ /** The server-wide switch, relative to the plugins directory. */
+ static final String SERVER_WIDE_CONFIG_PATH = "trace" + File.separator + "config.yml";
+
+ /** Exactly what a missing server-wide switch file is created with. */
+ static final String SERVER_WIDE_CONFIG_CONTENT =
+ "# Server-wide switch for usage reporting by plugins that report to trace\n"
+ + "# (https://github.com/Stephenson-Software/trace#usage-reporting).\n"
+ + "# 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";
+
+ private static final Pattern ENABLED_LINE = Pattern.compile("^\\s*enabled\\s*:\\s*(\\S+)");
+
+ // 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.
+ static Function environment = System::getenv;
+
private final String endpoint;
private final String key;
private final String application;
private final Logger logger;
+ private final String disabledReason; // null when enabled
private final ThreadPoolExecutor executor; // null when disabled
private TraceClient(Builder builder) {
@@ -81,8 +143,8 @@ private TraceClient(Builder builder) {
this.key = builder.key;
this.application = builder.application;
this.logger = builder.logger;
- boolean enabled = builder.enabled && builder.key != null && !builder.key.trim().isEmpty();
- if (enabled) {
+ this.disabledReason = disabledReason(builder);
+ if (disabledReason == null) {
this.executor = new ThreadPoolExecutor(
1, 1, 30, TimeUnit.SECONDS,
new ArrayBlockingQueue(QUEUE_CAPACITY),
@@ -116,6 +178,81 @@ public boolean isEnabled() {
return executor != null;
}
+ /**
+ * Why {@link #report} sends nothing: {@code null} when enabled, otherwise
+ * one of {@link #REASON_ENVIRONMENT}, {@link #REASON_SERVER_WIDE},
+ * {@link #REASON_CONFIG} or {@link #REASON_NO_KEY}, verbatim, so a program
+ * can print {@code "Usage reporting is off (" + reason + ")."}.
+ */
+ public String disabledReason() {
+ return disabledReason;
+ }
+
+ private String disabledReason(Builder builder) {
+ if (environmentDisables()) {
+ return REASON_ENVIRONMENT;
+ }
+ if (builder.pluginsDirectory != null && serverWideConfigDisables(builder.pluginsDirectory)) {
+ return REASON_SERVER_WIDE;
+ }
+ if (!builder.enabled) {
+ return REASON_CONFIG;
+ }
+ if (builder.key == null || builder.key.trim().isEmpty()) {
+ return REASON_NO_KEY;
+ }
+ return null;
+ }
+
+ private static boolean environmentDisables() {
+ return isOff(environment.apply(ENV_USAGE_REPORTING)) || isYes(environment.apply(ENV_DO_NOT_TRACK));
+ }
+
+ private static boolean isOff(String value) {
+ if (value == null) {
+ return false;
+ }
+ String v = value.trim().toLowerCase();
+ return v.equals("off") || v.equals("false") || v.equals("0") || v.equals("no");
+ }
+
+ private static boolean isYes(String value) {
+ if (value == null) {
+ return false;
+ }
+ String v = value.trim().toLowerCase();
+ return v.equals("1") || v.equals("true") || v.equals("yes");
+ }
+
+ /**
+ * 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.
+ */
+ private boolean serverWideConfigDisables(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
+ }
+ List lines = Files.readAllLines(file, StandardCharsets.UTF_8);
+ for (String line : lines) {
+ Matcher matcher = ENABLED_LINE.matcher(line);
+ if (matcher.find()) {
+ return 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;
+ }
+ }
+
/** Reports that {@code name} happened. */
public void report(String name) {
report(name, null, null);
@@ -166,7 +303,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.1.1 (" + application + ")");
+ connection.setRequestProperty("User-Agent", "trace-client/0.2.0 (" + application + ")");
connection.setDoOutput(true);
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
connection.setFixedLengthStreamingMode(bytes.length);
@@ -264,6 +401,7 @@ public static final class Builder {
private final String application;
private String key;
private boolean enabled = true;
+ private File pluginsDirectory;
private Logger logger;
private Builder(String baseUrl, String application) {
@@ -289,12 +427,33 @@ public Builder enabled(boolean enabled) {
return this;
}
+ /**
+ * The server-wide opt-out shared by every plugin on a Spigot server.
+ * Given the plugins directory ({@code getDataFolder().getParentFile()}
+ * in a Bukkit plugin), {@link #build()} makes sure
+ * {@code plugins/trace/config.yml} exists -- creating it with
+ * {@code enabled: true} if it is missing -- and honours
+ * {@code enabled: false} in it. The file is never rewritten once it
+ * exists. Optional; programs that are not plugins leave it unset.
+ */
+ public Builder serverWideConfig(File pluginsDirectory) {
+ this.pluginsDirectory = pluginsDirectory;
+ return this;
+ }
+
/** Where dropped reports are mentioned, at {@link Level#FINE}. Optional. */
public Builder logger(Logger logger) {
this.logger = logger;
return this;
}
+ /**
+ * Builds the client. The environment variables
+ * {@value TraceClient#ENV_USAGE_REPORTING} and
+ * {@value TraceClient#ENV_DO_NOT_TRACK} are always consulted first,
+ * then the server-wide config if one was given, then
+ * {@link #enabled(boolean)}, then the key. Never throws.
+ */
public TraceClient build() {
return new TraceClient(this);
}
diff --git a/src/test/java/software/stephenson/trace/TraceClientTest.java b/src/test/java/software/stephenson/trace/TraceClientTest.java
index 983f66b..ffb6b5c 100644
--- a/src/test/java/software/stephenson/trace/TraceClientTest.java
+++ b/src/test/java/software/stephenson/trace/TraceClientTest.java
@@ -4,12 +4,17 @@
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayOutputStream;
+import java.io.File;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.util.Collections;
+import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -17,6 +22,7 @@
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.LogRecord;
@@ -35,6 +41,12 @@ class TraceClientTest {
private volatile int replyStatus = 201;
private volatile CountDownLatch arrived = new CountDownLatch(1);
+ // What the client sees as its environment. Empty unless a test says
+ // otherwise, so a DO_NOT_TRACK on the machine running the suite cannot
+ // fail the tests that expect an enabled client.
+ private final Map environment = new HashMap<>();
+ private Function realEnvironment;
+
private static final class Received {
final String method;
final String path;
@@ -51,6 +63,17 @@ private static final class Received {
}
}
+ @BeforeEach
+ void isolateEnvironment() {
+ realEnvironment = TraceClient.environment;
+ TraceClient.environment = environment::get;
+ }
+
+ @AfterEach
+ void restoreEnvironment() {
+ TraceClient.environment = realEnvironment;
+ }
+
@BeforeEach
void startServer() throws Exception {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
@@ -333,6 +356,175 @@ void close_stillReturnsWithinTheTimeoutWhenTheServerHangs() throws Exception {
slow.stop(0);
}
+ @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");
+ }
+
+ @Test
+ void serverWideConfig_isCreatedWithTheExactContentWhenMissing(@TempDir Path plugins) throws Exception {
+ // Arrange
+ File pluginsDirectory = plugins.toFile();
+ Path file = plugins.resolve("trace").resolve("config.yml");
+ assertFalse(Files.exists(file));
+
+ // Act
+ TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k")
+ .serverWideConfig(pluginsDirectory).build();
+
+ // Assert
+ assertTrue(Files.exists(file), "plugins/trace/config.yml should have been created");
+ String expected = "# Server-wide switch for usage reporting by plugins that report to trace\n"
+ + "# (https://github.com/Stephenson-Software/trace#usage-reporting).\n"
+ + "# 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";
+ assertEquals(expected, new String(Files.readAllBytes(file), StandardCharsets.UTF_8));
+ assertTrue(client.isEnabled(), "a freshly created switch file means enabled");
+ assertNull(client.disabledReason());
+ client.close();
+ }
+
+ @Test
+ void serverWideConfig_enabledFalseDisablesWithTheServerWideReason(@TempDir Path plugins) throws Exception {
+ // Arrange
+ Path file = plugins.resolve("trace").resolve("config.yml");
+ Files.createDirectories(file.getParent());
+ String operatorsFile = "# my notes\n enabled : False # turned off by the operator\nother: true\n";
+ Files.write(file, operatorsFile.getBytes(StandardCharsets.UTF_8));
+
+ // Act
+ TraceClient client = TraceClient.builder(baseUrl(), "MyPlugin").key("k").enabled(true)
+ .serverWideConfig(plugins.toFile()).build();
+ client.report("startup");
+ client.close();
+
+ // Assert
+ assertFalse(client.isEnabled());
+ assertEquals("server-wide config: plugins/trace/config.yml", client.disabledReason());
+ assertFalse(arrived.await(300, TimeUnit.MILLISECONDS), "nothing should have been sent");
+ assertEquals(operatorsFile, new String(Files.readAllBytes(file), StandardCharsets.UTF_8),
+ "an existing switch file is never rewritten");
+ }
+
+ @Test
+ void serverWideConfig_acceptsEverySpellingOfOff(@TempDir Path plugins) throws Exception {
+ Path file = plugins.resolve("trace").resolve("config.yml");
+ Files.createDirectories(file.getParent());
+ 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(),
+ "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(),
+ "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(),
+ "a file without an enabled: line means enabled");
+ }
+
+ @Test
+ void environment_disablesAndWinsOverTheServerWideFile(@TempDir Path plugins) throws Exception {
+ // 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();
+ assertEquals("enabled: true\n", lastLine(plugins.resolve("trace").resolve("config.yml")));
+
+ 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();
+ assertFalse(client.isEnabled(), "TRACE_USAGE_REPORTING=" + off + " should disable");
+ assertEquals("environment", client.disabledReason());
+ client.report("startup");
+ client.close();
+ }
+ 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();
+ assertFalse(client.isEnabled(), "DO_NOT_TRACK=" + yes + " should disable");
+ assertEquals("environment", client.disabledReason());
+ client.report("startup");
+ client.close();
+ }
+ assertFalse(arrived.await(300, TimeUnit.MILLISECONDS), "nothing should have been sent");
+
+ // Values that are not an opt-out leave the client alone.
+ 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());
+ }
+
+ @Test
+ void disabledReason_followsThePrecedenceEnvironmentThenServerWideThenConfigThenKey(@TempDir Path plugins) throws Exception {
+ // Arrange: everything says off at once.
+ Path file = plugins.resolve("trace").resolve("config.yml");
+ Files.createDirectories(file.getParent());
+ Files.write(file, "enabled: false\n".getBytes(StandardCharsets.UTF_8));
+ environment.put("DO_NOT_TRACK", "1");
+ File pluginsDirectory = plugins.toFile();
+
+ // Act + Assert: peel the reasons off one at a time, in order.
+ assertEquals("environment",
+ TraceClient.builder(baseUrl(), "MyPlugin").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());
+ Files.write(file, "enabled: true\n".getBytes(StandardCharsets.UTF_8));
+ assertEquals("config.yml",
+ TraceClient.builder(baseUrl(), "MyPlugin").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();
+ assertNull(enabled.disabledReason());
+ assertTrue(enabled.isEnabled());
+ enabled.close();
+ }
+
+ @Test
+ void serverWideConfig_ioFailureIsLoggedFineAndTreatedAsEnabled(@TempDir Path scratch) throws Exception {
+ // Arrange
+ // A "plugins directory" that is a regular file: trace/config.yml can
+ // be neither created nor read under it. (Permission bits are no use
+ // here -- the suite may run as root.)
+ Path notADirectory = scratch.resolve("plugins");
+ Files.write(notADirectory, "not a directory".getBytes(StandardCharsets.UTF_8));
+ RecordingHandler log = new RecordingHandler();
+ Logger logger = Logger.getLogger("TraceClientTest.serverWideIo");
+ logger.setLevel(Level.ALL);
+ logger.addHandler(log);
+
+ // Act
+ TraceClient client = assertDoesNotThrow(() -> TraceClient.builder(baseUrl(), "MyPlugin").key("k")
+ .serverWideConfig(notADirectory.toFile()).logger(logger).build());
+
+ // Assert
+ assertTrue(client.isEnabled(), "a switch file that cannot be handled must not turn reporting off");
+ assertNull(client.disabledReason());
+ assertTrue(log.await(1, TimeUnit.SECONDS), "the failure should be mentioned at FINE");
+ assertEquals(Level.FINE, log.records.get(0).getLevel());
+ assertTrue(log.records.get(0).getMessage().contains("server-wide config"), log.records.get(0).getMessage());
+ client.report("startup");
+ assertTrue(arrived.await(5, TimeUnit.SECONDS), "and it still reports");
+ 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 byte[] readAll(InputStream in) throws java.io.IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];