Compare commits

...

6 Commits

Author SHA1 Message Date
Naiyuan Qing
2241b30a18 fix(desktop): breadcrumb field precedence + document limits
Spread the persisted context FIRST so explicit event fields (source,
recovered) always win over a future colliding context key. Document why
preload-error skips the breadcrumb and the single-slot last-write-wins
undercount limitation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:33:03 +08:00
Naiyuan Qing
e7dabd0cba test: cover breadcrumb state machine and freeze watchdog
The breadcrumb persist/clear orchestration is the correctness-critical part
and was untested. Cover: hang->write, recover->clear (no double-count),
recover-before-delay->no-op, force-quit->retained, crash->write-and-never-
clear, clean-exit->no-write. Add watchdog tests (threshold, idempotent,
SSR/PerformanceObserver no-op) via a fake observer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:33:03 +08:00
Naiyuan Qing
075765c460 feat(analytics): scrub PII from $exception before send
Error messages can interpolate user input (typed values, URLs with tokens).
Add a before_send hook that redacts emails, URL query strings, and long
opaque tokens from the exception message and $exception_list values, keeping
type + stack frames (code locations, not user data). Addresses the privacy
gap from leaving capture_exceptions on with no sanitizer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:33:03 +08:00
Naiyuan Qing
443c6b7d8c feat(desktop): report true hangs and crashes via breadcrumb
A real hang or crashed renderer can't report itself. The main process now
persists a breadcrumb on unresponsive / render-process-gone, and the next
renderer boot flushes it to PostHog (client_unresponsive / client_crash).
A recovered hang clears its breadcrumb so it isn't double-counted by the
in-thread watchdog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:08:12 +08:00
Naiyuan Qing
dd0886480e feat(diagnostics): add shared freeze watchdog
Long-task observer (>=2s) emits client_unresponsive via captureEvent;
client_type super-property tags desktop vs web for free. Installed once in
CoreProvider so web and desktop share one in-thread, SSR-safe detector for
recoverable freezes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:08:04 +08:00
Naiyuan Qing
e1e73db59a 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>
2026-06-16 15:08:04 +08:00
17 changed files with 823 additions and 10 deletions

View File

