From 691dcb6db6a4b5d183f56b4988a1cf657e25f385 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sat, 3 Oct 2026 16:44:43 -0600 Subject: [PATCH] Tag every event with a random per-installation ID (0.4.0) Port of trace-client-java 0.5.0's install ID: new installId and installIdFile options, TraceClient.installIdFromFile(path), the installId accessor, and the install tag on every event (an event's own install tag wins; never past the 32-tag cap). Resolved only after the opt-outs, so a disabled client never generates or writes an ID. node:fs is reached through process.getBuiltinModule at call time, so edge runtimes still load the file and fall back to an in-memory ID. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014ztkfamsbEqQfcu76me5SL --- CHANGELOG.md | 21 +++++ README.md | 65 ++++++++++++++- package-lock.json | 4 +- package.json | 2 +- tests/trace-client.test.ts | 158 +++++++++++++++++++++++++++++++++++- trace-client.ts | 162 +++++++++++++++++++++++++++++++++++-- 6 files changed, 400 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b13183..3fb2eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ All notable changes to this project are recorded here. The version in the header comment of `trace-client.ts` is the one consumers vendor; check it against this file to see what a re-vendor would bring. +## 0.4.0 — 2026-10-03 + +A random per-installation ID, matching trace-client-java 0.5.0, so the +trace server can count installations rather than events. API-compatible +with 0.3.0: nothing changes unless one of the new options is passed. + +- New options `installId` (an ID the program stores itself; trimmed, blank + means none, over 255 characters throws) and `installIdFile` (a path the + program chooses; read or created with a random UUID by + `TraceClient.installIdFromFile(path)`). `installId` wins. +- Every event then carries the tag `install`. An event's own `install` tag + wins, and it is not added to an event that already has 32 tags + (`TraceClient.MAX_TAGS`). +- Resolved only after the opt-outs: a disabled client never generates, + reads or writes an ID. `trace.installId` is the ID in use, or `null`. +- `TraceClient.installIdFromFile(path)` never throws: an unreadable or + unwritable file, or a runtime without `node:fs` (reached only through + `process.getBuiltinModule`, so edge runtimes still load the file), yields + an in-memory ID for that run. No default location exists. +- The exported values are unchanged; the new API hangs off `TraceClient`. + ## 0.3.0 — 2026-09-29 Every event carries the program's version, matching trace-client-java 0.4.0. diff --git a/README.md b/README.md index c914012..0734bd9 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,64 @@ constructor (the `version` from your `package.json`, or a constant the build fills in). The hand-added `version` tags can then be dropped if you like; left in, they still win, so nothing changes on the wire. +## Every event carries a random installation ID + +Since 0.4.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. It is the +same idea as trace-client-java 0.5.0's server ID, 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 random UUID (`crypto.randomUUID()`), made on first run. It +is not derived from anything — not a hostname, an IP address, a 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.** Wherever the program says, and nowhere else: there is +no hidden default location, and without one of the two options below no ID +is made up and no `install` tag is sent. A program that wants to be counted +either names a file the client keeps the ID in — + +```ts +const trace = new TraceClient(url, "my-game", { + version, + key, + installIdFile: join(configDir, "trace-install-id"), // created on first run +}); +``` + +— or passes an ID it stores itself: + +```ts +new TraceClient(url, "my-game", { version, key, installId: settings.installId }); // null or blank: none sent +``` + +`installIdFile` uses `TraceClient.installIdFromFile(path)`: the first line +made of letters, digits, `_`, `.` and `-` (at most 255 characters) is the ID; +when the file is missing or has no such line, a new UUID is written to it +(parent directories created). If the file cannot be read or written — or the +runtime has no `node:fs`, as in an edge runtime; the file is reached through +`process.getBuiltinModule` (Node 22.3+), never a top-level import — a fresh +ID is used in memory for that run only. It never throws. An explicit +`installId` wins over `installIdFile`; it is trimmed, and over 255 +characters throws, the same as `version`. + +`trace.installId` is the ID in use (`null` when disabled or when there is +none), so a program can print it. An event that passes its own `install` +tag keeps it, and the tag is not added to an event that already has 32 tags +(`TraceClient.MAX_TAGS`). + +**Resetting it.** Delete the file (or its ID line); the next start writes a +new one. Or put your own value in it. + +**Opting out.** Every opt-out — `TRACE_USAGE_REPORTING=off`, +`DO_NOT_TRACK=1`, `enabled: false`, no key — also stops the ID: a disabled +client never reads, generates or writes one, so `installIdFile` is never +created. (`TraceClient.installIdFromFile(path)` called directly writes +regardless; pass the path as `installIdFile` to keep that guarantee.) + ## What `report` promises | Property | Meaning | @@ -69,6 +127,8 @@ Besides `version`, `key`, `enabled` and `env`, the constructor's options take: |---|---|---| | `timeoutMs` | `5000` | How long one request may take before it is aborted. Anything but a positive finite number — `0`, negative, `NaN`, `Infinity`, not a number — falls back to the default. | | `fetch` | the global `fetch` | The `fetch` to send with, for a runtime that provides its own or for tests. | +| `installId` | none | The installation's ID, sent as the tag `install`; see [above](#every-event-carries-a-random-installation-id). | +| `installIdFile` | none | A file holding the installation's ID, created with a random UUID on first run by an enabled client. | | `debug` | none | Called with one line per dropped report, prefixed `[trace] `. A `debug` that throws is ignored. | `flush(timeoutMs)` and `close(timeoutMs)` take their own bound, defaulting to @@ -208,7 +268,7 @@ site can flip `USAGE_REPORTING_ENABLED=false` and nothing is sent. ## The wire format `POST {baseUrl}/api/metrics` with `Content-Type: application/json`, -`Authorization: Bearer `, `User-Agent: trace-client-js/0.3.0 ()` +`Authorization: Bearer `, `User-Agent: trace-client-js/0.4.0 ()` and a body of ```json @@ -216,7 +276,8 @@ and a body of ``` `value` is omitted when not given (`NaN` and infinities count as not given); -`tags` always holds at least `version`. The `User-Agent` carries the +`tags` always holds at least `version`, plus `install` when the program +gave an installation ID. The `User-Agent` carries the client's version, the `version` tag the program's. The server assigns the timestamp. Any `2xx` is success; anything else is passed to `debug` and dropped. A trailing slash on `baseUrl` is tolerated. diff --git a/package-lock.json b/package-lock.json index 7703d3d..50e73ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "trace-client-js", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trace-client-js", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "devDependencies": { "typescript": "^5.6.0" diff --git a/package.json b/package.json index 5ffc15b..14bb68f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "trace-client-js", - "version": "0.3.0", + "version": "0.4.0", "private": true, "description": "One-file usage-reporting client for trace, for Node and edge runtimes (vendor it, bStats-style)", "type": "module", diff --git a/tests/trace-client.test.ts b/tests/trace-client.test.ts index 99a5b40..d75c26d 100644 --- a/tests/trace-client.test.ts +++ b/tests/trace-client.test.ts @@ -4,6 +4,9 @@ import { after, afterEach, before, beforeEach, describe, it } from "node:test"; import assert from "node:assert/strict"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import * as clientModule from "../trace-client.ts"; import { TraceClient, TRACE_CLIENT_VERSION, isBot, pagePath } from "../trace-client.ts"; @@ -517,6 +520,159 @@ describe("TraceClient", () => { }); }); +describe("installation ID", () => { + let capture: Capture; + let server: Server; + let baseUrl: string; + let dir: string; + const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + + beforeEach(async () => { + capture = new Capture(); + server = await serve(capture); + baseUrl = baseUrlOf(server); + dir = mkdtempSync(join(tmpdir(), "trace-install-")); + }); + + afterEach(async () => { + capture.release(); + await stop(server); + rmSync(dir, { recursive: true, force: true }); + for (const name of OPT_OUT_VARIABLES) delete process.env[name]; + }); + + const tagsOf = (index: number) => JSON.parse(capture.requests[index].body).tags; + + it("persists a random ID once, creating parent directories, and reuses it", async () => { + const file = join(dir, "nested", "deeper", "install-id"); + const first = new TraceClient(baseUrl, "MyGame", { version: "1.0", key: "k", installIdFile: file }); + assert.match(first.installId ?? "", UUID); + assert.equal(readFileSync(file, "utf8"), first.installId + "\n"); + const second = new TraceClient(baseUrl, "MyGame", { version: "1.0", key: "k", installIdFile: file }); + assert.equal(second.installId, first.installId); + assert.equal(TraceClient.installIdFromFile(file), first.installId); + await first.report("startup"); + assert.deepEqual(tagsOf(0), { version: "1.0", install: first.installId }); + await first.close(); + await second.close(); + }); + + it("reads the first valid line, and replaces a file that has none", () => { + const file = join(dir, "id"); + writeFileSync(file, "\n not valid! \n my-server_1.0 \nsecond\n"); + assert.equal(TraceClient.installIdFromFile(file), "my-server_1.0"); + writeFileSync(file, "nothing usable here\n" + "x".repeat(256) + "\n"); + const made = TraceClient.installIdFromFile(file); + assert.match(made, UUID); + assert.equal(readFileSync(file, "utf8"), made + "\n"); + }); + + it("falls back to an in-memory ID when the file cannot be read or written, without throwing", () => { + // A directory cannot be read as a file: left alone, not overwritten. + const asDirectory = TraceClient.installIdFromFile(dir); + assert.match(asDirectory, UUID); + // A parent that is a regular file cannot hold a directory: write fails. + const blocker = join(dir, "blocker"); + writeFileSync(blocker, "a file\n"); + const underFile = join(blocker, "sub", "id"); + const id = TraceClient.installIdFromFile(underFile); + assert.match(id, UUID); + assert.equal(readFileSync(blocker, "utf8"), "a file\n"); + assert.notEqual(TraceClient.installIdFromFile(underFile), id, "nothing persisted, so a new one each call"); + const client = new TraceClient(baseUrl, "MyGame", { version: "1.0", key: "k", installIdFile: underFile }); + assert.match(client.installId ?? "", UUID); + for (const bad of ["", " ", null, undefined, 42]) { + assert.match(TraceClient.installIdFromFile(bad as unknown as string), UUID); + } + }); + + it("never creates the file or makes up an ID when disabled", () => { + const file = join(dir, "sub", "install-id"); + const disabled = [ + new TraceClient(baseUrl, "MyGame", { version: "1.0", key: "k", enabled: false, installIdFile: file }), + new TraceClient(baseUrl, "MyGame", { version: "1.0", installIdFile: file }), + new TraceClient(baseUrl, "MyGame", { version: "1.0", key: "k", env: { TRACE_USAGE_REPORTING: "off" }, installIdFile: file }), + new TraceClient(baseUrl, "MyGame", { version: "1.0", key: "k", env: { DO_NOT_TRACK: "1" }, installIdFile: file, installId: "explicit" }), + ]; + process.env.DO_NOT_TRACK = "1"; + disabled.push(new TraceClient(baseUrl, "MyGame", { version: "1.0", key: "k", installIdFile: file })); + for (const client of disabled) { + assert.equal(client.enabled, false); + assert.equal(client.installId, null); + } + assert.equal(existsSync(join(dir, "sub")), false, "a disabled client must write nothing"); + assert.equal(TraceClient.disabled().installId, null); + }); + + it("sends no install tag when no ID is configured", async () => { + const client = new TraceClient(baseUrl, "MyGame", { version: "1.0", key: "k" }); + assert.equal(client.installId, null); + await client.report("startup"); + assert.deepEqual(tagsOf(0), { version: "1.0" }); + await client.close(); + }); + + it("uses an explicit ID, trimmed, over the file; blank means none; overlong throws", async () => { + const file = join(dir, "install-id"); + const client = new TraceClient(baseUrl, "MyGame", { version: "1.0", key: "k", installId: " abc-123 ", installIdFile: file }); + assert.equal(client.installId, "abc-123"); + assert.equal(existsSync(file), false, "an explicit ID wins, so the file is not touched"); + await client.report("startup"); + assert.deepEqual(tagsOf(0), { version: "1.0", install: "abc-123" }); + await client.close(); + for (const blank of ["", " ", null, undefined]) { + assert.equal(new TraceClient(baseUrl, "MyGame", { version: "1.0", key: "k", installId: blank }).installId, null); + } + const blankWithFile = new TraceClient(baseUrl, "MyGame", { version: "1.0", key: "k", installId: " ", installIdFile: file }); + assert.match(blankWithFile.installId ?? "", UUID, "a blank explicit ID falls through to the file"); + const longest = "i".repeat(TraceClient.MAX_VERSION_LENGTH); + assert.equal(new TraceClient(baseUrl, "MyGame", { version: "1.0", key: "k", installId: ` ${longest} ` }).installId, longest); + assert.throws( + () => new TraceClient(baseUrl, "MyGame", { version: "1.0", key: "k", installId: longest + "i" }), + /installId is longer than 255/, + ); + }); + + it("lets an event's own install tag win, without modifying the caller's tags", async () => { + const client = new TraceClient(baseUrl, "MyGame", { version: "1.0", key: "k", installId: "mine" }); + const tags = { install: "theirs" }; + await client.report("startup", { tags }); + await client.report("startup", { tags: { install: null as unknown as string } }); + assert.deepEqual(tagsOf(0), { install: "theirs", version: "1.0" }); + assert.deepEqual(tagsOf(1), { version: "1.0", install: "mine" }, "a null install tag is dropped, so the ID fills it"); + assert.deepEqual(tags, { install: "theirs" }); + await client.close(); + }); + + it("never pushes an event past the tag cap", async () => { + const client = new TraceClient(baseUrl, "MyGame", { version: "1.0", key: "k", installId: "mine" }); + const full: Record = {}; + for (let i = 0; i < TraceClient.MAX_TAGS - 1; i++) full["t" + i] = String(i); + await client.report("full", { tags: full }); // 31 + version = 32: no room + const room: Record = {}; + for (let i = 0; i < TraceClient.MAX_TAGS - 2; i++) room["t" + i] = String(i); + await client.report("room", { tags: room }); // 30 + version = 31: install fits + assert.equal(TraceClient.MAX_TAGS, 32); + assert.equal(Object.keys(tagsOf(0)).length, 32); + assert.equal(tagsOf(0).install, undefined); + assert.equal(Object.keys(tagsOf(1)).length, 32); + assert.equal(tagsOf(1).install, "mine"); + await client.close(); + }); + + it("keeps the ID in memory where the runtime has no getBuiltinModule", () => { + const file = join(dir, "install-id"); + const original = process.getBuiltinModule; + try { + (process as { getBuiltinModule?: unknown }).getBuiltinModule = undefined; + assert.match(TraceClient.installIdFromFile(file), UUID); + } finally { + process.getBuiltinModule = original; + } + assert.equal(existsSync(file), false); + }); +}); + describe("API shape", () => { it("exports exactly the values 0.1.0 did", () => { assert.deepEqual(Object.keys(clientModule).sort(), ["TRACE_CLIENT_VERSION", "TraceClient", "isBot", "pagePath"]); @@ -529,7 +685,7 @@ describe("API shape", () => { assert.equal(await client.flush(50), undefined); assert.equal(await client.close(50), undefined); assert.equal(TraceClient.disabled().enabled, false); - assert.equal(TRACE_CLIENT_VERSION, "0.3.0"); + assert.equal(TRACE_CLIENT_VERSION, "0.4.0"); }); }); diff --git a/trace-client.ts b/trace-client.ts index 1a5c247..28f1f6f 100644 --- a/trace-client.ts +++ b/trace-client.ts @@ -1,16 +1,19 @@ /** - * trace-client 0.3.0 -- https://github.com/Stephenson-Software/trace-client-js + * trace-client 0.4.0 -- https://github.com/Stephenson-Software/trace-client-js * * One call to report that a program was used. Copy this file into a project * as is; there is nothing else to add. Zero dependencies and no Node-only - * APIs -- only `fetch`, `AbortController`, `setTimeout` and `URL`, plus + * imports -- only `fetch`, `AbortController`, `setTimeout` and `URL`, plus * `process.env` when the runtime has one -- so it runs in Node 18+, in the - * Next.js Edge runtime, and in any other runtime that has those four. + * Next.js Edge runtime, and in any other runtime that has those four. The + * optional install-ID file (`installIdFile`, `TraceClient.installIdFromFile`) + * reaches `node:fs` only through `process.getBuiltinModule` (Node 22.3+) at + * call time; where that is missing, the ID is kept in memory instead. * * MIT licensed. Keep this header when vendoring so the file can be found again. */ -export const TRACE_CLIENT_VERSION = "0.3.0"; +export const TRACE_CLIENT_VERSION = "0.4.0"; // Everything added since 0.1.0 hangs off TraceClient (static members) rather // than being a new top-level export, so the file's exported values stay @@ -29,7 +32,23 @@ const DO_NOT_TRACK_VALUES = ["1", "true", "yes"]; // Declared here rather than taken from @types/node so the file still // type-checks against ES2022 + DOM alone; at runtime `process` may simply not // exist (a browser-like edge runtime), which processEnvironment() allows for. -declare const process: { env?: Record } | undefined; +declare const process: + | { env?: Record; getBuiltinModule?: (id: string) => unknown } + | undefined; + +// The few node:fs / node:path calls installIdFromFile makes, typed here for +// the same reason as `process` above. +interface NodeFs { + readFileSync(path: string, encoding: "utf8"): string; + writeFileSync(path: string, data: string, options: { encoding: "utf8"; flag: string }): void; + mkdirSync(path: string, options: { recursive: true }): unknown; +} +interface NodePath { + dirname(path: string): string; +} + +/** An installation ID read from a file: what Java accepts on a `server-id:` line. */ +const INSTALL_ID_LINE = /^[A-Za-z0-9_.-]{1,255}$/; export interface TraceClientOptions { /** @@ -55,6 +74,22 @@ export interface TraceClientOptions { * or a stand-in in tests. */ env?: Environment; + /** + * The installation's ID, sent as the tag `install` on every event so the + * trace server can count installations rather than events. It should be + * random -- e.g. a `crypto.randomUUID()` the program stores itself -- and + * never derived from a person, account or address. Trimmed; missing or + * blank means none; longer than {@link TraceClient.MAX_VERSION_LENGTH} + * characters throws. Wins over `installIdFile`. + */ + installId?: string | null; + /** + * A file, chosen by the program, that holds the installation's ID: read + * with {@link TraceClient.installIdFromFile}, which creates it with a new + * random UUID when it is missing. Only touched by an enabled client, so an + * opt-out never creates the file. Ignored when `installId` is given. + */ + installIdFile?: string | null; } export interface ReportOptions { @@ -97,6 +132,12 @@ export interface ReportOptions { * tied to a release as well as a `startup` one. An event's own `version` tag * wins over it. * + * Every event also carries the installation's ID as the tag `install` when + * the program gives one -- `installId`, or `installIdFile` for a file holding + * a random UUID made on first run -- so the trace server can count + * installations rather than events. It is resolved only by an enabled + * client; an event's own `install` tag wins over it. + * * ```ts * const trace = new TraceClient("https://trace.example.org", "my-site", { * version: "1.4.0", @@ -114,6 +155,10 @@ export class TraceClient { static readonly TIMEOUT_MS = 5000; /** The longest program version accepted, after trimming. */ static readonly MAX_VERSION_LENGTH = 255; + /** At most this many tags; the `install` tag is not added to an event that already has this many. */ + static readonly MAX_TAGS = 32; + /** The tag every event carries the installation's ID as. */ + static readonly INSTALL_TAG = "install"; /** * Environment variables that turn reporting off for every program using a @@ -161,6 +206,7 @@ export class TraceClient { private readonly debug: ((message: string) => void) | undefined; private readonly inFlight = new Set>(); private readonly reason: DisabledReason | null; + private readonly install: string | null; private active: boolean; constructor(baseUrl: string, application: string, options: TraceClientOptions) { @@ -178,6 +224,11 @@ export class TraceClient { throw new Error(`version is longer than ${TraceClient.MAX_VERSION_LENGTH} characters`); } this.version = version.trim(); + const explicitInstall = options.installId; + const installId = typeof explicitInstall === "string" && explicitInstall.trim() ? explicitInstall.trim() : null; + if (installId !== null && installId.length > TraceClient.MAX_VERSION_LENGTH) { + throw new Error(`installId is longer than ${TraceClient.MAX_VERSION_LENGTH} characters`); + } this.endpoint = baseUrl.trim().replace(/\/+$/, "") + "/api/metrics"; this.application = application.trim(); this.key = (options.key ?? "").trim(); @@ -199,6 +250,59 @@ export class TraceClient { this.reason = null; } this.active = this.reason === null; + // After the opt-outs, never before: a disabled client neither makes up an + // ID nor writes one to disk. + if (!this.active) { + this.install = null; + } else if (installId !== null) { + this.install = installId; + } else if (typeof options.installIdFile === "string" && options.installIdFile.trim()) { + this.install = TraceClient.installIdFromFile(options.installIdFile); + } else { + this.install = null; + } + } + + /** + * Loads the installation's ID from `path`, or creates it there. The first + * line made of letters, digits, `_`, `.` and `-` (at most 255 characters, + * surrounding space ignored) is the ID. When the file is missing or holds + * no such line, a new random UUID is written to it (parent directories + * created) and returned. Never throws: when the file cannot be read for any + * reason but not existing, cannot be written, or the runtime has no + * `node:fs`, a fresh UUID is returned for this process only, and nothing is + * written. + * + * This writes whatever the opt-outs say. Pass the path as the client's + * `installIdFile` option instead to have it read only by an enabled client. + */ + static installIdFromFile(path: string): string { + const fresh = randomId(); + if (typeof path !== "string" || !path.trim()) return fresh; + const fs = builtin("node:fs"); + const paths = builtin("node:path"); + if (!fs || !paths) return fresh; + try { + let text: string | null = null; + try { + text = fs.readFileSync(path, "utf8"); + } catch (failure) { + // Only a missing file is created; one that is there but unreadable + // (a directory, no permission) is left alone. + if ((failure as { code?: unknown } | null)?.code !== "ENOENT") return fresh; + } + if (text !== null) { + for (const line of text.split(/\r?\n/)) { + const candidate = line.trim(); + if (INSTALL_ID_LINE.test(candidate)) return candidate; + } + } + fs.mkdirSync(paths.dirname(path), { recursive: true }); + fs.writeFileSync(path, fresh + "\n", { encoding: "utf8", flag: "w" }); + return fresh; + } catch { + return fresh; + } } /** A client that reports nothing. Useful as a default before settings are read. */ @@ -224,6 +328,15 @@ export class TraceClient { return this.reason; } + /** + * The installation's ID every event carries as the tag `install`, or + * `null` when the client is disabled or has none (neither `installId` nor + * `installIdFile` given). Fixed at construction. + */ + get installId(): string | null { + return this.install; + } + /** * Report that `name` happened, with an optional numeric value and optional * string tags. The returned promise is the delivery attempt itself: it is @@ -240,7 +353,7 @@ export class TraceClient { } let body: string; try { - body = serialize(this.application, name, options.value, withVersion(options.tags, this.version)); + body = serialize(this.application, name, options.value, withInstall(withVersion(options.tags, this.version), this.install)); } catch (failure) { this.log(`could not serialize ${name}: ${describe(failure)}`); return Promise.resolve(); @@ -403,6 +516,43 @@ function withVersion(tags: Record | undefined, version: string): return merged; } +/** + * The tags plus `install`, unless they already carry one, there is no ID, or + * adding it would pass {@link TraceClient.MAX_TAGS}. + */ +function withInstall(tags: Record, installId: string | null): Record { + if ( + installId === null || + Object.prototype.hasOwnProperty.call(tags, TraceClient.INSTALL_TAG) || + Object.keys(tags).length >= TraceClient.MAX_TAGS + ) { + return tags; + } + return { ...tags, [TraceClient.INSTALL_TAG]: installId }; +} + +/** A random UUID; never throws, even where `crypto.randomUUID` is missing. */ +function randomId(): string { + try { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID(); + } catch { + // fall through + } + const hex = (n: number) => + Array.from({ length: n }, () => Math.floor(Math.random() * 16).toString(16)).join(""); + return `${hex(8)}-${hex(4)}-4${hex(3)}-${"89ab"[Math.floor(Math.random() * 4)]}${hex(3)}-${hex(12)}`; +} + +/** A Node built-in module via `process.getBuiltinModule`, or null where the runtime has none. */ +function builtin(id: string): T | null { + try { + if (typeof process === "undefined" || !process || typeof process.getBuiltinModule !== "function") return null; + return (process.getBuiltinModule(id) as T | undefined) ?? null; + } catch { + return null; + } +} + function serialize( application: string, name: string,