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
61 changes: 58 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,60 @@ example `Application.version` in Unity, or the assembly's informational
version elsewhere. Pass `key`, `enabled` and `log` by name, as above, so a
key can never land in the version's place.

## Every event carries a random installation ID

Since 0.3.0, every event can also carry the tag `install`: a random ID for
the installation, so the trace server can count **distinct installations**
("active installs in the last 30 days") rather than raw events. This is the
same idea as bStats' `serverUuid`, and it is said out loud here because it is
the one thing the client sends that is the same from one event to the next.

**What it is.** A `Guid.NewGuid()`. It is not derived from anything — not a
hostname, an IP address, a MAC address, a player, an account or a path. It
identifies no person and no address; all it can say is "these events came
from the same installation". (The trace server still sees the IP address of
every HTTP request, as every web server does.)

**Where it lives.** Wherever the program decides — there is no hidden
default location, and without one of the options below no ID is made up and
nothing is written. The simplest way is a file the program chooses:

```csharp
var trace = new TraceClient(url, "my-game", "1.4.0",
key: settings.UsageReportingKey,
enabled: settings.UsageReportingEnabled,
installIdFile: Path.Combine(settings.DataDirectory, "trace-install-id"));
```

The client reads the first line of that file that is 1–255 of
`[A-Za-z0-9_.-]`; when the file is missing or holds no such line, it writes a
new random ID there (creating parent directories). If the file cannot be read
or written, a fresh ID is used in memory for that run only — the constructor
still never throws for it. `TraceClient.InstallIdFromFile(path)` does the same
on its own, but note that calling it directly reads and writes the file
whatever the opt-outs say; passing `installIdFile:` lets the client do it only
when reporting is on.

A program that already keeps settings can store the ID there instead and pass
it explicitly; `installId:` wins over `installIdFile:`:

```csharp
var trace = new TraceClient(url, "my-game", "1.4.0", key: key,
installId: settings.UsageReportingInstallId); // null or blank: none sent
```

An explicit ID is trimmed; one over 255 characters throws `ArgumentException`,
like an overlong version. An event that passes its own `install` tag keeps
it, and `install` is never added past the server's 32-tag limit.
`trace.InstallId` returns the ID in use (`null` when disabled or when there is
none), so a program can print it.

**Resetting it.** Delete the file (or the setting); the next start makes a new
one. Or put your own value in it.

