Files
PaperClipAI/server/src/sentry.ts
T
Devin FoleyandPaperclip 92d4868e79 fix(server): isolate run errors and redact runtime capability headers (#13826)
## Thinking Path

> - Paperclip manages AI agents and their work.
> - Operators use Sentry to investigate failed runs and server errors.
> - Run reports attach a task ID, run ID, error code, and adapter
fingerprint.
> - The server skips Sentry's OpenTelemetry setup to preserve its
separate tracing and privacy settings.
> - Without an async context manager, a scope mutation can attach old
run data to later errors.
> - The HTTP logger also retains a runtime credential capability header.
> - This change isolates run metadata and redacts that header so
diagnostics identify failures without leaking credentials.

## Linked Issues or Issue Description

Refs #13446 and #13719.

**What happened?**

After a terminal run failure, an unrelated server exception can inherit
that run's tags, context, and fingerprint. Sentry then groups a database
error with an earlier adapter failure. The real SDK reproduces this with
the application's `skipOpenTelemetrySetup: true` setting. HTTP request
logs also retain the `x-paperclip-github-capability` header, which must
be treated as a credential.

**Expected behavior**

Run metadata belongs to the terminal run event. Later exceptions must
not inherit it. Every genuine error must still be captured. Runtime
capability headers must be redacted on success and failure logs.

**Steps to reproduce**

1. Initialize the optional Sentry SDK with the application's options and
an in-memory transport.
2. Capture a terminal run failure.
3. Capture an unrelated exception.
4. Inspect the second event. Before this fix, it contains the first
run's identity and fingerprint.
5. Send a request with a fixture runtime GitHub capability header.
Before this fix, HTTP logs retain the fixture value.

## What Changed

- Pass tags, context, and fingerprint directly to `captureException`
instead of mutating the ambient scope.
- Preserve the existing run fields, grouping keys, ordinary exception
capture, and privacy settings.
- Test two run identities interleaved with unrelated exceptions against
the real optional SDK.
- Update the capture contract tests and document event-local run
metadata.
- Redact the runtime GitHub capability header through the existing HTTP
logger policy. Test successful, denied, and failed requests.
- Add a dedicated GitHub-hosted CI check that installs the exact
optional SDK version declared in `server/package.json`. It fails if the
real-SDK regression would be skipped. The SDK stays outside the
workspace and production dependency graph.

## Verification

- The real-SDK regression failed before the fix because the unrelated
event contained `contexts.run_failure`.
- Five focused suites passed: 123 tests, including all optional SDK
tests. Suites: `run-failure-sentry-real-sdk.test.ts`,
`run-failure-sentry.test.ts`, `sentry.test.ts`,
`run-failure-report.test.ts`, and `http-log-redaction.test.ts`. A custom
in-memory transport prevented outbound Sentry delivery.
- All three new header-redaction cases failed before the policy fix and
passed afterward.
- The dedicated CI command passed locally with
`PAPERCLIP_REQUIRE_SENTRY_TEST_SDK=1` and the audited SDK available
through `NODE_PATH`.
- Server TypeScript check passed with a scratch configuration that
resolves this checkout's workspace packages. The existing dependency
links point to another checkout.
- `node scripts/check-module-boundaries.mjs` and `git diff --check`
passed.
- Gitleaks and a separate private-data scan passed before push.
- Full local workspace typecheck, test, and build were not run. The
machine has less than 2 GiB free and those commands include Rust builds.
Full PR CI must pass before merge.
- The dedicated real-SDK GitHub check passed with 1 test executed and no
skips: https://github.com/paperclipai/paperclip/actions/runs/35774449002
- Greptile reviewed c9db03bcab at 5/5. Its only thread is resolved. Full
PR CI passed on that same head:
https://github.com/paperclipai/paperclip/actions/runs/35774449020

## Risks

Small change to error attribution. Unrelated errors may now form their
correct Sentry groups instead of reopening a prior run group. No errors
are filtered or suppressed. No tracing is enabled and no new event
fields are added. No schema or runtime-execution changes. HTTP logs
retain their request and status diagnostics while masking the capability
value. The new SDK job has read-only permissions, no secrets, and an
in-memory Sentry transport.

## Model Used

OpenAI GPT-6 (Codex), with tool use and code execution.

## 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
#` 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 references)
- [x] My branch name describes the change 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>
2026-09-22 12:53:31 -07:00

305 lines
12 KiB
TypeScript

// Optional Sentry error monitoring for the server process.
//
// Activated only when the backend DSN resolves to a value — see
// `resolveSentryDsns` in `sentry-dsn.ts` for the precedence between
// `SENTRY_DSN_BACKEND` and the legacy `SENTRY_DSN` fallback. When it
// resolves to `null`, no Sentry package is loaded at all.
//
// The import is dynamic and the package is an optional runtime dependency —
// operators who want server-side error monitoring install `@sentry/node`
// themselves. That keeps Sentry off the default dependency graph and avoids
// forcing a lockfile bump for an opt-in feature. This gate mirrors the
// OpenTelemetry gate in `instrumentation.ts`.
//
// OpenTelemetry keeps ownership of trace setup: the initializer passes
// `skipOpenTelemetrySetup: true` and `tracesSampleRate: 0`, so this module
// adds error monitoring only and starts no span or trace behavior of its
// own.
//
// `serverName`: the initializer sets this to the host name of the process,
// with `os.hostname()`. The `@sentry/node` client already falls back to the
// same host name when the caller omits this option, so this line makes an
// existing default explicit instead of changing captured event content. An
// explicit value stays correct if a future SDK version changes its default.
//
// An operator can still send a different value in place of the host name.
// When the environment variable `SENTRY_NAME` holds a non-empty string, the
// initializer uses that value instead. This keeps the same order the
// `@sentry/node` client itself uses when the caller omits `serverName`.
//
// Default-integration privacy note: `sendDefaultPii: false` filters values
// by name, inside the `RequestData` integration only. Three other default
// integrations copy raw values past that filter, so the initializer removes
// or narrows them with built-in Sentry options — no custom filter code:
// - `Console` turns a `console.*` call into a breadcrumb with the raw
// arguments. The initializer drops it.
// - `ContextLines` reads local source lines around each stack frame off
// the host disk. The initializer drops it.
// - `Http` records a breadcrumb for each outbound request, with its URL
// and query string. The initializer keeps the integration (`RequestData`
// and request isolation need it) and turns the breadcrumb off with the
// integration's own `breadcrumbs` option.
//
// `onUnhandledRejectionIntegration` defaults to `mode: "warn"`, which
// registers a `process.on("unhandledRejection")` listener. Node cancels its
// own crash-on-unhandled-rejection behavior when any listener is registered.
// The server relies on that crash today, so the initializer passes
// `mode: "strict"`: Sentry still captures the event, then exits the process,
// so the existing crash-and-restart behavior stays.
//
// Before it imports the package, the bootstrap checks the installed
// `@sentry/node` version against the exact version this manifest's
// `peerDependencies` declares — the same audited version documented in
// `doc/observability.md`. A missing or a mismatched version logs one
// diagnostic and leaves the server running without error monitoring; it
// never throws. This gate mirrors the OpenTelemetry gate in
// `instrumentation.ts`.
import os from "node:os";
import { readBuildCommit } from "./build-commit.js";
import { checkExactPeerVersions } from "./peer-version-check.js";
import { resolveSentryDsns } from "./sentry-dsn.js";
const { backend: dsn, legacyFallbackUsed } = resolveSentryDsns();
if (legacyFallbackUsed) {
// eslint-disable-next-line no-console
console.warn(
"[paperclip] SENTRY_DSN_FRONTEND or SENTRY_DSN_BACKEND is not set. " +
"The server uses the legacy SENTRY_DSN value for the affected " +
"component. Set SENTRY_DSN_FRONTEND and SENTRY_DSN_BACKEND to send " +
"each component to its own Sentry project.",
);
}
/** Event-local context accepted by the optional Sentry package. */
interface SentryCaptureContext {
tags: Record<string, string>;
contexts: Record<string, Record<string, unknown>>;
fingerprint: string[];
}
/** The subset of the `@sentry/node` client surface this gate calls. */
interface SentryHandle {
captureException(error: unknown, context?: SentryCaptureContext): string;
close(timeout?: number): Promise<boolean>;
}
let sentryHandle: SentryHandle | null = null;
let shutdownPromise: Promise<void> | null = null;
/**
* Resolves once the Sentry SDK has started, or once bootstrap has failed and
* logged, or at once when the backend DSN resolves to `null`. No caller
* needs to await this before calling `captureException` — it is a no-op
* until ready — but `index.ts` awaits it at startup so the first real error
* has a live client.
*/
export const sentryReady: Promise<void> = dsn ? bootstrapSentry(dsn) : Promise.resolve();
/**
* Report an error to Sentry. A no-op before the gate opens, when the gate
* never opens (the backend DSN resolves to `null`), or when bootstrap
* failed. Never throws — observability must not change control flow.
*/
export function captureException(error: unknown): void {
if (!sentryHandle) return;
try {
sentryHandle.captureException(error);
} catch (err) {
// eslint-disable-next-line no-console
console.error("[paperclip] Sentry captureException failed", err);
}
}
/** The run status values that mark a run as a genuine terminal failure. */
export type RunFailureStatus = "failed" | "timed_out";
/**
* The diagnostic values `captureRunFailure` sends with a terminal-failure
* event. `errorCode` is `null` when the run holds no error code.
*/
export interface RunFailureEvent {
/** The task UUID the run belongs to. */
taskId: string;
/** The `heartbeat_runs` row id. */
runId: string;
/** The redacted error message. */
errorMessage: string;
/** The run's error code, or `null` when the run holds none. */
errorCode: string | null;
/** The agent's adapter type, or `"unknown"` when the agent row is absent. */
agentAdapter: string;
/** The run status that triggered this report. */
runStatus: RunFailureStatus;
}
/**
* Report one terminal run failure to Sentry. A no-op before the gate opens
* or when the gate never opens. Never throws — observability must not
* change run control flow.
*
* Sets the fingerprint to `[errorCode, agentAdapter]`, in that order, so
* Sentry groups events by error code and adapter. The error message stays
* out of the fingerprint — it still travels as the exception message and as
* a field of the `run_failure` context.
*/
export function captureRunFailure(event: RunFailureEvent): void {
if (!sentryHandle) return;
const handle = sentryHandle;
try {
const errorCode = event.errorCode ?? "unknown";
// Sentry's async scope isolation is absent when OTel setup is skipped.
// A withScope mutation can then persist into unrelated later captures.
// Pass these fields on this event only; do not mutate the ambient scope.
handle.captureException(new Error(event.errorMessage), {
tags: {
run_id: event.runId,
task_id: event.taskId,
error_code: errorCode,
agent_adapter: event.agentAdapter,
run_status: event.runStatus,
},
contexts: {
run_failure: {
taskId: event.taskId,
runId: event.runId,
errorMessage: event.errorMessage,
errorCode,
agentAdapter: event.agentAdapter,
},
},
fingerprint: [errorCode, event.agentAdapter],
});
} catch (err) {
// eslint-disable-next-line no-console
console.error("[paperclip] Sentry captureRunFailure failed", err);
}
}
/**
* Flush buffered events and close the Sentry client. Idempotent — concurrent
* callers share one shutdown. A no-op when monitoring is off or bootstrap
* failed.
*/
export function shutdownSentry(): Promise<void> {
shutdownPromise ??= (async () => {
await sentryReady;
if (!sentryHandle) return;
try {
// Awaiting matters: the client flushes buffered events to Sentry
// during close; exiting before it settles silently drops them.
await sentryHandle.close(5_000);
} catch (err) {
// eslint-disable-next-line no-console
console.error("[paperclip] Sentry shutdown failed", err);
}
})();
return shutdownPromise;
}
/**
* The subset of the `@sentry/node` module surface the initializer needs to
* build its options object. A structural type, not the real Sentry type —
* the real type is unavailable at compile time because the package is an
* optional runtime dependency (see the module comment above).
*/
interface SentryModuleLike {
httpIntegration(options: { breadcrumbs: boolean }): { name: string };
onUnhandledRejectionIntegration(options: { mode: "strict" }): { name: string };
}
/** The `Sentry.init` options this gate builds. */
export interface SentryInitOptions {
dsn: string;
release?: string;
skipOpenTelemetrySetup: boolean;
tracesSampleRate: number;
sendDefaultPii: boolean;
serverName: string;
integrations: (defaults: Array<{ name: string }>) => Array<{ name: string }>;
}
/**
* Build the `Sentry.init` options object. A pure function, split out from
* `bootstrapSentry` so a test can call it with a real `@sentry/node` module
* and assert the resolved integration list and the captured-event shape
* against the true SDK, not a stand-in.
*/
export function buildSentryInitOptions(
dsn: string,
Sentry: SentryModuleLike,
): SentryInitOptions {
return {
dsn,
release: process.env.SENTRY_RELEASE?.trim() || readBuildCommit() || undefined,
skipOpenTelemetrySetup: true,
tracesSampleRate: 0,
sendDefaultPii: false,
serverName: process.env.SENTRY_NAME || os.hostname(),
integrations: (defaults: Array<{ name: string }>) => {
const kept = defaults.filter(
(integration) =>
integration.name !== "Console" &&
integration.name !== "ContextLines" &&
integration.name !== "Http" &&
integration.name !== "OnUnhandledRejection",
);
return [
...kept,
// Keep the rest of the Http integration — RequestData and request
// isolation need it — but turn the outbound breadcrumb off.
Sentry.httpIntegration({ breadcrumbs: false }),
// Keep today's crash-on-unhandled-rejection behavior. See the
// module comment above for why the default mode cannot stay.
Sentry.onUnhandledRejectionIntegration({ mode: "strict" }),
];
},
};
}
async function bootstrapSentry(dsn: string): Promise<void> {
// Gate on the exact peer version before touching the dynamic import: a
// package installed at the wrong version can still load and start, which
// would silently invalidate the privacy audit `doc/observability.md`
// records against one exact version. Checking first turns that into one
// precise, fail-open diagnostic.
const versionCheck = checkExactPeerVersions(["@sentry/node"]);
if (!versionCheck.ok) {
// eslint-disable-next-line no-console
console.warn(
"[paperclip] The backend Sentry DSN is set, but the @sentry/node " +
"package is not installed, or is installed at an unsupported " +
"version. Install the declared version of @sentry/node to enable " +
"server error monitoring. Continuing without it.",
versionCheck.detail,
);
return;
}
try {
// Dynamic import so type-resolution doesn't require the package to be
// installed unless the operator actually opts in.
// @ts-ignore optional peer dep
const Sentry = await import("@sentry/node");
Sentry.init(buildSentryInitOptions(dsn, Sentry));
sentryHandle = {
captureException: (...args) => Sentry.captureException(...args),
close: (timeout) => Sentry.close(timeout),
};
} catch (err) {
// The exact-version gate above already confirmed @sentry/node is
// installed at the declared version, so only a load or init failure
// after that point reaches this block.
// eslint-disable-next-line no-console
console.warn(
"[paperclip] The backend Sentry DSN is set, and @sentry/node passed " +
"the version check, but it failed to load or initialize. " +
"Continuing without error monitoring.",
err,
);
}
}