Skip to content

Commit 1fb2a4f

Browse files
Attach a process-wide session id to every telemetry capture (#412)
* Attach a process-wide session id to every telemetry capture Product and future AI observability events need to group into one PostHog session per process. Generate the session id once at module load and attach it to every capture() payload's common properties, alongside the existing installation-UUID distinct_id, so nothing downstream has to thread a session identifier through call sites. * Replace the tautological session-id re-creation test with a real one The prior test called createTelemetry() twice but asserted against getSessionId(), which never reads from a Telemetry instance — it would have passed even if createTelemetry() were never called. The replacement drives the actual toggle.ts enable/disable/enable cycle and asserts session_id stays constant across the captured payloads. * Document the session_id telemetry property Callers reading the privacy section need to know session_id is a fresh in-memory UUID per process run, never persisted, so it cannot be used to correlate events across separate CLI launches.
1 parent 959a96e commit 1fb2a4f

4 files changed

Lines changed: 90 additions & 5 deletions

File tree

docs/TELEMETRY.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Three events, each with a small set of properties:
1515
| `inference_turn` | Once per completed turn | `provider_id`, `model_id`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `thinking_tokens`, `duration_ms` |
1616

1717
Common properties attached to every event: a random installation UUID
18-
(`distinct_id`), `service_version`, `os_type`, `os_arch`, and a
18+
(`distinct_id`), `session_id`, `service_version`, `os_type`, `os_arch`, and a
1919
`schema_version` for forward compatibility.
2020

2121
Approximate country-level location is derived server-side by PostHog from the
@@ -81,6 +81,11 @@ first run and stored in `~/.corbits/settings.json`. It identifies an
8181
installation, not a person — there is no account, email, or other PII behind
8282
it.
8383

84+
`session_id` is a separate random UUID minted fresh each time the CLI
85+
process starts; it lives only in memory and is never written to disk or to
86+
`~/.corbits/settings.json`. It lets events be correlated within a single run
87+
and cannot be used to link one run to another.
88+
8489
## Backend
8590

8691
Events are sent to PostHog. PostHog derives an approximate country from the

src/telemetry/index.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { randomUUID } from "node:crypto";
2+
13
import pkg from "../../package.json" with { type: "json" };
24
import { ENV_PREFIX } from "../branding.js";
35
import type { Settings } from "../config/settings.js";
@@ -26,10 +28,22 @@ export const TELEMETRY_NOTICE =
2628

2729
export type TelemetryEvent = "cli_start" | "session_end" | "inference_turn";
2830

31+
// One id per interactive process (TUI session or CLI invocation), generated
32+
// once at module load and reused by every createTelemetry() instance for the
33+
// life of the process — including across the toggle handler's re-creation on
34+
// enable/disable — so PostHog can group every event this process emits into
35+
// one session. Future emitters (AI turn events, feedback) read this via
36+
// getSessionId() rather than generating their own.
37+
const SESSION_ID = randomUUID();
38+
39+
export function getSessionId(): string {
40+
return SESSION_ID;
41+
}
42+
2943
// Per-event property allowlist. Anything not listed here is stripped before
3044
// the payload leaves the process. Together with the fixed common properties
31-
// capture() appends (service_version, os_type, os_arch, schema_version),
32-
// this bounds everything telemetry can ever contain.
45+
// capture() appends (service_version, os_type, os_arch, schema_version,
46+
// session_id), this bounds everything telemetry can ever contain.
3347
const EVENT_PROPERTY_ALLOWLIST: Record<TelemetryEvent, readonly string[]> = {
3448
cli_start: [],
3549
session_end: ["status", "turn_count", "duration_ms", "session_mode", "exit_reason"],
@@ -140,6 +154,7 @@ export function createTelemetry(options: CreateTelemetryOptions): Telemetry {
140154
os_type: process.platform,
141155
os_arch: process.arch,
142156
schema_version: 1,
157+
session_id: SESSION_ID,
143158
},
144159
};
145160

tests/unit/telemetry-toggle.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { test, expect } from "bun:test";
22
import { createTelemetryToggleHandler, type TelemetryToggleDeps } from "../../src/telemetry/toggle.js";
3-
import { createTelemetry } from "../../src/telemetry/index.js";
3+
import { createTelemetry, getSessionId } from "../../src/telemetry/index.js";
44
import type { Settings } from "../../src/config/settings.js";
55
import type { Telemetry } from "../../src/telemetry/index.js";
66

@@ -178,3 +178,39 @@ test("toggle on re-enables after settings load/save resolve", async () => {
178178
expect(saved?.telemetry?.enabled).toBe(true);
179179
expect(getInstance().enabled).toBe(true);
180180
});
181+
182+
test("session_id on captured payloads stays constant across an enable/disable/enable toggle cycle", async () => {
183+
const capturedBodies: { properties: Record<string, unknown> }[] = [];
184+
const fetchFn = ((_url: string, init: RequestInit) => {
185+
capturedBodies.push(JSON.parse(init.body as string));
186+
return Promise.resolve(new Response("1", { status: 200 }));
187+
}) as unknown as typeof fetch;
188+
const { deps, getInstance } = fakeDeps({
189+
createTelemetry: (opts) =>
190+
createTelemetry({ ...opts, env: opts.env ?? {}, apiKey: opts.apiKey ?? "test-key", fetchFn }),
191+
});
192+
const handler = createTelemetryToggleHandler("/fake/path", deps);
193+
194+
// Re-enable once up front so the captured instance is one built through
195+
// deps.createTelemetry (and thus fetchFn) rather than fakeDeps' bootstrap
196+
// instance, which is wired to its own separate fetch counter.
197+
handler(true);
198+
await new Promise((resolve) => setTimeout(resolve, 10));
199+
getInstance().capture("cli_start");
200+
201+
handler(false);
202+
await new Promise((resolve) => setTimeout(resolve, 10));
203+
getInstance().capture("cli_start"); // disabled: no fetch, but proves the swapped instance is live
204+
205+
handler(true);
206+
await new Promise((resolve) => setTimeout(resolve, 10));
207+
getInstance().capture("cli_start");
208+
209+
await new Promise((resolve) => setTimeout(resolve, 0));
210+
expect(capturedBodies.length).toBe(2);
211+
const sessionId = capturedBodies[0].properties.session_id;
212+
expect(typeof sessionId).toBe("string");
213+
expect((sessionId as string).length).toBeGreaterThan(0);
214+
expect(capturedBodies[1].properties.session_id).toBe(sessionId);
215+
expect(sessionId).toBe(getSessionId());
216+
});

tests/unit/telemetry.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { test, expect } from "bun:test";
22
import { mkdtemp, rm } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
5-
import { createTelemetry, resolveTelemetryEnabled, telemetryDisabledByEnv } from "../../src/telemetry/index.js";
5+
import {
6+
createTelemetry,
7+
getSessionId,
8+
resolveTelemetryEnabled,
9+
telemetryDisabledByEnv,
10+
} from "../../src/telemetry/index.js";
611
import { ensureTelemetrySettings } from "../../src/config/settings.js";
712
import type { Settings } from "../../src/config/settings.js";
813

@@ -240,6 +245,30 @@ test("flush resolves immediately when nothing is pending", async () => {
240245
await expect(telemetry.flush()).resolves.toBeUndefined();
241246
});
242247

248+
test("capture attaches the same session_id across multiple events in one process", async () => {
249+
const calls: unknown[] = [];
250+
const impl = ((_url: string, init: RequestInit) => {
251+
calls.push(JSON.parse(init.body as string));
252+
return Promise.resolve(new Response("1", { status: 200 }));
253+
}) as unknown as typeof fetch;
254+
const telemetry = createTelemetry({
255+
settings: settingsWith("my-install-id"),
256+
env: {},
257+
fetchFn: impl,
258+
apiKey: "test-key",
259+
});
260+
telemetry.capture("cli_start");
261+
telemetry.capture("session_end", { status: "ok" });
262+
await new Promise((resolve) => setTimeout(resolve, 0));
263+
expect(calls.length).toBe(2);
264+
const bodies = calls as { properties: Record<string, unknown> }[];
265+
const sessionId = bodies[0].properties.session_id;
266+
expect(typeof sessionId).toBe("string");
267+
expect((sessionId as string).length).toBeGreaterThan(0);
268+
expect(bodies[1].properties.session_id).toBe(sessionId);
269+
expect(sessionId).toBe(getSessionId());
270+
});
271+
243272
test("ensureTelemetrySettings called twice keeps installationId and enabled flag unchanged", async () => {
244273
const dir = await mkdtemp(join(tmpdir(), "corbits-telemetry-settings-"));
245274
const path = join(dir, "settings.json");

0 commit comments

Comments
 (0)