From e1e73db59a33c001d68aa111ba822c97ed62a52b Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:08:04 +0800 Subject: [PATCH] feat(analytics): capture JS exceptions to PostHog Turn on posthog-js exception autocapture (window.onerror + unhandled rejections, with stack) and add a buffered captureException() wrapper for boundary-caught React errors those handlers can't see. Wire the web route-level global-error boundary to report through it. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/app/global-error.tsx | 58 +++++++++++++++++++++++++++ packages/core/analytics/index.test.ts | 33 +++++++++++++++ packages/core/analytics/index.ts | 39 +++++++++++++++++- 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/global-error.tsx diff --git a/apps/web/app/global-error.tsx b/apps/web/app/global-error.tsx new file mode 100644 index 000000000..da0e2f713 --- /dev/null +++ b/apps/web/app/global-error.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect } from "react"; +import { captureException } from "@multica/core/analytics"; + +/** + * Route-level error boundary for the web app. Next.js renders this (replacing + * the root layout) when an error escapes everything below it — the full-page + * white-screen case. React catches these before they reach window.onerror, so + * posthog-js's automatic exception capture never sees them; we report them + * explicitly here. Section-level failures are handled in place by + * `@multica/ui` ErrorBoundary and don't reach this far. + */ +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + captureException(error, { source: "global-error", digest: error.digest }); + }, [error]); + + return ( + + +
+

Something went wrong

+

+ The page hit an unexpected error. Try reloading. +

+ +
+ + + ); +} diff --git a/packages/core/analytics/index.test.ts b/packages/core/analytics/index.test.ts index 310f16c05..d4d6e67c3 100644 --- a/packages/core/analytics/index.test.ts +++ b/packages/core/analytics/index.test.ts @@ -9,6 +9,7 @@ vi.mock("posthog-js", () => { reset: vi.fn(), identify: vi.fn(), capture: vi.fn(), + captureException: vi.fn(), }; return { default: mock }; }); @@ -22,10 +23,12 @@ async function loadModule() { init: ReturnType; register: ReturnType; reset: ReturnType; + captureException: ReturnType; }; posthog.init.mockClear(); posthog.register.mockClear(); posthog.reset.mockClear(); + posthog.captureException.mockClear(); return { analytics, posthog }; } @@ -183,3 +186,33 @@ describe("capturePageview", () => { expect(capture).toHaveBeenCalledTimes(2); }); }); + +describe("captureException", () => { + it("buffers a pre-init exception and flushes it on init", async () => { + const { analytics, posthog } = await loadModule(); + const err = new Error("boom"); + + // Before init: buffered, nothing sent yet. + analytics.captureException(err, { source: "global-error" }); + expect(posthog.captureException).not.toHaveBeenCalled(); + + // Init flushes the buffer in order. + analytics.initAnalytics({ key: "k", host: "" }); + expect(posthog.captureException).toHaveBeenCalledTimes(1); + expect(posthog.captureException).toHaveBeenCalledWith( + err, + expect.objectContaining({ source: "global-error" }), + ); + }); + + it("sends immediately once initialized", async () => { + const { analytics, posthog } = await loadModule(); + analytics.initAnalytics({ key: "k", host: "" }); + posthog.captureException.mockClear(); + + const err = new Error("later"); + analytics.captureException(err); + expect(posthog.captureException).toHaveBeenCalledTimes(1); + expect(posthog.captureException).toHaveBeenCalledWith(err, expect.any(Object)); + }); +}); diff --git a/packages/core/analytics/index.ts b/packages/core/analytics/index.ts index 654f5771a..831c5ba2b 100644 --- a/packages/core/analytics/index.ts +++ b/packages/core/analytics/index.ts @@ -56,7 +56,8 @@ let lastCapturedPath: string | null = null; // buffer stays small (~one step-transition worth). type PendingOp = | { kind: "event"; name: string; props?: Record } - | { kind: "set"; props: Record }; + | { kind: "set"; props: Record } + | { kind: "exception"; error: unknown; props?: Record }; const pendingOps: PendingOp[] = []; // Cached super-properties so resetAnalytics() can re-register them after // posthog.reset() wipes the persisted set. Without this, logout / account @@ -142,7 +143,14 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole autocapture: false, capture_heatmaps: false, capture_dead_clicks: false, - capture_exceptions: false, + // Exception autocapture IS on: posthog-js attaches window.onerror + + // unhandledrejection handlers and sends `$exception` events with the + // error's stack. Unlike the click/heatmap autocapture above, this is + // explicit failure signal (not behavioral noise) and is the one PostHog + // surface that natively handles thrown JS errors — see the failure-tier + // split in packages/core/diagnostics. (Production builds are minified; + // upload source maps to PostHog to de-minify the stacks.) + capture_exceptions: true, disable_session_recording: true, disable_surveys: true, }); @@ -184,6 +192,8 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole const op = pendingOps.shift()!; if (op.kind === "event") { posthog.capture(op.name, withClientEventProperties(op.props)); + } else if (op.kind === "exception") { + posthog.captureException(op.error, withClientEventProperties(op.props)); } else { capturePersonSet(op.props); } @@ -250,6 +260,31 @@ export function captureEvent( posthog.capture(name, withClientEventProperties(props)); } +/** + * Report a caught exception that never reached `window.onerror` — a React + * render-phase error swallowed by an error boundary. Global uncaught errors + * and unhandled rejections are already captured automatically by posthog-js + * (`capture_exceptions: true`); this wrapper is for the boundary case those + * handlers can't see. + * + * Currently called by the web route-level `global-error`. Section-level + * `@multica/ui` ErrorBoundary can opt in by passing `onError={captureException}` + * at its call sites; it is not wired app-wide (those failures already degrade + * gracefully with fallback UI). + * + * Calls before initAnalytics() buffer in order, same as captureEvent. + */ +export function captureException( + error: unknown, + props?: Record, +): void { + if (!initialized) { + pendingOps.push({ kind: "exception", error, props }); + return; + } + posthog.captureException(error, withClientEventProperties(props)); +} + /** * Set (overwrite) person properties on the currently identified user. * Mirrors the backend's `Event.Set` path — keep these aligned so the