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:
parent
eaebd08ff9
commit
fd86e1869a
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
"../src/main/**/*",
|
||||
"../src/preload/**/*",
|
||||
"../src/shared/**/*",
|
||||
"../src/relay/**/*"
|
||||
"../src/relay/**/*",
|
||||
"../src/types/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue