Files
PaperClipAI/ui/src/lib/sentry.ts
T
Devin FoleyandPaperclip faf72cb1d5 fix: preserve company context across browser hot reload (#14482)
## Thinking Path

> - Paperclip uses a shared company context for browser providers and
consumers.
> - Vite can load a new consumer module while an older provider is
mounted.
> - Recreating the context disconnects that consumer from the mounted
provider.
> - The consumer then reports a missing provider even though one is in
its React ancestry.
> - This change preserves the context object across development module
refreshes.
> - Bounded global error diagnostics distinguish development bundles and
otherwise context-free promise rejections.

## Linked Issues or Issue Description

**What happened?**
A refreshed company consumer can throw `useCompany must be used within a
CompanyProvider`. A real Vite and Chromium reproduction confirms that a
retained provider and a refreshed consumer can hold different context
objects. Global promise rejections also lack the bounded document state
already attached to React boundary errors.

**Expected behavior**
A refreshed consumer should read the mounted provider. Error reports
should identify the loaded bundle mode and bounded browser state while
preserving monitoring opt-in, sign-out, and privacy behavior.

**Steps to reproduce**
Run `pnpm test:e2e:browser-context`. The isolated Vite fixture renders
the real CompanyProvider, imports a new timestamped consumer module, and
renders that consumer below the retained provider. The test fails before
the context change and passes after it. The SDK regression invokes its
real unhandled-rejection handler with an undefined reason.

## What Changed

- Keep the React context object in Vite's per-module `hot.data`. Account
values stay in React.
- Add an isolated browser regression with mocked API responses and no
live instance, discovered by the existing Chrome CI shards.
- Add document-state diagnostics to global errors while preserving
earlier boundary snapshots.
- Tag events with development or production bundle mode and the type of
an unhandled rejected value.
- Document the new test command and diagnostic fields.

## Verification

- Company context, browser context, and Sentry suites: 59 passed.
- `pnpm test:e2e:browser-context`: passed in Chromium. The original
context code fails the reproduction.
- Real SDK tests preserve DSN/sign-out behavior and omit request
context, breadcrumbs, and private DOM data.
- `pnpm check:token-gates`: passed.
- `pnpm -r typecheck`: passed.
- `pnpm build`: passed.
- Local `pnpm test:run` exited in the general-server phase: 13,819
passed, 86 skipped, 21 failures in unchanged filesystem and host-port
suites. Cache permission and long-path failures also reproduce on the
unmodified base; seven other failures involve local runtime port
ownership. This is not a full local pass.
- Full Linux CI and review passed at a0b5270662 (53 successful checks;
two conditional checks skipped). Three jobs interrupted by a runner
shutdown passed on rerun. Greptile is 5/5 with no unresolved comments.
The real browser regression also passed in the normal Chrome CI shard
(3.2 seconds).
- Code-owner approval remains required because the dedicated test
command changes `package.json`.
- Scanned the diff and PR text for credentials and private data before
pushing.

## Risks

The context cache applies only to development hot reload. It retains the
context object, not account state. Production continues to create an
ordinary React context. The diagnostic hook adds only bounded state and
type values, preserves boundary snapshots, and returns the original
event if enrichment fails. It does not suppress errors or restore raw
breadcrumbs. The global rejection diagnostics do not identify the
promise's originating operation by themselves.

## Model Used

OpenAI GPT-6 through Codex, with repository inspection, code editing,
and command execution. The runtime does not expose a more specific model
revision or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (focused suites and
Chromium regression; full local limitations documented above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-28 16:15:14 -07:00

211 lines
9.2 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, readBrowserErrorState, 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");
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(),
};
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 reason = hint.originalException;
event.tags.browser_rejection_kind = reason === null ? "null" : typeof reason;
}
} catch {
// Diagnostics must not replace or discard the original error.
}
return event;
},
integrations: (defaults) =>
defaults.filter(
(integration) => integration.name !== "HttpContext" && integration.name !== "Breadcrumbs",
),
};
}