From 3f264581aaae20c040f573c92d08a02f9848a80c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 14:27:38 -0700 Subject: [PATCH 1/3] 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. --- src/telemetry/index.ts | 19 ++++++++++++++++-- tests/unit/telemetry.test.ts | 39 +++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) 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.test.ts b/tests/unit/telemetry.test.ts index af2cc8ba1..6cba3770b 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,38 @@ 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("getSessionId is stable across separate createTelemetry instances in the same process", () => { + const first = createTelemetry({ settings: settingsWith("id"), env: {}, apiKey: "" }); + const second = createTelemetry({ settings: settingsWith("id"), env: {}, apiKey: "" }); + void first; + void second; + expect(getSessionId()).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"); From 311201d17c84e75653013e2a6bd328ecffc75a7e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 14:34:55 -0700 Subject: [PATCH 2/3] Replace the tautological session-id re-creation test with a real one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/unit/telemetry-toggle.test.ts | 38 ++++++++++++++++++++++++++++- tests/unit/telemetry.test.ts | 8 ------ 2 files changed, 37 insertions(+), 9 deletions(-) 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 6cba3770b..a34bfd48d 100644 --- a/tests/unit/telemetry.test.ts +++ b/tests/unit/telemetry.test.ts @@ -269,14 +269,6 @@ test("capture attaches the same session_id across multiple events in one process expect(sessionId).toBe(getSessionId()); }); -test("getSessionId is stable across separate createTelemetry instances in the same process", () => { - const first = createTelemetry({ settings: settingsWith("id"), env: {}, apiKey: "" }); - const second = createTelemetry({ settings: settingsWith("id"), env: {}, apiKey: "" }); - void first; - void second; - expect(getSessionId()).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"); From e2f88a66ce5efd999654e49f1c2a714a3f334adf Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:42:19 -0700 Subject: [PATCH 3/3] 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. --- docs/TELEMETRY.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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