diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index 7e86f2779..9133aaf55 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -15,7 +15,7 @@ Three events, each with a small set of properties: | `inference_turn` | Once per completed turn | `provider_id`, `model_id`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `thinking_tokens`, `duration_ms` | Common properties attached to every event: a random installation UUID -(`distinct_id`), `service_version`, `os_type`, `os_arch`, and a +(`distinct_id`), `session_id`, `service_version`, `os_type`, `os_arch`, and a `schema_version` for forward compatibility. 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 installation, not a person — there is no account, email, or other PII behind it. +`session_id` is a separate random UUID minted fresh each time the CLI +process starts; it lives only in memory and is never written to disk or to +`~/.corbits/settings.json`. It lets events be correlated within a single run +and cannot be used to link one run to another. + ## Backend Events are sent to PostHog. PostHog derives an approximate country from the diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts index c1901a328..ba61adcc9 100644 --- a/src/telemetry/index.ts +++ b/src/telemetry/index.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "node:crypto"; + import pkg from "../../package.json" with { type: "json" }; import { ENV_PREFIX } from "../branding.js"; import type { Settings } from "../config/settings.js"; @@ -26,10 +28,22 @@ export const TELEMETRY_NOTICE = export type TelemetryEvent = "cli_start" | "session_end" | "inference_turn"; +// One id per interactive process (TUI session or CLI invocation), generated +// once at module load and reused by every createTelemetry() instance for the +// life of the process — including across the toggle handler's re-creation on +// enable/disable — so PostHog can group every event this process emits into +// one session. Future emitters (AI turn events, feedback) read this via +// getSessionId() rather than generating their own. +const SESSION_ID = randomUUID(); + +export function getSessionId(): string { + return SESSION_ID; +} + // Per-event property allowlist. Anything not listed here is stripped before // the payload leaves the process. Together with the fixed common properties -// capture() appends (service_version, os_type, os_arch, schema_version), -// this bounds everything telemetry can ever contain. +// capture() appends (service_version, os_type, os_arch, schema_version, +// session_id), this bounds everything telemetry can ever contain. const EVENT_PROPERTY_ALLOWLIST: Record = { cli_start: [], session_end: ["status", "turn_count", "duration_ms", "session_mode", "exit_reason"], @@ -140,6 +154,7 @@ export function createTelemetry(options: CreateTelemetryOptions): Telemetry { os_type: process.platform, os_arch: process.arch, schema_version: 1, + session_id: SESSION_ID, }, }; diff --git a/tests/unit/telemetry-toggle.test.ts b/tests/unit/telemetry-toggle.test.ts index 1e4fb541a..bd5e95f5a 100644 --- a/tests/unit/telemetry-toggle.test.ts +++ b/tests/unit/telemetry-toggle.test.ts @@ -1,6 +1,6 @@ import { test, expect } from "bun:test"; import { createTelemetryToggleHandler, type TelemetryToggleDeps } from "../../src/telemetry/toggle.js"; -import { createTelemetry } from "../../src/telemetry/index.js"; +import { createTelemetry, getSessionId } from "../../src/telemetry/index.js"; import type { Settings } from "../../src/config/settings.js"; import type { Telemetry } from "../../src/telemetry/index.js"; @@ -178,3 +178,39 @@ test("toggle on re-enables after settings load/save resolve", async () => { expect(saved?.telemetry?.enabled).toBe(true); expect(getInstance().enabled).toBe(true); }); + +test("session_id on captured payloads stays constant across an enable/disable/enable toggle cycle", async () => { + const capturedBodies: { properties: Record }[] = []; + const fetchFn = ((_url: string, init: RequestInit) => { + capturedBodies.push(JSON.parse(init.body as string)); + return Promise.resolve(new Response("1", { status: 200 })); + }) as unknown as typeof fetch; + const { deps, getInstance } = fakeDeps({ + createTelemetry: (opts) => + createTelemetry({ ...opts, env: opts.env ?? {}, apiKey: opts.apiKey ?? "test-key", fetchFn }), + }); + const handler = createTelemetryToggleHandler("/fake/path", deps); + + // Re-enable once up front so the captured instance is one built through + // deps.createTelemetry (and thus fetchFn) rather than fakeDeps' bootstrap + // instance, which is wired to its own separate fetch counter. + handler(true); + await new Promise((resolve) => setTimeout(resolve, 10)); + getInstance().capture("cli_start"); + + handler(false); + await new Promise((resolve) => setTimeout(resolve, 10)); + getInstance().capture("cli_start"); // disabled: no fetch, but proves the swapped instance is live + + handler(true); + await new Promise((resolve) => setTimeout(resolve, 10)); + getInstance().capture("cli_start"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(capturedBodies.length).toBe(2); + const sessionId = capturedBodies[0].properties.session_id; + expect(typeof sessionId).toBe("string"); + expect((sessionId as string).length).toBeGreaterThan(0); + expect(capturedBodies[1].properties.session_id).toBe(sessionId); + expect(sessionId).toBe(getSessionId()); +}); diff --git a/tests/unit/telemetry.test.ts b/tests/unit/telemetry.test.ts index af2cc8ba1..a34bfd48d 100644 --- a/tests/unit/telemetry.test.ts +++ b/tests/unit/telemetry.test.ts @@ -2,7 +2,12 @@ import { test, expect } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createTelemetry, resolveTelemetryEnabled, telemetryDisabledByEnv } from "../../src/telemetry/index.js"; +import { + createTelemetry, + getSessionId, + resolveTelemetryEnabled, + telemetryDisabledByEnv, +} from "../../src/telemetry/index.js"; import { ensureTelemetrySettings } from "../../src/config/settings.js"; import type { Settings } from "../../src/config/settings.js"; @@ -240,6 +245,30 @@ test("flush resolves immediately when nothing is pending", async () => { await expect(telemetry.flush()).resolves.toBeUndefined(); }); +test("capture attaches the same session_id across multiple events in one process", async () => { + const calls: unknown[] = []; + const impl = ((_url: string, init: RequestInit) => { + calls.push(JSON.parse(init.body as string)); + return Promise.resolve(new Response("1", { status: 200 })); + }) as unknown as typeof fetch; + const telemetry = createTelemetry({ + settings: settingsWith("my-install-id"), + env: {}, + fetchFn: impl, + apiKey: "test-key", + }); + telemetry.capture("cli_start"); + telemetry.capture("session_end", { status: "ok" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(calls.length).toBe(2); + const bodies = calls as { properties: Record }[]; + const sessionId = bodies[0].properties.session_id; + expect(typeof sessionId).toBe("string"); + expect((sessionId as string).length).toBeGreaterThan(0); + expect(bodies[1].properties.session_id).toBe(sessionId); + expect(sessionId).toBe(getSessionId()); +}); + test("ensureTelemetrySettings called twice keeps installationId and enabled flag unchanged", async () => { const dir = await mkdtemp(join(tmpdir(), "corbits-telemetry-settings-")); const path = join(dir, "settings.json");