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:
authored and GitHub committed 2026-10-11 19:26:44 -07:00
1 parent bfe7c6357d
commit f331d386dc
4 files changed
+267 -4

No files matched your search

+79 -1
View File
@@ -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();
});
});
+119
View File
@@ -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(),
},
};
}
+44
View File
@@ -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
View File
@@ -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.