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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 63 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down Expand Up @@ -208,15 +268,16 @@ 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.3.0 (<application>)`
`Authorization: Bearer <key>`, `User-Agent: trace-client-js/0.4.0 (<application>)`
and a body of

```json
{"application":"my-site","name":"page-view","tags":{"page":"/blog/hello","version":"1.4.0"}}
```

`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.
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.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",
Expand Down
158 changes: 157 additions & 1 deletion tests/trace-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, string> = {};
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<string, string> = {};
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"]);
Expand All @@ -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");
});
});

Expand Down
Loading
Loading