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
7 changes: 6 additions & 1 deletion docs/TELEMETRY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions src/telemetry/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<TelemetryEvent, readonly string[]> = {
cli_start: [],
session_end: ["status", "turn_count", "duration_ms", "session_mode", "exit_reason"],
Expand Down Expand Up @@ -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,
},
};

Expand Down
38 changes: 37 additions & 1 deletion tests/unit/telemetry-toggle.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<string, unknown> }[] = [];
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());
});
31 changes: 30 additions & 1 deletion tests/unit/telemetry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string, unknown> }[];
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");
Expand Down
Loading