Files
multica/packages/core/analytics/redact-exception.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

62 lines
2.4 KiB
TypeScript

// 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;
}