From 454f531931a33a5119f4e0bdd73f7fa69e25d511 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sat, 3 Oct 2026 16:47:02 -0600 Subject: [PATCH] Tag every event with an optional random installation ID (0.3.0) Port of trace-client-java 0.5.0's per-installation ID. A new constructor overload takes a trace_client::InstallId: InstallId::of(id) for an ID the program stores itself, InstallId::fromFile(path) for a random UUID kept in a file the program chooses (TraceClient::installIdFromFile). It is resolved only after every opt-out, so a disabled client never makes up or writes an ID. Every event carries it as the tag "install" unless the event has its own or is already at the 32-tag cap. installId() returns the ID in use. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ztkfamsbEqQfcu76me5SL --- CMakeLists.txt | 2 +- README.md | 68 ++++++++- test/test_trace_client.cpp | 290 +++++++++++++++++++++++++++++++++++++ trace_client.hpp | 245 ++++++++++++++++++++++++++++--- 4 files changed, 581 insertions(+), 24 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index c6c89ce..b01b368 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ # For toolchains without make (MSVC on Windows). The client itself needs no # build step: include trace_client.hpp. This only builds and runs the tests. cmake_minimum_required(VERSION 3.10) -project(trace_client_cpp VERSION 0.2.0 LANGUAGES CXX) +project(trace_client_cpp VERSION 0.3.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/README.md b/README.md index cb2d2ff..c820fc4 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,61 @@ nothing and `disabledReason()` is `"unavailable"`. Before 0.2.0, the constructor took no version and only events tagged by hand carried one. Upgrading is one argument, after the application name. +## Every event carries a random installation ID + +Since 0.3.0, a client can tag every event with `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. 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. The Java client sends the same tag (there, a +Spigot server's `server-id:`). + +**What it is.** A random version 4 UUID from `std::random_device`. It is not +derived from anything — not a hostname, an IP or MAC address, a user, 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.** In a file the program chooses. There is no hidden default +location: without an `InstallId` argument, no ID is made up, nothing is +written anywhere and no `install` tag is sent. + +```cpp +trace_client::TraceClient trace("https://trace.danielstephenson.dev", "MyGame", + MYGAME_VERSION, settings.usageReportingKey, + trace_client::InstallId::fromFile(saveDirectory + "/trace-install-id"), + settings.usageReportingEnabled); +``` + +`TraceClient::installIdFromFile(path)` does the work: the first line of the +file that is 1–255 characters of `[A-Za-z0-9_.-]` (surrounding whitespace +ignored) is the ID. If the file is missing or has no such line, a new UUID is +written to it — parent directories created — and used. If the path exists but +cannot be read, or the write fails, a fresh UUID is used in memory for that run +only. It never throws. It can be called directly, but then it writes whatever +the opt-outs say; passing `InstallId::fromFile(path)` to the constructor +instead means it runs only for a client that is enabled. + +A program that keeps the ID in its own settings passes it instead: + +```cpp +trace_client::TraceClient trace(url, "MyGame", MYGAME_VERSION, key, + trace_client::InstallId::of(settings.installId)); // blank: none sent +``` + +An explicit ID is trimmed, wins over a file (which is then not touched), and +one over 255 bytes is handled like an overlong version: the client reports +nothing and `disabledReason()` is `"unavailable"`. An event that passes its +own `install` tag keeps it, and `install` is never added to an event that +already has 32 tags. `trace.installId()` returns the ID in use — empty when +the client is disabled or was given none — so a program can print it. + +**Resetting it.** Delete the file; the next start writes a new one. Or write +your own value into it. + +**Opting out.** Every [opt-out](#turning-it-off) also stops the ID: a disabled +client never generates one, never reads or writes the file, and sends nothing. + ## What `report` promises | Property | Meaning | @@ -64,8 +119,8 @@ starts no thread and costs nothing; the first that applies is the reason: program's own setting. Any other value, or an unset variable, leaves that setting in charge. `trace_client::environmentOptsOut()` answers the same question on its own. -- **The program's own setting:** `enabled = false` (the fifth constructor - argument). +- **The program's own setting:** `enabled = false` (the constructor argument + after the key, or after the `InstallId`). - **No key** (or a blank one). `client.disabledReason()` says which one applied — `"environment"`, `"config"` @@ -74,6 +129,8 @@ browser build, a blank base URL, application name or version, a version over 255 bytes, or the thread could not be started) — and is empty when the client reports, so a program can print it. A default-constructed `TraceClient` is disabled with reason `"config"`. +A disabled client also never makes up or writes an +[installation ID](#every-event-carries-a-random-installation-id). A program that runs on other people's machines should expose the `enabled` switch in its settings — and say so once, the first time it runs, so the @@ -149,13 +206,14 @@ The executable can be overridden before the include, e.g. `POST {baseUrl}/api/metrics` with `Authorization: Bearer `, `Content-Type: application/json; charset=utf-8`, -`User-Agent: trace-client-cpp/0.2.0 ()` and a body of +`User-Agent: trace-client-cpp/0.3.0 ()` and a body of ```json -{"application":"MyGame","name":"command","tags":{"name":"home","version":"1.4.0"}} +{"application":"MyGame","name":"command","tags":{"install":"0f8b6c1e-3a52-4c8e-9a0d-6e2f1b7c4d90","name":"home","version":"1.4.0"}} ``` -`value` is omitted when not given; `tags` always holds at least `version`. The server assigns the +`value` is omitted when not given; `tags` always holds at least `version`, and +`install` when the client was given an [installation ID](#every-event-carries-a-random-installation-id). The server assigns the timestamp. A `201` is success; anything else is passed to the `Logger` and dropped. diff --git a/test/test_trace_client.cpp b/test/test_trace_client.cpp index 747d969..9fead48 100644 --- a/test/test_trace_client.cpp +++ b/test/test_trace_client.cpp @@ -9,7 +9,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -712,6 +714,284 @@ static void closeIsPromptAndIdempotent() { client.report("after-close"); // a no-op, not a crash } +// ---------------------------------------------------------------- installation ID + +static std::string tempBase() { + std::string base = trace_client::detail::getEnvironment("TMPDIR"); + if (base.empty()) base = trace_client::detail::getEnvironment("TEMP"); + if (base.empty()) base = "/tmp"; + return base + "/trace-client-test-" + trace_client::detail::randomUuid(); +} + +static bool exists(const std::string &path) { + return trace_client::detail::pathKind(path) != trace_client::detail::PATH_MISSING; +} + +static std::string readFile(const std::string &path) { + std::ifstream in(path.c_str(), std::ios::in | std::ios::binary); + std::string content((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + return content; +} + +static void writeFile(const std::string &path, const std::string &content) { + trace_client::detail::makeParentDirectories(path); + std::ofstream out(path.c_str(), std::ios::out | std::ios::trunc | std::ios::binary); + out << content; +} + +static void removeDirectory(const std::string &path) { +#if defined(_WIN32) + _rmdir(path.c_str()); +#else + ::rmdir(path.c_str()); +#endif +} + +static bool isUuid(const std::string &id) { + if (id.size() != 36) return false; + for (std::size_t i = 0; i < id.size(); ++i) { + char c = id[i]; + if (i == 8 || i == 13 || i == 18 || i == 23) { + if (c != '-') return false; + } else if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) { + return false; + } + } + return id[14] == '4' && std::string("89ab").find(id[19]) != std::string::npos; +} + +static std::string installTagIn(const std::string &body) { + std::string marker = "\"install\":\""; + std::size_t at = body.find(marker); + if (at == std::string::npos) return std::string(); + at += marker.size(); + return body.substr(at, body.find('"', at) - at); +} + +static void randomUuidIsAVersion4Uuid() { + std::string a = trace_client::detail::randomUuid(); + std::string b = trace_client::detail::randomUuid(); + CHECK(isUuid(a)); + CHECK(isUuid(b)); + CHECK(a != b); +} + +static void installIdFromFilePersistsOnceAndReusesIt() { + std::string base = tempBase(); + std::string path = base + "/nested/dir/install-id"; + std::string first = trace_client::TraceClient::installIdFromFile(path); + CHECK(isUuid(first)); + CHECK_EQ(first + "\n", readFile(path)); + std::string second = trace_client::TraceClient::installIdFromFile(path); + CHECK_EQ(first, second); + CHECK_EQ(first + "\n", readFile(path)); // read, not rewritten + std::remove(path.c_str()); + removeDirectory(base + "/nested/dir"); + removeDirectory(base + "/nested"); + removeDirectory(base); +} + +static void installIdFromFileReadsTheFirstValidLine() { + std::string base = tempBase(); + std::string path = base + "/install-id"; + std::string content = "\n \nnot valid!\r\n my-id_1.0 \r\nsecond-id\n"; + writeFile(path, content); + CHECK_EQ(std::string("my-id_1.0"), trace_client::TraceClient::installIdFromFile(path)); + CHECK_EQ(content, readFile(path)); + // a file with no usable line gets a fresh ID written to it + writeFile(path, "has spaces\n" + std::string(trace_client::MAX_LENGTH + 1, 'x') + "\n"); + std::string fresh = trace_client::TraceClient::installIdFromFile(path); + CHECK(isUuid(fresh)); + CHECK_EQ(fresh + "\n", readFile(path)); + std::remove(path.c_str()); + removeDirectory(base); +} + +static void installIdFromFileFallsBackToMemoryWhenThePathIsUnusable() { + // A path under a regular file can never be created, even as root. + std::string base = tempBase(); + std::string blocker = base + "/a-file"; + writeFile(blocker, "keep me\n"); + std::string a = trace_client::TraceClient::installIdFromFile(blocker + "/install-id"); + std::string b = trace_client::TraceClient::installIdFromFile(blocker + "/install-id"); + CHECK(isUuid(a)); + CHECK(isUuid(b)); + CHECK(a != b); // nothing persisted + CHECK_EQ(std::string("keep me\n"), readFile(blocker)); + // A path that is a directory is not readable as a file and is left alone. + std::string directory = base + "/a-directory"; + trace_client::detail::makeParentDirectories(directory + "/"); + CHECK(isUuid(trace_client::TraceClient::installIdFromFile(directory))); + CHECK(trace_client::detail::pathKind(directory) == trace_client::detail::PATH_OTHER); + // and an empty path is no path + CHECK(isUuid(trace_client::TraceClient::installIdFromFile(""))); + std::remove(blocker.c_str()); + removeDirectory(directory); + removeDirectory(base); +} + +static void everyEventCarriesTheInstallIdFromTheFile() { + StubServer server; + std::string base = tempBase(); + std::string path = base + "/install-id"; + std::string id; + { + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "k", + trace_client::InstallId::fromFile(path)); + CHECK(client.isEnabled()); + id = client.installId(); + CHECK(isUuid(id)); + CHECK_EQ(id + "\n", readFile(path)); + client.report("startup"); + client.report("command", {{"name", "home"}}); + CHECK(server.waitFor(2, 10)); + client.close(); + CHECK_EQ(id, client.installId()); // unchanged by close() + } + std::vector got = server.received(); + CHECK_EQ(2u, got.size()); + for (std::size_t i = 0; i < got.size(); ++i) CHECK_EQ(id, installTagIn(got[i].body)); + if (!got.empty()) { + CHECK_EQ(std::string("{\"application\":\"MyGame\",\"name\":\"startup\",\"tags\":{\"install\":\"" + id + + "\",\"version\":\"1.2.3\"}}"), got[0].body); + } + // a second client on the same file reuses the ID + trace_client::TraceClient again(server.baseUrl(), "MyGame", "1.2.3", "k", + trace_client::InstallId::fromFile(path)); + CHECK_EQ(id, again.installId()); + again.close(); + std::remove(path.c_str()); + removeDirectory(base); +} + +static void aDisabledClientNeverMakesUpOrWritesAnInstallId() { + StubServer server; + std::string base = tempBase(); + std::string path = base + "/install-id"; + { + trace_client::TraceClient off(server.baseUrl(), "MyGame", "1.2.3", "k", + trace_client::InstallId::fromFile(path), false); + CHECK(!off.isEnabled()); + CHECK(off.installId().empty()); + trace_client::TraceClient keyless(server.baseUrl(), "MyGame", "1.2.3", " ", + trace_client::InstallId::fromFile(path)); + CHECK(keyless.installId().empty()); + trace_client::TraceClient unnamed(server.baseUrl(), " ", "1.2.3", "k", + trace_client::InstallId::fromFile(path)); + CHECK(unnamed.installId().empty()); + setEnv("TRACE_USAGE_REPORTING", "off"); + trace_client::TraceClient environment(server.baseUrl(), "MyGame", "1.2.3", "k", + trace_client::InstallId::fromFile(path)); + CHECK_EQ(std::string("environment"), environment.disabledReason()); + CHECK(environment.installId().empty()); + setEnv("TRACE_USAGE_REPORTING", NULL); + setEnv("DO_NOT_TRACK", "1"); + trace_client::TraceClient dnt(server.baseUrl(), "MyGame", "1.2.3", "k", + trace_client::InstallId::of("explicit")); + CHECK(dnt.installId().empty()); + setEnv("DO_NOT_TRACK", NULL); + trace_client::TraceClient nothing; + CHECK(nothing.installId().empty()); + } + CHECK(!exists(path)); + CHECK(!exists(base)); + CHECK_EQ(0u, server.received().size()); +} + +static void anExplicitInstallIdIsTrimmedAndWinsOverTheFile() { + StubServer server; + std::string base = tempBase(); + std::string path = base + "/install-id"; + trace_client::InstallId install; + install.id = " my-own-id "; + install.file = path; + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "k", install); + CHECK_EQ(std::string("my-own-id"), client.installId()); + client.report("startup"); + CHECK(server.waitFor(1, 10)); + client.close(); + CHECK(!exists(path)); // the file is not consulted, so not written + std::vector got = server.received(); + if (!got.empty()) CHECK_EQ(std::string("my-own-id"), installTagIn(got[0].body)); + trace_client::TraceClient viaOf(server.baseUrl(), "MyGame", "1.2.3", "k", trace_client::InstallId::of("abc")); + CHECK_EQ(std::string("abc"), viaOf.installId()); +} + +static void aBlankInstallIdMeansNoneAndAnOverlongOneIsRejected() { + StubServer server; + { + trace_client::TraceClient blank(server.baseUrl(), "MyGame", "1.2.3", "k", trace_client::InstallId::of(" \t ")); + CHECK(blank.isEnabled()); + CHECK(blank.installId().empty()); + blank.report("startup"); + CHECK(server.waitFor(1, 10)); + blank.close(); + trace_client::TraceClient overlong(server.baseUrl(), "MyGame", "1.2.3", "k", + trace_client::InstallId::of(std::string(trace_client::MAX_LENGTH + 1, 'x'))); + CHECK(!overlong.isEnabled()); + CHECK_EQ(std::string("unavailable"), overlong.disabledReason()); + CHECK(overlong.installId().empty()); + overlong.report("startup"); + trace_client::TraceClient longest(server.baseUrl(), "MyGame", "1.2.3", "k", + trace_client::InstallId::of(" " + std::string(trace_client::MAX_LENGTH, 'x') + " ")); + CHECK(longest.isEnabled()); + CHECK_EQ(trace_client::MAX_LENGTH, longest.installId().size()); + longest.close(); + trace_client::TraceClient none(server.baseUrl(), "MyGame", "1.2.3", "k"); + CHECK(none.installId().empty()); + } + std::vector got = server.received(); + CHECK_EQ(1u, got.size()); + if (!got.empty()) { + CHECK_EQ(std::string("{\"application\":\"MyGame\",\"name\":\"startup\",\"tags\":{\"version\":\"1.2.3\"}}"), + got[0].body); + } +} + +static void anEventsOwnInstallTagWins() { + StubServer server; + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "k", trace_client::InstallId::of("configured")); + trace_client::Tags tags; + tags["install"] = "per-event"; + client.report("startup", tags); + CHECK(server.waitFor(1, 10)); + client.close(); + std::vector got = server.received(); + if (!got.empty()) CHECK_EQ(std::string("per-event"), installTagIn(got[0].body)); + CHECK_EQ(1u, tags.size()); // the caller's tags are never modified +} + +static void theInstallTagNeverPassesTheTagCap() { + trace_client::Tags full; + for (std::size_t i = 0; i < trace_client::MAX_TAGS; ++i) full["t" + std::to_string(100 + i)] = "v"; + CHECK(trace_client::detail::withInstall(full, "id").count("install") == 0); + CHECK_EQ(trace_client::MAX_TAGS, trace_client::detail::withInstall(full, "id").size()); + trace_client::Tags room(full); + room.erase(room.begin()); + trace_client::Tags merged = trace_client::detail::withInstall(room, "id"); + CHECK_EQ(trace_client::MAX_TAGS, merged.size()); + CHECK_EQ(std::string("id"), merged["install"]); + CHECK(trace_client::detail::withInstall(room, "").count("install") == 0); + + // Through the client: 31 of the event's own tags plus "version" fill the cap. + StubServer server; + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "k", trace_client::InstallId::of("configured")); + trace_client::Tags many; + for (std::size_t i = 0; i + 1 < trace_client::MAX_TAGS; ++i) many["t" + std::to_string(100 + i)] = "v"; + client.report("startup", many); + many.erase(many.begin()); + client.report("second", many); + CHECK(server.waitFor(2, 10)); + client.close(); + std::vector got = server.received(); + CHECK_EQ(2u, got.size()); + for (std::size_t i = 0; i < got.size(); ++i) { + bool first = got[i].body.find("\"name\":\"startup\"") != std::string::npos; + CHECK_EQ(first ? std::string() : std::string("configured"), installTagIn(got[i].body)); + CHECK(got[i].body.find("\"version\":\"1.2.3\"") != std::string::npos); + } +} + int main() { #if defined(_WIN32) WSADATA wsa; @@ -750,6 +1030,16 @@ int main() { {"anEventsOwnVersionTagWinsOverTheProgramVersion", anEventsOwnVersionTagWinsOverTheProgramVersion}, {"theCallersTagsAreNeverModified", theCallersTagsAreNeverModified}, {"aBlankOrOverlongVersionIsRejected", aBlankOrOverlongVersionIsRejected}, + {"randomUuidIsAVersion4Uuid", randomUuidIsAVersion4Uuid}, + {"installIdFromFilePersistsOnceAndReusesIt", installIdFromFilePersistsOnceAndReusesIt}, + {"installIdFromFileReadsTheFirstValidLine", installIdFromFileReadsTheFirstValidLine}, + {"installIdFromFileFallsBackToMemoryWhenThePathIsUnusable", installIdFromFileFallsBackToMemoryWhenThePathIsUnusable}, + {"everyEventCarriesTheInstallIdFromTheFile", everyEventCarriesTheInstallIdFromTheFile}, + {"aDisabledClientNeverMakesUpOrWritesAnInstallId", aDisabledClientNeverMakesUpOrWritesAnInstallId}, + {"anExplicitInstallIdIsTrimmedAndWinsOverTheFile", anExplicitInstallIdIsTrimmedAndWinsOverTheFile}, + {"aBlankInstallIdMeansNoneAndAnOverlongOneIsRejected", aBlankInstallIdMeansNoneAndAnOverlongOneIsRejected}, + {"anEventsOwnInstallTagWins", anEventsOwnInstallTagWins}, + {"theInstallTagNeverPassesTheTagCap", theInstallTagNeverPassesTheTagCap}, }; const std::size_t count = sizeof tests / sizeof tests[0]; for (std::size_t i = 0; i < count; ++i) { diff --git a/trace_client.hpp b/trace_client.hpp index 4649450..117d41c 100644 --- a/trace_client.hpp +++ b/trace_client.hpp @@ -1,5 +1,5 @@ /* - * trace-client 0.2.0 (C++) -- https://github.com/Stephenson-Software/trace-client-cpp + * trace-client 0.3.0 (C++) -- https://github.com/Stephenson-Software/trace-client-cpp * * One call to report that a program was used. Copy this header into a project * as is; there is nothing else to add. C++11 or later, no library to link @@ -10,7 +10,7 @@ #ifndef TRACE_CLIENT_HPP #define TRACE_CLIENT_HPP -#define TRACE_CLIENT_VERSION "0.2.0" +#define TRACE_CLIENT_VERSION "0.3.0" // The executable that carries a report over HTTPS: the system's own curl // (shipped with macOS, with Windows 10 1803 and later, and with nearly every @@ -21,21 +21,31 @@ #endif #include +#include #include #include #include +#include #include #include #include +#include #include #include #include #include #include +#include #include #include #include +#include +#include +#if defined(_WIN32) +#include +#endif + #if defined(TRACE_CLIENT_USE_LIBCURL) #include #elif defined(__EMSCRIPTEN__) @@ -252,6 +262,93 @@ inline std::string number(double value) { * The report body. JSON is written by hand so this file has no dependencies; * the shape is fixed and small, three scalars and a flat string map. */ +/** The tag every event carries the installation's ID as. */ +static const char *const INSTALL_TAG = "install"; + +/** + * The tags plus "install", unless they already carry one, there is no ID, or + * adding it would pass MAX_TAGS. A copy; the caller's tags are never modified. + */ +inline Tags withInstall(const Tags &tags, const std::string &installId) { + if (installId.empty() || tags.count(INSTALL_TAG) != 0 || tags.size() >= MAX_TAGS) return tags; + Tags merged(tags); + merged.insert(Tags::value_type(INSTALL_TAG, installId)); + return merged; +} + +/** Whether text is a usable installation ID: 1..MAX_LENGTH of [A-Za-z0-9_.-]. */ +inline bool isValidInstallId(const std::string &text) { + if (text.empty() || text.size() > MAX_LENGTH) return false; + for (std::size_t i = 0; i < text.size(); ++i) { + unsigned char c = static_cast(text[i]); + bool ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') + || c == '_' || c == '.' || c == '-'; + if (!ok) return false; + } + return true; +} + +/** + * A random (version 4) UUID, lower-case 8-4-4-4-12 hex. Derived from nothing + * but std::random_device; falls back to the clock if that is unavailable. + */ +inline std::string randomUuid() { + unsigned long long hi = 0, lo = 0; + try { + std::random_device device; + std::seed_seq seed{device(), device(), device(), device(), device(), device(), device(), device()}; + std::mt19937_64 engine(seed); + hi = engine(); + lo = engine(); + } catch (...) { + std::mt19937_64 engine(static_cast( + std::chrono::high_resolution_clock::now().time_since_epoch().count())); + hi = engine(); + lo = engine(); + } + hi = (hi & ~0xF000ULL) | 0x4000ULL; // version 4 + lo = (lo & 0x3FFFFFFFFFFFFFFFULL) | 0x8000000000000000ULL; // RFC 4122 variant + char out[37]; + std::snprintf(out, sizeof out, "%08llx-%04llx-%04llx-%04llx-%012llx", + (hi >> 32) & 0xFFFFFFFFULL, (hi >> 16) & 0xFFFFULL, hi & 0xFFFFULL, + (lo >> 48) & 0xFFFFULL, lo & 0xFFFFFFFFFFFFULL); + return std::string(out); +} + +enum PathKind { PATH_MISSING, PATH_FILE, PATH_OTHER }; + +/** Missing (may be created), a regular file, or anything else (left alone). */ +inline PathKind pathKind(const std::string &path) { +#if defined(_WIN32) + struct _stat info; + if (_stat(path.c_str(), &info) != 0) { + return errno == ENOENT || errno == ENOTDIR ? PATH_MISSING : PATH_OTHER; + } + return (info.st_mode & _S_IFMT) == _S_IFREG ? PATH_FILE : PATH_OTHER; +#else + struct stat info; + if (::stat(path.c_str(), &info) != 0) { + return errno == ENOENT || errno == ENOTDIR ? PATH_MISSING : PATH_OTHER; + } + return S_ISREG(info.st_mode) ? PATH_FILE : PATH_OTHER; +#endif +} + +/** Creates every missing directory above path. Failures are ignored: the write that follows says. */ +inline void makeParentDirectories(const std::string &path) { + for (std::size_t i = 1; i < path.size(); ++i) { + char c = path[i]; +#if defined(_WIN32) + if (c != '/' && c != '\\') continue; + if (path[i - 1] == ':') continue; // a drive, "C:\\" + _mkdir(path.substr(0, i).c_str()); +#else + if (c != '/') continue; + ::mkdir(path.substr(0, i).c_str(), 0777); +#endif + } +} + inline std::string json(const std::string &application, const std::string &name, bool hasValue, double value, const Tags &tags) { std::string out = "{\"application\":" + quote(cleanUtf8(application, std::string::npos)); @@ -771,6 +868,29 @@ inline bool environmentOptsOut() { return dnt == "1" || dnt == "true" || dnt == "yes"; } +/** + * Where an installation's ID comes from, for the constructor that takes one. + * An explicit id wins over a file; with neither, no "install" tag is sent. + */ +struct InstallId { + /** An ID the program stores itself. Trimmed; blank means none. */ + std::string id; + /** A file installIdFromFile keeps a random UUID in. Blank means none. */ + std::string file; + + static InstallId of(const std::string &id) { + InstallId install; + install.id = id; + return install; + } + + static InstallId fromFile(const std::string &path) { + InstallId install; + install.file = path; + return install; + } +}; + /** * Reports usage events to a trace server, and never gets in the way of the * program doing the reporting. @@ -797,8 +917,19 @@ inline bool environmentOptsOut() { * bytes, is treated like a blank base URL or application: nothing throws, the * client reports nothing and disabledReason() is REASON_UNAVAILABLE. * + * Every event can also carry a random per-installation ID as the tag + * "install", so the trace server can count installations rather than raw + * events. There is no hidden default: without an InstallId argument no + * "install" tag is sent. InstallId::fromFile(path) keeps a random UUID in a + * file the program chooses (see installIdFromFile); InstallId::of(id) passes + * one the program stores itself. Either is resolved only once every opt-out + * above has been checked, so a disabled client never makes up an ID or writes + * one. An event's own "install" tag wins, and it is never added past MAX_TAGS. + * * trace_client::TraceClient trace("https://trace.example.org", "MyGame", MYGAME_VERSION, - * settings.key, settings.usageReporting); + * settings.key, + * trace_client::InstallId::fromFile(dataDir + "/trace-install-id"), + * settings.usageReporting); * trace.report("startup"); * ... * trace.close(); // or let the destructor do it @@ -811,27 +942,64 @@ class TraceClient { /** * A client for version (the program's own, sent as the tag "version" on * every event) of application, reporting to the trace server at baseUrl. + * Its events carry no "install" tag; see the overload taking an InstallId. */ TraceClient(const std::string &baseUrl, const std::string &application, const std::string &version, const std::string &key, bool enabled = true, Logger logger = Logger()) { + init(baseUrl, application, version, key, InstallId(), enabled, logger); + } + + /** + * As above, and every event carries the installation's ID (install) as + * the tag "install". The explicit ID is trimmed; a blank one means none, + * and one longer than MAX_LENGTH bytes is treated like an overlong + * version: nothing throws, the client reports nothing and + * disabledReason() is REASON_UNAVAILABLE. + */ + TraceClient(const std::string &baseUrl, const std::string &application, const std::string &version, + const std::string &key, const InstallId &install, bool enabled = true, Logger logger = Logger()) { + init(baseUrl, application, version, key, install, enabled, logger); + } + + /** + * The installation's ID kept in the file at path: the first line that is + * 1..MAX_LENGTH characters of [A-Za-z0-9_.-] (surrounding whitespace + * ignored). When the file is missing, or has no such line, a new random + * UUID is written to it (parent directories created) and returned. When + * the path exists but cannot be read, or the write fails, a new random + * UUID is returned for this process only and nothing is written. Never + * throws. + * + * Calling this directly writes the file whatever the opt-outs say; pass + * InstallId::fromFile(path) to the constructor instead so that a disabled + * client never writes it. + */ + static std::string installIdFromFile(const std::string &path) { + std::string fresh; try { - version_ = detail::trim(version); - if (environmentOptsOut()) { - reason_ = REASON_ENVIRONMENT; - } else if (!enabled) { - reason_ = REASON_CONFIG; - } else if (detail::isBlank(key)) { - reason_ = REASON_NO_KEY; - } else if (detail::isBlank(baseUrl) || detail::isBlank(application) || version_.empty() - || version_.size() > MAX_LENGTH) { - reason_ = REASON_UNAVAILABLE; - } else { - start(baseUrl, application, key, logger); + fresh = detail::randomUuid(); + detail::PathKind kind = detail::pathKind(path); + if (path.empty() || kind == detail::PATH_OTHER) return fresh; + if (kind == detail::PATH_FILE) { + std::ifstream in(path.c_str(), std::ios::in | std::ios::binary); + if (!in) return fresh; // there but unreadable: never overwritten + std::string line; + while (std::getline(in, line)) { + std::string id = detail::trim(line); + if (detail::isValidInstallId(id)) return id; + } + if (in.bad()) return fresh; + } + detail::makeParentDirectories(path); + std::ofstream out(path.c_str(), std::ios::out | std::ios::trunc | std::ios::binary); + if (out) { + out << fresh << '\n'; + out.flush(); } } catch (...) { - shared_.reset(); - reason_ = REASON_UNAVAILABLE; + // an ID that only lives in memory is still an ID } + return fresh; } ~TraceClient() { close(); } @@ -845,6 +1013,13 @@ class TraceClient { */ const std::string &disabledReason() const { return reason_; } + /** + * The installation's ID every event carries as the tag "install": empty + * when the client is disabled or was given no InstallId. Unchanged by + * close(). + */ + const std::string &installId() const { return installId_; } + /** Reports that name happened, with optional tags. Returns immediately. */ void report(const std::string &name, const Tags &tags = Tags()) { enqueue(name, false, 0.0, tags); } @@ -894,6 +1069,39 @@ class TraceClient { TraceClient(const TraceClient &); TraceClient &operator=(const TraceClient &); + void init(const std::string &baseUrl, const std::string &application, const std::string &version, + const std::string &key, const InstallId &install, bool enabled, const Logger &logger) { + try { + version_ = detail::trim(version); + std::string explicitId = detail::trim(install.id); + if (environmentOptsOut()) { + reason_ = REASON_ENVIRONMENT; + } else if (!enabled) { + reason_ = REASON_CONFIG; + } else if (detail::isBlank(key)) { + reason_ = REASON_NO_KEY; + } else if (detail::isBlank(baseUrl) || detail::isBlank(application) || version_.empty() + || version_.size() > MAX_LENGTH || explicitId.size() > MAX_LENGTH) { + reason_ = REASON_UNAVAILABLE; + } else { + start(baseUrl, application, key, logger); + // After the opt-outs, never before: a disabled client neither + // makes up an ID nor writes one to disk. + if (shared_) { + if (!explicitId.empty()) { + installId_ = explicitId; + } else if (!detail::isBlank(install.file)) { + installId_ = installIdFromFile(install.file); + } + } + } + } catch (...) { + shared_.reset(); + installId_.clear(); + reason_ = REASON_UNAVAILABLE; + } + } + void start(const std::string &baseUrl, const std::string &application, const std::string &key, const Logger &logger) { #if defined(TRACE_CLIENT_CAN_SEND) @@ -930,7 +1138,7 @@ class TraceClient { if (!shared_ || detail::isBlank(name)) return; try { std::string body = detail::json(shared_->application, name, hasValue, value, - detail::withVersion(tags, version_)); + detail::withInstall(detail::withVersion(tags, version_), installId_)); bool full = false; { std::lock_guard lock(shared_->mutex); @@ -950,6 +1158,7 @@ class TraceClient { std::thread thread_; std::string version_; std::string reason_; + std::string installId_; }; } // namespace trace_client