**Opting out.** Every [opt-out](#turning-it-off) also stops the ID: a disabled
client never generates one, never writes one, and sends nothing.

## What `Report` promises

| Property | Meaning |
Expand Down Expand Up @@ -104,14 +158,15 @@ distribution.
## The wire format

`POST {baseUrl}/api/metrics` with `Authorization: Bearer <key>`,
`User-Agent: trace-client/0.2.0 (<application>)` and a body of
`User-Agent: trace-client/0.3.0 (<application>)` and a body of

```json
{"application":"my-game","name":"level-complete","value":42,"tags":{"version":"1.4.0"}}
{"application":"my-game","name":"level-complete","value":42,"tags":{"version":"1.4.0","install":"0f8b6c1e-3a52-4c8e-9a0d-6e2f1b7c4d90"}}
```

`value` is omitted when not given, and `tags` always holds at least
`version`. `value` is written with the invariant culture, so a German locale
`version`, plus `install` when the program gave the client an
[installation ID](#every-event-carries-a-random-installation-id). `value` is written with the invariant culture, so a German locale
still sends `2.5`. The server assigns
the timestamp. A `201` is success; anything else is logged and dropped.

Expand Down
177 changes: 161 additions & 16 deletions src/TraceClient/TraceClient.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* trace-client 0.2.0 -- https://github.com/Stephenson-Software/trace-client-csharp
* trace-client 0.3.0 -- https://github.com/Stephenson-Software/trace-client-csharp
*
* One call to report that a program was used. Copy this file into a project as
* is, or reference the project; either way there is nothing else to add.
Expand All @@ -12,8 +12,10 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;

namespace StephensonSoftware.Trace
Expand Down Expand Up @@ -45,6 +47,12 @@ namespace StephensonSoftware.Trace
/// <c>version</c> -- the third constructor argument, required, so a
/// <c>command</c> event can be tied to a release as well as a
/// <c>startup</c> one. An event's own <c>version</c> tag wins over it.</para>
/// <para>Every event also carries a random per-installation ID as the tag
/// <c>install</c> when the program supplies one -- <c>installId</c>, or a file
/// it chooses via <c>installIdFile</c> (see <see cref="InstallIdFromFile"/>) --
/// so the trace server can count distinct installations rather than raw
/// events. It is resolved only after every opt-out: a disabled client never
/// makes one up or writes one. An event's own <c>install</c> tag wins over it.</para>
/// <code>
/// var trace = new TraceClient("https://trace.example.org", "my-game", ProgramVersion,
/// key: settings.UsageReportingKey,
Expand All @@ -57,7 +65,7 @@ namespace StephensonSoftware.Trace
public sealed class TraceClient : IDisposable
{
/// <summary>The client version, as sent in the User-Agent header.</summary>
public const string Version = "0.2.0";
public const string Version = "0.3.0";

/// <summary>How many reports may wait to be sent before new ones are dropped.</summary>
public const int QueueCapacity = 256;
Expand Down Expand Up @@ -86,6 +94,13 @@ public sealed class TraceClient : IDisposable
/// <summary>Reason given when no key was supplied.</summary>
public const string ReasonNoKey = "no key";

/// <summary>The tag every event carries the installation's ID as.</summary>
public const string InstallTag = "install";

// What a stored installation ID may look like: the same characters the
// Java client accepts in server-id:, at most MaxLength of them.
private static readonly Regex InstallIdLine = new Regex("^[A-Za-z0-9_.-]{1,255}$");

// Where environment variables come from. A seam rather than
// System.Environment directly, so tests can point it at a dictionary;
// nothing else should touch it.
Expand Down Expand Up @@ -118,8 +133,17 @@ public sealed class TraceClient : IDisposable
/// <param name="key">The program's write key. Without one the client is a no-op.</param>
/// <param name="enabled">The program's own opt-out. <c>false</c> yields a client that reports nothing.</param>
/// <param name="log">Where dropped reports are mentioned. Optional; treat as debug-level.</param>
/// <param name="installId">The installation's ID, sent as the tag <c>install</c> on every
/// event. It should be random -- e.g. a <see cref="Guid.NewGuid"/> the program stores in its
/// own settings -- and never derived from a person, account or address. Trimmed; <c>null</c>
/// or blank means none. Longer than <see cref="MaxLength"/> characters throws
/// <see cref="ArgumentException"/>. Wins over <paramref name="installIdFile"/>.</param>
/// <param name="installIdFile">A file, chosen by the program, that holds the installation's
/// ID; read or created with <see cref="InstallIdFromFile"/>, but only when the client is
/// enabled, so a disabled client never writes it. Optional; there is no default location.</param>
public TraceClient(string baseUrl, string application, string version, string key = null,
bool enabled = true, Action<string> log = null)
bool enabled = true, Action<string> log = null,
string installId = null, string installIdFile = null)
{
if (string.IsNullOrWhiteSpace(baseUrl))
{
Expand All @@ -137,6 +161,11 @@ public TraceClient(string baseUrl, string application, string version, string ke
{
throw new ArgumentException("version is longer than " + MaxLength + " characters", "version");
}
string explicitInstallId = installId == null ? null : installId.Trim();
if (explicitInstallId != null && explicitInstallId.Length > MaxLength)
{
throw new ArgumentException("installId is longer than " + MaxLength + " characters", "installId");
}
try
{
_endpoint = new Uri(baseUrl.Trim().TrimEnd('/') + "/api/metrics");
Expand Down Expand Up @@ -169,6 +198,16 @@ public TraceClient(string baseUrl, string application, string version, string ke
{
return;
}
// After the opt-outs, never before: a disabled client neither makes
// up an ID nor writes one to disk.
if (!string.IsNullOrEmpty(explicitInstallId))
{
InstallId = explicitInstallId;
}
else if (!string.IsNullOrWhiteSpace(installIdFile))
{
InstallId = ReadOrCreateInstallId(installIdFile, Log);
}
_queue = new BlockingCollection<string>(new ConcurrentQueue<string>(), QueueCapacity);
_stop = new CancellationTokenSource();
_http = new HttpClient { Timeout = Timeout };
Expand All @@ -190,6 +229,92 @@ public static TraceClient Disabled()
/// </summary>
public string DisabledReason { get; private set; }

/// <summary>
/// The random per-installation ID every event carries as the tag <c>install</c>,
/// or <c>null</c> when the client is disabled or was given none (no
/// <c>installId</c> and no <c>installIdFile</c>). Decided once, in the constructor.
/// </summary>
public string InstallId { get; private set; }

/// <summary>
/// The installation ID stored in <paramref name="path"/>, creating it if needed:
/// the first line that is 1-255 of <c>[A-Za-z0-9_.-]</c> is the ID. When the file
/// is missing or holds no such line, a new random <see cref="Guid"/> is written to
/// it (parent directories created) and returned. When the file cannot be read or
/// written -- or <paramref name="path"/> is blank -- a new random ID is returned
/// for this process only. Never throws. There is no default location: the program
/// chooses where its ID lives, and deleting the file resets it.
/// </summary>
/// <remarks>
/// Calling this directly reads and writes the file whatever the opt-outs say.
/// Pass the path as the constructor's <c>installIdFile</c> instead to keep the
/// promise that a disabled client writes nothing.
/// </remarks>
public static string InstallIdFromFile(string path)
{
return ReadOrCreateInstallId(path, null);
}

private static string ReadOrCreateInstallId(string path, Action<string> log)
{
string fresh = Guid.NewGuid().ToString("D");
if (string.IsNullOrWhiteSpace(path))
{
return fresh;
}
try
{
foreach (string line in File.ReadAllLines(path))
{
string candidate = line.Trim();
if (InstallIdLine.IsMatch(candidate))
{
return candidate;
}
}
}
catch (Exception failure) when (failure is FileNotFoundException || failure is DirectoryNotFoundException)
{
// nothing stored yet; write one below
}
catch (Exception failure)
{
// A file that is there but cannot be read is not overwritten.
Note(log, "using an in-memory install ID for this process: could not read " + path + ": " + failure.Message);
return fresh;
}
try
{
string directory = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(path, fresh + "\n");
}
catch (Exception failure)
{
Note(log, "using an in-memory install ID for this process: could not write " + path + ": " + failure.Message);
}
return fresh;
}

private static void Note(Action<string> log, string message)
{
if (log == null)
{
return;
}
try
{
log("[trace] " + message);
}
catch (Exception)
{
// a throwing logger must not break the promise either
}
}

/// <summary>Whether <see cref="Report"/> will actually send anything. <c>false</c> after <see cref="Close"/> too.</summary>
public bool IsEnabled
{
Expand Down Expand Up @@ -252,7 +377,7 @@ public void Report(string name, double? value = null, IEnumerable<KeyValuePair<s
try
{
string body;
string problem = Json(_application, name, value, WithVersion(tags, _version), out body);
string problem = Json(_application, name, value, WithInstall(WithVersion(tags, _version), InstallId), out body);
if (problem != null)
{
Log("dropped " + name + ": " + problem);
Expand Down Expand Up @@ -361,18 +486,7 @@ private void Send(string body)

private void Log(string message)
{
if (_log == null)
{
return;
}
try
{
_log("[trace] " + message);
}
catch (Exception)
{
// a throwing logger must not break the promise either
}
Note(_log, message);
}

/// <summary>
Expand Down Expand Up @@ -406,6 +520,37 @@ internal static List<KeyValuePair<string, string>> WithVersion(
return merged;
}

/// <summary>
/// The tags plus <c>install</c>, unless they already carry one, there is no
/// ID, or adding it would pass <see cref="MaxTags"/>. Modifies and returns
/// <paramref name="tags"/>, which is already a copy from <see cref="WithVersion"/>.
/// </summary>
internal static List<KeyValuePair<string, string>> WithInstall(
List<KeyValuePair<string, string>> tags, string installId)
{
if (installId == null)
{
return tags;
}
int sent = 0; // counted the way Json counts: blank keys and null values are skipped
foreach (KeyValuePair<string, string> tag in tags)
{
if (tag.Key == InstallTag)
{
return tags;
}
if (!string.IsNullOrWhiteSpace(tag.Key) && tag.Value != null)
{
sent++;
}
}
if (sent < MaxTags)
{
tags.Add(new KeyValuePair<string, string>(InstallTag, installId));
}
return tags;
}

// JSON is written by hand so this file has no dependencies. The shape is
// fixed and small -- three scalars and a flat string map. Returns null
// and sets body when the report is sendable, otherwise the reason it is not.
Expand Down
2 changes: 1 addition & 1 deletion src/TraceClient/TraceClient.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<LangVersion>7.3</LangVersion>
<AssemblyName>StephensonSoftware.Trace</AssemblyName>
<RootNamespace>StephensonSoftware.Trace</RootNamespace>
<Version>0.2.0</Version>
<Version>0.3.0</Version>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
Expand Down
Loading
Loading