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,