From 93e66a09bddbace8688b7c9e377326b5b0d2d6d8 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Wed, 23 Sep 2026 23:47:35 -0600 Subject: [PATCH] trace-client 0.2.0: environment opt-outs and disabledReason The constructor now checks TRACE_USAGE_REPORTING=off|false|0|no and DO_NOT_TRACK=1|true|yes before enabled and the key, as trace-client-java and trace-client-python 0.2.0 do, and exposes why a client sends nothing as disabledReason ("environment", "config", "no key"; null when on). The environment is process.env when the runtime has one, read by literal name and guarded so a runtime without process (or with an unreadable env) opts nothing out and never throws; the new env option replaces it. Everything new hangs off TraceClient as static members, so the file's exported values stay exactly those of 0.1.0 and existing wrappers (the Next.js sites, dpc-mcp-server's CommonJS derivation) need no change. User-Agent and header bumped to 0.2.0; tests, README and CHANGELOG updated. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 29 +++++++ README.md | 54 +++++++++++- package-lock.json | 4 +- package.json | 2 +- tests/trace-client.test.ts | 174 +++++++++++++++++++++++++++++++++++++ trace-client.ts | 134 ++++++++++++++++++++++++++-- 6 files changed, 381 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c22b375..45927dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ 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.2.0 — 2026-09-24 + +The environment opt-outs the Java and Python clients gained in 0.2.0, so a +user can switch off every trace-reporting program at once. API-compatible +with 0.1.0: the file exports exactly the same values, and every existing +constructor call, `report`, `flush`, `close` and `TraceClient.disabled()` +behaves as before unless the environment opts out. + +- The constructor checks `TRACE_USAGE_REPORTING=off` (also `false`, `0`, + `no`) and `DO_NOT_TRACK=1` (also `true`, `yes`) — case and surrounding + space ignored — **before** `enabled` and the key. Either one yields a + client that sends nothing. +- The environment is `process.env` when the runtime has one, read by the + two literal names (so bundlers such as Next.js can inline them for the Edge + runtime); a runtime with no `process`, or one whose `process.env` throws, + opts nothing out and never fails. The new `env` option replaces + `process.env` — for runtimes that hand the environment to the handler + (Cloudflare Workers) and for tests. +- `disabledReason`: `"environment"`, `"config"` (`enabled: false`) or + `"no key"`, first match wins; `null` when the client reports. Fixed at + construction, so `close()` changes `enabled` but not the reason. The values + are also `TraceClient.REASON_ENVIRONMENT`, `REASON_CONFIG` and + `REASON_NO_KEY`, matching trace-client-python and trace-client-java. +- `TraceClient.environmentOptsOut(env?)`, and the variable names as + `TraceClient.ENV_TRACE_USAGE_REPORTING` / `ENV_DO_NOT_TRACK`, for a program + that wants to decide before building a client (to skip writing a settings + file, say). +- `User-Agent: trace-client-js/0.2.0 ()`. + ## 0.1.0 — 2026-09-12 First release. One file, zero dependencies, no Node-only APIs (Node 18+ and diff --git a/README.md b/README.md index 149adc5..26024a5 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ A zero-dependency TypeScript client for a [trace](https://trace.danielstephenson server — the central place a fleet of programs reports usage events to. The whole library is one file, `trace-client.ts`, and the integration on the program side is meant to stay one call. It uses only `fetch`, -`AbortController`, `setTimeout` and `URL`, so it runs in Node 18+, in the -Next.js Edge runtime, and anywhere else that has those four. The Python and +`AbortController`, `setTimeout` and `URL` (plus `process.env` where the +runtime has one), so it runs in Node 18+, in the Next.js Edge runtime, and +anywhere else that has those four. The Python and Java counterparts are [trace-client-python](https://github.com/Stephenson-Software/trace-client-python) and [trace-client-java](https://github.com/Stephenson-Software/trace-client-java); @@ -42,6 +43,50 @@ one). A program that runs on other people's machines should expose that switch in its settings — and say so once, so whoever runs it knows it is on and where to turn it off. +## Turning it off from the environment + +Two environment variables switch off **every** program that uses a trace +client — this one, trace-client-python and trace-client-java alike — and +they are checked before the program's own `enabled` setting and before the +key: + +| Variable | Values that turn reporting off | +|---|---| +| `TRACE_USAGE_REPORTING` | `off`, `false`, `0`, `no` | +| `DO_NOT_TRACK` | `1`, `true`, `yes` ([consoledonottrack.com](https://consoledonottrack.com)) | + +Case and surrounding space are ignored; any other value, or none, leaves the +program's own setting in charge. The client reads them from `process.env` +when the runtime has one (by their literal names, so Next.js can inline them +into the Edge runtime); a runtime without `process`, or whose environment +cannot be read, simply opts nothing out. Pass `env` to use another source: + +```ts +// Cloudflare Workers and other runtimes that hand the environment to the handler +const trace = new TraceClient(endpoint, "my-worker", { key: env.USAGE_REPORTING_KEY, env }); +``` + +`disabledReason` says why a client sends nothing — the first that applies, in +this order — so a program can say so in its startup notice: + +| `disabledReason` | Cause | +|---|---| +| `"environment"` | `TRACE_USAGE_REPORTING` or `DO_NOT_TRACK` above | +| `"config"` | `enabled: false` | +| `"no key"` | the key was missing or blank | +| `null` | the client reports | + +```ts +console.log(trace.enabled + ? "Usage reporting is on. Details: https://github.com/Stephenson-Software/trace#usage-reporting" + : `Usage reporting is off (${trace.disabledReason}).`); +``` + +The reason is fixed when the client is built: `close()` turns `enabled` off +but leaves `disabledReason` as it was. `TraceClient.environmentOptsOut(env?)` +answers the environment question on its own, for a program that wants to +decide before it builds a client (to skip writing a settings file, say). + ## Getting it **Copy the file.** `trace-client.ts` has no dependencies. Drop it into your @@ -99,7 +144,8 @@ capped at 200 characters), `isBot()` keeps crawlers, link previewers and uptime monitors out of the count, and the middleware above never reads anything else off the request. -**The opt-out.** Three environment variables, all optional: +**The opt-out.** Three environment variables, all optional, on top of +`TRACE_USAGE_REPORTING` / `DO_NOT_TRACK` (which the client checks by itself): | Variable | Default | Meaning | |---|---|---| @@ -114,7 +160,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.1.0 ()` +`Authorization: Bearer `, `User-Agent: trace-client-js/0.2.0 ()` and a body of ```json diff --git a/package-lock.json b/package-lock.json index 2377786..6c4247e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "trace-client-js", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trace-client-js", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "devDependencies": { "typescript": "^5.6.0" diff --git a/package.json b/package.json index 4fb353c..5d9f21d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "trace-client-js", - "version": "0.1.0", + "version": "0.2.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 f896245..d28fba4 100644 --- a/tests/trace-client.test.ts +++ b/tests/trace-client.test.ts @@ -5,8 +5,30 @@ import assert from "node:assert/strict"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; +import * as clientModule from "../trace-client.ts"; import { TraceClient, TRACE_CLIENT_VERSION, isBot, pagePath } from "../trace-client.ts"; +const { REASON_CONFIG, REASON_ENVIRONMENT, REASON_NO_KEY } = TraceClient; +const environmentOptsOut = (env?: Record) => TraceClient.environmentOptsOut(env); + +// The client reads TRACE_USAGE_REPORTING / DO_NOT_TRACK from process.env by +// default, so a developer's own opt-out must not turn the delivery tests into +// no-ops. Cleared for the whole file and put back afterwards. +const OPT_OUT_VARIABLES = ["TRACE_USAGE_REPORTING", "DO_NOT_TRACK"] as const; +const savedEnvironment: Record = {}; +before(() => { + for (const name of OPT_OUT_VARIABLES) { + savedEnvironment[name] = process.env[name]; + delete process.env[name]; + } +}); +after(() => { + for (const name of OPT_OUT_VARIABLES) { + if (savedEnvironment[name] === undefined) delete process.env[name]; + else process.env[name] = savedEnvironment[name]; + } +}); + interface Captured { path: string; headers: Record; @@ -308,6 +330,158 @@ describe("TraceClient", () => { }); }); +describe("0.1.0 compatibility", () => { + it("exports exactly the values 0.1.0 did, so re-vendoring needs no change", () => { + assert.deepEqual(Object.keys(clientModule).sort(), ["TRACE_CLIENT_VERSION", "TraceClient", "isBot", "pagePath"]); + }); + + it("keeps the 0.1.0 constructor, report, flush, close and disabled() shapes", async () => { + const client = new TraceClient("http://127.0.0.1:9", "MyGame", { key: "k", enabled: true, timeoutMs: 50, fetch, debug: () => {}, env: {} }); + assert.equal(client.enabled, true); + assert.equal(await client.report("startup", { value: 1, tags: { version: "1" } }), undefined); + 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.2.0"); + }); +}); + +describe("environment opt-outs", () => { + let capture: Capture; + let server: Server; + let baseUrl: string; + + beforeEach(async () => { + capture = new Capture(); + server = await serve(capture); + baseUrl = baseUrlOf(server); + }); + + afterEach(async () => { + capture.release(); + await stop(server); + for (const name of OPT_OUT_VARIABLES) delete process.env[name]; + }); + + it("recognises exactly the documented values, ignoring case and surrounding space", () => { + for (const value of ["off", "false", "0", "no", "OFF", " No ", "False"]) { + assert.equal(environmentOptsOut({ TRACE_USAGE_REPORTING: value }), true, `TRACE_USAGE_REPORTING=${value}`); + } + for (const value of ["1", "true", "yes", "TRUE", " Yes "]) { + assert.equal(environmentOptsOut({ DO_NOT_TRACK: value }), true, `DO_NOT_TRACK=${value}`); + } + for (const value of ["", "on", "true", "1", "yes", "disabled"]) { + assert.equal(environmentOptsOut({ TRACE_USAGE_REPORTING: value }), false, `TRACE_USAGE_REPORTING=${value}`); + } + for (const value of ["", "0", "false", "no", "off"]) { + assert.equal(environmentOptsOut({ DO_NOT_TRACK: value }), false, `DO_NOT_TRACK=${value}`); + } + assert.equal(environmentOptsOut({}), false); + }); + + it("sends nothing and reports \"environment\" when TRACE_USAGE_REPORTING or DO_NOT_TRACK opts out", async () => { + const clients = [ + new TraceClient(baseUrl, "MyGame", { key: "k", env: { TRACE_USAGE_REPORTING: "off" } }), + new TraceClient(baseUrl, "MyGame", { key: "k", env: { DO_NOT_TRACK: "1" } }), + ]; + for (const client of clients) { + assert.equal(client.enabled, false); + assert.equal(client.disabledReason, REASON_ENVIRONMENT); + await client.report("startup"); + await client.close(); + } + await sleep(200); + assert.deepEqual(capture.requests, []); + }); + + it("checks the environment before enabled and the key", () => { + const off = { TRACE_USAGE_REPORTING: "off" }; + assert.equal(new TraceClient(baseUrl, "MyGame", { key: "k", enabled: false, env: off }).disabledReason, REASON_ENVIRONMENT); + assert.equal(new TraceClient(baseUrl, "MyGame", { env: off }).disabledReason, REASON_ENVIRONMENT); + assert.equal(new TraceClient(baseUrl, "MyGame", { enabled: false, env: {} }).disabledReason, REASON_CONFIG); + assert.equal(new TraceClient(baseUrl, "MyGame", { key: " ", env: {} }).disabledReason, REASON_NO_KEY); + assert.equal(TraceClient.disabled().disabledReason, REASON_CONFIG); + }); + + it("reports null as the reason when on, and keeps the build reason after close()", async () => { + const on = new TraceClient(baseUrl, "MyGame", { key: "k", env: {} }); + assert.equal(on.disabledReason, null); + assert.equal(on.enabled, true); + await on.close(); + assert.equal(on.enabled, false); + assert.equal(on.disabledReason, null, "close() is not a reason the client was built off"); + const off = new TraceClient(baseUrl, "MyGame", { key: "k", enabled: false }); + await off.close(); + assert.equal(off.disabledReason, REASON_CONFIG); + }); + + it("reads process.env by default", async () => { + process.env.TRACE_USAGE_REPORTING = "off"; + assert.equal(new TraceClient(baseUrl, "MyGame", { key: "k" }).disabledReason, REASON_ENVIRONMENT); + delete process.env.TRACE_USAGE_REPORTING; + process.env.DO_NOT_TRACK = "true"; + assert.equal(new TraceClient(baseUrl, "MyGame", { key: "k" }).disabledReason, REASON_ENVIRONMENT); + delete process.env.DO_NOT_TRACK; + const client = new TraceClient(baseUrl, "MyGame", { key: "k" }); + assert.equal(client.disabledReason, null); + await client.report("startup"); + assert.equal(capture.requests.length, 1); + await client.close(); + }); + + it("an explicit env replaces process.env rather than adding to it", () => { + process.env.TRACE_USAGE_REPORTING = "off"; + const client = new TraceClient(baseUrl, "MyGame", { key: "k", env: {} }); + assert.equal(client.disabledReason, null); + assert.equal(environmentOptsOut(), true); + assert.equal(environmentOptsOut({}), false); + }); + + it("works in a runtime with no process, or whose environment cannot be read", async () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "process"); + assert.ok(descriptor, "Node defines globalThis.process"); + const swapIn = (value: unknown) => Object.defineProperty(globalThis, "process", { value, configurable: true, writable: true }); + const built: TraceClient[] = []; + let noProcess: boolean; + let throwingEnv: boolean; + try { + // Built synchronously while swapped out, so nothing else runs meanwhile. + swapIn(undefined); + noProcess = environmentOptsOut(); + built.push(new TraceClient(baseUrl, "MyGame", { key: "k" })); + swapIn({ + get env(): never { + throw new Error("env access denied"); + }, + }); + throwingEnv = environmentOptsOut(); + built.push(new TraceClient(baseUrl, "MyGame", { key: "k" })); + swapIn({}); + built.push(new TraceClient(baseUrl, "MyGame", { key: "k" })); + } finally { + Object.defineProperty(globalThis, "process", descriptor); + } + assert.equal(noProcess, false); + assert.equal(throwingEnv, false); + for (const client of built) { + assert.equal(client.disabledReason, null); + await client.report("startup"); + await client.close(); + } + assert.equal(capture.requests.length, built.length); + }); + + it("an env whose lookups throw opts nothing out and does not escape", () => { + const hostile = new Proxy({}, { + get(): never { + throw new Error("no"); + }, + }); + assert.equal(environmentOptsOut(hostile), false); + assert.equal(new TraceClient(baseUrl, "MyGame", { key: "k", env: hostile }).disabledReason, null); + }); +}); + describe("pagePath", () => { it("keeps the path and drops query, fragment, host and trailing slash", () => { assert.equal(pagePath("/a/b/?x=1#f"), "/a/b"); diff --git a/trace-client.ts b/trace-client.ts index b0bda57..0504b38 100644 --- a/trace-client.ts +++ b/trace-client.ts @@ -1,16 +1,35 @@ /** - * trace-client 0.1.0 -- https://github.com/Stephenson-Software/trace-client-js + * trace-client 0.2.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` -- so it - * runs in Node 18+, in the Next.js Edge runtime, and in any other runtime - * that has those four. + * APIs -- 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. * * MIT licensed. Keep this header when vendoring so the file can be found again. */ -export const TRACE_CLIENT_VERSION = "0.1.0"; +export const TRACE_CLIENT_VERSION = "0.2.0"; + +// Everything new in 0.2.0 hangs off TraceClient (static members) rather than +// being a new top-level export, so the file's exported values stay exactly +// those of 0.1.0 -- TRACE_CLIENT_VERSION, TraceClient, pagePath, isBot -- and +// a consumer that re-vendors it (or derives CommonJS from it) needs no change. + +/** Why a client reports nothing; see {@link TraceClient.disabledReason}. First match wins, in this order. */ +export type DisabledReason = "environment" | "config" | "no key"; + +/** An environment to read the opt-outs from: `process.env`, or a stand-in. */ +export type Environment = Readonly>; + +const OFF_VALUES = ["off", "false", "0", "no"]; +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; export interface TraceClientOptions { /** The program's write key. Blank or missing yields a client that does nothing. */ @@ -23,6 +42,13 @@ export interface TraceClientOptions { fetch?: typeof fetch; /** Receives one line per dropped report. Default: silence. */ debug?: (message: string) => void; + /** + * Where to read `TRACE_USAGE_REPORTING` and `DO_NOT_TRACK` from. Default: + * `process.env` when the runtime has one, otherwise nothing. Pass the + * handler's `env` in a runtime that has no `process` (Cloudflare Workers), + * or a stand-in in tests. + */ + env?: Environment; } export interface ReportOptions { @@ -51,14 +77,21 @@ export interface ReportOptions { * unreachable for a week costs a few kilobytes, not the host's memory. * * Reporting is opt-out: `enabled: false`, or no key, yields a client that does - * nothing and costs nothing. Programs that run on other people's machines - * should expose that switch in their settings and say so once. + * nothing and costs nothing. So does the environment: the constructor checks + * `TRACE_USAGE_REPORTING=off` and `DO_NOT_TRACK=1` before it looks at + * `enabled`, so a user can switch off every trace-reporting program at once. + * {@link disabledReason} says which of those applied (`"environment"`, + * `"config"` or `"no key"`; `null` when on) so the program can say so in its + * notice. Programs that run on other people's machines should expose that + * switch in their settings and say so once, pointing at + * https://github.com/Stephenson-Software/trace#usage-reporting. * * ```ts * const trace = new TraceClient("https://trace.example.org", "my-site", { * key: process.env.USAGE_REPORTING_KEY, * enabled: process.env.USAGE_REPORTING_ENABLED !== "false", * }); + * if (!trace.enabled) console.log(`Usage reporting is off (${trace.disabledReason}).`); * trace.report("startup", { tags: { version: "1.4.0" } }); * ... * await trace.close(); // on shutdown @@ -68,6 +101,43 @@ export class TraceClient { static readonly IN_FLIGHT_CAPACITY = 256; static readonly TIMEOUT_MS = 5000; + /** + * Environment variables that turn reporting off for every program using a + * trace client, checked before the program's own setting: + * `TRACE_USAGE_REPORTING=off` (also `false`, `0`, `no`; case and + * surrounding space do not matter) or `DO_NOT_TRACK=1` (also `true`, `yes`; + * see https://consoledonottrack.com). Any other value, including an empty + * one, leaves the program's own setting in charge. + */ + static readonly ENV_TRACE_USAGE_REPORTING = "TRACE_USAGE_REPORTING"; + static readonly ENV_DO_NOT_TRACK = "DO_NOT_TRACK"; + + /** {@link disabledReason} when `TRACE_USAGE_REPORTING` or `DO_NOT_TRACK` opted out. */ + static readonly REASON_ENVIRONMENT = "environment"; + /** {@link disabledReason} when the program passed `enabled: false`. */ + static readonly REASON_CONFIG = "config"; + /** {@link disabledReason} when the key was missing or blank. */ + static readonly REASON_NO_KEY = "no key"; + + /** + * Whether the environment asks for usage reporting to be off, via + * `TRACE_USAGE_REPORTING=off` or `DO_NOT_TRACK=1`. Reads `env` when given, + * otherwise `process.env` if the runtime has one; never throws, and a + * runtime without an environment opts nothing out. + */ + static environmentOptsOut(env?: Environment): boolean { + let trace: unknown; + let doNotTrack: unknown; + try { + const source = env ?? processEnvironment(); + trace = source[TraceClient.ENV_TRACE_USAGE_REPORTING]; + doNotTrack = source[TraceClient.ENV_DO_NOT_TRACK]; + } catch { + return false; // an environment that cannot be read opts nothing out + } + return matches(trace, OFF_VALUES) || matches(doNotTrack, DO_NOT_TRACK_VALUES); + } + private readonly endpoint: string; private readonly application: string; private readonly key: string; @@ -75,6 +145,7 @@ export class TraceClient { private readonly fetchImpl: typeof fetch | undefined; private readonly debug: ((message: string) => void) | undefined; private readonly inFlight = new Set>(); + private readonly reason: DisabledReason | null; private active: boolean; constructor(baseUrl: string, application: string, options: TraceClientOptions = {}) { @@ -94,7 +165,17 @@ export class TraceClient { : TraceClient.TIMEOUT_MS; this.fetchImpl = options.fetch; this.debug = options.debug; - this.active = options.enabled !== false && this.key !== ""; + // Decided once, here: the environment, then the program's switch, then the key. + if (TraceClient.environmentOptsOut(options.env)) { + this.reason = TraceClient.REASON_ENVIRONMENT; + } else if (options.enabled === false) { + this.reason = TraceClient.REASON_CONFIG; + } else if (this.key === "") { + this.reason = TraceClient.REASON_NO_KEY; + } else { + this.reason = null; + } + this.active = this.reason === null; } /** A client that reports nothing. Useful as a default before settings are read. */ @@ -102,11 +183,24 @@ export class TraceClient { return new TraceClient("http://disabled.invalid", "disabled", { enabled: false }); } - /** Whether {@link report} will actually send anything. */ + /** + * Whether {@link report} will actually send anything. `false` after + * {@link close} too; {@link disabledReason} keeps the reason the client was + * built off, if it was. + */ get enabled(): boolean { return this.active; } + /** + * Why this client reports nothing: `"environment"` (`TRACE_USAGE_REPORTING` + * / `DO_NOT_TRACK`), `"config"` (`enabled: false`) or `"no key"`; `null` + * when it reports. Fixed at construction; {@link close} does not change it. + */ + get disabledReason(): DisabledReason | null { + return this.reason; + } + /** * Report that `name` happened, with an optional numeric value and optional * string tags. The returned promise is the delivery attempt itself: it is @@ -245,6 +339,28 @@ export function isBot(userAgent: string | null | undefined): boolean { // -- helpers ---------------------------------------------------------------- +/** + * The two opt-out variables from `process.env`, or an empty environment where + * there is no `process` (or reading it throws, as a permission-gated runtime + * may). Each is read by its literal name, which is what lets bundlers such as + * Next.js inline it for the Edge runtime. + */ +function processEnvironment(): Environment { + try { + if (typeof process === "undefined" || !process || !process.env) return {}; + return { + TRACE_USAGE_REPORTING: process.env.TRACE_USAGE_REPORTING, + DO_NOT_TRACK: process.env.DO_NOT_TRACK, + }; + } catch { + return {}; + } +} + +function matches(value: unknown, accepted: readonly string[]): boolean { + return typeof value === "string" && accepted.includes(value.trim().toLowerCase()); +} + function serialize( application: string, name: string,