@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
writeFreezeBreadcrumb,
readAndClearFreezeBreadcrumb,
clearFreezeBreadcrumb,
type FreezeBreadcrumb,
} from "./freeze-breadcrumb";
// Each test gets its own temp dir so the on-disk breadcrumb is isolated.
const dirs: string[] = [];
function tempFile(): string {
const dir = mkdtempSync(join(tmpdir(), "freeze-breadcrumb-"));
dirs.push(dir);
return join(dir, "last-client-failure.json");
}
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
const sample: FreezeBreadcrumb = {
kind: "unresponsive",
context: { desktopRoute: { path: "/acme/issues" } },
ts: 1_700_000_000_000,
version: "0.3.1",
};
describe("freeze breadcrumb round-trip", () => {
it("writes then reads back the breadcrumb", () => {
const file = tempFile();
writeFreezeBreadcrumb(file, sample);
expect(readAndClearFreezeBreadcrumb(file)).toEqual(sample);
});
it("read clears the file so a failure reports exactly once", () => {
const file = tempFile();
writeFreezeBreadcrumb(file, sample);
expect(readAndClearFreezeBreadcrumb(file)).toEqual(sample);
expect(existsSync(file)).toBe(false);
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
it("clearFreezeBreadcrumb removes a pending breadcrumb (hang recovered)", () => {
const file = tempFile();
writeFreezeBreadcrumb(file, sample);
clearFreezeBreadcrumb(file);
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
});
// The breadcrumb crosses a process boundary (main writes, renderer flushes via
// IPC) and lives across app versions — a future write shape or a corrupt file
// must never throw into boot. CLAUDE.md "API Response Compatibility".
describe("freeze breadcrumb defends against malformed input", () => {
it("returns null when no file exists", () => {
expect(readAndClearFreezeBreadcrumb(tempFile())).toBeNull();
});
it("returns null on corrupt JSON", () => {
const file = tempFile();
writeFileSync(file, "{ not valid json", "utf8");
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
it("returns null when `kind` is missing", () => {
const file = tempFile();
writeFileSync(file, JSON.stringify({ ts: 1, version: "x" }), "utf8");
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
it("returns null when `kind` is the wrong type", () => {
const file = tempFile();
writeFileSync(file, JSON.stringify({ kind: 42, context: {} }), "utf8");
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
it("returns null on a JSON null payload", () => {
const file = tempFile();
writeFileSync(file, "null", "utf8");
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
it("clearing a non-existent file is a no-op, never throws", () => {
expect(() => clearFreezeBreadcrumb(tempFile())).not.toThrow();
});
});

View File

@@ -0,0 +1,76 @@
import { writeFileSync, readFileSync, rmSync } from "node:fs";
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
// When the renderer truly hangs or its process dies, it can't send telemetry
// itself — the thread is blocked or gone. The main process (always alive) is
// the only watcher that can react, but during the hang it can't reach the
// renderer's posthog-js either. So it writes a breadcrumb to disk; the next
// time a renderer boots, it reads + clears the file and reports the event.
// This survives even a force-quit, which is the whole point.
export type { FreezeBreadcrumb };
/**
* Best-effort write. A breadcrumb we can't persist is lost, never fatal.
*
* Known limitation: this is a single slot — last write wins. Multiple failures
* within one session collapse to the last one, so per-session failure counts
* are undercounted. Acceptable for now: telemetry aggregates presence and
* frequency across users, not exhaustive per-session sequences. Upgrade to an
* append/ring buffer if per-session failure chains become a question.
*/
export function writeFreezeBreadcrumb(filePath: string, breadcrumb: FreezeBreadcrumb): void {
try {
writeFileSync(filePath, JSON.stringify(breadcrumb), "utf8");
} catch {
// Disk full / permissions — drop silently.
}
}
/**
* Delete a persisted breadcrumb. Called when the renderer recovers from a hang
* (a `responsive` event after `unresponsive`): the breadcrumb was written
* pre-emptively while the thread was stuck, but since it came back, the
* in-thread long-task watchdog already reports it — keeping the breadcrumb
* would double-count it AND mislabel a recovered window as `recovered: false`.
* Best-effort; a stale breadcrumb only costs one duplicate report.
*/
export function clearFreezeBreadcrumb(filePath: string): void {
try {
rmSync(filePath, { force: true });
} catch {
// Nothing to clear / permissions — ignore.
}
}
/**
* Read the breadcrumb and delete it in the same call, so a failure is reported
* exactly once. Returns null when there's no breadcrumb (the normal case) or
* when the file is unreadable / corrupt.
*/
export function readAndClearFreezeBreadcrumb(filePath: string): FreezeBreadcrumb | null {
let raw: string;
try {
raw = readFileSync(filePath, "utf8");
} catch {
return null;
}
try {
rmSync(filePath, { force: true });
} catch {
// If we can't delete it we'd re-report next launch; acceptable over throwing.
}
try {
const parsed: unknown = JSON.parse(raw);
if (
parsed &&
typeof parsed === "object" &&
typeof (parsed as FreezeBreadcrumb).kind === "string"
) {
return parsed as FreezeBreadcrumb;
}
} catch {
// Corrupt JSON — drop.
}
return null;
}

View File

@@ -23,6 +23,11 @@ import {
installRendererRecoveryHandlers,
type RendererRecoveryWindow,
} from "./renderer-recovery";
import {
writeFreezeBreadcrumb,
readAndClearFreezeBreadcrumb,
clearFreezeBreadcrumb,
} from "./freeze-breadcrumb";
// Bundled icon used for dock/taskbar branding. macOS/Windows production
// builds let the OS pick up the icon from the .app bundle / .exe resources,
@@ -66,6 +71,13 @@ if (process.platform !== "win32") {
const PROTOCOL = "multica";
// Where the main process parks a freeze/crash breadcrumb until the next
// renderer boot flushes it to telemetry. Lives in userData so it survives a
// force-quit. Resolved lazily — app.getPath is only valid after `ready`.
function freezeBreadcrumbPath(): string {
return join(app.getPath("userData"), "last-client-failure.json");
}
let mainWindow: BrowserWindow | null = null;
let latestRendererRouteContext: RendererRouteContext | null = null;
let runtimeConfigResult: RuntimeConfigResult = {
@@ -275,6 +287,21 @@ function createWindow(): void {
? { desktopRoute: latestRendererRouteContext }
: {}),
}),
// Only persist in production: a true hang/crash can't report itself, so we
// write a breadcrumb and the next renderer boot flushes it to PostHog. Dev
// is excluded to keep field telemetry clean.
persistBreadcrumb: is.dev
? undefined
: (payload) =>
writeFreezeBreadcrumb(freezeBreadcrumbPath(), {
kind: payload.kind,
context: payload.context,
ts: Date.now(),
version: getAppVersion(),
}),
clearBreadcrumb: is.dev
? undefined
: () => clearFreezeBreadcrumb(freezeBreadcrumbPath()),
});
installContextMenu(window.webContents);
@@ -413,6 +440,14 @@ if (!gotTheLock) {
event.returnValue = { version: getAppVersion(), os };
});
// Sync IPC: read + clear any freeze/crash breadcrumb left by a previous
// session. The renderer flushes it to telemetry on boot (it couldn't be
// reported when it happened — the renderer was hung or gone). Read-and-
// clear so a failure reports exactly once.
ipcMain.on("freeze:get-last", (event) => {
event.returnValue = readAndClearFreezeBreadcrumb(freezeBreadcrumbPath());
});
// Sync IPC: preload exposes the validated runtime config before renderer
// boot. If desktop.json exists but is invalid, renderer receives the
// blocking error and must not silently fall back to the cloud defaults.

View File

@@ -175,3 +175,97 @@ describe("installRendererRecoveryHandlers", () => {
expect(detail).toContain("Diagnostic details:\nkind: unresponsive\ncontext: {}");
});
});
describe("freeze/crash breadcrumb state machine", () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.useRealTimers());
function install(fixture: ReturnType<typeof makeWindow>) {
const persistBreadcrumb = vi.fn();
const clearBreadcrumb = vi.fn();
installRendererRecoveryHandlers(fixture.window, {
isDev: false,
showReloadPrompt: vi.fn(async () => "dismiss" as const),
persistBreadcrumb,
clearBreadcrumb,
unresponsivePromptDelayMs: 100,
});
return { persistBreadcrumb, clearBreadcrumb };
}
it("a sustained hang writes exactly one unresponsive breadcrumb", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
fixture.windowHandlers.get("unresponsive")?.();
await vi.advanceTimersByTimeAsync(100);
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
expect(persistBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({ kind: "unresponsive" }),
);
expect(clearBreadcrumb).not.toHaveBeenCalled();
});
it("recovering after a written breadcrumb clears it (no double-count, no false recovered:false)", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
fixture.windowHandlers.get("unresponsive")?.();
await vi.advanceTimersByTimeAsync(100);
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
fixture.windowHandlers.get("responsive")?.();
expect(clearBreadcrumb).toHaveBeenCalledTimes(1);
});
it("recovering before the delay never writes a breadcrumb, so nothing to clear", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
fixture.windowHandlers.get("unresponsive")?.();
fixture.windowHandlers.get("responsive")?.();
await vi.advanceTimersByTimeAsync(100);
expect(persistBreadcrumb).not.toHaveBeenCalled();
expect(clearBreadcrumb).not.toHaveBeenCalled();
});
it("a hang that never recovers (force-quit) keeps its breadcrumb for next-boot reporting", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
fixture.windowHandlers.get("unresponsive")?.();
await vi.advanceTimersByTimeAsync(100);
// No "responsive" ever fires — the breadcrumb must survive uncleared.
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
expect(clearBreadcrumb).not.toHaveBeenCalled();
});
it("a recoverable crash writes a breadcrumb and never clears it (a dead process never recovers)", () => {
const fixture = makeWindow();
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" });
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
expect(persistBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({ kind: "render-process-gone" }),
);
expect(clearBreadcrumb).not.toHaveBeenCalled();
});
it("a clean (non-crash) renderer exit writes no breadcrumb", () => {
const fixture = makeWindow();
const { persistBreadcrumb } = install(fixture);
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "clean-exit" });
expect(persistBreadcrumb).not.toHaveBeenCalled();
});
});

