mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
4f70893b18
commit
88dee44892
8 changed files with 697 additions and 22 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
298
apps/web/src/analytics/error-tracking.ts
Normal file
298
apps/web/src/analytics/error-tracking.ts
Normal 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 };
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
245
apps/web/tests/analytics/error-tracking.test.ts
Normal file
245
apps/web/tests/analytics/error-tracking.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue