From f331d386dc3c2e2d4914d794a9a5e2d4b8fd58e5 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 11 Oct 2026 19:26:44 -0700 Subject: [PATCH] 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) Co-Authored-By: Claude Opus 5.5 --- ui/src/lib/browser-error-context.test.ts | 80 ++++++++++++++- ui/src/lib/browser-error-context.ts | 119 +++++++++++++++++++++++ ui/src/lib/sentry.test.ts | 44 +++++++++ ui/src/lib/sentry.ts | 28 +++++- 4 files changed, 267 insertions(+), 4 deletions(-) diff --git a/ui/src/lib/browser-error-context.test.ts b/ui/src/lib/browser-error-context.test.ts index ee4949d161..4b90c6a634 100644 --- a/ui/src/lib/browser-error-context.test.ts +++ b/ui/src/lib/browser-error-context.test.ts @@ -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 extends Promise {})(() => {})], + ["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(); + }); +}); diff --git a/ui/src/lib/browser-error-context.ts b/ui/src/lib/browser-error-context.ts index 700e6f62dd..7660a472d0 100644 --- a/ui/src/lib/browser-error-context.ts +++ b/ui/src/lib/browser-error-context.ts @@ -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(), }, }; } diff --git a/ui/src/lib/sentry.test.ts b/ui/src/lib/sentry.test.ts index 8ed4f83c7e..4607f27fbd 100644 --- a/ui/src/lib/sentry.test.ts +++ b/ui/src/lib/sentry.test.ts @@ -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("@sentry/browser"); + const events: Array> = []; + vi.doMock("@sentry/browser", () => ({ + ...Sentry, + init: (options: Parameters[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); + 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 diff --git a/ui/src/lib/sentry.ts b/ui/src/lib/sentry.ts index 852e2b855b..39fac4b2ad 100644 --- a/ui/src/lib/sentry.ts +++ b/ui/src/lib/sentry.ts @@ -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 = 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.