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`. com.github.Stephenson-Software trace-client-java - 0.1.1 + 0.2.0 ``` diff --git a/pom.xml b/pom.xml index fadeedf..9f6d79a 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ software.stephenson trace-client - 0.1.1 + 0.2.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 9858a81..4bb3085 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.1.1 -- https://github.com/Stephenson-Software/trace-client-java + * trace-client 0.2.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. @@ -8,20 +8,27 @@ */ package software.stephenson.trace; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.function.Function; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Reports usage events to a trace server, and never gets in the way of the @@ -43,18 +50,40 @@ * not the host's heap. * * - *

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

    + *
  1. the environment: {@code TRACE_USAGE_REPORTING=off} (or {@code false}, + * {@code 0}, {@code no}) or {@code DO_NOT_TRACK=1} (or {@code true}, + * {@code yes}), case-insensitive -- reason {@code environment};
  2. + *
  3. the server-wide switch, when {@link Builder#serverWideConfig(File)} + * was given: {@code enabled: false} in {@code plugins/trace/config.yml} + * -- reason {@code server-wide config: plugins/trace/config.yml};
  4. + *
  5. the program's own setting, {@link Builder#enabled(boolean) + * enabled(false)} -- reason {@code config.yml};
  6. + *
  7. no key -- reason {@code no key}.
  8. + *
+ * + *

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];