feat(telemetry): PR 2 — transport (client, validator, burst cap, IPC, build gate) (#1374)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-05-03 16:51:31 -07:00 committed by GitHub
parent eaebd08ff9
commit fd86e1869a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 2049 additions and 7 deletions

1
.gitignore vendored
View File

@ -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/

View File

@ -5,7 +5,8 @@
"../src/main/**/*",
"../src/preload/**/*",
"../src/shared/**/*",
"../src/relay/**/*"
"../src/relay/**/*",
"../src/types/**/*"
],
"compilerOptions": {
"composite": true,

View File

@ -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.

View File

@ -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",

View File

@ -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

View File

@ -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()
})
}
})

View File

@ -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()

View File

@ -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

View File

@ -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<string, (_event: unknown, ...args: unknown[]) => 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<string, infer V> ? 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()
})
})

70
src/main/ipc/telemetry.ts Normal file
View File

@ -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<EventName>` 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<EventName>
)
}
)
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)
})
}

View File

@ -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)
})
})

View File

@ -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<string, TokenBucket>()
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<string, TokenBucket>
perSessionCount: number
consentMutationCount: number
} {
return { perEventBuckets, perSessionCount, consentMutationCount }
}

View File

@ -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<typeof vi.fn>
optIn: ReturnType<typeof vi.fn>
optOut: ReturnType<typeof vi.fn>
shutdown: ReturnType<typeof vi.fn>
}
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<GlobalSettings>) => {
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<string, string | undefined> {
const stash: Record<string, string | undefined> = {}
for (const name of CONSENT_ENV_VARS) {
stash[name] = process.env[name]
delete process.env[name]
}
return stash
}
function restoreConsentEnv(stash: Record<string, string | undefined>): 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<string, string | undefined>
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<string, string | undefined>
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()
})
})

View File

@ -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<N extends EventName>(name: N, props: EventProps<N>): 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<void> {
// 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
}

View File

@ -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',

View File

@ -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)
})
})

View File

@ -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<N extends EventName> =
| { ok: true; props: EventProps<N> }
| { ok: false; reason: string }
const WARN_WINDOW_MS = 60_000
const lastWarnAt = new Map<string, number>()
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<N extends EventName>(name: N, props: unknown): ValidationResult<N> {
// 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('.') : '<root>'
const reason = `${String(name)}: ${path}: ${issue?.message ?? 'invalid'}`
warnRateLimited(String(name), reason)
return { ok: false, reason }
}
return { ok: true, props: parsed.data as EventProps<N> }
}
/** 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 }

View File

@ -568,6 +568,14 @@ export type PreloadApi = {
complete: () => Promise<void>
forceShow: () => Promise<void>
}
/** 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<N>()` from `src/renderer/src/lib/telemetry.ts`
* for the `EventMap`-based type safety, not reach for this directly. */
telemetryTrack: (name: string, props: Record<string, unknown>) => Promise<void>
/** 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<void>
settings: {
get: () => Promise<GlobalSettings>
set: (args: Partial<GlobalSettings>) => Promise<GlobalSettings>

View File

@ -615,6 +615,16 @@ const api = {
forceShow: (): Promise<void> => 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<N>()` / `setOptIn()` wrappers via
// src/renderer/src/lib/telemetry.ts, which is what call sites import.
telemetryTrack: (name: string, props: Record<string, unknown>): Promise<void> =>
ipcRenderer.invoke('telemetry:track', name, props),
telemetrySetOptIn: (optedIn: boolean): Promise<void> =>
ipcRenderer.invoke('telemetry:setOptIn', optedIn),
settings: {
get: (): Promise<unknown> => ipcRenderer.invoke('settings:get'),

View File

@ -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<string,
// unknown>) => Promise<void>` 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<N extends EventName>(name: N, props: EventProps<N>): 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<string, unknown>)?.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.
}
}

View File

@ -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)
}
})
})

View File

@ -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<typeof agentKindSchema>
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<typeof errorClassSchema>
// 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<typeof agentErrorNameSchema>
export const repoMethodSchema = z.enum(['folder_picker', 'clone_url', 'drag_drop'])
export type RepoMethod = z.infer<typeof repoMethodSchema>
export const workspaceSourceSchema = z.enum([
'command_palette',
'sidebar',
'shortcut',
'drag_drop',
'unknown'
])
export type WorkspaceSource = z.infer<typeof workspaceSourceSchema>
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<typeof launchSourceSchema>
export const requestKindSchema = z.enum(['new', 'resume', 'followup'])
export type RequestKind = z.infer<typeof requestKindSchema>
// `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<typeof optInViaSchema>
// 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<typeof settingsChangedKeySchema>
// ── 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<N extends EventName> = 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<typeof commonPropsSchema>

13
src/types/build-constants.d.ts vendored Normal file
View File

@ -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