mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 13:38:01 +02:00
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>
This commit is contained in:
4 files changed
+267
-4
No files matched your search
@@ -1,9 +1,17 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildBrowserErrorContext, sanitizeComponentStack } from "./browser-error-context";
|
||||
import {
|
||||
__resetPageDiagnostics,
|
||||
buildBrowserErrorContext,
|
||||
readPageLifecycle,
|
||||
sanitizeComponentStack,
|
||||
startPageDiagnostics,
|
||||
takeRejectionEventSnapshot,
|
||||
} from "./browser-error-context";
|
||||
|
||||
afterEach(() => {
|
||||
__resetPageDiagnostics();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
document.documentElement.className = "";
|
||||
@@ -62,6 +70,7 @@ unrecognized private content`;
|
||||
contexts: {
|
||||
react: { componentStack: "\n at TaskDetail" },
|
||||
browser_state: { visibility_state: document.visibilityState },
|
||||
page_lifecycle: expect.objectContaining({ pagehide_seen: false }),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -71,3 +80,72 @@ unrecognized private content`;
|
||||
expect(buildBrowserErrorContext({ boundary: "app" }).contexts.browser_state).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
/** A dispatched (untrusted) rejection event; jsdom has no PromiseRejectionEvent. */
|
||||
function dispatchRejection(reason: unknown, promise?: unknown) {
|
||||
const event = new Event("unhandledrejection");
|
||||
Object.defineProperty(event, "reason", { value: reason });
|
||||
Object.defineProperty(event, "promise", { value: promise });
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
describe("page diagnostics", () => {
|
||||
startPageDiagnostics();
|
||||
|
||||
function setNow(ms: number) {
|
||||
vi.spyOn(performance, "now").mockReturnValue(ms);
|
||||
}
|
||||
|
||||
it("records only the latest lifecycle transition with coarse timing", () => {
|
||||
setNow(5_000);
|
||||
expect(readPageLifecycle()).toEqual({ since_load: "1-10s", pagehide_seen: false });
|
||||
|
||||
vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden");
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
setNow(65_000);
|
||||
expect(readPageLifecycle()).toEqual({
|
||||
since_load: "1-10m", pagehide_seen: false, last_transition: "hidden", since_transition: "1-10m",
|
||||
});
|
||||
|
||||
document.dispatchEvent(new Event("freeze"));
|
||||
document.dispatchEvent(new Event("resume"));
|
||||
setNow(65_500);
|
||||
expect(readPageLifecycle()).toMatchObject({ last_transition: "resume", since_transition: "<1s" });
|
||||
});
|
||||
|
||||
it("keeps a pagehide marker until the page is shown again", () => {
|
||||
setNow(1_000_000);
|
||||
window.dispatchEvent(new Event("pagehide"));
|
||||
expect(readPageLifecycle()).toEqual({
|
||||
since_load: ">10m", pagehide_seen: true, last_transition: "pagehide", since_transition: "<1s",
|
||||
});
|
||||
window.dispatchEvent(new Event("pageshow"));
|
||||
expect(readPageLifecycle()).toMatchObject({ pagehide_seen: false, last_transition: "pageshow" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["native", Promise.resolve()],
|
||||
["subclass", new (class Custom<T> extends Promise<T> {})(() => {})],
|
||||
["other", { then() {} }],
|
||||
["missing", undefined],
|
||||
])("classifies a %s rejection promise without reading the reason", (kind, promise) => {
|
||||
setNow(100);
|
||||
const reason = { get secret() { throw new Error("must not be read"); } };
|
||||
dispatchRejection(reason, promise);
|
||||
expect(takeRejectionEventSnapshot("object")).toEqual({ trusted: false, promise: kind });
|
||||
});
|
||||
|
||||
it("pairs a snapshot once, with the same rejection kind, inside the pairing window", () => {
|
||||
setNow(100);
|
||||
dispatchRejection(undefined, Promise.resolve());
|
||||
expect(takeRejectionEventSnapshot("undefined")).toEqual({ trusted: false, promise: "native" });
|
||||
expect(takeRejectionEventSnapshot("undefined")).toBeUndefined();
|
||||
|
||||
dispatchRejection(undefined, Promise.resolve());
|
||||
expect(takeRejectionEventSnapshot("string")).toBeUndefined();
|
||||
|
||||
dispatchRejection(undefined, Promise.resolve());
|
||||
setNow(5_101);
|
||||
expect(takeRejectionEventSnapshot("undefined")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,124 @@ export function readBrowserErrorState() {
|
||||
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);
|
||||
@@ -57,6 +175,7 @@ export function buildBrowserErrorContext(details: BrowserErrorDetails) {
|
||||
contexts: {
|
||||
react: componentStack ? { componentStack } : {},
|
||||
browser_state: readBrowserErrorState(),
|
||||
page_lifecycle: readPageLifecycle(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -294,6 +294,7 @@ describe("captureBrowserException", () => {
|
||||
contexts: {
|
||||
react: { componentStack: "\n at TaskDetail" },
|
||||
browser_state: expect.objectContaining({ translation_marker: true }),
|
||||
page_lifecycle: expect.objectContaining({ pagehide_seen: false }),
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(mocks.captureException.mock.calls)).not.toContain("private-value");
|
||||
@@ -379,6 +380,49 @@ describe("browser error diagnostics with the real SDK", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("unhandled rejection attribution through the gate", () => {
|
||||
it("pairs a dispatched undefined rejection with its event shape and page lifecycle", async () => {
|
||||
const Sentry = await vi.importActual<typeof import("@sentry/browser")>("@sentry/browser");
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
vi.doMock("@sentry/browser", () => ({
|
||||
...Sentry,
|
||||
init: (options: Parameters<typeof Sentry.init>[0]) => Sentry.init({
|
||||
...options,
|
||||
transport: () => ({ send: async () => ({}), flush: async () => true }),
|
||||
beforeSend: async (event, hint) => {
|
||||
const enriched = await options?.beforeSend?.(event, hint) ?? event;
|
||||
events.push(enriched as unknown as Record<string, unknown>);
|
||||
return enriched;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
const gate = await importFreshSentry();
|
||||
// The production init path registers the snapshot listener before the SDK handler.
|
||||
await gate.initBrowserErrorMonitoring(DSN, "staging");
|
||||
try {
|
||||
window.dispatchEvent(new Event("pagehide"));
|
||||
const event = new Event("unhandledrejection");
|
||||
Object.defineProperty(event, "reason", { value: undefined });
|
||||
Object.defineProperty(event, "promise", { value: Promise.resolve() });
|
||||
window.dispatchEvent(event);
|
||||
await Sentry.flush(2000);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
tags: { browser_rejection_kind: "undefined", browser_rejection_event: "synthetic" },
|
||||
contexts: {
|
||||
rejection_event: { trusted: false, promise: "native" },
|
||||
page_lifecycle: { pagehide_seen: true, last_transition: "pagehide", since_transition: "<1s" },
|
||||
},
|
||||
exception: { values: [{ type: "UnhandledRejection" }] },
|
||||
});
|
||||
expect(events[0]).not.toHaveProperty("request");
|
||||
} finally {
|
||||
window.dispatchEvent(new Event("pageshow"));
|
||||
await gate.teardownBrowserErrorMonitoring();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Call the `integrations` option with the given default list. The real
|
||||
* `@sentry/browser` option type allows `integrations` to be an array instead
|
||||
|
||||
+25
-3
@@ -38,7 +38,15 @@
|
||||
// (`GlobalHandlers`), the two React error boundaries, deduplicates a repeat
|
||||
// event (`Dedupe`), and links a caused-by chain (`LinkedErrors`).
|
||||
|
||||
import { buildBrowserErrorContext, readBrowserErrorState, type BrowserErrorDetails } from "./browser-error-context";
|
||||
import {
|
||||
buildBrowserErrorContext,
|
||||
classifyRejectionReason,
|
||||
readBrowserErrorState,
|
||||
readPageLifecycle,
|
||||
startPageDiagnostics,
|
||||
takeRejectionEventSnapshot,
|
||||
type BrowserErrorDetails,
|
||||
} from "./browser-error-context";
|
||||
|
||||
let queue: Promise<void> = Promise.resolve();
|
||||
|
||||
@@ -72,6 +80,11 @@ export function initBrowserErrorMonitoring(dsn: string, environment?: string | n
|
||||
if (sentry) return;
|
||||
try {
|
||||
const Sentry = await import("@sentry/browser");
|
||||
// Before init: the rejection snapshot listener must precede the SDK's
|
||||
// `onunhandledrejection` handler. Diagnostics never block monitoring.
|
||||
try {
|
||||
startPageDiagnostics();
|
||||
} catch { /* diagnostic unavailable */ }
|
||||
Sentry.init(buildBrowserSentryInitOptions(dsn, environment));
|
||||
sentry = Sentry;
|
||||
} catch (err) {
|
||||
@@ -183,6 +196,7 @@ export function buildBrowserSentryInitOptions(
|
||||
event.contexts = {
|
||||
...event.contexts,
|
||||
browser_state: event.contexts?.browser_state ?? readBrowserErrorState(),
|
||||
page_lifecycle: event.contexts?.page_lifecycle ?? readPageLifecycle(),
|
||||
};
|
||||
event.tags = {
|
||||
...event.tags,
|
||||
@@ -194,8 +208,16 @@ export function buildBrowserSentryInitOptions(
|
||||
)) {
|
||||
// The SDK supplies the rejected value directly. Classify without
|
||||
// reading object properties, coercing strings, or copying its value.
|
||||
const reason = hint.originalException;
|
||||
event.tags.browser_rejection_kind = reason === null ? "null" : typeof reason;
|
||||
const kind = classifyRejectionReason(hint.originalException);
|
||||
event.tags.browser_rejection_kind = kind;
|
||||
// Whether the browser or a script dispatched the event, and which
|
||||
// promise implementation carried it. Without a stack these are the
|
||||
// remaining attribution facts for an `undefined` rejection.
|
||||
const rejection = takeRejectionEventSnapshot(kind);
|
||||
if (rejection) {
|
||||
event.tags.browser_rejection_event = rejection.trusted ? "trusted" : "synthetic";
|
||||
event.contexts.rejection_event = rejection;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Diagnostics must not replace or discard the original error.
|
||||
|
||||
Reference in new issue
Block a user