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) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-06-16 15:08:04 +08:00
parent 241a3582cf
commit e1e73db59a
3 changed files with 128 additions and 2 deletions

View File

@@ -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 (
<html>
<body
style={{
display: "flex",
minHeight: "100vh",
alignItems: "center",
justifyContent: "center",
fontFamily: "system-ui, sans-serif",
}}
>
<div style={{ maxWidth: 420, textAlign: "center" }}>
<h1 style={{ fontSize: 18, fontWeight: 600 }}>Something went wrong</h1>
<p style={{ marginTop: 8, color: "#666" }}>
The page hit an unexpected error. Try reloading.
</p>
<button
type="button"
onClick={reset}
style={{
marginTop: 16,
padding: "8px 16px",
borderRadius: 6,
border: "1px solid #ccc",
cursor: "pointer",
}}
>
Reload
</button>
</div>
</body>
</html>
);
}

View File

@@ -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<typeof vi.fn>;
register: ReturnType<typeof vi.fn>;
reset: ReturnType<typeof vi.fn>;
captureException: ReturnType<typeof vi.fn>;
};
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));
});
});

View File

@@ -56,7 +56,8 @@ let lastCapturedPath: string | null = null;
// buffer stays small (~one step-transition worth).
type PendingOp =
| { kind: "event"; name: string; props?: Record<string, unknown> }
| { kind: "set"; props: Record<string, unknown> };
| { kind: "set"; props: Record<string, unknown> }
| { kind: "exception"; error: unknown; props?: Record<string, unknown> };
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<string, unknown>,
): 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