diff --git a/.gitignore b/.gitignore index b81df4cb2..a3f366a86 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ src/**/*.d.ts !src/preload/index.d.ts !src/renderer/src/env.d.ts !src/renderer/src/mermaid.d.ts +!src/types/build-constants.d.ts # Dependencies node_modules/ diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 640da09f4..a8145eb76 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -5,7 +5,8 @@ "../src/main/**/*", "../src/preload/**/*", "../src/shared/**/*", - "../src/relay/**/*" + "../src/relay/**/*", + "../src/types/**/*" ], "compilerOptions": { "composite": true, diff --git a/electron.vite.config.ts b/electron.vite.config.ts index a9ed3e921..611999e84 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -3,6 +3,31 @@ import { defineConfig } from 'electron-vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +// Why: the telemetry transport is gated by two compile-time constants that +// only the official CI release workflow sets. Contributor / `pnpm dev` / +// third-party rebuilds must substitute literal `null` at these sites so +// `IS_OFFICIAL_BUILD` in `src/main/telemetry/client.ts` evaluates `false` +// at module load and the track() wrapper short-circuits to console-mirror. +// The substitution happens at compile time — there is no runtime env-var +// fallback — so a curious contributor cannot spoof transmission with a +// shell export. +// +// CI injects real values via GitHub Actions secrets +// (ORCA_BUILD_IDENTITY='stable' | 'rc', ORCA_POSTHOG_WRITE_KEY=phc_...); +// every other build path resolves these env vars to undefined, which the +// JSON.stringify below folds to the literal `null`. Ambient declarations +// for the two constants live in `src/types/build-constants.d.ts`. +const orcaBuildIdentity = process.env.ORCA_BUILD_IDENTITY +const ORCA_BUILD_IDENTITY_LITERAL = + orcaBuildIdentity === 'stable' || orcaBuildIdentity === 'rc' + ? JSON.stringify(orcaBuildIdentity) + : 'null' +const orcaPostHogWriteKey = process.env.ORCA_POSTHOG_WRITE_KEY +const ORCA_POSTHOG_WRITE_KEY_LITERAL = + typeof orcaPostHogWriteKey === 'string' && orcaPostHogWriteKey.length > 0 + ? JSON.stringify(orcaPostHogWriteKey) + : 'null' + export default defineConfig({ main: { build: { @@ -20,6 +45,12 @@ export default defineConfig({ } } }, + // Why: compile-time substitution for the telemetry gate. See the block + // above for the full rationale. + define: { + ORCA_BUILD_IDENTITY: ORCA_BUILD_IDENTITY_LITERAL, + ORCA_POSTHOG_WRITE_KEY: ORCA_POSTHOG_WRITE_KEY_LITERAL + }, // Why: @xterm/headless declares "exports": null in package.json, which // prevents Vite's default resolver from finding the CJS entry. Point // directly at the published main file so the bundler can inline it. diff --git a/package.json b/package.json index 18dc5631f..0dd8f905f 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "monaco-editor": "^0.55.1", "node-pty": "^1.1.0", "pdfjs-dist": "^5.6.205", + "posthog-node": "^5.33.0", "radix-ui": "^1.4.3", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 071808dcd..1eaf07240 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,6 +163,9 @@ importers: pdfjs-dist: specifier: ^5.6.205 version: 5.6.205 + posthog-node: + specifier: ^5.33.0 + version: 5.33.0 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1482,6 +1485,12 @@ packages: engines: {node: '>=18'} hasBin: true + '@posthog/core@1.28.0': + resolution: {integrity: sha512-753giUMWuk602UtS101tDZuNcwiKkr+3UEhLgfOwHAk2W32n53knOxAjyWT0JwMq5/+0uSQ2y4uaZXQAxwvBSw==} + + '@posthog/types@1.372.6': + resolution: {integrity: sha512-sqI36LBvuo8xcYsXIlVa0q3IXJJjqtatM2LrXlyOM7kgHrldBwS4ldzaTXrTdpe/TiIl1b4ZHxtSHMzPig+DnQ==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -5298,6 +5307,15 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + posthog-node@5.33.0: + resolution: {integrity: sha512-9Sj1kKQTFlHoiWl0LvD3+8InhlD9jxeIiJrMLtU87bnYuQUDqAVR16v9/ZGaQ9C0vDVGVJJQG1a6XYeySy9hmg==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + postject@1.0.0-alpha.6: resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} engines: {node: '>=14.0.0'} @@ -7371,6 +7389,12 @@ snapshots: dependencies: playwright: 1.59.1 + '@posthog/core@1.28.0': + dependencies: + '@posthog/types': 1.372.6 + + '@posthog/types@1.372.6': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -11688,6 +11712,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + posthog-node@5.33.0: + dependencies: + '@posthog/core': 1.28.0 + postject@1.0.0-alpha.6: dependencies: commander: 9.5.0 diff --git a/src/main/index.ts b/src/main/index.ts index 3535d8151..650ea1404 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -15,6 +15,7 @@ import { initDaemonPtyProvider, disconnectDaemon } from './daemon/daemon-init' import { setAppRuntimeFlags } from './ipc/app' import { closeAllWatchers } from './ipc/filesystem-watcher' import { registerCoreHandlers } from './ipc/register-core-handlers' +import { initTelemetry, shutdownTelemetry } from './telemetry/client' import { triggerStartupNotificationRegistration } from './ipc/notifications' import { OrcaRuntimeService } from './runtime/orca-runtime' import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc' @@ -373,6 +374,13 @@ app.whenReady().then(async () => { } store = new Store() + // Why: telemetry must initialize before any IPC handler / renderer can + // call `track()`. The client is a no-op in dev/contributor builds + // (`IS_OFFICIAL_BUILD === false`) and a no-op while `TELEMETRY_ENABLED` + // is false in PR 2 — so this call is safe to run early; it only records + // the Store reference, seeds common props, and resets per-session burst + // caps. Actual transport initialization is still gated by both flags. + initTelemetry(store) stats = new StatsCollector() claudeUsage = new ClaudeUsageStore(store) codexUsage = new CodexUsageStore(store) @@ -621,10 +629,21 @@ app.on('will-quit', (e) => { // RPC stop + owned-metadata clear to complete before Electron exits. // Using allSettled (not all) preserves the existing fail-open posture: // if disconnectDaemon rejects, we still quit instead of hanging the app. - Promise.allSettled([disconnectDaemon(), rpcStopAndClear]).then(() => { - daemonDisconnectDone = true - app.quit() - }) + // + // Telemetry shutdown folds in after the daemon/RPC teardown and BEFORE + // app.quit(): the PostHog client has up to 2s of bounded flush. Errors + // inside `shutdownTelemetry()` are caught by the client itself — we + // catch again here defensively so a flush failure cannot cancel the + // quit chain. + Promise.allSettled([disconnectDaemon(), rpcStopAndClear]) + .then(() => shutdownTelemetry()) + .catch(() => { + /* swallow — telemetry must never prevent app.quit() */ + }) + .then(() => { + daemonDisconnectDone = true + app.quit() + }) } }) diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 9b10341db..916c8e978 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -12,6 +12,7 @@ const { registerNotificationHandlersMock, registerDeveloperPermissionHandlersMock, registerSettingsHandlersMock, + registerTelemetryHandlersMock, registerShellHandlersMock, registerSidekickHandlersMock, registerSessionHandlersMock, @@ -43,6 +44,7 @@ const { registerNotificationHandlersMock: vi.fn(), registerDeveloperPermissionHandlersMock: vi.fn(), registerSettingsHandlersMock: vi.fn(), + registerTelemetryHandlersMock: vi.fn(), registerShellHandlersMock: vi.fn(), registerSidekickHandlersMock: vi.fn(), registerSessionHandlersMock: vi.fn(), @@ -112,6 +114,10 @@ vi.mock('./settings', () => ({ registerSettingsHandlers: registerSettingsHandlersMock })) +vi.mock('./telemetry', () => ({ + registerTelemetryHandlers: registerTelemetryHandlersMock +})) + vi.mock('./shell', () => ({ registerShellHandlers: registerShellHandlersMock })) @@ -190,6 +196,7 @@ describe('registerCoreHandlers', () => { registerNotificationHandlersMock.mockReset() registerDeveloperPermissionHandlersMock.mockReset() registerSettingsHandlersMock.mockReset() + registerTelemetryHandlersMock.mockReset() registerShellHandlersMock.mockReset() registerSidekickHandlersMock.mockReset() registerSessionHandlersMock.mockReset() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index a64361f27..704358fc2 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -21,6 +21,7 @@ import { registerDeveloperPermissionHandlers } from './developer-permissions' import { setTrustedBrowserRendererWebContentsId, setAgentBrowserBridgeRef } from './browser' import { registerSessionHandlers } from './session' import { registerSettingsHandlers } from './settings' +import { registerTelemetryHandlers } from './telemetry' import { registerBrowserHandlers } from './browser' import { browserSessionRegistry } from '../browser/browser-session-registry' import { registerShellHandlers } from './shell' @@ -82,6 +83,7 @@ export function registerCoreHandlers( registerNotificationHandlers(store) registerDeveloperPermissionHandlers() registerSettingsHandlers(store) + registerTelemetryHandlers() registerBrowserHandlers() // Why: applyPendingCookieImport MUST run before restorePersistedUserAgent // because the latter calls session.fromPartition() which initializes diff --git a/src/main/ipc/telemetry.test.ts b/src/main/ipc/telemetry.test.ts new file mode 100644 index 000000000..3908206c1 --- /dev/null +++ b/src/main/ipc/telemetry.test.ts @@ -0,0 +1,115 @@ +// IPC boundary behavior. Strict type narrows must drop obviously-malformed +// calls before they reach the validator (the renderer is in the threat +// model). Also pins the consent-mutation rate limit: ≤5 +// `telemetry:setOptIn` calls per session. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const handlers = new Map unknown>() +const { handleMock, trackMock, setOptInMock, consumeConsentMutationTokenMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + trackMock: vi.fn(), + setOptInMock: vi.fn(), + consumeConsentMutationTokenMock: vi.fn() +})) + +vi.mock('electron', () => ({ ipcMain: { handle: handleMock } })) +vi.mock('../telemetry/client', () => ({ + track: trackMock, + setOptIn: setOptInMock +})) +vi.mock('../telemetry/burst-cap', () => ({ + consumeConsentMutationToken: consumeConsentMutationTokenMock +})) + +import { registerTelemetryHandlers } from './telemetry' + +function captureHandlers(): void { + handlers.clear() + for (const call of handleMock.mock.calls) { + const [channel, handler] = call as [string, typeof handlers extends Map ? V : never] + handlers.set(channel, handler) + } +} + +describe('telemetry IPC handlers', () => { + beforeEach(() => { + handleMock.mockReset() + trackMock.mockReset() + setOptInMock.mockReset() + consumeConsentMutationTokenMock.mockReset() + consumeConsentMutationTokenMock.mockReturnValue(true) + registerTelemetryHandlers() + captureHandlers() + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('registers both channels', () => { + expect(handlers.has('telemetry:track')).toBe(true) + expect(handlers.has('telemetry:setOptIn')).toBe(true) + }) + + // ── telemetry:track ────────────────────────────────────────────────── + + it('forwards a well-typed track call to track()', () => { + const handler = handlers.get('telemetry:track')! + handler({}, 'app_opened', {}) + expect(trackMock).toHaveBeenCalledTimes(1) + expect(trackMock).toHaveBeenCalledWith('app_opened', {}) + }) + + it('drops track calls with a non-string name', () => { + const handler = handlers.get('telemetry:track')! + handler({}, 42, {}) + handler({}, null, {}) + handler({}, { event: 'app_opened' }, {}) + expect(trackMock).not.toHaveBeenCalled() + }) + + it('drops track calls with non-object props', () => { + const handler = handlers.get('telemetry:track')! + handler({}, 'app_opened', 'string-not-object') + handler({}, 'app_opened', 42) + expect(trackMock).not.toHaveBeenCalled() + }) + + it('treats null/undefined props as an empty object', () => { + const handler = handlers.get('telemetry:track')! + handler({}, 'app_opened', null) + handler({}, 'app_opened', undefined) + expect(trackMock).toHaveBeenCalledTimes(2) + expect(trackMock).toHaveBeenNthCalledWith(1, 'app_opened', {}) + expect(trackMock).toHaveBeenNthCalledWith(2, 'app_opened', {}) + }) + + // ── telemetry:setOptIn ─────────────────────────────────────────────── + + it('drops setOptIn with non-boolean optedIn', () => { + const handler = handlers.get('telemetry:setOptIn')! + handler({}, 'true') + handler({}, 1) + handler({}, null) + handler({}, undefined) + expect(setOptInMock).not.toHaveBeenCalled() + // None of these should have consumed a mutation token either. + expect(consumeConsentMutationTokenMock).not.toHaveBeenCalled() + }) + + it('forwards a boolean setOptIn to setOptIn(settings, optedIn) when the token is available', () => { + const handler = handlers.get('telemetry:setOptIn')! + consumeConsentMutationTokenMock.mockReturnValue(true) + handler({}, true) + expect(setOptInMock).toHaveBeenCalledWith('settings', true) + handler({}, false) + expect(setOptInMock).toHaveBeenCalledWith('settings', false) + }) + + it('drops setOptIn past the consent-mutation rate limit', () => { + const handler = handlers.get('telemetry:setOptIn')! + consumeConsentMutationTokenMock.mockReturnValue(false) + handler({}, true) + expect(setOptInMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts new file mode 100644 index 000000000..4d280b552 --- /dev/null +++ b/src/main/ipc/telemetry.ts @@ -0,0 +1,70 @@ +// IPC surface for the telemetry transport. Two handlers, both renderer- +// facing: one pipe (`telemetry:track`) and one consent-mutation +// (`telemetry:setOptIn`). Every track call from the renderer lands here, +// and from here funnels into the same `track()` the main-originated events +// go through — the validator is the single enforcement point, not this +// file. +// +// Threat model: the renderer renders attacker-controllable content (agent +// output, MCP responses, file contents, markdown, diff views). An +// XSS-equivalent rendering bug in any of those surfaces gives an attacker +// the ability to invoke `window.api.telemetry*` at will. Both handlers +// below are designed to fail closed under that model: +// +// - Strict main-side type narrows. TypeScript types do not survive IPC +// serialization; the renderer can pass anything across the wire, so we +// narrow at the boundary. Non-string `name` or non-object `props` on +// `track` → drop silently. Non-boolean `optedIn` on `setOptIn` → drop. +// - Consent-mutation rate limit. A real user flips the Privacy pane +// toggle a handful of times at most; beyond 5 per session it is either +// a UI bug or a compromised renderer. Drop silently past the cap. + +import { ipcMain } from 'electron' +import { consumeConsentMutationToken } from '../telemetry/burst-cap' +import { setOptIn, track } from '../telemetry/client' +import type { EventName, EventProps } from '../../shared/telemetry-events' + +export function registerTelemetryHandlers(): void { + ipcMain.handle( + 'telemetry:track', + (_event, name: unknown, props: unknown): void => { + // Strict input typing: non-string names are dropped at the boundary + // before the validator even sees them. The validator would also drop + // (unknown event name), but the main-side narrow keeps the attack + // surface minimal — a flood of bogus payloads does not exercise the + // Zod parser for no reason. + if (typeof name !== 'string') { + return + } + // `props` may legitimately be omitted; treat `undefined`/`null` as an + // empty object before the validator. Anything else non-object (e.g. + // a string, a number) is a boundary violation. + if (props !== null && props !== undefined && typeof props !== 'object') { + return + } + // The casts to `EventName` / `EventProps` here are + // pass-through only — this file does NOT pretend the renderer's + // name/props are type-safe. The validator inside `track()` is the + // single enforcement point at runtime; these casts only feed the + // typed channel that the validator will re-check. + track( + name as EventName, + (props ?? {}) as EventProps + ) + } + ) + + ipcMain.handle('telemetry:setOptIn', (_event, optedIn: unknown): void => { + // Strict input typing — renderer can pass anything over IPC. + if (typeof optedIn !== 'boolean') { + return + } + // Consent-mutation bucket: ≤5 per session. See `burst-cap.ts`. Does not + // apply to main-originated consent mutations that bypass IPC (none + // today; this is future-proofing rather than a current code path). + if (!consumeConsentMutationToken()) { + return + } + setOptIn('settings', optedIn) + }) +} diff --git a/src/main/telemetry/burst-cap.test.ts b/src/main/telemetry/burst-cap.test.ts new file mode 100644 index 000000000..a7b3ae456 --- /dev/null +++ b/src/main/telemetry/burst-cap.test.ts @@ -0,0 +1,163 @@ +// Burst-cap behavior. These tests pin the three independent buckets (per- +// event token bucket, per-session global ceiling, consent-mutation bucket), +// the refill math, and the "exactly one warn per cap crossing per session" +// rule. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + _getBurstCapStateForTests, + consumeBurstToken, + consumeConsentMutationToken, + resetBurstCapsForSession +} from './burst-cap' + +describe('burst-cap', () => { + beforeEach(() => { + resetBurstCapsForSession() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-03T00:00:00Z')) + }) + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + // ── Per-event token bucket ─────────────────────────────────────────── + + it('allows up to 30 events/min for default-bucket event names', () => { + // 30 tokens; 30 consumes should all succeed, 31st fails in the same + // instant (no time has elapsed for refill). + for (let i = 0; i < 30; i++) { + expect(consumeBurstToken('app_opened')).toBe(true) + } + expect(consumeBurstToken('app_opened')).toBe(false) + }) + + it('allows up to 20 events/min for agent_error (tighter cap)', () => { + for (let i = 0; i < 20; i++) { + expect(consumeBurstToken('agent_error')).toBe(true) + } + expect(consumeBurstToken('agent_error')).toBe(false) + }) + + it('refills tokens continuously over the window', () => { + // Drain the bucket… + for (let i = 0; i < 30; i++) { + consumeBurstToken('app_opened') + } + expect(consumeBurstToken('app_opened')).toBe(false) + // …then advance half the refill window. Half of 30 = 15 tokens back. + vi.advanceTimersByTime(30_000) + let allowed = 0 + for (let i = 0; i < 20; i++) { + if (consumeBurstToken('app_opened')) { + allowed++ + } + } + expect(allowed).toBeGreaterThanOrEqual(14) + expect(allowed).toBeLessThanOrEqual(15) + }) + + it('emits exactly one warn the first time the per-event cap is crossed', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + for (let i = 0; i < 30; i++) { + consumeBurstToken('app_opened') + } + // Four overflow attempts — only one warn across all of them. + consumeBurstToken('app_opened') + consumeBurstToken('app_opened') + consumeBurstToken('app_opened') + consumeBurstToken('app_opened') + expect(warn).toHaveBeenCalledTimes(1) + }) + + // ── Unknown event names ────────────────────────────────────────────── + + it('rejects unknown event names without creating a bucket (prevents unbounded Map growth)', () => { + // The IPC handler casts any renderer string to `EventName`, so a + // compromised renderer could flood unique bogus names. `consumeBurstToken` + // must short-circuit before `getOrCreateBucket` to keep `perEventBuckets` + // bounded by the compile-time `eventSchemas` size. + const sizeBefore = _getBurstCapStateForTests().perEventBuckets.size + // Cast through unknown — this is the renderer-controlled-string scenario. + expect(consumeBurstToken('totally_bogus_event_name' as unknown as 'app_opened')).toBe(false) + expect(consumeBurstToken('another_fake_name' as unknown as 'app_opened')).toBe(false) + expect(consumeBurstToken('yet_another' as unknown as 'app_opened')).toBe(false) + const sizeAfter = _getBurstCapStateForTests().perEventBuckets.size + expect(sizeAfter).toBe(sizeBefore) + }) + + it('rejects Object.prototype key names without creating a bucket', () => { + // Regression: the guard originally used `name in eventSchemas`, which + // walks the prototype chain — so `'toString'`, `'__proto__'`, + // `'constructor'`, etc. would all pass the check and seed buckets. + // `Object.hasOwn` is an own-property check and rejects them. + const sizeBefore = _getBurstCapStateForTests().perEventBuckets.size + expect(consumeBurstToken('toString' as unknown as 'app_opened')).toBe(false) + expect(consumeBurstToken('__proto__' as unknown as 'app_opened')).toBe(false) + expect(consumeBurstToken('constructor' as unknown as 'app_opened')).toBe(false) + expect(consumeBurstToken('hasOwnProperty' as unknown as 'app_opened')).toBe(false) + expect(consumeBurstToken('valueOf' as unknown as 'app_opened')).toBe(false) + const sizeAfter = _getBurstCapStateForTests().perEventBuckets.size + expect(sizeAfter).toBe(sizeBefore) + }) + + // ── Per-session ceiling ────────────────────────────────────────────── + + it('enforces the 1000-event per-session ceiling even if per-event caps refill', () => { + // Cycle through enum event names to keep per-event buckets alive, and + // advance time so the per-event bucket always has a token. After + // ceiling is hit, all further attempts must fail regardless of which + // event name. + let accepted = 0 + for (let i = 0; i < 2000; i++) { + vi.advanceTimersByTime(10_000) // generous refill + if (consumeBurstToken('app_opened')) { + accepted++ + } + } + expect(accepted).toBe(1000) + }) + + it('resets the per-session ceiling on resetBurstCapsForSession()', () => { + for (let i = 0; i < 1500; i++) { + vi.advanceTimersByTime(10_000) + consumeBurstToken('app_opened') + } + resetBurstCapsForSession() + expect(consumeBurstToken('app_opened')).toBe(true) + expect(_getBurstCapStateForTests().perSessionCount).toBe(1) + }) + + // ── Consent-mutation bucket ────────────────────────────────────────── + + it('allows up to 5 consent mutations per session and then drops', () => { + for (let i = 0; i < 5; i++) { + expect(consumeConsentMutationToken()).toBe(true) + } + expect(consumeConsentMutationToken()).toBe(false) + expect(consumeConsentMutationToken()).toBe(false) + }) + + it('emits exactly one warn when the consent-mutation cap is first crossed', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + for (let i = 0; i < 5; i++) { + consumeConsentMutationToken() + } + // Multiple overflow attempts — one warn only. + consumeConsentMutationToken() + consumeConsentMutationToken() + consumeConsentMutationToken() + expect(warn).toHaveBeenCalledTimes(1) + }) + + it('consent-mutation bucket resets across sessions, not within one', () => { + for (let i = 0; i < 5; i++) { + consumeConsentMutationToken() + } + expect(consumeConsentMutationToken()).toBe(false) + resetBurstCapsForSession() + expect(consumeConsentMutationToken()).toBe(true) + }) +}) diff --git a/src/main/telemetry/burst-cap.ts b/src/main/telemetry/burst-cap.ts new file mode 100644 index 000000000..ae11520f5 --- /dev/null +++ b/src/main/telemetry/burst-cap.ts @@ -0,0 +1,170 @@ +// Burst caps for the telemetry transport. Three independent buckets — all +// must be satisfied for an event to transmit or a consent mutation to apply. +// +// (1) Per-event-name token bucket — defends against runaway-`useEffect` +// bugs and repeated error serializations. `agent_error` is rate-limited +// slightly tighter (20/min) than the default (30/min). +// +// (2) Per-session global ceiling (1,000 events) — defends against a +// compromised renderer. A renderer rendering attacker-controllable +// content can invoke `window.api.telemetryTrack` at any rate the +// per-event-name bucket allows; without a global ceiling, 24h of +// abuse at the per-event cap could emit ~650K events and blow the +// PostHog billing cap in one session. +// +// (3) Consent-mutation bucket (≤5 `setOptIn` calls per session) — a real +// user flips the Privacy pane toggle a handful of times at most; +// beyond that it is either a bug or a compromised renderer. +// +// All buckets reset when `resetBurstCapsForSession()` is called at the start +// of a fresh telemetry session (on `initTelemetry`). Within a session, the +// per-event token bucket refills continuously at its `capacity / 60_000` ms +// rate; the global ceiling and the consent-mutation bucket do not refill +// within a session by design — the whole point of the global ceiling is to +// cap aggregate per-session volume. +// +// Overflow is silent past the first crossing: each bucket logs exactly one +// `console.warn` the first time it rejects an attempt in a given session, +// then drops quietly until the next session reset. Rate-limiting the logs +// themselves is what keeps a pathological caller from DoSing stderr. + +import { eventSchemas, type EventName } from '../../shared/telemetry-events' + +const PER_EVENT_DEFAULT_CAPACITY = 30 +const PER_EVENT_AGENT_ERROR_CAPACITY = 20 +const WINDOW_MS = 60_000 + +const PER_SESSION_CEILING = 1_000 +const CONSENT_MUTATION_CEILING = 5 + +type TokenBucket = { + tokens: number + capacity: number + lastRefill: number + warned: boolean +} + +// Module-level state. One Store instance / one telemetry session per main +// process; there is no multi-tenant reuse to worry about. `initTelemetry` +// calls `resetBurstCapsForSession()` to start fresh on each session. +const perEventBuckets = new Map() +let perSessionCount = 0 +let perSessionWarned = false +let consentMutationCount = 0 +let consentMutationWarned = false + +function capacityFor(name: string): number { + return name === 'agent_error' ? PER_EVENT_AGENT_ERROR_CAPACITY : PER_EVENT_DEFAULT_CAPACITY +} + +function getOrCreateBucket(name: string, now: number): TokenBucket { + let bucket = perEventBuckets.get(name) + if (!bucket) { + const capacity = capacityFor(name) + bucket = { tokens: capacity, capacity, lastRefill: now, warned: false } + perEventBuckets.set(name, bucket) + return bucket + } + // Continuous refill — `capacity` tokens per `WINDOW_MS` ms. Computed + // lazily on each access so we do not need a timer; equivalent to the + // standard token-bucket formula used by most rate limiters. + const elapsed = now - bucket.lastRefill + if (elapsed > 0) { + const refill = (elapsed / WINDOW_MS) * bucket.capacity + bucket.tokens = Math.min(bucket.capacity, bucket.tokens + refill) + bucket.lastRefill = now + } + return bucket +} + +/** + * Consume one token for the given event name. Returns `true` if the event is + * allowed to proceed, `false` if any of the buckets rejected it. + * + * Ordering rationale: per-event bucket first, per-session ceiling second. + * The per-event bucket is the attention-conserving check (drops runaway + * useEffects early, before counting against the session ceiling); the global + * ceiling is the correctness backstop against a compromised renderer that + * cycles through event names to evade the per-event caps. + */ +export function consumeBurstToken(name: EventName): boolean { + // Reject unknown event names here so renderer-controlled strings cannot + // grow `perEventBuckets` past the fixed `eventSchemas` size. The IPC + // `telemetry:track` handler casts any string to `EventName`, so a + // compromised renderer could otherwise flood unique bogus names and + // unboundedly grow the Map before the validator rejects them. Downstream + // validator still rejects with the proper "unknown event" reason. + // + // Use `Object.hasOwn` rather than `in` — the latter walks the prototype + // chain, so a compromised renderer could pass `'toString'`, `'__proto__'`, + // `'constructor'`, etc. to bypass the guard and seed buckets for every + // `Object.prototype` key. Growth would be bounded (~12 keys) but the whole + // point of this check is to keep the Map size pinned to the compile-time + // `eventSchemas` surface. + if (!Object.hasOwn(eventSchemas, name)) { + return false + } + const now = Date.now() + const bucket = getOrCreateBucket(name, now) + if (bucket.tokens < 1) { + if (!bucket.warned) { + bucket.warned = true + console.warn(`[telemetry] per-event burst cap hit for '${name}'; dropping further events`) + } + return false + } + if (perSessionCount >= PER_SESSION_CEILING) { + if (!perSessionWarned) { + perSessionWarned = true + console.warn( + `[telemetry] per-session event ceiling (${PER_SESSION_CEILING}) hit; dropping further events` + ) + } + return false + } + bucket.tokens -= 1 + perSessionCount += 1 + return true +} + +/** + * Consume one token from the consent-mutation bucket. Returns `true` if the + * caller is allowed to apply a consent mutation, `false` if the per-session + * ceiling has been reached. Renderer-triggered IPC calls are the only + * callers of this bucket — main-originated consent mutations bypass IPC and + * are not rate-limited here. + */ +export function consumeConsentMutationToken(): boolean { + if (consentMutationCount >= CONSENT_MUTATION_CEILING) { + if (!consentMutationWarned) { + consentMutationWarned = true + console.warn( + `[telemetry] consent-mutation rate limit (${CONSENT_MUTATION_CEILING}/session) hit; dropping further mutations` + ) + } + return false + } + consentMutationCount += 1 + return true +} + +/** + * Reset every bucket. Called at the start of each telemetry session from + * `initTelemetry`. Tests also call it to get a clean slate between cases. + */ +export function resetBurstCapsForSession(): void { + perEventBuckets.clear() + perSessionCount = 0 + perSessionWarned = false + consentMutationCount = 0 + consentMutationWarned = false +} + +/** Test-only introspection. Not part of the runtime API. */ +export function _getBurstCapStateForTests(): { + perEventBuckets: Map + perSessionCount: number + consentMutationCount: number +} { + return { perEventBuckets, perSessionCount, consentMutationCount } +} diff --git a/src/main/telemetry/client.test.ts b/src/main/telemetry/client.test.ts new file mode 100644 index 000000000..52a82af94 --- /dev/null +++ b/src/main/telemetry/client.test.ts @@ -0,0 +1,331 @@ +// End-to-end behavior of the track() wrapper against a mock PostHog. These +// tests pin the ordering contracts: +// +// - shutdown gate fires before anything else +// - burst cap runs BEFORE consent resolve (opted-out flood does not hit +// resolveConsent) +// - per-session 1000-event ceiling enforced +// - opt-out event fires BEFORE posthog.optOut() — the one event that +// transmits against the user's new preference +// - serialized capture payload is exactly CommonProps ∪ EventProps ∪ the +// allowed auto-properties ({ $process_person_profile }); an unexpected +// auto-property from a future SDK upgrade fails the drift check +// +// Tests inject a fake PostHog and a fake Store. No network, no real SDK. + +import type { PostHog } from 'posthog-node' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { CommonProps } from '../../shared/telemetry-events' +import type { GlobalSettings } from '../../shared/types' +import type { Store } from '../persistence' +import { resetBurstCapsForSession } from './burst-cap' +import { + _enableTransportForTests, + _setCommonPropsForTests, + _setPostHogClientForTests, + _setShuttingDownForTests, + _setStoreForTests, + setOptIn, + shutdownTelemetry, + track +} from './client' + +// Minimal mock of the PostHog client surface the wrapper actually calls. +// Deliberately narrow — if the client wrapper ever starts calling something +// else (identify, alias, groupIdentify, etc.) we want the type mismatch to +// force review against the no-identify invariant. +type MockPostHog = { + capture: ReturnType + optIn: ReturnType + optOut: ReturnType + shutdown: ReturnType +} + +function makeMockPostHog(): MockPostHog { + return { + capture: vi.fn(), + optIn: vi.fn(), + optOut: vi.fn(), + shutdown: vi.fn(async () => {}) + } +} + +function makeFakeSettings(telemetry: GlobalSettings['telemetry']): GlobalSettings { + return { telemetry } as unknown as GlobalSettings +} + +function makeFakeStore(settings: GlobalSettings): Store { + return { + getSettings: vi.fn(() => settings), + updateSettings: vi.fn((updates: Partial) => { + if (updates.telemetry) { + settings.telemetry = { ...settings.telemetry, ...updates.telemetry } as typeof settings.telemetry + } + return settings + }) + } as unknown as Store +} + +// Env vars read by `resolveConsent` (see `consent.ts`). Any of these set at +// test time — most commonly `CI=true` on GitHub Actions — would make every +// `track()` call drop at the consent gate and every capture assertion fail. +// We clear them per-test and restore in afterEach so tests behave identically +// on a dev laptop (where none are set) and in CI (where `CI` always is). +const CONSENT_ENV_VARS = [ + 'DO_NOT_TRACK', + 'ORCA_TELEMETRY_DISABLED', + 'CI', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'CIRCLECI', + 'TRAVIS', + 'BUILDKITE', + 'JENKINS_URL', + 'TEAMCITY_VERSION' +] as const + +function stashAndClearConsentEnv(): Record { + const stash: Record = {} + for (const name of CONSENT_ENV_VARS) { + stash[name] = process.env[name] + delete process.env[name] + } + return stash +} + +function restoreConsentEnv(stash: Record): void { + for (const name of CONSENT_ENV_VARS) { + const prior = stash[name] + if (prior === undefined) { + delete process.env[name] + } else { + process.env[name] = prior + } + } +} + +const BASE_COMMON: CommonProps = { + app_version: '1.3.33', + platform: 'darwin', + arch: 'arm64', + os_release: '25.3.0', + install_id: '00000000-0000-4000-8000-000000000000', + session_id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', + orca_channel: 'stable' +} + +describe('track()', () => { + let mock: MockPostHog + let store: Store + let envStash: Record + + beforeEach(() => { + envStash = stashAndClearConsentEnv() + vi.spyOn(console, 'debug').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + resetBurstCapsForSession() + mock = makeMockPostHog() + store = makeFakeStore( + makeFakeSettings({ + optedIn: true, + installId: BASE_COMMON.install_id, + existedBeforeTelemetryRelease: false + }) + ) + _setPostHogClientForTests(mock as unknown as PostHog) + _setCommonPropsForTests(BASE_COMMON) + _setStoreForTests(store) + _setShuttingDownForTests(false) + _enableTransportForTests(true) + }) + afterEach(() => { + _enableTransportForTests(false) + _setPostHogClientForTests(null) + _setCommonPropsForTests(null) + _setStoreForTests(null) + vi.restoreAllMocks() + restoreConsentEnv(envStash) + }) + + it('captures a valid event with merged common + event props and $process_person_profile false', () => { + track('app_opened', {}) + expect(mock.capture).toHaveBeenCalledTimes(1) + const call = mock.capture.mock.calls[0]![0] + expect(call.event).toBe('app_opened') + expect(call.distinctId).toBe(BASE_COMMON.install_id) + expect(call.properties.$process_person_profile).toBe(false) + for (const key of Object.keys(BASE_COMMON) as (keyof CommonProps)[]) { + expect(call.properties[key]).toBe(BASE_COMMON[key]) + } + }) + + // Drift-check: the full set of keys on the capture payload is bounded by + // CommonProps ∪ EventProps ∪ {$process_person_profile}. A future + // posthog-node upgrade that adds a new auto-property (e.g. $session_id) + // via our `properties` would widen this — we'd want to review before + // shipping, so this test fails loudly. + it('serialized property set is exactly CommonProps ∪ EventProps ∪ {$process_person_profile}', () => { + track('workspace_created', { source: 'command_palette', from_existing_branch: true }) + const call = mock.capture.mock.calls[0]![0] + const allowed = new Set([ + ...Object.keys(BASE_COMMON), + 'source', + 'from_existing_branch', + '$process_person_profile' + ]) + for (const key of Object.keys(call.properties)) { + expect(allowed.has(key)).toBe(true) + } + }) + + it('respects the shutdown gate', () => { + _setShuttingDownForTests(true) + track('app_opened', {}) + expect(mock.capture).not.toHaveBeenCalled() + }) + + // Core security-ordering invariant: a compromised renderer of an + // opted-out user should not be able to burn consent-resolve CPU. The + // observable signal is `store.getSettings()` — `resolveConsent` calls it + // exactly once per evaluation, so the call count on the spy is a proxy + // for "how many events reached the consent gate." + it('burst cap runs BEFORE consent resolve', () => { + // Flip the fake store to opted-out. Any track that reaches the consent + // gate also reads settings via `store.getSettings()` — so the + // getSettings call count is our observable. + ;(store.getSettings as ReturnType).mockReturnValue( + makeFakeSettings({ + optedIn: false, + installId: BASE_COMMON.install_id, + existedBeforeTelemetryRelease: false + }) + ) + // Exhaust the per-event bucket (30 default). + for (let i = 0; i < 30; i++) { + track('app_opened', {}) + } + const callsAtBoundary = (store.getSettings as ReturnType).mock.calls.length + // Further calls must short-circuit in the burst cap — consent is never + // reached for the post-cap flood, so getSettings is not called again. + for (let i = 0; i < 20; i++) { + track('app_opened', {}) + } + expect((store.getSettings as ReturnType).mock.calls.length).toBe(callsAtBoundary) + expect(mock.capture).not.toHaveBeenCalled() + }) + + it('enforces per-event burst cap (30 per minute default)', () => { + for (let i = 0; i < 50; i++) { + track('app_opened', {}) + } + expect(mock.capture).toHaveBeenCalledTimes(30) + }) + + it('enforces the per-session 1000-event global ceiling across event names', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-03T00:00:00Z')) + // Advance time between calls so the per-event token buckets refill — + // the only remaining cap is the session ceiling. + for (let i = 0; i < 1500; i++) { + vi.advanceTimersByTime(10_000) + track('app_opened', {}) + } + expect(mock.capture).toHaveBeenCalledTimes(1000) + vi.useRealTimers() + }) + + it('drops invalid events before calling capture', () => { + // Raw error strings on agent_error are rejected by `.strict()`. + track('agent_error', { + error_class: 'auth_expired', + agent_kind: 'claude-code', + error_message: 'leaked message' // rejected by .strict() + } as never) + expect(mock.capture).not.toHaveBeenCalled() + }) +}) + +describe('setOptIn()', () => { + let mock: MockPostHog + let store: Store + let settings: GlobalSettings + let envStash: Record + + beforeEach(() => { + envStash = stashAndClearConsentEnv() + vi.spyOn(console, 'debug').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + resetBurstCapsForSession() + mock = makeMockPostHog() + settings = makeFakeSettings({ + optedIn: true, + installId: BASE_COMMON.install_id, + existedBeforeTelemetryRelease: false + }) + store = makeFakeStore(settings) + _setPostHogClientForTests(mock as unknown as PostHog) + _setCommonPropsForTests(BASE_COMMON) + _setStoreForTests(store) + _setShuttingDownForTests(false) + _enableTransportForTests(true) + }) + afterEach(() => { + _enableTransportForTests(false) + _setPostHogClientForTests(null) + _setCommonPropsForTests(null) + _setStoreForTests(null) + vi.restoreAllMocks() + restoreConsentEnv(envStash) + }) + + // Ordering invariant: the opt-out event is the one signal that transmits + // against the user's new preference. It MUST reach capture() before we + // disable the SDK — otherwise the signal is lost. + it('fires telemetry_opted_out BEFORE posthog.optOut()', () => { + const order: string[] = [] + mock.capture.mockImplementation(() => { + order.push('capture') + }) + mock.optOut.mockImplementation(() => { + order.push('optOut') + }) + setOptIn('settings', false) + expect(order).toEqual(['capture', 'optOut']) + }) + + it('fires telemetry_opted_in AFTER posthog.optIn()', () => { + // Flip settings to currently-opted-out so the flip to true exercises + // the opt-in branch cleanly. + settings.telemetry!.optedIn = false + const order: string[] = [] + mock.optIn.mockImplementation(() => order.push('optIn')) + mock.capture.mockImplementation(() => order.push('capture')) + setOptIn('settings', true) + expect(order).toEqual(['optIn', 'capture']) + }) +}) + +describe('shutdownTelemetry()', () => { + // Reset module-level state that persists across tests: shutdownTelemetry + // leaves shuttingDown=true, which would silently drop events in any + // later-added test. Also null out the client so a stale mock from one + // test cannot leak into the next. + afterEach(() => { + _setShuttingDownForTests(false) + _setPostHogClientForTests(null) + }) + + it('sets the shutdown gate and calls posthog.shutdown(2000)', async () => { + const mock = makeMockPostHog() + _setPostHogClientForTests(mock as unknown as PostHog) + _setShuttingDownForTests(false) + await shutdownTelemetry() + expect(mock.shutdown).toHaveBeenCalledWith(2_000) + _setPostHogClientForTests(null) + }) + + it('is a no-op when no client is initialized', async () => { + _setPostHogClientForTests(null) + await expect(shutdownTelemetry()).resolves.toBeUndefined() + }) +}) diff --git a/src/main/telemetry/client.ts b/src/main/telemetry/client.ts new file mode 100644 index 000000000..353822c8b --- /dev/null +++ b/src/main/telemetry/client.ts @@ -0,0 +1,350 @@ +// Main-process telemetry transport. One `posthog-node` client per process, +// one source of truth for common props, one `track()` entry that every event +// (main-originated AND IPC-arrived) funnels through. The validator in +// `validator.ts` is the single gate that protects the wire from malformed +// or over-sized payloads; the burst cap in `burst-cap.ts` protects against +// runaway useEffects and a compromised renderer. +// +// Ordering inside `track()` — MUST be preserved: +// 1. shutdown gate — will-quit already set `shuttingDown = true`; +// late IPC arrivals drop, never crash. +// 2. burst cap — O(1). Runs BEFORE consent resolve so an +// opted-out user whose renderer is compromised +// cannot burn handler CPU by forcing a +// settings read + consent evaluation on every +// attempt. +// 3. consent resolve — reads the live settings, never a cached +// boolean. Env-var / CI / opt-out all funnel +// through here. +// 4. validator — schema-level safeParse. Fail-closed. +// 5. posthog.capture — the only place this module calls into the +// vendor SDK. +// +// `$process_person_profile: false` is attached on every capture because +// posthog-node has no init-time equivalent of posthog-js's +// `person_profiles: 'identified_only'` — without the per-capture flag, the +// server SDK would materialize a PostHog person per install_id, which we +// explicitly do not want for anonymous-only events. + +import { randomUUID } from 'node:crypto' +import { arch as osArch, platform as osPlatform, release as osRelease } from 'node:os' +import { app } from 'electron' +import { PostHog } from 'posthog-node' +import type { CommonProps, EventName, EventProps } from '../../shared/telemetry-events' +import type { Store } from '../persistence' +import { consumeBurstToken, resetBurstCapsForSession } from './burst-cap' +import { resolveConsent } from './consent' +import { commonPropsSchema, validate } from './validator' + +// Compile-time feature flag. PR 2 ships with this `false` — the SDK is wired +// but no event transmits. PR 3 flips it to `true` once the PostHog project +// is live and dashboards are verified. Independent of the build-identity +// gate below: both must be satisfied to transmit, so flipping the flag +// alone still leaves contributor builds silent. +const TELEMETRY_ENABLED = false + +// Eligible-to-transmit only if the CI release pipeline injected BOTH the +// build-identity constant and a write key. One without the other is treated +// as a pipeline misconfiguration and fails closed. Contributor / `pnpm dev` +// / third-party rebuilds get literal `null` from electron-vite's `define`, +// so `IS_OFFICIAL_BUILD` evaluates `false` at module load. There is no +// runtime env-var fallback. +// +// The `globalThis` dance exists for the vitest harness. `declare const` +// lets TypeScript type-check against the substituted symbols, but vitest +// does not run electron-vite's `define` pass, so the identifiers are +// undefined at test-runtime. Routing the read through `globalThis` gives +// us the compile-time substitution in production and a safe `undefined` +// in tests — both of which resolve to `IS_OFFICIAL_BUILD === false`, which +// is the fail-closed default we want anywhere outside an official CI build. +const BUILD_IDENTITY: 'stable' | 'rc' | null = + typeof ORCA_BUILD_IDENTITY !== 'undefined' + ? ORCA_BUILD_IDENTITY + : ((globalThis as { ORCA_BUILD_IDENTITY?: 'stable' | 'rc' | null }).ORCA_BUILD_IDENTITY ?? null) +const WRITE_KEY: string | null = + typeof ORCA_POSTHOG_WRITE_KEY !== 'undefined' + ? ORCA_POSTHOG_WRITE_KEY + : ((globalThis as { ORCA_POSTHOG_WRITE_KEY?: string | null }).ORCA_POSTHOG_WRITE_KEY ?? null) +const IS_OFFICIAL_BUILD: boolean = + (BUILD_IDENTITY === 'stable' || BUILD_IDENTITY === 'rc') && + typeof WRITE_KEY === 'string' && + WRITE_KEY.length > 0 + +// Module-level singletons. There is exactly one Store / one main process / +// one telemetry session at a time; threading `store` through every export +// is verbose without buying anything. +let posthog: PostHog | null = null +let sessionId: string | null = null +let commonProps: CommonProps | null = null +let shuttingDown = false +let storeRef: Store | null = null + +// Test-only override for the transport gate. Set by `_enableTransportForTests` +// so the client.test.ts suite can exercise the full pipeline (burst cap, +// consent, validator, capture) without waiting on a real CI build. Left +// `false` in production; an accidental call from non-test code would still +// be bounded by `resolveConsent` + the validator. +let testTransportEnabled = false + +function buildCommonProps( + installId: string, + sid: string, + channel: 'stable' | 'rc' +): CommonProps { + // `.max(64)` on every free-form string field in `commonPropsSchema` is the + // upper bound; node's platform / arch / release strings are always well + // under that in practice. We do not truncate here because the validator's + // schema cap is the authoritative check — truncating pre-validator would + // silently mask an unexpected-long-string case we want to see as a drop. + return { + app_version: app.getVersion(), + platform: osPlatform(), + arch: osArch(), + os_release: osRelease(), + install_id: installId, + session_id: sid, + orca_channel: channel + } +} + +export function initTelemetry(store: Store): void { + // Set `storeRef` unconditionally so `setOptIn` can persist consent + // changes even in console-mirror builds — opt-out must still write to + // disk on a contributor laptop, not just on official builds. + storeRef = store + resetBurstCapsForSession() + shuttingDown = false + + if (!TELEMETRY_ENABLED || !IS_OFFICIAL_BUILD) { + return + } + + const settings = store.getSettings() + const installId = settings.telemetry?.installId + if (!installId) { + // Migration guarantees this is set; if it isn't, we're in an invariant- + // violation state and must not transmit with a missing distinct_id. + console.warn('[telemetry] installId missing after migration; skipping transport init') + return + } + + sessionId = randomUUID() + commonProps = buildCommonProps( + installId, + sessionId, + // Non-null at this point: `IS_OFFICIAL_BUILD` gated this branch and + // narrows the identity constant to the `'stable' | 'rc'` arm. + BUILD_IDENTITY as 'stable' | 'rc' + ) + + // Fail-closed on bad common props — the validator is the single enforcement + // point for wire shape, including common props. A bad `install_id` (e.g. + // empty string from a migration bug) would collapse all events into one + // distinct_id, so we must refuse to initialize transport rather than ship + // malformed identity on every capture. + // + // Validated once here at init — NOT on every `track()` call — because + // `commonProps` is a module-level singleton built exactly once from inputs + // that do not change across the session (app version, OS, install_id, + // session_id, channel). Re-validating per event would be wasted work on + // a value that cannot drift. If a future refactor makes `commonProps` + // mutable mid-session, move this check accordingly. + const parsedCommon = commonPropsSchema.safeParse(commonProps) + if (!parsedCommon.success) { + console.warn('[telemetry] common props failed schema validation; skipping transport init') + commonProps = null + return + } + + posthog = new PostHog(WRITE_KEY as string, { + host: 'https://us.i.posthog.com', + flushAt: 20, + flushInterval: 10_000, + // Strip every auto-attached property we do not want on our wire: no + // GeoIP, no client IP enrichment. Our wire is exactly + // `CommonProps ∪ EventProps ∪ a small allow-list of SDK auto-props`. + disableGeoip: true, + // Default is 1000; past that, the SDK drops oldest-first. Bumped to + // 5000 to tolerate long-offline sessions (flights, VPN-down, tunnels). + // The per-session 1,000-event ceiling in `track()` caps normal + // operation well below this; the 5000 slots are the absolute ceiling + // across any conceivable offline duration. + maxQueueSize: 5000 + }) + + // Re-apply the user's persisted opt-out on every boot: the PostHog SDK's + // in-memory opt-out flag does NOT persist across process restarts, and + // `GlobalSettings.telemetry.optedIn` is what actually gates whether a user + // has said yes. Do not remove this re-apply thinking it is redundant with + // the persisted setting; the SDK flag is the thing that gates capture(). + const consent = resolveConsent(settings) + if (consent.effective !== 'enabled') { + posthog.optOut() + } +} + +export function track(name: N, props: EventProps): void { + // Console mirror: always in non-official builds (so the whole team — + // contributors included — sees exactly what would transmit) and also in + // official builds when `TELEMETRY_ENABLED` is off (PR 2 verification). + // These are the only two paths that short-circuit before the pipeline. + if (!testTransportEnabled && (!IS_OFFICIAL_BUILD || !TELEMETRY_ENABLED)) { + console.debug('[telemetry]', name, props) + return + } + + // (1) Shutdown gate. Late IPC arrivals should not attempt to enqueue + // against a client that is actively flushing. + if (shuttingDown) { + console.debug('[telemetry] shutdown-gate drop:', name) + return + } + if (!posthog || !commonProps || !storeRef) { + return + } + + // (2) Burst cap BEFORE consent. A compromised renderer of an opted-out + // user should not be able to burn CPU by forcing a settings read and a + // `resolveConsent` evaluation on every attempt — the cap is O(1), the + // consent resolve reads the live settings object. This ordering is the + // difference between "opt-out is a free drop" and "opt-out is a cheap + // drop at the cost of a settings read per event." + if (!consumeBurstToken(name)) { + return + } + + // (3) Consent resolve — reads live settings every call; never a cached + // module-level boolean that could drift from the persisted state or the + // env-var precedence. + const consent = resolveConsent(storeRef.getSettings()) + if (consent.effective !== 'enabled') { + return + } + + // (4) Validator — single enforcement point for schema, enum, strict key + // set, and per-string length caps. + const result = validate(name, props) + if (!result.ok) { + return + } + + // (5) Capture. `$process_person_profile: false` is the server-SDK + // equivalent of posthog-js's `person_profiles: 'identified_only'` — + // attached per-event because posthog-node has no init-time option. + // Without this, posthog-node materializes a PostHog person per + // `install_id`, which we explicitly do not want for anonymous-only + // events. + posthog.capture({ + distinctId: commonProps.install_id, + event: name, + properties: { + ...commonProps, + ...result.props, + $process_person_profile: false + } + }) +} + +export function setOptIn( + via: 'settings' | 'first_launch_banner' | 'first_launch_notice', + optedIn: boolean +): void { + if (!storeRef) { + return + } + const settings = storeRef.getSettings() + // `updateSettings` is a partial-merge (see persistence.ts:552). The Store's + // `telemetry` field is deep-merged there specifically so an `optedIn` flip + // from the Privacy pane / consent flow does not clobber `installId` or + // `existedBeforeTelemetryRelease`. + storeRef.updateSettings({ + telemetry: { + ...(settings.telemetry ?? { installId: '', existedBeforeTelemetryRelease: true }), + optedIn + } + }) + + if (!posthog) { + return + } + if (optedIn) { + posthog.optIn() + track('telemetry_opted_in', { via }) + } else { + // Fire opt-out event BEFORE disabling the SDK. This is the one event + // that transmits against the user's new preference — the user chose to + // tell us they are opting out, and that single signal is what tells us + // the opt-out flow is working. + // + // Capture directly (not via `track()`) because `updateSettings` above + // just flipped `optedIn` to `false`; `track()` would re-read settings, + // call `resolveConsent`, and drop on `user_opt_out` — at which point the + // one signal that tells us the opt-out flow works would be silent. + // Burst cap + validator still run; consent is the only gate bypassed, + // and it is bypassed exactly once per user per session at most (IPC + // consent-mutation cap is 5/session). + if (!shuttingDown && commonProps && consumeBurstToken('telemetry_opted_out')) { + const validated = validate('telemetry_opted_out', { via }) + if (validated.ok) { + posthog.capture({ + distinctId: commonProps.install_id, + event: 'telemetry_opted_out', + properties: { + ...commonProps, + ...validated.props, + $process_person_profile: false + } + }) + } + } + posthog.optOut() + } +} + +export async function shutdownTelemetry(): Promise { + // Setting the shutdown gate is synchronous and cheap — it matters that + // late IPC-arrived tracks hit it before the bounded flush starts. + shuttingDown = true + const instance = posthog + if (!instance) { + return + } + try { + // PostHog's bounded flush caps at 2s. Observed quit delay goes up by at + // most that on top of the current daemon-teardown budget. + await instance.shutdown(2_000) + } catch (err) { + // Telemetry must never crash the app on quit. Swallow. + console.warn('[telemetry] shutdown error (ignored):', err) + } +} + +// ── Test-only introspection ───────────────────────────────────────────── +// +// The test suite needs to inject a fake PostHog and observe capture calls +// without touching the network. Kept under a `_`-prefixed name so it is +// obvious in code review that this is not a runtime API. + +export function _setPostHogClientForTests(client: PostHog | null): void { + posthog = client +} + +export function _setCommonPropsForTests(props: CommonProps | null): void { + commonProps = props +} + +export function _setStoreForTests(store: Store | null): void { + storeRef = store +} + +export function _setShuttingDownForTests(value: boolean): void { + shuttingDown = value +} + +export function _getSessionIdForTests(): string | null { + return sessionId +} + +export function _enableTransportForTests(enabled: boolean): void { + testTransportEnabled = enabled +} diff --git a/src/main/telemetry/consent.ts b/src/main/telemetry/consent.ts index 116e886be..b72e79c4b 100644 --- a/src/main/telemetry/consent.ts +++ b/src/main/telemetry/consent.ts @@ -23,8 +23,7 @@ export type ConsentState = | { effective: 'pending_banner' } // Precedence for the `disabled` branches is documented alongside -// `resolveConsent` below and in docs/telemetry-plan.md §"Env vars and consent -// precedence". Keep this list in sync with that table. +// `resolveConsent` below. const CI_ENV_VARS = [ 'CI', 'GITHUB_ACTIONS', diff --git a/src/main/telemetry/validator.test.ts b/src/main/telemetry/validator.test.ts new file mode 100644 index 000000000..3da5b021b --- /dev/null +++ b/src/main/telemetry/validator.test.ts @@ -0,0 +1,135 @@ +// Fail-closed validator behavior. These tests exist to catch the classes of +// input the validator is designed to reject at the IPC boundary: unknown +// event names, extra properties (via `.strict()`), missing required keys, +// wrong enum values, and overlength free-form strings. Every rejected case +// returns `{ ok: false, reason }` — the client.ts wrapper then drops the +// event instead of calling posthog.capture. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { _resetValidatorWarnCacheForTests, validate } from './validator' + +describe('validate', () => { + beforeEach(() => { + _resetValidatorWarnCacheForTests() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + vi.restoreAllMocks() + }) + + it('accepts a well-formed app_opened payload', () => { + const result = validate('app_opened', {}) + expect(result.ok).toBe(true) + }) + + it('accepts a well-formed agent_started payload', () => { + const result = validate('agent_started', { + agent_kind: 'claude-code', + launch_source: 'command_palette', + request_kind: 'new' + }) + expect(result.ok).toBe(true) + }) + + it('drops unknown event names', () => { + const result = validate('not_a_real_event' as never, {}) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.reason).toMatch(/unknown event/) + } + }) + + it('rejects extra keys via .strict()', () => { + const result = validate('app_opened', { unexpected: 'value' }) + expect(result.ok).toBe(false) + }) + + // Core invariant: agent_error is enum-only. If a call site ever tries to + // attach raw error strings to the event, the validator drops it and nothing + // transmits. + it('rejects error_message on agent_error', () => { + const result = validate('agent_error', { + error_class: 'auth_expired', + agent_kind: 'claude-code', + error_message: 'at /Users/alice/secret/path/index.ts:42' + } as never) + expect(result.ok).toBe(false) + }) + + it('rejects error_stack on agent_error', () => { + const result = validate('agent_error', { + error_class: 'auth_expired', + agent_kind: 'claude-code', + error_stack: 'Error: boom\n at /Users/alice/...' + } as never) + expect(result.ok).toBe(false) + }) + + it('drops on missing required key', () => { + const result = validate('agent_started', { + agent_kind: 'claude-code', + launch_source: 'sidebar' + // missing request_kind + } as never) + expect(result.ok).toBe(false) + }) + + it('drops on wrong enum value', () => { + const result = validate('agent_started', { + agent_kind: 'claude-code', + launch_source: 'command_palette', + request_kind: 'restart' // not in ['new', 'resume', 'followup'] + } as never) + expect(result.ok).toBe(false) + }) + + it('drops overlength strings past the .max() cap', () => { + // commonPropsSchema's `.max(64)` caps don't live on per-event schemas — + // the per-event schemas use enum-only strings — so we exercise the cap + // via a whitelisted `error_name` that is *one specific enum value* + // meaning any attempt to smuggle a long string fails the enum check + // regardless. For explicit string-length cap coverage we also confirm + // the agent_error schema does not accept an overlength error_name + // even if it matches the prefix of a whitelisted value. + const result = validate('agent_error', { + error_class: 'auth_expired', + agent_kind: 'claude-code', + error_name: 'AuthExpiredButAlsoWithExtraGarbageThatMakesItTooLong' + } as never) + expect(result.ok).toBe(false) + }) + + it('accepts whitelisted error_name on agent_error', () => { + const result = validate('agent_error', { + error_class: 'auth_expired', + agent_kind: 'claude-code', + error_name: 'AuthExpired' + }) + expect(result.ok).toBe(true) + }) + + it('rejects error_name outside the whitelist', () => { + const result = validate('agent_error', { + error_class: 'auth_expired', + agent_kind: 'claude-code', + error_name: 'CustomErrorNotInList' + } as never) + expect(result.ok).toBe(false) + }) + + // Rate-limit: at most one warn per event name per 60s. We cannot easily + // control Date.now() without mocking time, so the coarse assertion is + // that repeat-dropping the same event name does not emit a warn on every + // call. The first rejection should warn; the second within the window + // should not. + it('rate-limits warns to 1/min per event name', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + _resetValidatorWarnCacheForTests() + validate('app_opened', { bogus: true } as never) + const afterFirst = warn.mock.calls.length + validate('app_opened', { bogus2: true } as never) + const afterSecond = warn.mock.calls.length + expect(afterFirst).toBe(1) + expect(afterSecond).toBe(1) + }) +}) diff --git a/src/main/telemetry/validator.ts b/src/main/telemetry/validator.ts new file mode 100644 index 000000000..253af32e9 --- /dev/null +++ b/src/main/telemetry/validator.ts @@ -0,0 +1,82 @@ +// Fail-closed runtime validator. Thin wrapper around +// `eventSchemas[name].safeParse(props)` — the schema defined in +// `src/shared/telemetry-events.ts` IS the validator. There is no parallel +// `EVENT_SPEC` declaration to keep in sync with `EventMap`. +// +// The validator is the single enforcement point for both main-originated +// and IPC-arrived events. TypeScript types do not survive IPC serialization, +// so the renderer cannot be trusted to send well-typed payloads — the +// renderer is explicitly in the threat model. Every shape-level promise in +// `EventMap` is also a runtime check here. +// +// Contract (all drops go through the same fail-closed path — no event is +// ever emitted when any of these fire): +// - Unknown event name → drop + rate-limited `console.warn`. +// - Extra property key → drop + warn. (Enforced by `.strict()` on every +// per-event object schema — no separate check.) +// - Missing required key → drop + warn. +// - Wrong type / value not in declared enum → drop + warn. +// - Any string longer than its `.max(N)` cap → drop + warn. (Enforced by +// `.max()` in the schema; the cap and the schema are the same thing.) +// +// Warnings are rate-limited: ≤ 1 log per `event_name` per 60 s. Silent +// otherwise — a misbehaving caller should not be able to DoS stderr. + +import { + commonPropsSchema, + eventSchemas, + type EventName, + type EventProps +} from '../../shared/telemetry-events' + +export type ValidationResult = + | { ok: true; props: EventProps } + | { ok: false; reason: string } + +const WARN_WINDOW_MS = 60_000 +const lastWarnAt = new Map() + +function warnRateLimited(key: string, message: string): void { + const now = Date.now() + const prev = lastWarnAt.get(key) ?? 0 + if (now - prev < WARN_WINDOW_MS) { + return + } + lastWarnAt.set(key, now) + console.warn(`[telemetry] ${message}`) +} + +export function validate(name: N, props: unknown): ValidationResult { + // Event name must be a known key. `eventSchemas` is the source of truth for + // what names exist; a cast-bypass at a call site (`track('foo' as never, {})`) + // fails here at runtime. + const schema = eventSchemas[name] as (typeof eventSchemas)[EventName] | undefined + if (!schema) { + const reason = `unknown event: ${String(name)}` + warnRateLimited(`unknown:${String(name)}`, reason) + return { ok: false, reason } + } + + // `.safeParse()` is the single call that enforces exact key set (via + // `.strict()`), types, enum membership, and per-string `.max()` caps. + const parsed = schema.safeParse(props) + if (!parsed.success) { + const issue = parsed.error.issues[0] + const path = issue?.path.length ? issue.path.join('.') : '' + const reason = `${String(name)}: ${path}: ${issue?.message ?? 'invalid'}` + warnRateLimited(String(name), reason) + return { ok: false, reason } + } + + return { ok: true, props: parsed.data as EventProps } +} + +/** Test-only reset of the warn-rate-limit cache. */ +export function _resetValidatorWarnCacheForTests(): void { + lastWarnAt.clear() +} + +// Re-exported so `client.ts` can re-validate the merged outgoing payload +// without reaching into `src/shared/telemetry-events.ts` directly. Keeps the +// validator as the single surface the client depends on. +export { commonPropsSchema } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index ef80f59d0..75c661e85 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -568,6 +568,14 @@ export type PreloadApi = { complete: () => Promise forceShow: () => Promise } + /** Fire-and-forget track. Loose typing at the IPC boundary on purpose — + * the main-side validator is the single enforcement point. Renderer call + * sites should import `track()` from `src/renderer/src/lib/telemetry.ts` + * for the `EventMap`-based type safety, not reach for this directly. */ + telemetryTrack: (name: string, props: Record) => Promise + /** Flip the persisted opt-in preference. Subject to a per-session + * consent-mutation rate limit on the main side (≤5/session). */ + telemetrySetOptIn: (optedIn: boolean) => Promise settings: { get: () => Promise set: (args: Partial) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index 249d43b96..bb52c07c4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -615,6 +615,16 @@ const api = { forceShow: (): Promise => ipcRenderer.invoke('star-nag:forceShow') }, + // Why: telemetry uses a loose untyped surface at the preload boundary on + // purpose — the main-side validator (src/main/telemetry/validator.ts) is + // the single enforcement point, not the preload types. The renderer gets + // typed `track()` / `setOptIn()` wrappers via + // src/renderer/src/lib/telemetry.ts, which is what call sites import. + telemetryTrack: (name: string, props: Record): Promise => + ipcRenderer.invoke('telemetry:track', name, props), + telemetrySetOptIn: (optedIn: boolean): Promise => + ipcRenderer.invoke('telemetry:setOptIn', optedIn), + settings: { get: (): Promise => ipcRenderer.invoke('settings:get'), diff --git a/src/renderer/src/lib/telemetry.ts b/src/renderer/src/lib/telemetry.ts new file mode 100644 index 000000000..1228bc4c8 --- /dev/null +++ b/src/renderer/src/lib/telemetry.ts @@ -0,0 +1,35 @@ +// Typed renderer-side wrapper around the preload bridge. +// +// Renderer call sites import `track` from this module rather than reaching +// for `window.api.telemetryTrack` directly, because this wrapper is what +// gives them the `EventMap`-based type safety. The preload bridge is +// deliberately typed as a loose `(name: string, props: Record) => Promise` so it can cross the IPC boundary without +// pretending the renderer's types are load-bearing — the main-side +// validator is the single enforcement point. +// +// The renderer does NOT bundle `posthog-node` or any PostHog SDK. There is +// one PostHog client in the process tree and it lives in main. That +// invariant is what keeps the vendor out of the renderer's attack surface. + +import type { EventName, EventProps } from '../../../shared/telemetry-events' + +export function track(name: N, props: EventProps): void { + // Why: telemetry must never throw into the renderer. A missing bridge + // (tests, early init, sandboxed iframe) would turn `window.api.telemetryTrack` + // into a synchronous TypeError that defeats the documented fire-and-forget + // contract. Swallow both the sync throw and any promise rejection. + try { + void window.api?.telemetryTrack?.(name, props as Record)?.catch(() => {}) + } catch { + // Swallow — telemetry must never break the renderer. + } +} + +export function setOptIn(optedIn: boolean): void { + try { + void window.api?.telemetrySetOptIn?.(optedIn)?.catch(() => {}) + } catch { + // Swallow — telemetry must never break the renderer. + } +} diff --git a/src/shared/telemetry-events.test.ts b/src/shared/telemetry-events.test.ts new file mode 100644 index 000000000..ff6849578 --- /dev/null +++ b/src/shared/telemetry-events.test.ts @@ -0,0 +1,222 @@ +// Schema round-trip coverage for the event map. Fail-closed invariants that +// must hold: agent_error is enum-only (error_message / error_stack rejected +// by `.strict()`), error_name is whitelisted, unknown enum values fail, and +// any well-formed payload round-trips without coercion. + +import { describe, expect, it } from 'vitest' +import { + AGENT_ERROR_NAME_WHITELIST, + agentErrorNameSchema, + agentKindSchema, + commonPropsSchema, + errorClassSchema, + eventSchemas, + SETTINGS_CHANGED_WHITELIST, + settingsChangedKeySchema +} from './telemetry-events' + +describe('agent_error schema', () => { + it('round-trips a minimal {error_class, agent_kind} payload', () => { + const parsed = eventSchemas.agent_error.safeParse({ + error_class: 'auth_expired', + agent_kind: 'claude-code' + }) + expect(parsed.success).toBe(true) + }) + + it('round-trips every whitelisted error_name value', () => { + for (const name of AGENT_ERROR_NAME_WHITELIST) { + const parsed = eventSchemas.agent_error.safeParse({ + error_class: 'auth_expired', + agent_kind: 'claude-code', + error_name: name + }) + expect(parsed.success).toBe(true) + } + }) + + it('rejects error_name values outside the whitelist', () => { + const parsed = eventSchemas.agent_error.safeParse({ + error_class: 'auth_expired', + agent_kind: 'claude-code', + error_name: 'SomeNonWhitelistedName' + }) + expect(parsed.success).toBe(false) + }) + + // Core invariant: `.strict()` rejects raw error strings. If this test ever + // flips, the analytics lane is leaking UGC — revert the offending schema + // change. + it('rejects error_message via .strict()', () => { + const parsed = eventSchemas.agent_error.safeParse({ + error_class: 'auth_expired', + agent_kind: 'claude-code', + error_message: 'boom at /Users/alice/secret/path' + }) + expect(parsed.success).toBe(false) + }) + + it('rejects error_stack via .strict()', () => { + const parsed = eventSchemas.agent_error.safeParse({ + error_class: 'auth_expired', + agent_kind: 'claude-code', + error_stack: 'Error: boom\n at /Users/alice/...' + }) + expect(parsed.success).toBe(false) + }) + + it('rejects unknown error_class enum values', () => { + const parsed = eventSchemas.agent_error.safeParse({ + error_class: 'made_up_class', + agent_kind: 'claude-code' + }) + expect(parsed.success).toBe(false) + }) + + it('rejects unknown agent_kind enum values', () => { + const parsed = eventSchemas.agent_error.safeParse({ + error_class: 'auth_expired', + agent_kind: 'made_up_agent' + }) + expect(parsed.success).toBe(false) + }) +}) + +describe('workspace_created schema', () => { + it('rejects unknown source', () => { + const parsed = eventSchemas.workspace_created.safeParse({ + source: 'carrier_pigeon', + from_existing_branch: false + }) + expect(parsed.success).toBe(false) + }) + + it('accepts a valid payload', () => { + const parsed = eventSchemas.workspace_created.safeParse({ + source: 'command_palette', + from_existing_branch: true + }) + expect(parsed.success).toBe(true) + }) + + it('rejects extra keys via .strict()', () => { + const parsed = eventSchemas.workspace_created.safeParse({ + source: 'command_palette', + from_existing_branch: true, + branch: 'refs/heads/main' // raw branch name is UGC — rejected by .strict() + }) + expect(parsed.success).toBe(false) + }) +}) + +describe('agent_started schema', () => { + it('requires all three keys', () => { + const parsed = eventSchemas.agent_started.safeParse({ + agent_kind: 'claude-code', + launch_source: 'sidebar' + }) + expect(parsed.success).toBe(false) + }) +}) + +describe('settings_changed schema', () => { + it('accepts whitelisted setting keys', () => { + for (const key of SETTINGS_CHANGED_WHITELIST) { + const parsed = eventSchemas.settings_changed.safeParse({ + setting_key: key, + value_kind: 'bool' + }) + expect(parsed.success).toBe(true) + } + }) + + it('rejects non-whitelisted setting keys', () => { + const parsed = eventSchemas.settings_changed.safeParse({ + setting_key: 'telemetryOptIn', // deliberately excluded from the whitelist + value_kind: 'bool' + }) + expect(parsed.success).toBe(false) + }) +}) + +describe('commonPropsSchema', () => { + it('round-trips a realistic payload', () => { + const parsed = commonPropsSchema.safeParse({ + app_version: '1.3.33', + platform: 'darwin', + arch: 'arm64', + os_release: '25.3.0', + install_id: '00000000-0000-4000-8000-000000000000', + session_id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', + orca_channel: 'stable' + }) + expect(parsed.success).toBe(true) + }) + + it('rejects strings past the 64-char cap', () => { + const parsed = commonPropsSchema.safeParse({ + app_version: 'x'.repeat(65), + platform: 'darwin', + arch: 'arm64', + os_release: '25.3.0', + install_id: '00000000-0000-4000-8000-000000000000', + session_id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', + orca_channel: 'stable' + }) + expect(parsed.success).toBe(false) + }) + + // Empty `install_id` would collapse every event under one synthetic + // PostHog `distinctId`; empty `session_id` would blend unrelated process + // lifetimes. `.min(1)` guards both — this test pins that contract so a + // future edit can't relax it back to `.max(64)`-only. + it('rejects empty install_id', () => { + const parsed = commonPropsSchema.safeParse({ + app_version: '1.3.33', + platform: 'darwin', + arch: 'arm64', + os_release: '25.3.0', + install_id: '', + session_id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', + orca_channel: 'stable' + }) + expect(parsed.success).toBe(false) + }) + + it('rejects empty session_id', () => { + const parsed = commonPropsSchema.safeParse({ + app_version: '1.3.33', + platform: 'darwin', + arch: 'arm64', + os_release: '25.3.0', + install_id: '00000000-0000-4000-8000-000000000000', + session_id: '', + orca_channel: 'stable' + }) + expect(parsed.success).toBe(false) + }) +}) + +describe('exported enum schemas', () => { + it('agentKindSchema accepts the known product IDs', () => { + expect(agentKindSchema.safeParse('claude-code').success).toBe(true) + expect(agentKindSchema.safeParse('codex').success).toBe(true) + expect(agentKindSchema.safeParse('other').success).toBe(true) + }) + + it('errorClassSchema rejects novel classes', () => { + expect(errorClassSchema.safeParse('kernel_panic').success).toBe(false) + }) + + it('settingsChangedKeySchema membership matches SETTINGS_CHANGED_WHITELIST', () => { + for (const key of SETTINGS_CHANGED_WHITELIST) { + expect(settingsChangedKeySchema.safeParse(key).success).toBe(true) + } + }) + + it('agentErrorNameSchema membership matches AGENT_ERROR_NAME_WHITELIST', () => { + for (const name of AGENT_ERROR_NAME_WHITELIST) { + expect(agentErrorNameSchema.safeParse(name).success).toBe(true) + } + }) +}) diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts new file mode 100644 index 000000000..8840027b8 --- /dev/null +++ b/src/shared/telemetry-events.ts @@ -0,0 +1,249 @@ +// Single source of truth for telemetry event names, schemas, and enums. +// +// Zod-first: every event schema is declared once and the compile-time +// `EventMap` is `z.infer`-derived from the same record the runtime validator +// consumes. There is no parallel `EVENT_SPEC` / hand-rolled union to drift +// out of sync with. Adding an event means adding a schema to `eventSchemas`; +// `EventMap` picks it up automatically and call sites that reference an +// unknown event name fail `tsc`. +// +// `.strict()` on every object schema is the runtime counterpart to "no extra +// keys." Free-form string fields carry an explicit `.max(N)` cap at the +// schema — the cap and the schema are the same thing; the validator does not +// re-check string length. + +import { z } from 'zod' + +// ── Shared property enums ─────────────────────────────────────────────── + +// Mirrors the detectable agents in `src/shared/agent-detection.ts` +// (`AGENT_NAMES`), with one deliberate shift: `claude` in AGENT_NAMES ↔ +// `claude-code` here (product, not CLI string) so dashboards read cleanly. +// +// Enum values are limited to agents that have a real emit path today. Adding +// a new agent is additive-safe — extend this enum when the call site that +// would emit it lands, not in anticipation. +export const agentKindSchema = z.enum([ + 'claude-code', + 'codex', + 'gemini', + 'copilot', + 'cursor', + 'opencode', + 'aider', + 'other' +]) +export type AgentKind = z.infer + +export const errorClassSchema = z.enum([ + 'network_timeout', + 'auth_expired', + 'rate_limited', + 'provider_unavailable', + 'provider_error_generic', + 'binary_not_found', + 'binary_version_mismatch', + 'workspace_gone', + 'user_cancelled', + 'unknown' +]) +export type ErrorClass = z.infer + +// Closed whitelist of error `name` strings allowed on `agent_error`. This is +// the one free-ish string that can leave the machine on an agent_error event +// — the validator drops anything not in this set. +// +// A regex-shape check (e.g. `/^[A-Z][A-Za-z]{0,32}$/`) would permit +// identifier-shaped leaks like `PaymentFailedForUserAlice` or +// `TimeoutInRepoMyCompanyInternalMonorepo` — context-concatenation bugs +// under deadline pressure. A closed whitelist forces each new error name +// through review. Same pattern as `SETTINGS_CHANGED_WHITELIST`. +export const AGENT_ERROR_NAME_WHITELIST = [ + 'NetworkTimeout', + 'AuthExpired', + 'RateLimited', + 'ProviderUnavailable', + 'ProviderErrorGeneric', + 'BinaryNotFound', + 'BinaryVersionMismatch', + 'WorkspaceGone', + 'UserCancelled' +] as const +export const agentErrorNameSchema = z.enum(AGENT_ERROR_NAME_WHITELIST) +export type AgentErrorName = z.infer + +export const repoMethodSchema = z.enum(['folder_picker', 'clone_url', 'drag_drop']) +export type RepoMethod = z.infer + +export const workspaceSourceSchema = z.enum([ + 'command_palette', + 'sidebar', + 'shortcut', + 'drag_drop', + 'unknown' +]) +export type WorkspaceSource = z.infer + +export const launchSourceSchema = z.enum([ + 'command_palette', + 'sidebar', + 'tab_bar_quick_launch', + 'task_page', + 'new_workspace_composer', + 'workspace_jump_palette', + 'shortcut', + 'unknown' +]) +export type LaunchSource = z.infer + +export const requestKindSchema = z.enum(['new', 'resume', 'followup']) +export type RequestKind = z.infer + +// `env_var` is deliberately absent — env-var and CI paths override consent at +// runtime only (see consent.ts); they never mutate `optedIn` and therefore +// never fire a `telemetry_opted_in/out` event. If a future path explicitly +// persists an env-var-driven opt-out, add `env_var` back here together with +// the call site. +export const optInViaSchema = z.enum(['first_launch_banner', 'first_launch_notice', 'settings']) +export type OptInVia = z.infer + +// Whitelist of settings whose `setting_key` may be emitted on +// `settings_changed`. If a setting isn't in this list, we do not emit. +// +// Keys are camelCase to match the actual field names in `GlobalSettings`. +// `orca_channel` is intentionally absent — it is a build-time common +// property baked in from `ORCA_BUILD_IDENTITY`, not a user-togglable setting. +// +// Intentionally does NOT include the telemetry opt-in toggle — that is +// covered by the dedicated `telemetry_opted_in` / `telemetry_opted_out` +// events, which carry `via` context that a plain `settings_changed` could +// not. Listing it here would double-fire. +// +// Kept as an `as const` tuple so the Zod enum below and any call-site usage +// share one array — typo-drift is impossible. +export const SETTINGS_CHANGED_WHITELIST = [ + 'editorAutoSave', + 'openLinksInApp', + 'experimentalTerminalDaemon', + 'experimentalAgentDashboard' +] as const +export const settingsChangedKeySchema = z.enum(SETTINGS_CHANGED_WHITELIST) +export type SettingsChangedKey = z.infer + +// ── Per-event schemas ─────────────────────────────────────────────────── +// +// `.strict()` on every object is what enforces "no extra keys" at runtime — +// the validator does not need a separate extra-key check because zod rejects +// unknown keys at parse time. This is the runtime counterpart to the +// compile-time "unions of string literals, no raw `string`" rule. + +const emptySchema = z.object({}).strict() + +const repoAddedSchema = z.object({ method: repoMethodSchema }).strict() + +const workspaceCreatedSchema = z + .object({ + source: workspaceSourceSchema, + from_existing_branch: z.boolean() + }) + .strict() + +const agentStartedSchema = z + .object({ + agent_kind: agentKindSchema, + launch_source: launchSourceSchema, + request_kind: requestKindSchema + }) + .strict() + +// Enum-only by design for `error_class` + `agent_kind`. `error_name` is the +// one free-ish string that can leave the machine on this event, and it is +// drawn from the closed `AGENT_ERROR_NAME_WHITELIST` — adding a new value +// requires a PR to the whitelist, giving review a chance to catch +// context-concatenation patterns. +// +// `error_message` and `error_stack` are deliberately absent from this schema. +// `.strict()` rejects either key if a call site ever tries to attach one, +// which fails the validator and drops the event. Raw error strings carry +// arbitrary user/workspace/path content; keeping them off the wire is the +// only way to guarantee we never transmit them by accident. +const agentErrorSchema = z + .object({ + error_class: errorClassSchema, + agent_kind: agentKindSchema, + error_name: agentErrorNameSchema.optional() + }) + .strict() + +const settingsChangedSchema = z + .object({ + setting_key: settingsChangedKeySchema, + value_kind: z.enum(['bool', 'enum']) + }) + .strict() + +const telemetryOptedInSchema = z.object({ via: optInViaSchema }).strict() +const telemetryOptedOutSchema = z.object({ via: optInViaSchema }).strict() + +// ── Event registry: the one record the validator consumes ─────────────── +// +// The validator does `eventSchemas[name].safeParse(props)`. `EventMap` is +// `z.infer`-derived from this record, so there is exactly one source of +// truth for both compile-time types and runtime validation. +// +// Schema-evolution / versioning doctrine: +// Breaking changes (renaming a field, changing an enum's meaning, removing a +// required key) require a new event name (e.g. `agent_started_v2`), not an +// in-place edit. Additive-optional fields (`z.field().optional()`) are safe +// to add in place. This keeps PostHog funnels clean — an in-place breaking +// change silently blends pre- and post-change rows under one event name, +// which cannot be unmixed after the fact. +export const eventSchemas = { + app_opened: emptySchema, + + repo_added: repoAddedSchema, + workspace_created: workspaceCreatedSchema, + + agent_started: agentStartedSchema, + agent_error: agentErrorSchema, + + settings_changed: settingsChangedSchema, + + telemetry_opted_in: telemetryOptedInSchema, + telemetry_opted_out: telemetryOptedOutSchema +} as const + +export type EventMap = { [N in keyof typeof eventSchemas]: z.infer<(typeof eventSchemas)[N]> } +export type EventName = keyof EventMap +export type EventProps = EventMap[N] + +// Common props attached by the client — declared here so the validator knows +// which keys to allow on every outgoing event. +// +// No `env: 'prod' | 'dev'` property. Every transmitted event is by +// construction from an official CI build, so a wire discriminator would be +// redundant. Contributor / `pnpm dev` builds do not transmit at all; they +// console-mirror. +// +// Every string field carries the 64-char cap directly — this is what the +// validator's "string-length cap" rule is made of; there is no separate +// post-parse length check to keep in sync with the schema. +export const commonPropsSchema = z + .object({ + app_version: z.string().max(64), + platform: z.string().max(64), + arch: z.string().max(64), + os_release: z.string().max(64), + // `install_id` is used as PostHog's `distinctId` and `session_id` is the + // per-process correlation key — an empty string on either would collapse + // unrelated events into a single synthetic "user" / "session" and + // silently corrupt analytics. `.min(1)` rejects that actual observed + // failure mode without pinning the shape to UUIDs (both ids come from + // `randomUUID()` today, but forward-compatibility with a future id + // scheme is cheap to preserve). + install_id: z.string().min(1).max(64), + session_id: z.string().min(1).max(64), + orca_channel: z.enum(['stable', 'rc']) + }) + .strict() +export type CommonProps = z.infer diff --git a/src/types/build-constants.d.ts b/src/types/build-constants.d.ts new file mode 100644 index 000000000..ddbe3915e --- /dev/null +++ b/src/types/build-constants.d.ts @@ -0,0 +1,13 @@ +// Ambient declarations for compile-time build-identity constants substituted +// by electron-vite's `define` block (see `electron.vite.config.ts` main +// section). Contributor / `pnpm dev` / third-party builds substitute literal +// `null`, which `IS_OFFICIAL_BUILD` in `src/main/telemetry/client.ts` +// evaluates to `false` at module load — such builds console-mirror only. +// +// The CI release workflow (and only the CI release workflow) provides real +// values via GitHub Actions secrets. There is no runtime env-var fallback; +// the substitution happens at compile time so a curious contributor cannot +// spoof transmission with a shell export. + +declare const ORCA_BUILD_IDENTITY: 'stable' | 'rc' | null +declare const ORCA_POSTHOG_WRITE_KEY: string | null