Files
multica/packages/core/analytics/redact-exception.test.ts
Naiyuan Qing c222088262 feat: client failure telemetry (JS errors + freeze/crash) to PostHog (#4187)
* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:31:38 +08:00

69 lines
2.2 KiB
TypeScript

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();
});
});