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
40 changes: 33 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,19 +89,45 @@ There is no way to send events without it short of turning reporting off;
that is deliberate, so "how many servers" is a number that can be trusted.

**Programs that are not plugins.** Without `serverWideConfig(...)`, no ID is
made up and no hidden file is written anywhere. A program that wants to be
counted passes one it stores itself, e.g. a UUID it generated on first run
and keeps in its own config:
made up and no hidden file is written anywhere unless the program asks for
one. Since 0.6.0 a program can name a file to keep the ID in — the program
chooses it; there is no default location:

```java
TraceClient trace = TraceClient.builder(url, "mycli", version).key(key)
.enabled(settings.usageReportingEnabled())
.installIdFile(new File(dataDir, "trace-install-id")) // e.g. ~/.local/share/mycli/
.build();
```

The first time an *enabled* client starts, it writes a new random UUID to
that file (creating parent directories) and reuses it on every later run.
The first line that is an ID (`[A-Za-z0-9_.-]`, at most 255 characters) is
the one used. If the file cannot be read or written, a fresh ID is used in
memory for that run only — `build()` never throws over it, and a file that
exists but cannot be read is never overwritten. Delete the file to reset it,
or put your own value on its first line.

A program that already keeps its own settings can pass the ID instead:

```java
TraceClient.builder(url, "MyCli", version).key(key)
.installId(config.getString("usage-reporting.install-id")) // null or blank: none sent
.build();
```

An explicit `installId(...)` wins over `server-id:`. An event that passes its
own `install` tag keeps it. `trace.installId()` returns the ID in use (`null`
when disabled or when there is none), so a program can print it.
**Precedence.** An explicit `installId(...)` wins over `installIdFile(...)`,
which wins over the server-wide `server-id:` (the server-wide file then gains
no `server-id:` line; its `enabled:` switch and `tags:` still apply). An
event that passes its own `install` tag keeps it. `trace.installId()` returns
the ID in use (`null` when disabled or when there is none), so a program can
print it.

`TraceClient.installIdFromFile(file)` is the same load-or-create step on its
own, for a program that wants the ID for something else. **Called directly,
it writes the file whatever the opt-outs say** — pass the file to
`installIdFile(...)` to keep the guarantee that a disabled client never
generates, reads or writes an ID.

## What `report` promises

Expand Down Expand Up @@ -183,7 +209,7 @@ plugins already vendor bStats' `Metrics.java`.
<dependency>
<groupId>com.github.Stephenson-Software</groupId>
<artifactId>trace-client-java</artifactId>
<version>0.5.0</version>
<version>0.6.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.5.0</version>
<version>0.6.0</version>
<packaging>jar</packaging>

