mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The server and the browser need clear error reports when an operator enables external monitoring. > - Paperclip already uses an opt-in OpenTelemetry pattern for server traces. > - Sentry can provide error reports for both runtime paths when the operator sets one data source name. > - This pull request adds one opt-in Sentry gate for the server and the browser. > - The benefit is faster diagnosis while the default setup sends no Sentry data. ## Linked Issues or Issue Description **What is improved?** Paperclip gains optional error monitoring for server and browser failures. **Subsystem affected** Cross-cutting (server, UI, and shared authentication data). **Current behavior** Paperclip has no built-in Sentry error capture for server failures or browser boundary failures. Operators must inspect local logs and browser tools. **Proposed behavior** When the operator sets `SENTRY_DSN`, the server and authenticated browser use the same Sentry project. When the variable is absent, both paths stay inactive. The server loads Sentry dynamically and fails open when the optional package is absent. **Reason and benefit** Operators can inspect runtime errors in one Sentry project. The default setup remains local and sends no monitoring data. **Breaking changes** None when `SENTRY_DSN` remains unset. Authenticated session responses add the optional `sentryDsn` field. **Additional context** The implementation uses built-in Sentry privacy options. It disables default HTTP context and breadcrumb integrations and keeps `sendDefaultPii` false. ## What Changed - Add an opt-in server Sentry gate with dynamic package loading and fail-open behavior. - Add the Sentry data source name to the authenticated session response. - Add an authenticated browser Sentry gate and React error boundary capture. - Add tests for server, browser, route, and application error paths. - Document activation, installation, privacy settings, capture behavior, and operator controls. ## Verification - Run `npx vitest run server/src/__tests__/sentry.test.ts`. - Run `npx vitest run ui/src/lib/sentry.test.ts`. - Run `npx vitest run server/src/__tests__/auth-routes.test.ts server/src/__tests__/shutdown.test.ts`. - Confirm that the full continuous integration suite passes on this pull request. - Leave `SENTRY_DSN` unset and confirm that the server and browser gates stay inactive. - Set `SENTRY_DSN` and install the optional Sentry packages before a manual capture check. ## Risks The operator controls the Sentry project and accepts the data risk when the operator enables the feature. Error objects can contain messages, stacks, or cause chains with private values. The default configuration sends no data because the feature stays off without `SENTRY_DSN`. A missing optional server package does not stop server boot. ## Model Used OpenAI Codex, GPT-5, with tool use, repository inspection, GitHub CLI operations, and code review support. ## 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 - [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>
166 lines
7.2 KiB
TypeScript
166 lines
7.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`).
|
|
|
|
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): Promise<void> {
|
|
return enqueue(async () => {
|
|
if (sentry) return;
|
|
try {
|
|
const Sentry = await import("@sentry/browser");
|
|
Sentry.init(buildBrowserSentryInitOptions(dsn));
|
|
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): void {
|
|
void enqueue(async () => {
|
|
try {
|
|
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): BrowserSentryInitOptions {
|
|
return {
|
|
dsn,
|
|
tracesSampleRate: 0,
|
|
sendDefaultPii: false,
|
|
integrations: (defaults) =>
|
|
defaults.filter(
|
|
(integration) => integration.name !== "HttpContext" && integration.name !== "Breadcrumbs",
|
|
),
|
|
};
|
|
}
|