Files
PaperClipAI/ui/src/lib/sentry.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

233 lines
10 KiB
TypeScript

// Optional Sentry error monitoring for the browser.
//
// Activated only when the signed-in session carries a Sentry DSN. `SentryGate`
// reads the DSN off `GET /api/auth/get-session` and calls
// `initBrowserErrorMonitoring` once. A signed-out browser, or a browser with
// no DSN, calls this module never — see `SentryGate.tsx`.
//
// Sign-out must stop monitoring, not just stop starting it: `SentryGate`
// calls `teardownBrowserErrorMonitoring` when the session's DSN goes away.
// The function detaches the client from the current scope first
// (`Sentry.getCurrentScope().setClient(undefined)`), then closes that same
// client (`client.close()`), so a signed-out page's global handlers find no
// attached client at any point. Detach runs first because a client stays
// attached, and stays enabled, for the whole close call — closing first
// would leave the signed-out page able to send one more event to Sentry for
// as long as the close call takes. Both calls are built-in Sentry client
// calls — no custom filter code.
//
// `initBrowserErrorMonitoring`, `teardownBrowserErrorMonitoring`, and
// `captureBrowserException` each queue their work on one shared promise
// chain (`enqueue`) instead of running at once. One operation runs at a
// time, in call order, so no operation can ever observe another one
// part-finished — a sign-out always closes the client a sign-in already
// finished starting, never one still starting.
//
// `@sentry/browser` loads through a dynamic import, so Vite puts it in a
// separate chunk that a browser with no DSN never fetches.
//
// Default-integration privacy note: two default integrations copy values
// this app does not want inside a Sentry event, so the initializer removes
// them with a built-in Sentry option — no custom filter code:
// - `Breadcrumbs` turns a console call, a click, and a fetch call into a
// breadcrumb with the raw arguments and the raw request URL.
// - `HttpContext` copies the page URL, the query string, and the referrer
// onto every event.
// The initializer keeps every other default integration, so the browser
// still captures `window.onerror` and `window.onunhandledrejection`
// (`GlobalHandlers`), the two React error boundaries, deduplicates a repeat
// event (`Dedupe`), and links a caused-by chain (`LinkedErrors`).
import {
buildBrowserErrorContext,
classifyRejectionReason,
readBrowserErrorState,
readPageLifecycle,
startPageDiagnostics,
takeRejectionEventSnapshot,
type BrowserErrorDetails,
} from "./browser-error-context";
let queue: Promise<void> = Promise.resolve();
/** Run gate operations one at a time, in call order. */
function enqueue(op: () => Promise<void>): Promise<void> {
const next = queue.then(op);
queue = next.catch(() => {});
return next;
}
/** The `@sentry/browser` module shape, resolved once. */
type SentryBrowserModule = typeof import("@sentry/browser");
/**
* The `Sentry.init` options this gate builds. `Sentry.init`'s parameter is
* optional, so `Parameters<...>[0]` alone carries an `| undefined` arm this
* gate never returns. `NonNullable` removes only that arm — the object shape
* underneath stays the true `@sentry/browser` option type.
*/
type BrowserSentryInitOptions = NonNullable<Parameters<SentryBrowserModule["init"]>[0]>;
let sentry: SentryBrowserModule | null = null;
/**
* Load `@sentry/browser` and start the client with the given DSN. Idempotent
* — the session query can refetch and call this again, and a second call is
* a no-op because a client is already started.
*/
export function initBrowserErrorMonitoring(dsn: string, environment?: string | null): Promise<void> {
return enqueue(async () => {
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) {
// The dynamic import or the init call failed. Fall through with a
// single diagnostic. The gate fails open — the app keeps running
// without error monitoring rather than crashing on an opt-in feature.
// eslint-disable-next-line no-console
console.error("[paperclip] Sentry browser bootstrap failed", err);
}
});
}
/**
* Stop browser error monitoring and forget the started client. Call this on
* sign-out, so the browser sends Sentry no more events and no more
* breadcrumbs after the session ends. A no-op when monitoring never started.
*
* Detaches the client from the current scope, then closes that same client.
* A client stays attached, and stays enabled, for the whole close call — a
* signed-out page's `window.onerror` or `window.onunhandledrejection`
* handler would still reach it for as long as the close call takes.
* Detaching first removes that window instead of leaving it open.
*/
export function teardownBrowserErrorMonitoring(): Promise<void> {
return enqueue(async () => {
const Sentry = sentry;
sentry = null;
if (!Sentry) return;
// Read the client before detaching it. The scope holds no client after
// detach, so the close step below needs this reference to flush and
// close the right client.
const client = Sentry.getClient();
// Detach first, close second — the opposite order from a plain
// `Sentry.close()` call, which reads the client off the current scope
// and would find nothing to close if detach ran first. A separate
// guarded try keeps this step unable to reject, unlike a bare finally
// block: a thrown error here would still reach the caller, and
// `SentryGate.tsx` discards this function's returned promise with a
// bare `void` call, so a rejection would surface as an unhandled
// promise rejection in the browser.
try {
Sentry.getCurrentScope().setClient(undefined);
} catch (err) {
// eslint-disable-next-line no-console
console.error("[paperclip] Sentry client detach failed", err);
}
// A second, separate guarded try, for the same reason as the one
// above: this step must never reject the returned promise.
try {
// Awaiting matters: the client flushes buffered events to Sentry
// during close; a caller that does not wait may navigate away, or
// the page may unload, before the flush finishes.
await client?.close(2_000);
} catch (err) {
// eslint-disable-next-line no-console
console.error("[paperclip] Sentry teardownBrowserErrorMonitoring failed", err);
}
});
}
/**
* Report an error to Sentry. Never throws — observability must not change
* control flow. A no-op before the gate opens, when the gate never opens (no
* DSN on the session), or when bootstrap failed.
*/
export function captureBrowserException(error: unknown, details?: BrowserErrorDetails): void {
let context: ReturnType<typeof buildBrowserErrorContext> | undefined;
try {
if (details) context = buildBrowserErrorContext(details);
} catch {
// Diagnostics must never replace the original exception or its recovery UI.
}
void enqueue(async () => {
try {
if (context) sentry?.captureException(error, context);
else sentry?.captureException(error);
} catch (err) {
// eslint-disable-next-line no-console
console.error("[paperclip] Sentry captureBrowserException failed", err);
}
});
}
/**
* Build the `Sentry.init` options object. A pure function, split out from
* `initBrowserErrorMonitoring` so a test can call it with the real
* `@sentry/browser` module and assert the resolved integration list and the
* captured-event shape against the true SDK, not a stand-in.
*/
export function buildBrowserSentryInitOptions(
dsn: string,
environment?: string | null,
): BrowserSentryInitOptions {
return {
dsn,
environment: environment ?? undefined,
// Use the loaded bundle's build, even when the server has since deployed.
release:
typeof __PAPERCLIP_BUILD_COMMIT__ === "string"
? __PAPERCLIP_BUILD_COMMIT__
: undefined,
tracesSampleRate: 0,
sendDefaultPii: false,
beforeSend: (event, hint) => {
// Global handlers do not pass through our React boundaries. Give their
// reports the same bounded document state, without URLs or breadcrumbs.
// Preserve a boundary's earlier snapshot across the asynchronous queue.
try {
event.contexts = {
...event.contexts,
browser_state: event.contexts?.browser_state ?? readBrowserErrorState(),
page_lifecycle: event.contexts?.page_lifecycle ?? readPageLifecycle(),
};
event.tags = {
...event.tags,
browser_build_mode: import.meta.env.DEV ? "development" : "production",
};
if (event.exception?.values?.some((value) =>
value.mechanism?.type === "onunhandledrejection"
|| value.mechanism?.type === "auto.browser.global_handlers.onunhandledrejection",
)) {
// The SDK supplies the rejected value directly. Classify without
// reading object properties, coercing strings, or copying its value.
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.
}
return event;
},
integrations: (defaults) =>
defaults.filter(
(integration) => integration.name !== "HttpContext" && integration.name !== "Breadcrumbs",
),
};
}