feat(analytics): always-on $exception capture with early window hooks (#2521)

PostHog Error tracking was missing the vast majority of real exceptions:

  1. posthog-js's capture_exceptions: true is silenced by opt_out_capturing,
     so every opted-out user vanished from the error feed even though we
     could perfectly safely keep collecting their stacks (the consent
     toggle's user copy gates analytics, not safety telemetry).
  2. posthog-js is dynamically imported only after /api/analytics/config
     resolves AND the user has consented. Errors thrown during the first
     1-2 seconds (React hydration, early effects) had no listener to
     catch them.

Net effect: 14d $exception count was 54 events / 10 users across ~5k DAU,
producing the misleading 99.93% crash-free curve in PostHog's dashboard.

This PR makes exception capture independent of both gates:

  - apps/web/src/analytics/error-tracking.ts (new): own window.error +
    unhandledrejection handlers, in-memory buffer (capped at 50 entries),
    direct fetch to https://<host>/i/v0/e/ with the public phc_ key. Same
    scrub layer as the posthog-js path so file paths still get redacted.
  - apps/web/app/[[...slug]]/client-app.tsx: installErrorHandlers() at
    module-load, before React or any feature code can throw.
  - apps/web/src/analytics/provider.tsx: bootstrapExceptionTracking() in
    the identity useEffect, parallel to getAnalyticsClient() — runs
    regardless of consent state, fetches /api/analytics/config, hands the
    phc_ key + host + distinctId to the error tracker so buffered events
    can flush.
  - apps/web/src/analytics/client.ts: capture_exceptions: false so
    posthog-js stops also emitting $exception (would have produced
    duplicate events server-side); also re-bridges the error-tracking
    context inside the loaded() callback so future events inherit the
    fully-resolved appVersion / sessionId.
  - apps/daemon/src/server.ts + packages/contracts: /api/analytics/config
    now returns key + host even when consent=false. enabled still reflects
    only the analytics consent toggle (posthog-js full autocapture stays
    off when enabled=false), but the always-on error tracker can read key
    directly. Forks without POSTHOG_KEY still get key=null and the whole
    pipeline becomes a no-op — fork-safe by construction.
  - apps/web/src/analytics/scrub.ts: regex fix so packaged-mac paths like
    /Applications/Open Design.app/Contents/Resources/apps/web/... (which
    contain a space) get fully rewritten to app://apps/web/...; previously
    the [^\s] guard stopped at 'Open' and leaked the install dir.

Validation:

  - pnpm --filter @open-design/web typecheck: pass
  - pnpm --filter @open-design/web test: 199 files / 1823 tests pass
    (includes 8 new error-tracking.test.ts cases for buffer cap, hook
    install, scrub, and direct dispatch)
  - pnpm --filter @open-design/daemon test: 250 files / 2971 tests pass
  - pnpm guard: pass

After release/v0.8.0 ships and rolls out, expect the crash-free curve to
drop from the artificial 99.93% to a realistic 95-98% — that's not a
regression, it's the first time we're measuring it.
This commit is contained in:
lefarcen 2026-05-21 13:07:26 +08:00 committed by GitHub
parent 4f70893b18
commit 88dee44892
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 697 additions and 22 deletions

View file

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

View file

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

View file

@ -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<void> | null = null;
export function bootstrapExceptionTracking(context: AnalyticsContext): Promise<void> {
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<PostHog | null> {
@ -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<string, unknown>),
};
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;

View file

@ -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<string, unknown> };
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<string, unknown> = {
$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<Record<string, unknown>> {
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<Record<string, unknown>> {
if (!stack) {
if (metadata.filename) {
return [
{
function: '<anonymous>',
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<string, unknown> => frame != null);
}
function parseFrame(line: string): Record<string, unknown> | null {
const trimmed = line.trim();
if (!trimmed) return null;
const v8 = STACK_RE_V8.exec(trimmed);
if (v8) {
return {
function: v8[1] ?? '<anonymous>',
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] || '<anonymous>',
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<Record<string, unknown>>): 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 };

View file

@ -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<string | null>(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,

View file

@ -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/<user>/.../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<Record<string, unknown>>,
): Array<Record<string, unknown>> {
return list.map((entry) => {

View file

@ -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<string, unknown> {
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<string, unknown>;
}
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<unknown>;
};
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<string, unknown>;
expect(
(firstBody.properties as Record<string, unknown>).$exception_message,
).toBe('loop-25');
const lastBody = lastFetchedBody();
expect(
(lastBody.properties as Record<string, unknown>).$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<string, unknown>).$exception_list as Array<{
stacktrace?: { frames?: Array<Record<string, unknown>> };
}>;
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();
});
});

View file

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