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..d8a1254 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,33 @@ 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("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 e39c199..b0424a0 100644 --- a/trace-client.ts +++ b/trace-client.ts @@ -353,7 +353,9 @@ export class TraceClient { } let body: string; try { - body = serialize(this.application, name, options.value, withInstall(withVersion(options.tags, this.version), this.install)); + // 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)}`); return Promise.resolve();