From 82194026945c5b1ce7e9ed363756fc906735aa56 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Tue, 29 Sep 2026 21:27:51 -0600 Subject: [PATCH 1/2] Carry the program's version on every event (0.2.0) The constructor now takes the program's own version as a required argument after the application name. It is trimmed and sent as the tag "version" on every event; an event's own "version" tag wins, and the caller's tags are never modified. A blank version, or one over 255 bytes, disables the client with reason "unavailable", matching how a blank base URL or application name is handled, since nothing in the client throws. The default-constructed (disabled) client holds the placeholder version "disabled". The User-Agent still names the client's version. Breaking change: TRACE_CLIENT_VERSION 0.1.0 -> 0.2.0. Mirrors Stephenson-Software/trace-client-java#5. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AuTszT2gqv7nLVYrtKD4ji --- CMakeLists.txt | 2 +- README.md | 30 +++++++-- test/test_trace_client.cpp | 132 +++++++++++++++++++++++++++++-------- trace_client.hpp | 52 ++++++++++++--- 4 files changed, 173 insertions(+), 43 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6ebf523..c6c89ce 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 CXX) +project(trace_client_cpp VERSION 0.2.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) diff --git a/README.md b/README.md index f414e31..4812c56 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,10 @@ all three speak the same wire format and make the same promises. #include "trace_client.hpp" trace_client::TraceClient trace("https://trace.danielstephenson.dev", "MyGame", + MYGAME_VERSION, // the program's own version, e.g. "1.4.0" settings.usageReportingKey, settings.usageReportingEnabled); -trace.report("startup", {{"version", "1.4.0"}}); +trace.report("startup"); trace.report("level-complete", 3.0, {{"level", "forest"}}); // on shutdown -- also before a short-lived program exits, so the event is @@ -24,6 +25,22 @@ trace.report("level-complete", 3.0, {{"level", "forest"}}); trace.close(); ``` +## Every event carries the program's version + +The third constructor argument is the program's own version, and it is +required. Every event the client sends — `startup`, `command`, anything +else — carries it as the tag `version`, so every event can be tied to a +release, not just `startup`. An event that passes its own `version` tag keeps +it, and the tags passed to `report` are never modified. There is no need to +tag `startup` by hand any more. + +The version is trimmed. Since nothing in the client throws, a blank one, or +one over 255 bytes, is handled like a blank base URL: the client reports +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. + ## What `report` promises | Property | Meaning | @@ -47,13 +64,14 @@ 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 fourth constructor +- **The program's own setting:** `enabled = false` (the fifth constructor argument). - **No key** (or a blank one). `client.disabledReason()` says which one applied — `"environment"`, `"config"` or `"no key"`, or `"unavailable"` when the client cannot send at all (a -browser build, a blank base URL or application name, or the thread could +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"`. @@ -130,13 +148,13 @@ 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.1.0 ()` and a body of +`User-Agent: trace-client-cpp/0.2.0 ()` and a body of ```json -{"application":"MyGame","name":"startup","tags":{"version":"1.4.0"}} +{"application":"MyGame","name":"command","tags":{"name":"home","version":"1.4.0"}} ``` -`value` and `tags` are omitted when not given. The server assigns the +`value` is omitted when not given; `tags` always holds at least `version`. 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 ea372c3..3193b99 100644 --- a/test/test_trace_client.cpp +++ b/test/test_trace_client.cpp @@ -245,7 +245,7 @@ static void cleanEnvironment() { static void reportPostsTheEventToTheMetricsEndpointWithTheKey() { StubServer server; - trace_client::TraceClient client(server.baseUrl(), "MyGame", "secret-key"); + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "secret-key"); CHECK(client.isEnabled()); client.report("startup"); CHECK(server.waitFor(1, 10)); @@ -257,12 +257,12 @@ static void reportPostsTheEventToTheMetricsEndpointWithTheKey() { CHECK_EQ(std::string("/api/metrics"), got[0].path); CHECK_EQ(std::string("Bearer secret-key"), got[0].authorization); CHECK_EQ(std::string("application/json; charset=utf-8"), got[0].contentType); - CHECK_EQ(std::string("{\"application\":\"MyGame\",\"name\":\"startup\"}"), got[0].body); + CHECK_EQ(std::string("{\"application\":\"MyGame\",\"name\":\"startup\",\"tags\":{\"version\":\"1.2.3\"}}"), got[0].body); } static void reportCarriesValueAndTagsWhenGiven() { StubServer server; - trace_client::TraceClient client(server.baseUrl() + "/", "MyGame", "k"); + trace_client::TraceClient client(server.baseUrl() + "/", "MyGame", "1.2.3", "k"); client.report("command", 1.5, {{"name", "home"}, {"version", "1.0"}}); CHECK(server.waitFor(1, 10)); client.close(); @@ -275,7 +275,7 @@ static void reportCarriesValueAndTagsWhenGiven() { static void userAgentNamesTheClientVersion() { StubServer server; - trace_client::TraceClient client(server.baseUrl(), "MyGame", "k"); + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "k"); client.report("startup"); CHECK(server.waitFor(1, 10)); client.close(); @@ -287,7 +287,7 @@ static void userAgentNamesTheClientVersion() { static void reportReturnsBeforeTheServerAnswers() { StubServer server; server.hold(); - trace_client::TraceClient client(server.baseUrl(), "MyGame", "k"); + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "k"); std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); for (int i = 0; i < 10; ++i) client.report("startup"); CHECK(secondsSince(start) < 0.1); @@ -302,7 +302,7 @@ static void reportDoesNotThrowWhenNothingIsListening() { port = std::atoi(closed.baseUrl().substr(closed.baseUrl().rfind(':') + 1).c_str()); } Log log; - trace_client::TraceClient client("http://127.0.0.1:" + std::to_string(port), "MyGame", "k", true, log.logger()); + trace_client::TraceClient client("http://127.0.0.1:" + std::to_string(port), "MyGame", "1.2.3", "k", true, log.logger()); client.report("startup"); std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); client.close(); @@ -314,7 +314,7 @@ static void rejectedKeyIsLoggedNotThrown() { StubServer server; server.status = 401; Log log; - trace_client::TraceClient client(server.baseUrl(), "MyGame", "wrong", true, log.logger()); + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "wrong", true, log.logger()); client.report("startup"); client.close(); CHECK_EQ(1u, server.received().size()); @@ -329,7 +329,7 @@ static void missingCurlIsAnUnreachableServer() { setEnv("PATH", "/nonexistent-trace-client-test"); Log log; { - trace_client::TraceClient client(server.baseUrl(), "MyGame", "k", true, log.logger()); + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "k", true, log.logger()); CHECK(client.isEnabled()); client.report("startup"); std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); @@ -345,11 +345,11 @@ static void missingCurlIsAnUnreachableServer() { static void disabledClientSendsNothing() { StubServer server; { - trace_client::TraceClient off(server.baseUrl(), "MyGame", "k", false); + trace_client::TraceClient off(server.baseUrl(), "MyGame", "1.2.3", "k", false); CHECK(!off.isEnabled()); CHECK_EQ(std::string("config"), off.disabledReason()); off.report("startup"); - trace_client::TraceClient keyless(server.baseUrl(), "MyGame", " "); + trace_client::TraceClient keyless(server.baseUrl(), "MyGame", "1.2.3", " "); CHECK(!keyless.isEnabled()); CHECK_EQ(std::string("no key"), keyless.disabledReason()); keyless.report("startup"); @@ -358,7 +358,7 @@ static void disabledClientSendsNothing() { CHECK_EQ(std::string("config"), nothing.disabledReason()); nothing.report("startup"); nothing.close(); - trace_client::TraceClient unnamed(server.baseUrl(), " ", "k"); + trace_client::TraceClient unnamed(server.baseUrl(), " ", "1.2.3", "k"); CHECK_EQ(std::string("unavailable"), unnamed.disabledReason()); } std::this_thread::sleep_for(std::chrono::milliseconds(200)); @@ -367,7 +367,7 @@ static void disabledClientSendsNothing() { static void disabledReasonIsEmptyWhenTheClientReports() { StubServer server; - trace_client::TraceClient client(server.baseUrl(), "MyGame", "k"); + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "k"); CHECK(client.disabledReason().empty()); } @@ -377,7 +377,7 @@ static void environmentOptOutsDisableForEveryAcceptedValue() { for (std::size_t i = 0; i < sizeof off / sizeof off[0]; ++i) { cleanEnvironment(); setEnv("TRACE_USAGE_REPORTING", off[i]); - trace_client::TraceClient client(server.baseUrl(), "MyGame", "k"); + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "k"); CHECK(!client.isEnabled()); CHECK_EQ(std::string("environment"), client.disabledReason()); client.report("startup"); @@ -386,7 +386,7 @@ static void environmentOptOutsDisableForEveryAcceptedValue() { for (std::size_t i = 0; i < sizeof dnt / sizeof dnt[0]; ++i) { cleanEnvironment(); setEnv("DO_NOT_TRACK", dnt[i]); - trace_client::TraceClient client(server.baseUrl(), "MyGame", "k"); + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "k"); CHECK(!client.isEnabled()); CHECK_EQ(std::string("environment"), client.disabledReason()); } @@ -402,7 +402,7 @@ static void otherEnvironmentValuesLeaveTheProgramSettingInCharge() { cleanEnvironment(); setEnv("TRACE_USAGE_REPORTING", values[i]); setEnv("DO_NOT_TRACK", i == 0 ? "0" : "false"); - trace_client::TraceClient client(server.baseUrl(), "MyGame", "k"); + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "k"); CHECK(client.isEnabled()); } cleanEnvironment(); @@ -410,9 +410,9 @@ static void otherEnvironmentValuesLeaveTheProgramSettingInCharge() { static void environmentWinsOverTheConfigFlagAndTheKey() { setEnv("DO_NOT_TRACK", "1"); - trace_client::TraceClient off("http://127.0.0.1:9", "MyGame", "k", false); + trace_client::TraceClient off("http://127.0.0.1:9", "MyGame", "1.2.3", "k", false); CHECK_EQ(std::string("environment"), off.disabledReason()); - trace_client::TraceClient keyless("http://127.0.0.1:9", "MyGame", ""); + trace_client::TraceClient keyless("http://127.0.0.1:9", "MyGame", "1.2.3", ""); CHECK_EQ(std::string("environment"), keyless.disabledReason()); CHECK(trace_client::environmentOptsOut()); cleanEnvironment(); @@ -421,7 +421,7 @@ static void environmentWinsOverTheConfigFlagAndTheKey() { static void reportIgnoresABlankName() { StubServer server; - trace_client::TraceClient client(server.baseUrl(), "MyGame", "k"); + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "k"); client.report(""); client.report(" "); client.close(); @@ -469,7 +469,7 @@ static void nothingTheProgramPassesCanChangeWhereOrWhatCurlSends() { StubServer server; // A newline in any value would, unescaped, start a new curl option. std::string sneaky = "My\"Game\nurl = \"http://127.0.0.1:1/\"\n"; - trace_client::TraceClient client(server.baseUrl(), sneaky, "k\r\nX-Injected: 1"); + trace_client::TraceClient client(server.baseUrl(), sneaky, "1.2.3", "k\r\nX-Injected: 1"); client.report("startup\n", {{"t\\", "\"\n-o /tmp/x"}}); CHECK(server.waitFor(1, 10)); client.close(); @@ -480,14 +480,14 @@ static void nothingTheProgramPassesCanChangeWhereOrWhatCurlSends() { CHECK(lower(got[0].headerLines[i]).find("x-injected") != 0); } CHECK_EQ(trace_client::detail::json("My\"Game\nurl = \"http://127.0.0.1:1/\"", "startup\n", false, 0, - {{"t\\", "\"\n-o /tmp/x"}}), + {{"t\\", "\"\n-o /tmp/x"}, {"version", "1.2.3"}}), got[0].body); } static void aNewlineInTheBaseUrlCannotAddACurlOption() { StubServer server; // Unescaped, the second line would be an option of its own. - trace_client::TraceClient client(server.baseUrl() + "\nheader = X-Injected:1\n#", "MyGame", "k"); + trace_client::TraceClient client(server.baseUrl() + "\nheader = X-Injected:1\n#", "MyGame", "1.2.3", "k"); client.report("startup"); client.close(); std::vector got = server.received(); @@ -502,7 +502,7 @@ static void queueIsBoundedAndDropsRatherThanGrows() { StubServer server; server.hold(); // the sender waits on the first report Log log; - trace_client::TraceClient client(server.baseUrl(), "MyGame", "k", true, log.logger()); + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "k", true, log.logger()); const std::size_t flood = trace_client::QUEUE_CAPACITY * 3; for (std::size_t i = 0; i < flood; ++i) client.report("flood"); CHECK(log.count("queue full") >= flood - trace_client::QUEUE_CAPACITY - 1); @@ -516,7 +516,7 @@ static void closeSendsWhatWasJustQueuedBeforeStopping() { // a lost one visible. StubServer server; for (int i = 0; i < 20; ++i) { - trace_client::TraceClient client(server.baseUrl(), "MyCli", "k"); + trace_client::TraceClient client(server.baseUrl(), "MyCli", "1.2.3", "k"); client.report("startup", {{"run", std::to_string(i)}}); client.close(); } @@ -526,7 +526,7 @@ static void closeSendsWhatWasJustQueuedBeforeStopping() { static void destructorClosesToo() { StubServer server; { - trace_client::TraceClient client(server.baseUrl(), "MyCli", "k"); + trace_client::TraceClient client(server.baseUrl(), "MyCli", "1.2.3", "k"); client.report("startup"); } CHECK_EQ(1u, server.received().size()); @@ -535,7 +535,7 @@ static void destructorClosesToo() { static void closeReturnsWithinTheTimeoutWhenTheServerHangs() { StubServer server; server.hold(); // never answers while held - trace_client::TraceClient client(server.baseUrl(), "MyCli", "k"); + trace_client::TraceClient client(server.baseUrl(), "MyCli", "1.2.3", "k"); client.report("startup"); client.report("second"); std::this_thread::sleep_for(std::chrono::milliseconds(200)); @@ -546,9 +546,85 @@ static void closeReturnsWithinTheTimeoutWhenTheServerHangs() { server.release(); } +static void reportTagsEveryEventWithTheProgramVersionTrimmed() { + StubServer server; + trace_client::TraceClient client(server.baseUrl(), "MyGame", " 2.0.0-SNAPSHOT ", "k"); + CHECK(client.isEnabled()); + client.report("command", {{"name", "home"}}); + CHECK(server.waitFor(1, 10)); + client.close(); + std::vector got = server.received(); + if (got.empty()) { CHECK(!got.empty()); return; } + CHECK_EQ(std::string("{\"application\":\"MyGame\",\"name\":\"command\"," + "\"tags\":{\"name\":\"home\",\"version\":\"2.0.0-SNAPSHOT\"}}"), got[0].body); + // the program's version is a tag; the User-Agent still names the client's + CHECK_EQ(std::string("trace-client-cpp/" TRACE_CLIENT_VERSION " (MyGame)"), got[0].userAgent); +} + +static void anEventsOwnVersionTagWinsOverTheProgramVersion() { + StubServer server; + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "k"); + trace_client::Tags tags; + tags["version"] = "9.9.9"; + client.report("startup", tags); + CHECK(server.waitFor(1, 10)); + client.close(); + std::vector got = server.received(); + if (got.empty()) { CHECK(!got.empty()); return; } + CHECK_EQ(std::string("{\"application\":\"MyGame\",\"name\":\"startup\",\"tags\":{\"version\":\"9.9.9\"}}"), + got[0].body); + CHECK_EQ(1u, tags.size()); + CHECK_EQ(std::string("9.9.9"), tags["version"]); +} + +static void theCallersTagsAreNeverModified() { + StubServer server; + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "k"); + trace_client::Tags tags; + tags["name"] = "home"; + client.report("command", tags); + client.report("command", 1.0, tags); + CHECK(server.waitFor(2, 10)); + client.close(); + CHECK_EQ(1u, tags.size()); + CHECK(tags.find("version") == tags.end()); + + trace_client::Tags merged = trace_client::detail::withVersion(tags, "1.2.3"); + CHECK_EQ(1u, tags.size()); + CHECK_EQ(std::string("1.2.3"), merged["version"]); + CHECK_EQ(std::string("home"), merged["name"]); + CHECK_EQ(std::string("1.2.3"), trace_client::detail::withVersion(trace_client::Tags(), "1.2.3")["version"]); +} + +static void aBlankOrOverlongVersionIsRejected() { + // Nothing in the client throws: a bad version is a bad argument like a + // blank application name -- the client reports nothing, reason "unavailable". + StubServer server; + { + trace_client::TraceClient empty(server.baseUrl(), "MyGame", "", "k"); + CHECK(!empty.isEnabled()); + CHECK_EQ(std::string("unavailable"), empty.disabledReason()); + empty.report("startup"); + trace_client::TraceClient blank(server.baseUrl(), "MyGame", " \t ", "k"); + CHECK(!blank.isEnabled()); + CHECK_EQ(std::string("unavailable"), blank.disabledReason()); + blank.report("startup"); + trace_client::TraceClient overlong(server.baseUrl(), "MyGame", std::string(trace_client::MAX_LENGTH + 1, '9'), "k"); + CHECK(!overlong.isEnabled()); + CHECK_EQ(std::string("unavailable"), overlong.disabledReason()); + overlong.report("startup"); + // exactly the limit, once trimmed, is accepted + trace_client::TraceClient longest(server.baseUrl(), "MyGame", + " " + std::string(trace_client::MAX_LENGTH, '9') + " ", "k"); + CHECK(longest.isEnabled()); + longest.close(); + } + CHECK_EQ(0u, server.received().size()); +} + static void closeIsPromptAndIdempotent() { StubServer server; - trace_client::TraceClient client(server.baseUrl(), "MyGame", "k"); + trace_client::TraceClient client(server.baseUrl(), "MyGame", "1.2.3", "k"); client.report("startup"); std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); client.close(); @@ -588,6 +664,10 @@ int main() { {"destructorClosesToo", destructorClosesToo}, {"closeReturnsWithinTheTimeoutWhenTheServerHangs", closeReturnsWithinTheTimeoutWhenTheServerHangs}, {"closeIsPromptAndIdempotent", closeIsPromptAndIdempotent}, + {"reportTagsEveryEventWithTheProgramVersionTrimmed", reportTagsEveryEventWithTheProgramVersionTrimmed}, + {"anEventsOwnVersionTagWinsOverTheProgramVersion", anEventsOwnVersionTagWinsOverTheProgramVersion}, + {"theCallersTagsAreNeverModified", theCallersTagsAreNeverModified}, + {"aBlankOrOverlongVersionIsRejected", aBlankOrOverlongVersionIsRejected}, }; 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 11009d2..ab9a506 100644 --- a/trace_client.hpp +++ b/trace_client.hpp @@ -1,5 +1,5 @@ /* - * trace-client 0.1.0 (C++) -- https://github.com/Stephenson-Software/trace-client-cpp + * trace-client 0.2.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.1.0" +#define TRACE_CLIENT_VERSION "0.2.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 @@ -77,7 +77,7 @@ extern char **environ; namespace trace_client { -/** String tags on a report, such as {{"version", "1.4.0"}}. */ +/** String tags on a report, such as {{"name", "home"}}. */ typedef std::map Tags; /** @@ -141,6 +141,23 @@ inline bool isBlank(const std::string &text) { return true; } +inline std::string trim(const std::string &text) { + std::size_t begin = 0, end = text.size(); + while (begin < end && std::isspace(static_cast(text[begin]))) ++begin; + while (end > begin && std::isspace(static_cast(text[end - 1]))) --end; + return text.substr(begin, end - begin); +} + +/** + * The event's own tags plus "version", unless the event already carries one. + * A copy; the caller's tags are never modified. + */ +inline Tags withVersion(const Tags &tags, const std::string &version) { + Tags merged(tags); + merged.insert(Tags::value_type("version", version)); // never overwrites + return merged; +} + /** * Valid UTF-8, at most maxBytes long: invalid sequences become U+FFFD and a * code point is never cut in half. The server stores what it is sent, and a @@ -773,27 +790,40 @@ inline bool environmentOptsOut() { * program's own setting (REASON_CONFIG), a blank key (REASON_NO_KEY), or no * way to send (REASON_UNAVAILABLE). * - * trace_client::TraceClient trace("https://trace.example.org", "MyGame", + * Every event carries the program's own version as the tag "version" -- the + * second argument to the constructor, required, so a "command" event can be + * tied to a release as well as a "startup" one. An event's own "version" tag + * wins over it. It is trimmed; a blank one, or one longer than MAX_LENGTH + * bytes, is treated like a blank base URL or application: nothing throws, the + * client reports nothing and disabledReason() is REASON_UNAVAILABLE. + * + * trace_client::TraceClient trace("https://trace.example.org", "MyGame", MYGAME_VERSION, * settings.key, settings.usageReporting); - * trace.report("startup", {{"version", "1.4.0"}}); + * trace.report("startup"); * ... * trace.close(); // or let the destructor do it */ class TraceClient { public: /** A client that reports nothing (reason "config"). */ - TraceClient() : reason_(REASON_CONFIG) {} + TraceClient() : version_("disabled"), reason_(REASON_CONFIG) {} - TraceClient(const std::string &baseUrl, const std::string &application, const std::string &key, - bool enabled = true, Logger logger = Logger()) { + /** + * 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. + */ + TraceClient(const std::string &baseUrl, const std::string &application, const std::string &version, + const std::string &key, bool enabled = true, Logger logger = Logger()) { 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)) { + } else if (detail::isBlank(baseUrl) || detail::isBlank(application) || version_.empty() + || version_.size() > MAX_LENGTH) { reason_ = REASON_UNAVAILABLE; } else { start(baseUrl, application, key, logger); @@ -899,7 +929,8 @@ class TraceClient { void enqueue(const std::string &name, bool hasValue, double value, const Tags &tags) { if (!shared_ || detail::isBlank(name)) return; try { - std::string body = detail::json(shared_->application, name, hasValue, value, tags); + std::string body = detail::json(shared_->application, name, hasValue, value, + detail::withVersion(tags, version_)); bool full = false; { std::lock_guard lock(shared_->mutex); @@ -917,6 +948,7 @@ class TraceClient { std::shared_ptr shared_; std::thread thread_; + std::string version_; std::string reason_; }; From 416ab845f4bcc0a911189201a049e0833014b28a Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Tue, 29 Sep 2026 21:29:58 -0600 Subject: [PATCH 2/2] Doc comment: the version is the constructor's third argument Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AuTszT2gqv7nLVYrtKD4ji --- trace_client.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/trace_client.hpp b/trace_client.hpp index ab9a506..4649450 100644 --- a/trace_client.hpp +++ b/trace_client.hpp @@ -791,7 +791,7 @@ inline bool environmentOptsOut() { * way to send (REASON_UNAVAILABLE). * * Every event carries the program's own version as the tag "version" -- the - * second argument to the constructor, required, so a "command" event can be + * third argument to the constructor, required, so a "command" event can be * tied to a release as well as a "startup" one. An event's own "version" tag * wins over it. It is trimmed; a blank one, or one longer than MAX_LENGTH * bytes, is treated like a blank base URL or application: nothing throws, the