mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
58
apps/web/app/global-error.tsx
Normal file
58
apps/web/app/global-error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user