Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (<application>)`.

## 0.1.0 — 2026-09-12

First release. One file, zero dependencies, no Node-only APIs (Node 18+ and
Expand Down
54 changes: 50 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |
|---|---|---|
Expand All @@ -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 <key>`, `User-Agent: trace-client-js/0.1.0 (<application>)`
`Authorization: Bearer <key>`, `User-Agent: trace-client-js/0.2.0 (<application>)`
and a body of

```json
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
174 changes: 174 additions & 0 deletions tests/trace-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined>) => 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<string, string | undefined> = {};
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<string, string | undefined>;
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading