Files
PaperClipAI/ui/src/lib/browser-error-context.ts
T
f331d386dc fix(ui): attribute stackless unhandled rejections with event and lifecycle facts (#15940)
Diagnostics only for Sentry PAPERCLIP-FRONTEND-G: record trusted/synthetic unhandledrejection origin, promise kind, and coarse page lifecycle facts without reading rejected values.

Co-Authored-By: Amy (Opus) <claude@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:26:44 -07:00

182 lines
6.9 KiB
TypeScript

export interface BrowserErrorDetails {
boundary: "app" | "route";
componentStack?: string | null;
}
const MAX_STACK_LENGTH = 16_384;
const MAX_COMPONENT_FRAMES = 40;
/** Keep component names only: React stack locations can contain tenant URLs. */
export function sanitizeComponentStack(stack: string | null | undefined): string | undefined {
if (!stack) return undefined;
const frames: string[] = [];
for (const line of stack.slice(0, MAX_STACK_LENGTH).split("\n").slice(0, MAX_COMPONENT_FRAMES + 1)) {
// V8/React and Firefox/Safari component-frame formats. Never retain the
// location, arguments, or an unrecognized line as a fallback.
const name = /^\s*(?:at|in) ([A-Za-z_$][\w$.-]{0,99})(?:\s|$)/.exec(line)?.[1]
?? /^([A-Za-z_$][\w$.-]{0,99})@/.exec(line)?.[1];
if (name) frames.push(` at ${name}`);
if (frames.length === MAX_COMPONENT_FRAMES) break;
}
return frames.length ? `\n${frames.join("\n")}` : undefined;
}
/** Bounded document state; never read page content, locations, or identifiers. */
export function readBrowserErrorState() {
const browserState: {
ready_state?: "loading" | "interactive" | "complete";
visibility_state?: "visible" | "hidden";
translation_marker?: boolean;
} = {};
if (typeof document !== "undefined") {
// Each read is optional. A DOM wrapper or extension must not prevent the
// original exception and component trace from reaching the error monitor.
try {
const state = document.readyState;
if (state === "loading" || state === "interactive" || state === "complete") {
browserState.ready_state = state;
}
} catch { /* diagnostic unavailable */ }
try {
const state = document.visibilityState;
if (state === "visible" || state === "hidden") browserState.visibility_state = state;
} catch { /* diagnostic unavailable */ }
try {
const classes = document.documentElement.classList;
browserState.translation_marker = classes.contains("translated-ltr") || classes.contains("translated-rtl");
} catch { /* diagnostic unavailable */ }
}
return browserState;
}
type PageTransition = "visible" | "hidden" | "pagehide" | "pageshow" | "freeze" | "resume";
type ElapsedBucket = "<1s" | "1-10s" | "10-60s" | "1-10m" | ">10m";
type RejectionPromiseKind = "native" | "subclass" | "other" | "missing";
interface RejectionEventSnapshot {
at: number;
/** Same classification as the `browser_rejection_kind` tag, used to pair the event. */
kind: string;
trusted: boolean;
promise: RejectionPromiseKind;
}
/** A rejection report is paired with its DOM event only within this window. */
const REJECTION_PAIRING_WINDOW_MS = 5_000;
let lastTransition: { kind: PageTransition; at: number } | undefined;
let pagehideSeen = false;
let lastRejection: RejectionEventSnapshot | undefined;
let pageDiagnosticsStarted = false;
function now() {
return typeof performance !== "undefined" ? performance.now() : Date.now();
}
function bucketElapsed(ms: number): ElapsedBucket {
if (ms < 1_000) return "<1s";
if (ms < 10_000) return "1-10s";
if (ms < 60_000) return "10-60s";
if (ms < 600_000) return "1-10m";
return ">10m";
}
/** Classify without reading the rejected value or calling into the promise. */
export function classifyRejectionReason(reason: unknown) {
return reason === null ? "null" : typeof reason;
}
function classifyRejectionPromise(event: PromiseRejectionEvent): RejectionPromiseKind {
try {
const promise: unknown = event.promise;
if (typeof promise !== "object" || promise === null) return "missing";
if (Object.getPrototypeOf(promise) === Promise.prototype) return "native";
return promise instanceof Promise ? "subclass" : "other";
} catch {
return "other";
}
}
/**
* Record page lifecycle transitions and the shape of each unhandled rejection
* event. Only enum values and timestamps stay in memory; nothing is sent from
* here. Idempotent, and kept for the page lifetime so a later sign-in still
* registers before the error monitor's own rejection handler.
*/
export function startPageDiagnostics(target: Window = window, doc: Document = document): void {
if (pageDiagnosticsStarted) return;
pageDiagnosticsStarted = true;
const mark = (kind: PageTransition) => {
lastTransition = { kind, at: now() };
if (kind === "pagehide") pagehideSeen = true;
if (kind === "pageshow") pagehideSeen = false;
};
try {
doc.addEventListener("visibilitychange", () => {
try {
mark(doc.visibilityState === "hidden" ? "hidden" : "visible");
} catch { /* diagnostic unavailable */ }
});
doc.addEventListener("freeze", () => mark("freeze"));
doc.addEventListener("resume", () => mark("resume"));
target.addEventListener("pagehide", () => mark("pagehide"));
target.addEventListener("pageshow", () => mark("pageshow"));
// Capture phase, registered before the SDK assigns `onunhandledrejection`,
// so this snapshot exists before the SDK builds and filters its event.
target.addEventListener("unhandledrejection", (event) => {
try {
lastRejection = {
at: now(),
kind: classifyRejectionReason(event.reason),
trusted: event.isTrusted,
promise: classifyRejectionPromise(event),
};
} catch { /* diagnostic unavailable */ }
}, { capture: true });
} catch { /* diagnostic unavailable */ }
}
/** Coarse lifecycle timing; no page content, locations, or identifiers. */
export function readPageLifecycle() {
const at = now();
const lifecycle: {
since_load: ElapsedBucket;
pagehide_seen: boolean;
last_transition?: PageTransition;
since_transition?: ElapsedBucket;
} = { since_load: bucketElapsed(at), pagehide_seen: pagehideSeen };
if (lastTransition) {
lifecycle.last_transition = lastTransition.kind;
lifecycle.since_transition = bucketElapsed(at - lastTransition.at);
}
return lifecycle;
}
/** Consume the snapshot of the DOM event that produced this rejection report. */
export function takeRejectionEventSnapshot(kind: string) {
const snapshot = lastRejection;
lastRejection = undefined;
if (!snapshot || snapshot.kind !== kind || now() - snapshot.at > REJECTION_PAIRING_WINDOW_MS) return undefined;
return { trusted: snapshot.trusted, promise: snapshot.promise };
}
/** Test-only: forget recorded lifecycle state. Listeners stay attached. */
export function __resetPageDiagnostics() {
lastTransition = undefined;
pagehideSeen = false;
lastRejection = undefined;
}
/** A bounded snapshot, taken at the error rather than when the capture queue runs. */
export function buildBrowserErrorContext(details: BrowserErrorDetails) {
const componentStack = sanitizeComponentStack(details.componentStack);
return {
tags: { react_error_boundary: details.boundary },
contexts: {
react: componentStack ? { componentStack } : {},
browser_state: readBrowserErrorState(),
page_lifecycle: readPageLifecycle(),
},
};
}