View File

@@ -18,6 +18,21 @@ type RendererRecoveryOptions = {
isDev: boolean;
showReloadPrompt: (payload: ReloadPromptPayload) => Promise<ReloadPromptResult>;
getDiagnosticContext?: () => Record<string, unknown>;
/**
* Persist a freeze/crash breadcrumb to disk. The renderer can't report a
* true hang or process death itself (blocked / gone), so the main process
* writes it here and the next renderer boot flushes it to telemetry. Omit
* in dev to keep field telemetry clean.
*/
persistBreadcrumb?: (payload: ReloadPromptPayload) => void;
/**
* Delete a previously-persisted unresponsive breadcrumb. Called when the
* renderer recovers (`responsive` after `unresponsive`): the window came
* back, so the in-thread watchdog reports the freeze and the breadcrumb
* would only double-count it. Crash breadcrumbs are never cleared — a dead
* process never recovers.
*/
clearBreadcrumb?: () => void;
log?: (tag: string, ...args: unknown[]) => void;
unresponsivePromptDelayMs?: number;
};
@@ -28,11 +43,16 @@ export function installRendererRecoveryHandlers(
isDev,
showReloadPrompt,
getDiagnosticContext,
persistBreadcrumb,
clearBreadcrumb,
log = defaultDevLog,
unresponsivePromptDelayMs = 1500,
}: RendererRecoveryOptions,
) {
let unresponsivePromptTimer: ReturnType<typeof setTimeout> | null = null;
// True once a breadcrumb has been written for the current hang. A later
// `responsive` clears it; only a hang that never returns survives to report.
let unresponsiveBreadcrumbWritten = false;
const mergeDiagnosticContext = (context: Record<string, unknown>) => ({
...readDiagnosticContext(getDiagnosticContext),
...context,
@@ -49,12 +69,18 @@ export function installRendererRecoveryHandlers(
window.webContents.on("render-process-gone", (_event, details) => {
if (isDev) log("process-gone", JSON.stringify(details));
if (!isRecoverableRendererExit(details)) return;
maybePromptReload({
const payload: ReloadPromptPayload = {
kind: "render-process-gone",
context: mergeDiagnosticContext({ details }),
});
};
persistBreadcrumb?.(payload);
maybePromptReload(payload);
});
// preload-error intentionally does NOT persist a breadcrumb: it's a startup
// failure of the preload script itself, and the breadcrumb-flush path depends
// on that same preload exposing `getLastFreeze` — if preload is broken, the
// next boot couldn't read it back anyway. We only prompt for reload here.
window.webContents.on("preload-error", (_event, preloadPath, error) => {
if (isDev) log("preload-error", `path=${preloadPath} err=${formatError(error)}`);
maybePromptReload({
@@ -67,17 +93,27 @@ export function installRendererRecoveryHandlers(
if (isDev || unresponsivePromptTimer) return;
unresponsivePromptTimer = setTimeout(() => {
unresponsivePromptTimer = null;
maybePromptReload({
const payload: ReloadPromptPayload = {
kind: "unresponsive",
context: mergeDiagnosticContext({}),
});
};
persistBreadcrumb?.(payload);
unresponsiveBreadcrumbWritten = true;
maybePromptReload(payload);
}, unresponsivePromptDelayMs);
});
window.on("responsive", () => {
if (!unresponsivePromptTimer) return;
clearTimeout(unresponsivePromptTimer);
unresponsivePromptTimer = null;
if (unresponsivePromptTimer) {
clearTimeout(unresponsivePromptTimer);
unresponsivePromptTimer = null;
}
// The window came back: drop any breadcrumb written during this hang so it
// isn't re-reported (and mislabeled `recovered: false`) on next boot.
if (unresponsiveBreadcrumbWritten) {
clearBreadcrumb?.();
unresponsiveBreadcrumbWritten = false;
}
});
}

View File

@@ -2,6 +2,7 @@ import { ElectronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import type { NavigationGesture } from "../shared/navigation-gestures";
import type { RendererRouteContextInput } from "../shared/renderer-route-context";
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
interface DesktopAPI {
/** App version + normalized OS, captured synchronously at preload time. */
@@ -15,6 +16,9 @@ interface DesktopAPI {
onSystemLocaleChanged: (callback: (locale: string) => void) => () => void;
/** Validated runtime endpoint config, or a blocking config error. */
runtimeConfig: RuntimeConfigResult;
/** Read + clear any freeze/crash breadcrumb from a previous session, so the
* renderer can flush it to telemetry on boot. Null when nothing's pending. */
getLastFreeze: () => FreezeBreadcrumb | null;
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
onAuthToken: (callback: (token: string) => void) => () => void;
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */

View File

@@ -1,6 +1,7 @@
import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
import {
RENDERER_ROUTE_CONTEXT_CHANNEL,
type RendererRouteContextInput,
@@ -78,6 +79,16 @@ const desktopAPI = {
},
/** Validated runtime endpoint config, or a blocking config error. */
runtimeConfig,
/** Read + clear any freeze/crash breadcrumb left by a previous session, so
* the renderer can flush it to telemetry on boot. Returns null when there's
* nothing pending (the normal case). */
getLastFreeze: (): FreezeBreadcrumb | null => {
try {
return ipcRenderer.sendSync("freeze:get-last") as FreezeBreadcrumb | null;
} catch {
return null;
}
},
/** Listen for auth token delivered via deep link */
onAuthToken: (callback: (token: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, token: string) =>

View File

@@ -19,6 +19,7 @@ import { useTabStore } from "./stores/tab-store";
import { useWindowOverlayStore } from "./stores/window-overlay-store";
import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
import { createDesktopLocaleAdapter } from "./platform/i18n-adapter";
import { captureEvent } from "@multica/core/analytics";
import { RESOURCES } from "@multica/views/locales";
// BCP-47 region tags for the <html lang> attribute, mirroring
@@ -332,6 +333,26 @@ export default function App() {
const runtimeConfigResult = window.desktopAPI.runtimeConfig;
useCmdWCloseTab();
// Flush a freeze/crash breadcrumb the main process parked from a previous
// session. A true hang or process death can't report itself when it happens
// (the renderer is blocked or gone), so the main process persists it and we
// emit it here on the next boot. The in-thread, recoverable freeze tier is
// handled separately by the shared watchdog in CoreProvider.
useEffect(() => {
const last = window.desktopAPI.getLastFreeze();
if (!last) return;
const crashed = last.kind === "render-process-gone";
captureEvent(crashed ? "client_crash" : "client_unresponsive", {
// Spread context FIRST so our explicit fields below always win — a
// future context key (e.g. its own `source`) must not silently override.
...last.context,
source: crashed ? "render-process-gone" : "main-unresponsive",
recovered: false,
breadcrumb_ts: last.ts,
crashed_version: last.version,
});
}, []);
// Stable identity reference so downstream effects (WS reconnect) don't
// tear down on every parent render.
const identity = useMemo(

View File

@@ -0,0 +1,16 @@
/**
* A freeze/crash breadcrumb persisted by the main process and flushed to
* telemetry by the next renderer boot. Shared across main, preload, and
* renderer because all three touch it. See main/freeze-breadcrumb.ts for the
* read/write logic and the rationale.
*/
export interface FreezeBreadcrumb {
/** "unresponsive" (hang) or "render-process-gone" (crash). */
kind: string;
/** Diagnostic context captured at failure time (route, window url, …). */
context: Record<string, unknown>;
/** Epoch ms when the failure was recorded. */
ts: number;
/** App version at failure time. */
version: string;
}

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

@@ -13,6 +13,7 @@
// backend returns an empty key and this module stays inert.
import posthog from "posthog-js";
import { redactExceptionProperties } from "./redact-exception";
export const EVENT_SCHEMA_VERSION = 2;
@@ -56,7 +57,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 +144,25 @@ 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.)
//
// Error messages can interpolate user input (a validation error with the
// typed value, a URL with a token), so `before_send` scrubs the message
// and `$exception_list[].value` before the event leaves the client. Stack
// frames (code locations) are kept. See redact-exception.ts.
capture_exceptions: true,
before_send: (event) => {
if (event && event.event === "$exception") {
redactExceptionProperties(event.properties);
}
return event;
},
disable_session_recording: true,
disable_surveys: true,
});
@@ -184,6 +204,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 +272,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

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { redactText, redactExceptionProperties } from "./redact-exception";
describe("redactText", () => {
it("redacts email addresses", () => {
expect(redactText("Invalid email: alice@example.com")).toBe(
"Invalid email: [redacted]",
);
});
it("strips URL query strings that may carry tokens, keeping host + path", () => {
expect(
redactText("fetch failed https://api.multica.ai/issues?token=abc123secret"),
).toBe("fetch failed https://api.multica.ai/issues?[redacted]");
});
it("redacts long opaque tokens (JWT / API key / uuid)", () => {
expect(redactText("auth header eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9")).toBe(
"auth header [redacted]",
);
});
it("keeps the non-sensitive part of a message intact", () => {
expect(redactText("Cannot read property 'x' of undefined")).toBe(
"Cannot read property 'x' of undefined",
);
});
it("passes through non-strings unchanged", () => {
expect(redactText(undefined)).toBeUndefined();
expect(redactText(42)).toBe(42);
});
});
describe("redactExceptionProperties", () => {
it("scrubs the message and each $exception_list value, leaving frames untouched", () => {
const props = {
$exception_message: "Bad email bob@corp.com",
$exception_list: [
{
type: "TypeError",
value: "Token leaked: ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
stacktrace: { frames: [{ filename: "app.tsx", lineno: 5, function: "render" }] },
},
],
};
redactExceptionProperties(props);
const entry = props.$exception_list[0]!;
expect(props.$exception_message).toBe("Bad email [redacted]");
expect(entry.value).toBe("Token leaked: [redacted]");
// Frames are code locations, not user data — left intact.
expect(entry.stacktrace.frames[0]).toEqual({
filename: "app.tsx",
lineno: 5,
function: "render",
});
expect(entry.type).toBe("TypeError");
});
it("is safe on undefined / malformed properties", () => {
expect(redactExceptionProperties(undefined)).toBeUndefined();
expect(() =>
redactExceptionProperties({ $exception_list: "not-an-array" as unknown as [] }),
).not.toThrow();
});
});

View File

@@ -0,0 +1,61 @@
// PII scrubbing for `$exception` events before they leave the client.
//
// Exception autocapture (`capture_exceptions: true`) sends the error message
// and stack. Stack frames are code locations (file / line / function) and are
// safe, but a message often interpolates user input — a validation error with
// the typed value, a parse error with the raw text, a network error with a URL
// that may carry a token. We keep the diagnostic shape (type + frames + the
// non-sensitive part of the message) and redact the patterns that carry user
// data. Wired as posthog-js `before_send`; see initAnalytics.
const REDACTED = "[redacted]";
// Order matters: strip query strings before the generic long-token rule, so a
// URL's host isn't itself shredded.
const PATTERNS: Array<[RegExp, string]> = [
// Emails.
[/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi, REDACTED],
// URL query/fragment (may carry tokens / PII) — keep scheme+host+path.
[/((?:https?|file|multica):\/\/[^\s?#]*)[?#]\S*/gi, `$1?${REDACTED}`],
// Long opaque tokens: JWTs, API keys, UUIDs, session ids (24+ chars).
[/\b[A-Za-z0-9_-]{24,}\b/g, REDACTED],
];
/** Redact PII-ish substrings from a free-text string. */
export function redactText(input: unknown): unknown {
if (typeof input !== "string" || input.length === 0) return input;
let out = input;
for (const [pattern, replacement] of PATTERNS) {
out = out.replace(pattern, replacement);
}
return out;
}
/**
* Redact the user-facing strings on a `$exception` event's properties in
* place: the top-level message and every entry's `value` in `$exception_list`.
* Types and stack frames are left untouched (code locations, not user data).
* Returns the same properties object for chaining.
*/
export function redactExceptionProperties(
properties: Record<string, unknown> | undefined,
): Record<string, unknown> | undefined {
if (!properties || typeof properties !== "object") return properties;
if ("$exception_message" in properties) {
properties.$exception_message = redactText(properties.$exception_message);
}
const list = properties.$exception_list;
if (Array.isArray(list)) {
for (const entry of list) {
if (entry && typeof entry === "object" && "value" in entry) {
(entry as { value: unknown }).value = redactText(
(entry as { value: unknown }).value,
);
}
}
}
return properties;
}

View File

@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../analytics", () => ({ captureEvent: vi.fn() }));
// A controllable PerformanceObserver stand-in: records the callback so a test
// can fire synthetic long-task entries, and counts constructions so we can
// assert idempotent install.
let lastCallback: ((list: { getEntries: () => Array<{ duration: number }> }) => void) | null;
let constructed: number;
let observeCalls: number;
class FakePerformanceObserver {
constructor(cb: (list: { getEntries: () => Array<{ duration: number }> }) => void) {
constructed += 1;
lastCallback = cb;
}
observe() {
observeCalls += 1;
}
}
function fireLongTask(duration: number) {
lastCallback?.({ getEntries: () => [{ duration }] });
}
async function load() {
vi.resetModules();
const mod = await import("./freeze-watchdog");
const { captureEvent } = await import("../analytics");
return {
installFreezeWatchdog: mod.installFreezeWatchdog,
captureEvent: captureEvent as unknown as ReturnType<typeof vi.fn>,
};
}
beforeEach(() => {
lastCallback = null;
constructed = 0;
observeCalls = 0;
vi.stubGlobal("window", {});
vi.stubGlobal("location", { pathname: "/acme/issues" });
vi.stubGlobal("PerformanceObserver", FakePerformanceObserver);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("installFreezeWatchdog", () => {
it("reports a long task at or above the 2s threshold with duration + path", async () => {
const { installFreezeWatchdog, captureEvent } = await load();
installFreezeWatchdog();
fireLongTask(2300);
expect(captureEvent).toHaveBeenCalledTimes(1);
expect(captureEvent).toHaveBeenCalledWith("client_unresponsive", {
source: "longtask",
duration_ms: 2300,
path: "/acme/issues",
});
});
it("ignores blocks below the threshold (normal render cost)", async () => {
const { installFreezeWatchdog, captureEvent } = await load();
installFreezeWatchdog();
fireLongTask(600);
fireLongTask(1999);
expect(captureEvent).not.toHaveBeenCalled();
});
it("is idempotent — a second install does not add a second observer", async () => {
const { installFreezeWatchdog } = await load();
installFreezeWatchdog();
installFreezeWatchdog();
expect(constructed).toBe(1);
expect(observeCalls).toBe(1);
});
it("is a no-op on the server (no window)", async () => {
vi.stubGlobal("window", undefined);
const { installFreezeWatchdog, captureEvent } = await load();
expect(() => installFreezeWatchdog()).not.toThrow();
expect(constructed).toBe(0);
expect(captureEvent).not.toHaveBeenCalled();
});
it("is a no-op when PerformanceObserver is unavailable", async () => {
vi.stubGlobal("PerformanceObserver", undefined);
const { installFreezeWatchdog } = await load();
expect(() => installFreezeWatchdog()).not.toThrow();
});
});

View File

@@ -0,0 +1,57 @@
// Client freeze watchdog — shared by web and desktop.
//
// Installs a long-task observer in the main thread. A "long task" is any
// stretch where the thread didn't return to the event loop; the browser
// already tracks them and delivers each entry once the thread unblocks, so
// even a multi-second freeze reports its duration after the fact. We only
// emit for blocks at or above FREEZE_THRESHOLD_MS to keep this to genuine
// "almost froze" events, not the normal 50600ms render cost.
//
// This is the in-thread, recoverable tier: it catches freezes the thread
// survives. A true non-recoverable hang (the thread never unblocks) can only
// be caught from outside — on desktop that is the main process `unresponsive`
// handler (see apps/desktop renderer-recovery). Web has no free external
// watcher, so this observer is its only freeze signal for now.
//
// The emitted `client_unresponsive` event carries `client_type` automatically
// (an analytics super-property), so desktop vs web is queryable without any
// platform branch here.
import { captureEvent } from "../analytics";
// 2s is well above the normal switch/render cost (measured 50600ms) and just
// under Electron's renderer-hang threshold, so an event here means "the user
// felt a real stall" without flooding on routine heavy renders.
const FREEZE_THRESHOLD_MS = 2000;
let installed = false;
/**
* Install the long-task observer. Safe to call multiple times (idempotent) and
* safe on the server (no-op when `window` / `PerformanceObserver` is absent).
* Call once from a client-only effect.
*/
export function installFreezeWatchdog(): void {
if (installed) return;
if (typeof window === "undefined") return;
if (typeof PerformanceObserver === "undefined") return;
installed = true;
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration < FREEZE_THRESHOLD_MS) continue;
captureEvent("client_unresponsive", {
source: "longtask",
duration_ms: Math.round(entry.duration),
path: typeof location !== "undefined" ? location.pathname : undefined,
});
}
});
// No `buffered: true` — we only want freezes from now on. Replaying tasks
// buffered before install would mislabel slow startup as a runtime freeze.
observer.observe({ type: "longtask" });
} catch {
// longtask entry type unsupported on this engine — nothing else to do.
}
}

View File

@@ -1,7 +1,8 @@
"use client";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { ApiClient } from "../api/client";
import { installFreezeWatchdog } from "../diagnostics/freeze-watchdog";
import { setApiInstance, setSchemaLogger } from "../api";
import { createAuthStore, registerAuthStore } from "../auth";
import { createChatStore, registerChatStore } from "../chat";
@@ -80,6 +81,12 @@ export function CoreProvider({
// eslint-disable-next-line react-hooks/exhaustive-deps
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout, cookieAuth, identity), []);
// Client-only freeze watchdog — shared by web and desktop. No-op on the
// server and idempotent, so mounting it here covers both apps in one place.
useEffect(() => {
installFreezeWatchdog();
}, []);
// I18nProvider wraps everything else: server and client must use the same
// (locale, resources) to avoid hydration mismatch. Language switching goes
// through window.location.reload(), never client-side changeLanguage.