mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 09:59:44 +02:00
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>
182 lines
6.9 KiB
TypeScript
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(),
|
|
},
|
|
};
|
|
}
|