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