Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"));

Expand All @@ -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

Expand All @@ -54,7 +82,7 @@ plugins already vendor bStats' `Metrics.java`.
<dependency>
<groupId>com.github.Stephenson-Software</groupId>
<artifactId>trace-client-java</artifactId>
<version>0.1.1</version>
<version>0.2.0</version>
</dependency>
```

Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>software.stephenson</groupId>
<artifactId>trace-client</artifactId>
<version>0.1.1</version>
<version>0.2.0</version>
<packaging>jar</packaging>

<name>trace-client</name>
Expand Down
175 changes: 167 additions & 8 deletions src/main/java/software/stephenson/trace/TraceClient.java
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -43,18 +50,40 @@
* not the host's heap.</li>
* </ul>
*
* <p>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.
* <p>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:
*
* <ol>
* <li>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};</li>
* <li>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};</li>
* <li>the program's own setting, {@link Builder#enabled(boolean)
* enabled(false)} -- reason {@code config.yml};</li>
* <li>no key -- reason {@code no key}.</li>
* </ol>
*
* <p>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.
*
* <pre>{@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"));
*
Expand All @@ -70,19 +99,52 @@ 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<String, String> 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) {
this.endpoint = builder.baseUrl.replaceAll("/+$", "") + "/api/metrics";
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<Runnable>(QUEUE_CAPACITY),
Expand Down Expand Up @@ -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 <pluginsDirectory>/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<String> 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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
Expand Down
Loading
Loading