diff --git a/README.md b/README.md index f052fcd..fbb238f 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ export -s impact - [ ] Batch project comparison features ## Usage reporting -Usage reporting is on by default: Parpt reports that it was used to the maintainers' [trace](https://github.com/Stephenson-Software/trace) service at `https://trace.danielstephenson.dev`, sending a `startup` event carrying its name and version once per run, and a `project-created` event carrying nothing else when a project is saved. Nothing about your projects is sent: no project names, descriptions, scores or files, and no usernames, hostnames, IP addresses, paths or anything typed at the prompt. A one-line notice is printed the first time it runs on a machine (recorded in `~/.config/parpt/usage-reporting-notice-shown`). +Usage reporting is on by default: Parpt reports that it was used to the maintainers' [trace](https://github.com/Stephenson-Software/trace) service at `https://trace.danielstephenson.dev`, sending a `startup` event carrying its name and version once per run, and a `project-created` event carrying only the version when a project is saved. Nothing about your projects is sent: no project names, descriptions, scores or files, and no usernames, hostnames, IP addresses, paths or anything typed at the prompt. A one-line notice is printed the first time it runs on a machine (recorded in `~/.config/parpt/usage-reporting-notice-shown`). To turn it off, any one of these is enough: diff --git a/src/main/java/com/preponderous/parpt/trace/TraceClient.java b/src/main/java/com/preponderous/parpt/trace/TraceClient.java index 7a64d57..db35263 100644 --- a/src/main/java/com/preponderous/parpt/trace/TraceClient.java +++ b/src/main/java/com/preponderous/parpt/trace/TraceClient.java @@ -1,5 +1,5 @@ /* - * trace-client 0.2.0 -- https://github.com/Stephenson-Software/trace-client-java + * trace-client 0.4.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,12 +66,24 @@ *
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)}. + * + *
Every event carries the program's own version as the tag + * {@code version} -- the third argument to {@link #builder}, required, so a + * {@code command} event can be tied to a release as well as a + * {@code startup} one. An event's own {@code version} tag wins over it. + * *
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")
+ * TraceClient trace = TraceClient.builder("https://trace.example.org", "MyPlugin",
+ * getDescription().getVersion())
* .key(config.getString("usage-reporting.key"))
* .enabled(config.getBoolean("usage-reporting.enabled", true))
* .serverWideConfig(getDataFolder().getParentFile()) // plugins/
@@ -93,6 +105,9 @@
*/
public final class TraceClient {
+ /** This client's version, as sent in the User-Agent. */
+ public static final String VERSION = "0.4.0";
+
/** How many reports may wait to be sent before new ones are dropped. */
public static final int QUEUE_CAPACITY = 256;
@@ -123,9 +138,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.
@@ -134,16 +164,23 @@ public final class TraceClient {
private final String endpoint;
private final String key;
private final String application;
+ private final String version;
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) {
this.endpoint = builder.baseUrl.replaceAll("/+$", "") + "/api/metrics";
this.key = builder.key;
this.application = builder.application;
+ this.version = builder.version;
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,
@@ -162,15 +199,18 @@ private TraceClient(Builder builder) {
/**
* Starts describing a client for the program named {@code application},
- * reporting to the trace server at {@code baseUrl}.
+ * at {@code version}, reporting to the trace server at {@code baseUrl}.
+ * The version is sent as the tag {@code version} on every event; a blank
+ * one, or one longer than {@value #MAX_TAG_LENGTH} characters, is an
+ * {@link IllegalArgumentException}.
*/
- public static Builder builder(String baseUrl, String application) {
- return new Builder(baseUrl, application);
+ public static Builder builder(String baseUrl, String application, String version) {
+ return new Builder(baseUrl, application, version);
}
/** A client that reports nothing. Useful as a default before configuration is read. */
public static TraceClient disabled() {
- return new Builder("http://disabled.invalid", "disabled").enabled(false).build();
+ return new Builder("http://disabled.invalid", "disabled", "disabled").enabled(false).build();
}
/** Whether {@link #report} will actually send anything. */
@@ -188,11 +228,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 +264,230 @@ 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++;
+ }
+ 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;
}
- List lines = Files.readAllLines(file, StandardCharsets.UTF_8);
- for (String line : lines) {
+ if (disables == null) {
Matcher matcher = ENABLED_LINE.matcher(line);
if (matcher.find()) {
- return isOff(matcher.group(1));
+ disables = isOff(matcher.group(1));
+ }
+ }
+ }
+ 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());
}
}
- return false; // no enabled: line at all
- } catch (IOException | RuntimeException failure) {
- log("could not read server-wide config " + file + ": " + failure);
- return false;
}
+ 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;
+ }
+
+ /**
+ * The event's own tags plus {@code version}, unless the event already
+ * carries one. A copy; the caller's map is never modified.
+ */
+ static Map withVersion(Map tags, String version) {
+ 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());
+ }
+ }
+ }
+ if (!merged.containsKey("version")) {
+ merged.put("version", version);
+ }
+ return merged;
}
/** Reports that {@code name} happened. */
@@ -266,7 +503,8 @@ 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(withVersion(tags, version), serverWideTags));
executor.execute(() -> send(body));
}
@@ -303,7 +541,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);
@@ -399,20 +637,28 @@ static String quote(String text) {
public static final class Builder {
private final String baseUrl;
private final String application;
+ private final String version;
private String key;
private boolean enabled = true;
private File pluginsDirectory;
private Logger logger;
- private Builder(String baseUrl, String application) {
+ private Builder(String baseUrl, String application, String version) {
if (baseUrl == null || baseUrl.trim().isEmpty()) {
throw new IllegalArgumentException("baseUrl is required");
}
if (application == null || application.trim().isEmpty()) {
throw new IllegalArgumentException("application is required");
}
+ if (version == null || version.trim().isEmpty()) {
+ throw new IllegalArgumentException("version is required");
+ }
+ if (version.trim().length() > MAX_TAG_LENGTH) {
+ throw new IllegalArgumentException("version is longer than " + MAX_TAG_LENGTH + " characters");
+ }
this.baseUrl = baseUrl.trim();
this.application = application.trim();
+ this.version = version.trim();
}
/** The program's write key. Without one the client is a no-op. */
@@ -434,7 +680,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/main/java/com/preponderous/parpt/trace/UsageReporter.java b/src/main/java/com/preponderous/parpt/trace/UsageReporter.java
index d04770a..5622ef8 100644
--- a/src/main/java/com/preponderous/parpt/trace/UsageReporter.java
+++ b/src/main/java/com/preponderous/parpt/trace/UsageReporter.java
@@ -14,14 +14,13 @@
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
-import java.util.Collections;
/**
* Reports that Parpt was used, to the trace service, and never gets in the way of the shell.
*
*
Two events are sent, both off the calling thread through the vendored {@link TraceClient}:
- * {@code startup} once per process (tagged with the program version only) and
- * {@code project-created} when a project is saved, with no tags at all. Nothing about the
+ * {@code startup} once per process and {@code project-created} when a project is saved, each
+ * tagged with the program version only. Nothing about the
* projects themselves is sent: no names, descriptions, scores, paths or hostnames.
*
*
Reporting is on by default and switched off with {@code usage-reporting.enabled=false}
@@ -48,9 +47,10 @@ public class UsageReporter {
static final String NOTICE_MARKER_FILE = "usage-reporting-notice-shown";
/** The public page describing what trace collects and every way to turn it off. */
static final String DETAILS_URL = "https://github.com/Stephenson-Software/trace#usage-reporting";
+ /** Sent as the version when not run from a built JAR. */
+ static final String UNKNOWN_VERSION = "unknown";
private final TraceClient client;
- private final String version;
private final Path noticeMarker;
@Autowired
@@ -62,18 +62,18 @@ public UsageReporter(
}
UsageReporter(String enabled, String endpoint, String key, String version, Path noticeMarker) {
- this.client = buildClient(enabled, endpoint, key);
- this.version = version;
+ this.client = buildClient(enabled, endpoint, key,
+ version == null || version.isBlank() ? UNKNOWN_VERSION : version);
this.noticeMarker = noticeMarker;
}
- private static TraceClient buildClient(String enabled, String endpoint, String key) {
+ private static TraceClient buildClient(String enabled, String endpoint, String key, String version) {
// A blank value (an empty environment variable, say) means "default", i.e. on.
boolean on = enabled == null || enabled.isBlank() || !"false".equalsIgnoreCase(enabled.trim());
try {
// The program's own switch goes to the builder rather than short-circuiting here, so
// the client applies its precedence (environment first).
- return TraceClient.builder(endpoint, APPLICATION)
+ return TraceClient.builder(endpoint, APPLICATION, version)
.key(key)
.enabled(on)
.logger(java.util.logging.Logger.getLogger(UsageReporter.class.getName()))
@@ -93,7 +93,7 @@ static Path defaultNoticeMarker() {
return Paths.get(home, ".config", "parpt", NOTICE_MARKER_FILE);
}
- /** The version from the JAR manifest, or null when not run from a built JAR. */
+ /** The version from the JAR manifest, or null when not run from a built JAR (sent as "unknown"). */
static String resolveVersion() {
Package pkg = ParptApplication.class.getPackage();
String fromManifest = pkg == null ? null : pkg.getImplementationVersion();
@@ -118,14 +118,10 @@ void start() {
return;
}
showFirstRunNoticeOnce();
- if (version == null) {
- client.report(STARTUP_EVENT);
- } else {
- client.report(STARTUP_EVENT, null, Collections.singletonMap("version", version));
- }
+ client.report(STARTUP_EVENT);
}
- /** Reports that a project was saved. Carries nothing about the project. */
+ /** Reports that a project was saved. Carries the version only, nothing about the project. */
public void projectCreated() {
client.report(PROJECT_CREATED_EVENT);
}
@@ -141,7 +137,7 @@ public void close() {
/** The text shown once, the first time reporting runs on a machine. */
static String firstRunNotice() {
- return "Usage reporting is on: Parpt sends its name and version (a startup event) and a "
+ return "Usage reporting is on: Parpt sends its name and version with a startup event and a "
+ "project-created event (nothing else) to https://trace.danielstephenson.dev - nothing "
+ "about your projects or this machine. Turn it off with -Dusage-reporting.enabled=false, "
+ "USAGE_REPORTING_ENABLED=false or TRACE_USAGE_REPORTING=off. Details: " + DETAILS_URL;
diff --git a/src/test/java/com/preponderous/parpt/trace/UsageReporterTest.java b/src/test/java/com/preponderous/parpt/trace/UsageReporterTest.java
index db8ede0..e288f88 100644
--- a/src/test/java/com/preponderous/parpt/trace/UsageReporterTest.java
+++ b/src/test/java/com/preponderous/parpt/trace/UsageReporterTest.java
@@ -74,7 +74,7 @@ void blankKeySendsNothing(@TempDir Path home) {
}
@Test
- void sendsStartupWithVersionAndProjectCreatedWithNothingElse(@TempDir Path home) {
+ void sendsStartupAndProjectCreatedWithTheVersionOnly(@TempDir Path home) {
if (environmentOptsOut()) {
return; // the environment has switched reporting off; that path is the client's to test
}
@@ -89,7 +89,7 @@ void sendsStartupWithVersionAndProjectCreatedWithNothingElse(@TempDir Path home)
assertTrue(bodies.get(0).contains("\"name\":\"startup\""), bodies.get(0));
assertTrue(bodies.get(0).contains("\"version\":\"1.2.3\""), bodies.get(0));
assertTrue(bodies.get(1).contains("\"name\":\"project-created\""), bodies.get(1));
- assertFalse(bodies.get(1).contains("tags\":{\""), bodies.get(1));
+ assertTrue(bodies.get(1).endsWith("\"tags\":{\"version\":\"1.2.3\"}}"), bodies.get(1));
assertTrue(Files.exists(marker), "the first-run notice is recorded as shown");
}