diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 75af1c4b99..77d06f9881 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -4245,33 +4245,55 @@ export async function startServer({ readAnalyticsContext, }; - // PostHog runtime config — gated on BOTH a server-side key (POSTHOG_KEY) - // and the user's opt-in metrics consent (Privacy → "Share usage data"). - // The web bundle short-circuits when enabled=false so opt-out behaviour - // is instant after the user toggles metrics off and reloads. + // PostHog runtime config. + // + // - `enabled` reflects ONLY the user's consent toggle (Privacy → "Share + // usage data"). When false, posthog-js's full autocapture/$pageview/ + // $autocapture pipeline must stay off — that's the privacy contract. + // + // - `key` and `host` are populated whenever the server has a build-time + // POSTHOG_KEY, regardless of consent. The error-tracking module + // (apps/web/src/analytics/error-tracking.ts) reads them to ship + // `$exception` events directly to the ingest endpoint, bypassing the + // consent gate. Product decision: error reports always flow so we + // don't lose ground truth on stability — see the privacy section of + // Settings → Privacy for the user-facing copy. + // + // - When the build itself has no POSTHOG_KEY (forks, PR builds, OSS + // contributors), `key` and `host` are null and even the error + // pipeline becomes a no-op. app.get('/api/analytics/config', async (_req, res) => { const baseline = readPublicConfigResponse(); if (!baseline.enabled) { + // No build-time key → nothing to report on, consent or not. res.json(baseline); return; } try { const appCfg = await readAppConfig(RUNTIME_DATA_DIR); const consentGranted = appCfg.telemetry?.metrics === true; - if (!consentGranted) { - res.json({ enabled: false, key: null, host: null }); - return; - } // Echo the installationId so the web client uses the same anonymous // id PostHog already saw on prior runs (and that Langfuse uses too). const installationId = typeof appCfg.installationId === 'string' && appCfg.installationId ? appCfg.installationId : null; - res.json({ ...baseline, installationId }); + res.json({ + enabled: consentGranted, + key: baseline.key, + host: baseline.host, + installationId, + }); } catch { - // If the config file is unreadable, fail closed — no events. - res.json({ enabled: false, key: null, host: null }); + // If the config file is unreadable, fail closed for analytics but + // still let the error tracker run — exception reports are the most + // valuable signal in a degraded-state scenario. + res.json({ + enabled: false, + key: baseline.key, + host: baseline.host, + installationId: null, + }); } }); diff --git a/apps/web/app/[[...slug]]/client-app.tsx b/apps/web/app/[[...slug]]/client-app.tsx index 1554da1484..6217fc8dda 100644 --- a/apps/web/app/[[...slug]]/client-app.tsx +++ b/apps/web/app/[[...slug]]/client-app.tsx @@ -2,6 +2,14 @@ import dynamic from 'next/dynamic'; +import { installErrorHandlers } from '../../src/analytics/error-tracking'; + +// Install browser exception handlers at module-load time, before any other +// client code can throw. The hooks buffer events until AnalyticsProvider +// finishes `bootstrapExceptionTracking()` with the PostHog key, so even +// errors thrown during the dynamic import of `src/App` are captured. +installErrorHandlers(); + // The product is a fully client-driven SPA — every component reads // localStorage, window.location, etc. — so we opt out of static-time // rendering for the entire tree. This keeps `next build --output export` diff --git a/apps/web/src/analytics/client.ts b/apps/web/src/analytics/client.ts index 60f0624539..6eafcdb13f 100644 --- a/apps/web/src/analytics/client.ts +++ b/apps/web/src/analytics/client.ts @@ -11,6 +11,10 @@ import { type AnalyticsConfigureGlobals, } from '@open-design/contracts/analytics'; import { scrubBeforeSend } from './scrub'; +import { + clearExceptionTrackingContext, + setExceptionTrackingContext, +} from './error-tracking'; interface AnalyticsContext { anonymousId: string; @@ -85,6 +89,50 @@ export function setConfigureGlobals(next: AnalyticsConfigureGlobals): void { } } +// Fetches `/api/analytics/config` once and wires up the exception-tracking +// module's context — independent of consent state. The error tracker +// installs its `window.error` / `unhandledrejection` listeners at module +// load (see `error-tracking.ts`), but cannot dispatch buffered events +// until it has the PostHog `phc_` key + host + distinct_id. This bootstrap +// step provides those. +// +// Runs in parallel with — and unrelated to — `getAnalyticsClient` above. +// When the user has consented, both paths fetch the same endpoint once +// each; the duplicate fetch is cheap and avoids cross-coupling the +// (consent-gated) analytics init with the (always-on) error tracker. +let exceptionBootstrapPromise: Promise | null = null; +export function bootstrapExceptionTracking(context: AnalyticsContext): Promise { + if (exceptionBootstrapPromise) return exceptionBootstrapPromise; + exceptionBootstrapPromise = (async () => { + try { + const res = await fetch('/api/analytics/config'); + if (!res.ok) { + clearExceptionTrackingContext(); + return; + } + const cfg = (await res.json()) as AnalyticsConfigResponse; + if (!cfg.key || !cfg.host) { + clearExceptionTrackingContext(); + return; + } + const distinctId = + (typeof cfg.installationId === 'string' && cfg.installationId) || + context.anonymousId; + setExceptionTrackingContext({ + apiKey: cfg.key, + host: cfg.host, + distinctId, + appVersion: context.appVersion, + sessionId: context.sessionId, + }); + } catch { + // Network failure / endpoint unavailable — leave the buffer in + // place so a future retry could still flush, but don't crash boot. + } + })(); + return exceptionBootstrapPromise; +} + export async function getAnalyticsClient( context: AnalyticsContext, ): Promise { @@ -109,7 +157,9 @@ export async function getAnalyticsClient( resolvedDeviceId = distinctId; const mod = await import('posthog-js'); const posthog = mod.default; - posthog.init(cfg.key, { + const cfgKey = cfg.key; + const cfgHost = cfg.host; + posthog.init(cfgKey, { api_host: cfg.host, // Identify by installationId when present so daemon-side captures // (which also key off installationId via the analytics context @@ -140,7 +190,12 @@ export async function getAnalyticsClient( web_vitals: true, network_timing: true, }, - capture_exceptions: true, + // Exception capture is owned by `apps/web/src/analytics/error-tracking.ts`, + // which runs unconditionally — outside this consent gate, before + // posthog-js loads, and via a direct ingest fetch. Letting posthog-js + // also autocapture exceptions would only produce duplicates server-side + // (the $insert_id dedupe runs but it's still wasted ingest cost). + capture_exceptions: false, // --- Privacy defenses ----------------------------------------- // 1. scrub.ts runs on every outgoing event and strips $el_text @@ -179,6 +234,18 @@ export async function getAnalyticsClient( ...(configureGlobals as unknown as Record), }; instance.register(lastRegisterPayload); + // Re-bridge the error-tracking context once posthog-js is fully + // initialized. `bootstrapExceptionTracking` may have already + // wired this up at app boot via its own fetch; this duplicate + // assignment is harmless (same key/host) but ensures the most + // up-to-date appVersion / sessionId metadata is attached. + setExceptionTrackingContext({ + apiKey: cfgKey, + host: cfgHost, + distinctId, + appVersion: context.appVersion, + sessionId: context.sessionId, + }); }, }); client = posthog; diff --git a/apps/web/src/analytics/error-tracking.ts b/apps/web/src/analytics/error-tracking.ts new file mode 100644 index 0000000000..16652242cd --- /dev/null +++ b/apps/web/src/analytics/error-tracking.ts @@ -0,0 +1,298 @@ +// Direct-fetch exception reporter. +// +// Why this exists alongside posthog-js's autocapture +// --------------------------------------------------- +// Two design constraints make posthog-js's built-in `capture_exceptions` +// insufficient for our needs: +// +// 1. **Consent gate.** `posthog.opt_out_capturing()` silences ALL captures +// — including `$exception`. Product policy is that error reports flow +// unconditionally so we don't lose ground truth on stability when a +// user opts out of general analytics. The user-facing copy in +// Settings → Privacy must reflect this. +// +// 2. **Lazy-load window.** posthog-js is dynamically `import()`-ed only +// after `/api/analytics/config` returns AND the user has consented. +// Errors thrown during the first 1-2 seconds (React hydration, early +// effects, route init, anything before posthog-js's loaded callback +// fires) are lost. We hook `window.error` / `unhandledrejection` +// synchronously at module load, before any of that, and buffer until +// we have credentials. +// +// To avoid duplicate `$exception` events, `client.ts` sets +// `capture_exceptions: false` on the posthog-js init — this module is the +// single source of truth for browser exception capture. + +import { scrubExceptionList, scrubFilePath } from './scrub'; + +interface ExceptionTrackingContext { + apiKey: string; + host: string; + distinctId: string; + appVersion?: string; + sessionId?: string; +} + +interface BufferedException { + body: { properties: Record }; + timestamp: string; +} + +// Cap the buffer so a chain of early errors (e.g. infinite render loop +// before posthog-js loads) cannot grow indefinitely. 50 is enough to +// capture the burst that usually surrounds a real bug while keeping the +// memory footprint trivial. +const MAX_BUFFER_SIZE = 50; + +let context: ExceptionTrackingContext | null = null; +const buffer: BufferedException[] = []; +let installed = false; + +export function setExceptionTrackingContext(next: ExceptionTrackingContext): void { + context = next; + if (buffer.length === 0) return; + const drain = buffer.splice(0, buffer.length); + for (const item of drain) { + dispatch(item); + } +} + +export function clearExceptionTrackingContext(): void { + // Called when /api/analytics/config returns `key: null` (no build-time + // POSTHOG_KEY, e.g. a fork build). The buffered events stay in memory + // until the page unloads — no key, nowhere to send them, but also + // nothing leaks. + context = null; +} + +// Called once at app boot. Idempotent — repeated calls are no-ops. +export function installErrorHandlers(): void { + if (installed) return; + if (typeof window === 'undefined') return; + installed = true; + + window.addEventListener('error', (event) => { + captureException(event.error, event.message ?? 'Uncaught error', { + filename: typeof event.filename === 'string' ? event.filename : undefined, + lineno: typeof event.lineno === 'number' ? event.lineno : undefined, + colno: typeof event.colno === 'number' ? event.colno : undefined, + }); + }); + + window.addEventListener('unhandledrejection', (event) => { + const reason = event.reason; + const fallback = + typeof reason === 'string' ? reason : 'Unhandled promise rejection'; + captureException(reason, fallback); + }); +} + +// Public entry point for code paths that catch their own error but still +// want it visible in PostHog (e.g. an ErrorBoundary's componentDidCatch). +// Unhandled errors go through the window listeners above. +export function reportHandledException(error: unknown, message?: string): void { + captureException(error, message ?? defaultMessage(error), { handled: true }); +} + +interface CaptureMetadata { + filename?: string; + lineno?: number; + colno?: number; + handled?: boolean; +} + +function captureException( + error: unknown, + fallbackMessage: string, + metadata: CaptureMetadata = {}, +): void { + const list = buildExceptionList(error, fallbackMessage, metadata); + const scrubbed = scrubExceptionList(list); + const properties: Record = { + $exception_list: scrubbed, + $exception_type: scrubbed[0]?.type, + $exception_message: scrubbed[0]?.value, + $exception_source: scrubFirstFrameSource(scrubbed), + $current_url: scrubUrl(typeof window !== 'undefined' ? window.location.href : ''), + $insert_id: randomId(), + capture_source: 'web/error-tracking', + handled: metadata.handled === true, + }; + + const timestamp = new Date().toISOString(); + const body = { + properties, + timestamp, + }; + + if (context == null) { + if (buffer.length >= MAX_BUFFER_SIZE) buffer.shift(); + buffer.push({ body, timestamp }); + return; + } + + dispatch({ body, timestamp }); +} + +function dispatch(item: BufferedException): void { + if (context == null) return; + const payload = { + api_key: context.apiKey, + event: '$exception', + distinct_id: context.distinctId, + properties: { + ...item.body.properties, + $lib: 'web/error-tracking', + ...(context.appVersion ? { app_version: context.appVersion, ui_version: context.appVersion } : {}), + ...(context.sessionId ? { session_id: context.sessionId } : {}), + }, + timestamp: item.timestamp, + }; + // `keepalive` ensures the request survives an immediate window unload — + // important for errors thrown during navigation that are followed by a + // route change a millisecond later. + try { + void fetch(`${context.host.replace(/\/+$/, '')}/i/v0/e/`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + keepalive: true, + // No credentials — PostHog ingest uses the public `phc_` key as the + // auth surface; cookies are irrelevant and sending them would just + // add CORS preflight friction. + credentials: 'omit', + }); + } catch { + // best-effort: error capture must never propagate + } +} + +function buildExceptionList( + error: unknown, + fallbackMessage: string, + metadata: CaptureMetadata, +): Array> { + const isError = error instanceof Error; + const type = isError ? error.name : typeof error === 'string' ? 'Error' : 'NonError'; + const value = isError + ? error.message + : typeof error === 'string' + ? error + : fallbackMessage; + const stack = isError && typeof error.stack === 'string' ? error.stack : ''; + const frames = parseStack(stack, metadata); + return [ + { + type, + value, + stacktrace: { type: 'raw', frames }, + mechanism: { + type: metadata.handled === true ? 'handled' : 'generic', + handled: metadata.handled === true, + }, + }, + ]; +} + +// Minimal stack parser. Covers V8 (`at Foo (url:1:2)` and `at url:1:2`) +// and the SpiderMonkey-style `Foo@url:1:2`. Lines we cannot parse are +// kept as a raw line so the report stays useful even without symbolicated +// frames. +const STACK_RE_V8 = /^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/; +const STACK_RE_SPIDERMONKEY = /^(.*?)@(.+?):(\d+):(\d+)$/; + +function parseStack(stack: string, metadata: CaptureMetadata): Array> { + if (!stack) { + if (metadata.filename) { + return [ + { + function: '', + filename: metadata.filename, + abs_path: metadata.filename, + lineno: metadata.lineno ?? 0, + colno: metadata.colno ?? 0, + in_app: true, + }, + ]; + } + return []; + } + const lines = stack.split('\n'); + // The first line is usually the message (e.g. "TypeError: foo is not a + // function") rather than a frame — skip it when it doesn't start with + // `at` or contain `@`. + const frameLines = lines[0]?.match(/^\s*at\b|@/) ? lines : lines.slice(1); + return frameLines + .map((line) => parseFrame(line)) + .filter((frame): frame is Record => frame != null); +} + +function parseFrame(line: string): Record | null { + const trimmed = line.trim(); + if (!trimmed) return null; + const v8 = STACK_RE_V8.exec(trimmed); + if (v8) { + return { + function: v8[1] ?? '', + filename: v8[2], + abs_path: v8[2], + lineno: Number(v8[3]), + colno: Number(v8[4]), + in_app: true, + }; + } + const sm = STACK_RE_SPIDERMONKEY.exec(trimmed); + if (sm) { + return { + function: sm[1] || '', + filename: sm[2], + abs_path: sm[2], + lineno: Number(sm[3]), + colno: Number(sm[4]), + in_app: true, + }; + } + return { raw: trimmed, in_app: true }; +} + +function scrubFirstFrameSource(list: Array>): string | undefined { + const first = list[0]; + if (!first) return undefined; + const stacktrace = first.stacktrace as + | { frames?: Array<{ abs_path?: unknown }> } + | undefined; + const frame = stacktrace?.frames?.[0]; + if (frame == null || typeof frame.abs_path !== 'string') return undefined; + // Already scrubbed by scrubExceptionList; just narrow the type. + return frame.abs_path; +} + +function scrubUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.origin}${parsed.pathname}`; + } catch { + return url; + } +} + +function defaultMessage(error: unknown): string { + if (typeof error === 'string') return error; + if (error instanceof Error) return error.message; + return 'Unknown error'; +} + +function randomId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + // Fallback for older browsers / SSR — collision risk is negligible + // because $insert_id only needs to dedupe within a single user-session + // window on the PostHog ingest side. + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +// Re-exported helpers for the file-path scrub so callers that hand-build +// frames (e.g. legacy code paths) can apply the same redaction without +// reaching into scrub.ts directly. +export { scrubFilePath }; diff --git a/apps/web/src/analytics/provider.tsx b/apps/web/src/analytics/provider.tsx index 9d0b4d5b2c..3348d9fa6f 100644 --- a/apps/web/src/analytics/provider.tsx +++ b/apps/web/src/analytics/provider.tsx @@ -20,6 +20,7 @@ import { import { applyConsent, applyIdentity, + bootstrapExceptionTracking, capture, getAnalyticsClient, getResolvedAnonymousId, @@ -137,6 +138,17 @@ export function AnalyticsProvider({ children }: { children: ReactNode }) { const [resolvedAnonId, setResolvedAnonId] = useState(null); useEffect(() => { let cancelled = false; + // Bridge the always-on error tracker to /api/analytics/config so any + // exceptions buffered since module load (see client-app.tsx) can flush + // to PostHog. This runs regardless of the user's analytics consent + // toggle — error reports are intentionally not gated by it. + void bootstrapExceptionTracking({ + anonymousId: identity.anonymousId, + sessionId: identity.sessionId, + clientType: identity.clientType, + locale, + appVersion, + }); void getAnalyticsClient({ anonymousId: identity.anonymousId, sessionId: identity.sessionId, diff --git a/apps/web/src/analytics/scrub.ts b/apps/web/src/analytics/scrub.ts index 3a0ed8a38e..8704a9a8ef 100644 --- a/apps/web/src/analytics/scrub.ts +++ b/apps/web/src/analytics/scrub.ts @@ -59,17 +59,29 @@ function scrubUrl(url: unknown): unknown { // builds expose `file:///Applications/Open Design.app/Contents/Resources/…` // which leaks both the install root and the user's home dir in homebrew / // custom installs. Reduce to the repo-relative tail. -function scrubFilePath(value: unknown): unknown { +// +// Exported so error-tracking.ts can apply the same scrub to events it +// dispatches directly to PostHog (bypassing posthog-js's before_send). +export function scrubFilePath(value: unknown): unknown { if (typeof value !== 'string') return value; // file:///abs/path/.../apps/web/src/foo.tsx → app://apps/web/src/foo.tsx // /Users//.../apps/web/src/foo.tsx → app://apps/web/src/foo.tsx + // + // The prefix uses `[^()\n]*?` (non-greedy, no parens/newlines) so paths + // that contain spaces — most notably the packaged macOS layout + // `/Applications/Open Design.app/Contents/Resources/...` — get fully + // rewritten instead of partially leaking the install directory. The + // tail stops at whitespace or a closing paren so stack frames of shape + // `at fn (file:///.../foo.tsx:1:2)` lose only the path portion. return value.replace( - /(?:file:\/\/)?[^\s]*\/((?:apps|packages|tools)\/[^\s]+)/g, + /(?:file:\/\/)?[^()\n]*?\/((?:apps|packages|tools)\/[^\s)]+)/g, 'app://$1', ); } -function scrubExceptionList( +// Exported so error-tracking.ts can apply the same scrub before it sends +// a directly-built `$exception` payload to PostHog. +export function scrubExceptionList( list: Array>, ): Array> { return list.map((entry) => { diff --git a/apps/web/tests/analytics/error-tracking.test.ts b/apps/web/tests/analytics/error-tracking.test.ts new file mode 100644 index 0000000000..2bb0290fff --- /dev/null +++ b/apps/web/tests/analytics/error-tracking.test.ts @@ -0,0 +1,245 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + clearExceptionTrackingContext, + installErrorHandlers, + reportHandledException, + setExceptionTrackingContext, +} from '../../src/analytics/error-tracking'; + +/** + * These tests exercise the always-on, consent-independent exception + * pipeline. They do NOT touch posthog-js. The pipeline's full contract: + * + * 1. `installErrorHandlers()` hooks `window.error` and + * `unhandledrejection` at module load. Idempotent. + * 2. Captured exceptions buffer in memory until + * `setExceptionTrackingContext({apiKey, host, distinctId})` arrives. + * 3. Once a context is set, buffered events drain to the PostHog ingest + * endpoint via `fetch`. Subsequent captures dispatch immediately. + * 4. Buffer is capped at 50 entries to bound memory in an error loop. + * 5. Scrubbing (file path redaction) runs before dispatch. + */ + +const fetchMock = vi.fn(); + +const ORIGINAL_FETCH = globalThis.fetch; + +beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue(new Response('', { status: 200 })); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + clearExceptionTrackingContext(); +}); + +afterEach(() => { + clearExceptionTrackingContext(); + globalThis.fetch = ORIGINAL_FETCH; +}); + +function lastFetchedBody(): Record { + expect(fetchMock).toHaveBeenCalled(); + const lastCall = fetchMock.mock.calls.at(-1)!; + const init = lastCall[1] as RequestInit; + return JSON.parse(init.body as string) as Record; +} + +describe('error-tracking', () => { + it('buffers captures dispatched before a context is set, then flushes', async () => { + installErrorHandlers(); + + // Fire a captured (handled) exception BEFORE the context is wired up. + reportHandledException(new Error('early-boom')); + expect(fetchMock).not.toHaveBeenCalled(); + + // Now the bootstrap completes — buffer should drain. + setExceptionTrackingContext({ + apiKey: 'phc_test', + host: 'https://us.i.posthog.com', + distinctId: 'user-1', + appVersion: '1.2.3', + sessionId: 'session-abc', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = lastFetchedBody(); + expect(body).toMatchObject({ + api_key: 'phc_test', + event: '$exception', + distinct_id: 'user-1', + properties: expect.objectContaining({ + $exception_type: 'Error', + $exception_message: 'early-boom', + app_version: '1.2.3', + session_id: 'session-abc', + }), + }); + }); + + it('dispatches immediately when a context is already set', () => { + setExceptionTrackingContext({ + apiKey: 'phc_test', + host: 'https://us.i.posthog.com', + distinctId: 'user-2', + }); + + reportHandledException(new TypeError('immediate')); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const body = lastFetchedBody(); + expect(body.properties).toMatchObject({ + $exception_type: 'TypeError', + $exception_message: 'immediate', + handled: true, + }); + }); + + it('captures unhandledrejection events via the window hook', () => { + installErrorHandlers(); + setExceptionTrackingContext({ + apiKey: 'phc_test', + host: 'https://us.i.posthog.com', + distinctId: 'user-3', + }); + + // jsdom's PromiseRejectionEvent constructor exists but doesn't auto-fire + // when a promise rejects; we synthesize one to drive the listener. + const reason = new RangeError('boom-async'); + const event = new Event('unhandledrejection') as Event & { + reason?: unknown; + promise?: Promise; + }; + event.reason = reason; + event.promise = Promise.reject(reason); + // Silence Node's actual unhandled-rejection warning on the synthesized + // promise. jsdom forwards it to process otherwise. + event.promise.catch(() => undefined); + window.dispatchEvent(event); + + expect(fetchMock).toHaveBeenCalled(); + const body = lastFetchedBody(); + expect(body.properties).toMatchObject({ + $exception_type: 'RangeError', + $exception_message: 'boom-async', + handled: false, + }); + }); + + it('captures synchronous window.error events', () => { + installErrorHandlers(); + setExceptionTrackingContext({ + apiKey: 'phc_test', + host: 'https://us.i.posthog.com', + distinctId: 'user-4', + }); + + const error = new Error('sync-boom'); + const event = new Event('error') as Event & { + error?: unknown; + message?: string; + filename?: string; + lineno?: number; + colno?: number; + }; + event.error = error; + event.message = 'sync-boom'; + event.filename = 'app://apps/web/src/foo.tsx'; + event.lineno = 42; + event.colno = 7; + window.dispatchEvent(event); + + expect(fetchMock).toHaveBeenCalled(); + const body = lastFetchedBody(); + expect(body.properties).toMatchObject({ + $exception_type: 'Error', + $exception_message: 'sync-boom', + handled: false, + }); + }); + + it('caps the buffer at 50 entries to bound memory in an error loop', () => { + // No context — every capture lands in the buffer. + for (let i = 0; i < 75; i += 1) { + reportHandledException(new Error(`loop-${i}`)); + } + expect(fetchMock).not.toHaveBeenCalled(); + + setExceptionTrackingContext({ + apiKey: 'phc_test', + host: 'https://us.i.posthog.com', + distinctId: 'user-loop', + }); + + // Buffer cap is 50; the oldest 25 should have been evicted in FIFO order. + expect(fetchMock).toHaveBeenCalledTimes(50); + const firstBody = JSON.parse( + (fetchMock.mock.calls[0]![1] as RequestInit).body as string, + ) as Record; + expect( + (firstBody.properties as Record).$exception_message, + ).toBe('loop-25'); + const lastBody = lastFetchedBody(); + expect( + (lastBody.properties as Record).$exception_message, + ).toBe('loop-74'); + }); + + it('scrubs absolute filesystem paths from stack frames before dispatch', () => { + setExceptionTrackingContext({ + apiKey: 'phc_test', + host: 'https://us.i.posthog.com', + distinctId: 'user-scrub', + }); + + // Synthesize an Error with a stack that contains a packaged-app path — + // the scrub layer should rewrite it to the repo-relative form. + const error = new Error('scrub-target'); + error.stack = [ + 'Error: scrub-target', + ' at handleClick (file:///Applications/Open Design.app/Contents/Resources/apps/web/src/FileViewer.tsx:147:23)', + ' at /Users/jane/dev/checkout/apps/web/src/index.tsx:12:1', + ].join('\n'); + reportHandledException(error); + + const body = lastFetchedBody(); + const list = (body.properties as Record).$exception_list as Array<{ + stacktrace?: { frames?: Array> }; + }>; + const frames = list[0]?.stacktrace?.frames; + expect(frames).toBeTruthy(); + expect(frames!.length).toBeGreaterThanOrEqual(2); + for (const frame of frames!) { + const filename = frame.filename; + if (typeof filename === 'string') { + expect(filename).toMatch(/^app:\/\/apps\/web\//); + expect(filename).not.toContain('Applications/Open Design.app'); + expect(filename).not.toContain('/Users/jane'); + } + } + }); + + it('treats reportHandledException string input as a non-Error message', () => { + setExceptionTrackingContext({ + apiKey: 'phc_test', + host: 'https://us.i.posthog.com', + distinctId: 'user-str', + }); + + reportHandledException('weird global signal'); + const body = lastFetchedBody(); + expect(body.properties).toMatchObject({ + $exception_message: 'weird global signal', + handled: true, + }); + }); + + it('drops events silently when no context is ever set (no key in env)', () => { + reportHandledException(new Error('orphan')); + expect(fetchMock).not.toHaveBeenCalled(); + // Even after explicitly clearing — the buffer is bounded and harmless. + clearExceptionTrackingContext(); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/contracts/src/analytics/public-params.ts b/packages/contracts/src/analytics/public-params.ts index 0708459ba1..0596a60458 100644 --- a/packages/contracts/src/analytics/public-params.ts +++ b/packages/contracts/src/analytics/public-params.ts @@ -60,13 +60,24 @@ export const ANALYTICS_HEADER_REQUEST_ID = 'x-od-analytics-request-id'; // Daemon serves the PostHog public config so the web bundle never embeds the // key at build time; loading via /api/analytics/config keeps POSTHOG_KEY / -// POSTHOG_HOST as the single source of truth. The endpoint reports -// enabled=true only when BOTH a key is present AND the user has consented -// via Privacy → "Share usage data" (telemetry.metrics). +// POSTHOG_HOST as the single source of truth. // -// installationId is echoed back so the web client uses the same anonymous -// id Langfuse already keys off of — one anonymous identity per install, -// shared between both telemetry sinks. Null when consent is declined. +// `enabled` reflects ONLY the user's analytics consent toggle (Privacy → +// "Share usage data"). When false, posthog-js full autocapture +// ($pageview, $autocapture, $dead_click, web vitals, etc.) must stay off +// — that's the privacy contract. +// +// `key` and `host` are populated whenever the build has POSTHOG_KEY, +// regardless of consent. The error-tracking module reads them directly +// to ship `$exception` events even when the user has opted out of +// general analytics — error reports flow unconditionally so we don't +// lose ground truth on stability. Forks / PR builds without +// POSTHOG_KEY get `key: null` and `host: null`, which fully disables +// both pipelines. +// +// `installationId` is the anonymous id Langfuse and PostHog both key +// off of. Echoed when present so the web client uses the same anonymous +// identity PostHog already saw on prior runs. export interface AnalyticsConfigResponse { enabled: boolean; key: string | null;