<name>trace-client</name>
Expand Down
120 changes: 108 additions & 12 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.5.0 -- https://github.com/Stephenson-Software/trace-client-java
* trace-client 0.6.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 @@ -16,6 +16,7 @@
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.Collections;
Expand Down Expand Up @@ -85,10 +86,14 @@
* {@code plugins/trace/config.yml}, generated with
* {@link UUID#randomUUID()} and appended to that file the first time an
* enabled client finds none -- the same idea as bStats' {@code serverUuid}.
* Anything else may pass one with {@link Builder#installId(String)};
* without either, no {@code install} tag is sent. The ID is random: it
* names no person, account or address. A disabled client never generates
* or writes one. An event's own {@code install} tag wins over it.
* Anything else may pass a file to keep it in with
* {@link Builder#installIdFile(File)}, or the ID itself with
* {@link Builder#installId(String)}. When more than one is given, the
* explicit {@code installId} wins, then {@code installIdFile}, then the
* server-wide {@code server-id:}; with none of them, no {@code install} tag
* is sent. The ID is random: it names no person, account or address. A
* disabled client never generates, reads or writes one. An event's own
* {@code install} tag wins over it.
*
* <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
Expand Down Expand Up @@ -119,7 +124,7 @@
public final class TraceClient {

/** This client's version, as sent in the User-Agent. */
public static final String VERSION = "0.5.0";
public static final String VERSION = "0.6.0";

/** How many reports may wait to be sent before new ones are dropped. */
public static final int QUEUE_CAPACITY = 256;
Expand Down Expand Up @@ -173,6 +178,9 @@ public final class TraceClient {
/** The tag every event carries the installation's ID as. */
static final String INSTALL_TAG = "install";

/** What {@link #installIdFromFile(File)} accepts as an ID on a line of its file. */
private static final Pattern INSTALL_ID_LINE = Pattern.compile("[A-Za-z0-9_.\\-]{1,255}");

private static final Pattern ENABLED_LINE = Pattern.compile("^\\s*enabled\\s*:\\s*(\\S+)");
private static final Pattern TAGS_LINE = Pattern.compile("^tags\\s*:\\s*(#.*)?$");
private static final Pattern SERVER_ID_LINE = Pattern.compile("^server-id\\s*:(.*)$");
Expand Down Expand Up @@ -263,23 +271,28 @@ public String disabledReason() {
/**
* The random per-installation ID every event carries as the tag
* {@code install}, or {@code null} when the client is disabled or has
* none (no {@link Builder#serverWideConfig(File)} and no
* {@link Builder#installId(String)}).
* none (no {@link Builder#installId(String)}, no
* {@link Builder#installIdFile(File)} and no
* {@link Builder#serverWideConfig(File)}).
*/
public String installId() {
return installId;
}

/**
* The installation's ID: the builder's, else the server-wide file's
* {@code server-id:}, else a new random one appended to that file. If the
* The installation's ID: the builder's, else the one kept in the
* builder's install ID file, else the server-wide file's
* {@code server-id:}, else a new random one appended to that file. If a
* file could not be read or written, the new ID lives in memory for this
* process only. Never throws.
*/
private String resolveInstallId(Builder builder, ServerWideConfig serverWide) {
if (builder.installId != null) {
return builder.installId;
}
if (builder.installIdFile != null) {
return loadOrCreateInstallId(builder.installIdFile, logger);
}
if (builder.pluginsDirectory == null) {
return null;
}
Expand Down Expand Up @@ -316,6 +329,63 @@ private String resolveInstallId(Builder builder, ServerWideConfig serverWide) {
}
}

/**
* The installation's ID kept in {@code file}, which the program chooses
* -- there is no default location. The first line that is an ID
* ({@code [A-Za-z0-9_.-]}, at most {@value #MAX_TAG_LENGTH} characters,
* surrounding whitespace ignored) is returned. If the file does not exist
* or holds no such line, a new random {@link UUID#randomUUID()} is written
* to it (parent directories created) and returned. Delete the file to get
* a new one.
*
* <p>Never throws: if the file exists but cannot be read, or cannot be
* written, a fresh random ID is returned for this process only, and an
* unreadable file is left as it is.
*
* <p><b>Called directly, this writes whatever the opt-outs say.</b> Pass
* the file to {@link Builder#installIdFile(File)} instead to keep the
* guarantee that a disabled client writes nothing.
*/
public static String installIdFromFile(File file) {
return loadOrCreateInstallId(file, null);
}

private static String loadOrCreateInstallId(File file, Logger logger) {
String fresh = UUID.randomUUID().toString();
if (file == null || file.getPath().trim().isEmpty()) {
return fresh;
}
Path path;
try {
// Inside the try: toPath() throws InvalidPathException for a path
// the file system cannot represent.
path = file.toPath();
for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) {
String candidate = line.trim();
if (INSTALL_ID_LINE.matcher(candidate).matches()) {
return candidate;
}
}
} catch (NoSuchFileException missing) {
path = file.toPath(); // it converted, or there would be no NoSuchFileException
} catch (IOException | RuntimeException failure) {
// There but unreadable (a directory, no permission, not UTF-8):
// never overwrite it.
log(logger, "could not read install ID file " + file + ", using an in-memory one: " + failure);
return fresh;
}
try {
Path parent = path.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.write(path, (fresh + "\n").getBytes(StandardCharsets.UTF_8));
} catch (IOException | RuntimeException failure) {
log(logger, "could not write install ID file " + file + ", using an in-memory one: " + failure);
}
return fresh;
}

private static String disabledReason(Builder builder, ServerWideConfig serverWide) {
if (environmentDisables()) {
return REASON_ENVIRONMENT;
Expand Down Expand Up @@ -714,6 +784,10 @@ private static void drain(InputStream stream) throws IOException {
}

private void log(String message) {
log(logger, message);
}

private static void log(Logger logger, String message) {
if (logger != null) {
logger.log(Level.FINE, "[trace] " + message);
}
Expand Down Expand Up @@ -778,6 +852,7 @@ public static final class Builder {
private boolean enabled = true;
private File pluginsDirectory;
private String installId;
private File installIdFile;
private Logger logger;

private Builder(String baseUrl, String application, String version) {
Expand Down Expand Up @@ -845,8 +920,9 @@ public Builder serverWideConfig(File pluginsDirectory) {
* server can count installations. It should be random -- e.g. a
* {@link UUID#randomUUID()} the program stores in its own
* configuration -- and never derived from a person, account or
* address. Takes precedence over the server-wide {@code server-id:}.
* Trimmed; {@code null} or blank means none. Without it, and without
* address. Takes precedence over {@link #installIdFile(File)} and the
* server-wide {@code server-id:}. Trimmed; {@code null} or blank means
* none. Without it, {@link #installIdFile(File)} or
* {@link #serverWideConfig(File)}, no {@code install} tag is sent.
*
* @throws IllegalArgumentException when longer than {@value TraceClient#MAX_TAG_LENGTH} characters
Expand All @@ -860,6 +936,26 @@ public Builder installId(String installId) {
return this;
}

/**
* A file to keep the installation's ID in, for programs that are not
* Spigot plugins -- e.g. {@code <user data dir>/<program>/trace-install-id}.
* {@link #build()} resolves it with {@link TraceClient#installIdFromFile(File)}
* <b>only when the client is enabled</b>, after every opt-out: the first
* run writes a new random UUID there (creating parent directories) and
* later runs reuse it. A file that cannot be read or written yields an
* in-memory ID for this process; an unreadable one is never
* overwritten; {@code build()} still never throws.
*
* <p>Precedence: an explicit {@link #installId(String)} wins over this,
* and this wins over the server-wide {@code server-id:} of
* {@link #serverWideConfig(File)} (whose file then gains no
* {@code server-id:} line). {@code null} means none.
*/
public Builder installIdFile(File installIdFile) {
this.installIdFile = installIdFile;
return this;
}

/** Where dropped reports are mentioned, at {@link Level#FINE}. Optional. */
public Builder logger(Logger logger) {
this.logger = logger;
Expand Down
Loading
Loading