From 1563ecd00d825c0d7611d97fb21991d29b4ce691 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Fri, 9 Oct 2026 02:07:01 -0600 Subject: [PATCH 1/2] Send the event when report() is given null options A null options object from untyped code threw inside the serialize try, dropping the event with a misleading "could not serialize" debug line. Treat null or any non-object options as no options. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ tests/trace-client.test.ts | 14 ++++++++++++++ trace-client.ts | 4 +++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c00a7b0..3887b0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ against this file to see what a re-vendor would bring. the README example should update the URL in their own notice. - The README's Keys section no longer links to a decision record in that private repository. +- `report(name, null)` from untyped code now sends the event as if no + options were given. It used to drop the event and log a misleading + "could not serialize" line to `debug`. ## 0.4.0 — 2026-10-03 diff --git a/tests/trace-client.test.ts b/tests/trace-client.test.ts index d8f8404..ce6a499 100644 --- a/tests/trace-client.test.ts +++ b/tests/trace-client.test.ts @@ -10,6 +10,7 @@ import { join } from "node:path"; import * as clientModule from "../trace-client.ts"; import { TraceClient, TRACE_CLIENT_VERSION, isBot, pagePath } from "../trace-client.ts"; +import type { ReportOptions } from "../trace-client.ts"; const { REASON_CONFIG, REASON_ENVIRONMENT, REASON_NO_KEY } = TraceClient; const environmentOptsOut = (env?: Record) => TraceClient.environmentOptsOut(env); @@ -294,6 +295,19 @@ describe("TraceClient", () => { await client.close(); }); + it("sends the event when untyped code passes null or a non-object as options", async () => { + const client = new TraceClient(baseUrl, "MyGame", { version: "1.2.3", key: "k", debug }); + for (const options of [null, 42, "loose"]) { + await client.report("startup", options as unknown as ReportOptions); + } + assert.equal(capture.requests.length, 3, "no options was meant, so nothing is dropped"); + for (const request of capture.requests) { + assert.deepEqual(JSON.parse(request.body), { application: "MyGame", name: "startup", tags: { version: "1.2.3" } }); + } + await client.close(); + assert.deepEqual(log, [], "a missing options object is not a serialize failure"); + }); + it("rejects a missing baseUrl or application at construction", () => { assert.throws(() => new TraceClient("", "MyGame", { version: "1.2.3" })); assert.throws(() => new TraceClient(" ", "MyGame", { version: "1.2.3" })); diff --git a/trace-client.ts b/trace-client.ts index e39c199..1761852 100644 --- a/trace-client.ts +++ b/trace-client.ts @@ -351,9 +351,11 @@ export class TraceClient { this.log(`in-flight cap reached, dropped ${name}`); return Promise.resolve(); } + // A null (or other non-object) options from untyped code means "no options". + const { value, tags } = options !== null && typeof options === "object" ? options : ({} as ReportOptions); let body: string; try { - body = serialize(this.application, name, options.value, withInstall(withVersion(options.tags, this.version), this.install)); + body = serialize(this.application, name, value, withInstall(withVersion(tags, this.version), this.install)); } catch (failure) { this.log(`could not serialize ${name}: ${describe(failure)}`); return Promise.resolve(); From 28442188aef4a164cbf39cf240aecb463988a272 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Fri, 9 Oct 2026 02:10:01 -0600 Subject: [PATCH 2/2] Read report() options inside the serialize try Destructuring options outside the try let a throwing getter escape report(), breaking the never-throws promise. Move it back inside and pin the behavior with a test. Co-Authored-By: Claude Opus 5.5 --- tests/trace-client.test.ts | 14 ++++++++++++++ trace-client.ts | 4 ++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/trace-client.test.ts b/tests/trace-client.test.ts index ce6a499..d8a1254 100644 --- a/tests/trace-client.test.ts +++ b/tests/trace-client.test.ts @@ -308,6 +308,20 @@ describe("TraceClient", () => { assert.deepEqual(log, [], "a missing options object is not a serialize failure"); }); + it("does not throw when reading the options throws", async () => { + const client = new TraceClient(baseUrl, "MyGame", { version: "1.2.3", key: "k", debug }); + const hostile = { + get value(): number { + throw new Error("boom"); + }, + }; + await client.report("startup", hostile); + await client.close(); + assert.deepEqual(capture.requests, []); + assert.equal(log.length, 1, "the drop goes to debug instead of escaping report()"); + assert.match(log[0], /could not serialize startup/); + }); + it("rejects a missing baseUrl or application at construction", () => { assert.throws(() => new TraceClient("", "MyGame", { version: "1.2.3" })); assert.throws(() => new TraceClient(" ", "MyGame", { version: "1.2.3" })); diff --git a/trace-client.ts b/trace-client.ts index 1761852..b0424a0 100644 --- a/trace-client.ts +++ b/trace-client.ts @@ -351,10 +351,10 @@ export class TraceClient { this.log(`in-flight cap reached, dropped ${name}`); return Promise.resolve(); } - // A null (or other non-object) options from untyped code means "no options". - const { value, tags } = options !== null && typeof options === "object" ? options : ({} as ReportOptions); let body: string; try { + // A null (or other non-object) options from untyped code means "no options". + const { value, tags } = options !== null && typeof options === "object" ? options : ({} as ReportOptions); body = serialize(this.application, name, value, withInstall(withVersion(tags, this.version), this.install)); } catch (failure) { this.log(`could not serialize ${name}: ${describe(failure)}`);