Files
PaperClipAI/server/src/sentry.ts
T
Devin FoleyandClaude Opus 5.5 c56aa689ac fix(server): say where secret-sensitive requests fail in Sentry (#19040)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The server sends unexpected 5xx errors to Sentry through the shared
Express error handler
> - Some routes carry credentials: chat setup, provider webhooks, MCP,
company observability settings, Muse, avatars, and similar routes
> - On these routes the handler replaces the error with a generic
`Secret-sensitive request failed` error, because the original name and
message can contain secrets
> - That replacement also removes every useful fact. The Sentry event
does not show the route, the status, the error type, or the throw site,
so a maintainer cannot find the cause
> - This pull request adds a small fixed-shape record to that generic
error. The record says where the failure came from, and it never copies
request data or error text
> - The benefit is that the next event on these routes points to a route
and a code location, while the privacy rule stays the same

## Linked Issues or Issue Description

**What happened?**
A hosted staging server reported one event with the message
`Secret-sensitive request failed`. The event had only the stack of the
sanitizer itself. It had no route, no status, no error type, and no
frame from the original error. The trace had no spans and no logs. A
maintainer cannot find the root cause from this event.

**Expected behavior**
The event keeps the generic message, so no secret leaves the process.
The event also tells the maintainer which secret-sensitive route family
failed, the HTTP method and status, the error type, a safe error code
when one exists, the matched route definition, and the original throw
site.

**Steps to reproduce**
1. Make a route handler throw on a secret-sensitive path, for example
`PUT /api/companies/:companyId/observability`.
2. Enable the backend Sentry DSN.
3. Look at the captured event. It shows only `Secret-sensitive request
failed` and the sanitizer stack.

**Paperclip version or commit**
`master` at e107a75755.

I searched open and closed pull requests for `Secret-sensitive`. I did
not find a pull request that changes these diagnostics.

## What Changed

- `server/src/middleware/http-log-policy.ts`: add
`secretSensitiveRouteFamily()`. It returns a fixed label for the policy
branch that matched, or `null`. `isSecretSensitiveHttpRequest()` now
calls it. The matching rules do not change; the combined `/mcp/runner`
and avatar pattern is split into two branches with the same rules.
- `server/src/middleware/secret-sensitive-request-error.ts` (new):
`SecretSensitiveRequestError`. It keeps the generic message, `name:
"Error"`, its own stack, and default grouping. It adds frozen
diagnostics:
  - `routeFamily`: from the closed list above.
  - `method`: one of the standard HTTP methods, or `other`.
  - `status`: the 5xx `HttpError` status, or 500.
- `errorKind`: `http_error`, `type_error`, `range_error`,
`reference_error`, `syntax_error`, `error`, or `non_error`.
- `errnoCode`: only a key of `os.constants.errno`. `databaseErrorCode`:
only a five-character SQLSTATE. Both are read from own data properties
of the error and at most three causes. Getters are not called.
- `routePattern`: the matched Express route definition
(`req.route.path`), only when it is a string in a narrow character set.
A RegExp route becomes `null`.
- `originFrames`: up to eight complete V8 frames from the original
stack. The extractor first removes the exact `name: message` header that
V8 writes. It rebuilds the header like `Error.prototype.toString` from
data properties only and requires `stack` to start with it, so a
multiline message can never pass as a frame. If the header cannot be
identified, no frame is kept. Each remaining line must match a strict
frame pattern: a code-shaped function name and a location that may
contain spaces, Windows drive or UNC paths, or a percent-encoded
`file:///` URL.
- `server/src/middleware/error-handler.ts`: the sanitizer now returns
this error. The HTTP response, the request log context, and Telemetry do
not change.
- `server/src/sentry.ts`: `captureException` sends these diagnostics as
`secret_route_family`, `http_method`, `http_status`, `error_kind`,
optional `errno_code` and `db_error_code` tags, and a
`secret_sensitive_request` context. Run tags for agent requests stay.
The fingerprint stays `{{ default }}`. The module header documents the
new event fields next to the other Sentry privacy notes.
- `server/src/__tests__/secret-sensitive-request-error.test.ts` (new):
unit tests for each bounded field, the header-first frame extraction
(multiline-message canary, unidentifiable header, paths with spaces and
Windows paths), the route-family policy, and an end-to-end test through
a real Express app, the error handler, and the Sentry gate.

## Verification

- `cd server && pnpm vitest run
src/__tests__/secret-sensitive-request-error.test.ts
src/__tests__/http-log-policy.test.ts src/__tests__/sentry.test.ts
src/__tests__/error-handler.test.ts
src/__tests__/agent-commentary.integration.test.ts
src/__tests__/agent-profile-avatar.test.ts`: 6 files, 108 tests passed,
5 skipped.
- Red before green: with the old `sentry.ts` and the new test, the
end-to-end test fails because the event has no diagnostics tags. With
this change, it passes. The three frame-extraction tests added for
review feedback fail against the previous extractor and pass now.
- The end-to-end test puts a canary credential in the query string, the
JSON body, the error name, the error message, a cause message, and
`HttpError.details`. It checks that no Sentry argument and no reported
stack contains the canary. It also checks that the HTTP response stays
`{ "error": "Internal server error" }` with the original status.
- The existing tests in `error-handler.test.ts` for chat setup and
observability credentials still pass without change. They check that the
reported error, the error context, and Telemetry carry no canary.
- Server typecheck with `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 node
scripts/typecheck-layers.mjs`: no error in server files. My sandbox had
only the server dependency subset installed, so the layers also reported
missing-module errors in `cli/`, `ui/`, and `packages/`. These errors
are not related to this change. CI runs the full check.
- Note for local runs: an empty `ANTHROPIC_BASE_URL` makes
`sentry.test.ts` fail at import on `master` too. Unset it before you run
the suite.

## Risks

- Low risk. Sentry stays opt-in, and the change affects only events from
secret-sensitive routes.
- Privacy: the new values are closed labels, bounded numbers,
source-code route definitions, Node errno names, SQLSTATE codes, and V8
frames that match a strict pattern after the message header is removed.
A frame contains a function name and a file location. In theory, code
can give a function a name from data. The frame pattern accepts only
identifier-like names of at most 120 characters, and the event never
includes the error name or message.
- If code changes an error's `message` or `name` after its stack was
formatted, the header no longer matches and the event has no origin
frames. This fails closed.
- `req.route` holds the last route that the router dispatched. If a
later middleware fails after a route calls `next()`, `routePattern` can
name that earlier route. The route family is still correct because it
comes from the URL policy.
- Grouping does not change. The reported error keeps the same type,
message, and sanitizer stack, so existing Sentry issues keep collecting
events.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Provider: Anthropic. Model: Claude Opus 5.5 (`claude-opus-5-5`), used
through Claude Code with tool use (shell, file edits, test runs).
Extended reasoning was not explicitly enabled.

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:14:00 -08:00

551 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
//
// A failed secret-sensitive request (chat setup, provider webhooks, MCP,
// company observability settings, and similar routes) reports only the
// generic `Secret-sensitive request failed` error, with its own stack and
// default grouping. `SecretSensitiveRequestError` adds `secret_route_family`,
// `http_method`, `http_status` and `error_kind` tags, optional `errno_code`
// and `db_error_code` tags (a Node errno name or a SQLSTATE), and a
// `secret_sensitive_request` context. The context holds the matched Express
// route definition and up to eight complete V8 frames from the original
// stack. Every value is a closed label, a bounded number, or a code-shaped
// string; anything else is dropped. The event never carries the request
// URL, query, body, headers, or the original error name or message.
//
// `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.
//
// `beforeSend` adds run navigation links, then bounded provenance for the
// dynamic-import-callback error — see `sentry-dynamic-import-provenance.ts`.
//
// 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 type { HeartbeatRunStatus } from "@paperclipai/shared";
import { AdapterStopTimeoutError } from "./services/adapter-stop-timeout.js";
import { CloudPortfolioError } from "./services/cloud-portfolio-error.js";
import { SecretSensitiveRequestError } from "./middleware/secret-sensitive-request-error.js";
import { storageFailureDiagnosticsFor } from "./storage/failure-diagnostics.js";
import type { RunFailureDiagnostics } from "./services/run-failure-diagnostics.js";
import { readBuildCommit } from "./build-commit.js";
import { checkExactPeerVersions } from "./peer-version-check.js";
import { resolveSentryDsns } from "./sentry-dsn.js";
import { readRunFailureReportingMode } from "./sentry-run-failure-policy.js";
import { addSentryRunLinks, type SentryRunReference } from "./sentry-links.js";
import { addDynamicImportProvenance, type ProvenanceHint } from "./sentry-dynamic-import-provenance.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, reference?: SentryRunReference): void {
if (!sentryHandle) return;
try {
const tags = reference ? {
run_id: reference.runId,
...(reference.companyId ? { company_id: reference.companyId } : {}),
...(reference.agentId ? { agent_id: reference.agentId } : {}),
} : {};
const storageFailure = storageFailureDiagnosticsFor(error);
if (error instanceof AdapterStopTimeoutError) {
// Event-local, fixed-shape context: no ambient scope or raw error fields.
const exception = new Error(error.message);
exception.stack = error.stack;
sentryHandle.captureException(exception, {
tags: { ...tags, error_code: "adapter_stop_unconfirmed" },
contexts: { adapter_stop: { ...error.diagnostics } },
fingerprint: ["{{ default }}"],
});
} else if (error instanceof CloudPortfolioError) {
const exception = new Error(error.message);
exception.stack = error.stack;
sentryHandle.captureException(exception, {
tags: { ...tags, error_code: "cloud_portfolio_failure" },
contexts: { cloud_portfolio: { ...CloudPortfolioError.diagnosticsFor(error) } },
fingerprint: ["{{ default }}"],
});
} else if (error instanceof SecretSensitiveRequestError) {
// The sanitized error keeps its generic message, stack and default
// grouping. Only its fixed-shape diagnostics are added.
const diagnostics = error.diagnostics;
sentryHandle.captureException(error, {
tags: {
...tags,
secret_route_family: diagnostics.routeFamily,
http_method: diagnostics.method,
http_status: String(diagnostics.status),
error_kind: diagnostics.errorKind,
...(diagnostics.errnoCode ? { errno_code: diagnostics.errnoCode } : {}),
...(diagnostics.databaseErrorCode ? { db_error_code: diagnostics.databaseErrorCode } : {}),
},
contexts: { secret_sensitive_request: { ...diagnostics, originFrames: [...diagnostics.originFrames] } },
fingerprint: ["{{ default }}"],
});
} else if (storageFailure) {
// The original error keeps its default grouping; only the normalized
// record is added, never the provider's request or response.
sentryHandle.captureException(error, {
tags: { ...tags, storage_operation: storageFailure.operation, storage_error_code: storageFailure.errorCode },
contexts: { storage_failure: { ...storageFailure } },
fingerprint: ["{{ default }}"],
});
} else if (reference) {
sentryHandle.captureException(error, { tags, contexts: {}, fingerprint: ["{{ default }}"] });
} else {
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" | "cancelled";
/**
* The diagnostic values `captureRunFailure` sends with a terminal-failure
* event. `errorCode` is `null` when the run holds no error code.
*/
export interface RunFailureEvent {
taskId: string | null;
companyId?: string;
agentId?: 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: HeartbeatRunStatus;
failureSource?: "task_interruption";
/** Bounded, redacted diagnostics selected by the run failure reporter. */
diagnostics?: RunFailureDiagnostics;
/** Recorded process exit evidence, when available. Validated before capture. */
exitCode?: number | null;
signal?: string | null;
}
function normalizeRunExitCode(value: unknown): number | null {
// Match the persisted PostgreSQL integer. Do not coerce adapter-supplied text.
return typeof value === "number" &&
Number.isInteger(value) &&
value >= -2147483648 &&
value <= 2147483647
? value
: null;
}
function normalizeRunSignal(value: unknown): string | null {
if (value == null) return null;
// Only host signal constants may leave the process; arbitrary adapter text
// can contain output or credentials even when stored in the signal column.
return typeof value === "string" && Object.hasOwn(os.constants.signals, value)
? value
: "unknown";
}
/**
* 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.
*
*/
export function captureRunFailure(event: RunFailureEvent): boolean {
return captureRunFailureEvent(event) !== null;
}
function captureRunFailureEvent(event: RunFailureEvent): string | null {
if (!sentryHandle || readRunFailureReportingMode() === "off") return null;
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.
const diagnostics = event.diagnostics;
const rootCodeCandidate = diagnostics?.execution.originalFailureCode ??
diagnostics?.exceptions.at(-1)?.code;
const rootErrorCode = typeof rootCodeCandidate === "string" && /^[a-zA-Z][a-zA-Z0-9_]{1,99}$/.test(rootCodeCandidate)
? rootCodeCandidate : undefined;
// Without a usable root code, unrelated failures that share the error code,
// adapter and phase would collapse into one group. Fall back to the root
// exception's class name plus its HTTP status or numeric code. These are
// bounded values; the message stays out of the fingerprint.
const rootFallback: string[] = [];
const rootException = diagnostics?.exceptions.at(-1);
if (!rootErrorCode && rootException) {
if (typeof rootException.name === "string" && /^[A-Za-z_$][A-Za-z0-9_$]{0,99}$/.test(rootException.name)) {
rootFallback.push(`exception:${rootException.name}`);
}
if (rootException.status !== undefined) rootFallback.push(`status:${rootException.status}`);
else if (typeof rootException.code === "string" && /^-?[0-9]{1,10}$/.test(rootException.code)) {
rootFallback.push(`code:${rootException.code}`);
}
}
const phase = diagnostics?.execution.failurePhase;
const failurePhase = phase === "setup" || phase === "execute" ? phase : undefined;
const contexts: Record<string, Record<string, unknown>> = {};
if (event.companyId && event.agentId) {
const runPath = `/api/heartbeat-runs/${encodeURIComponent(event.runId)}`;
contexts.paperclip = {
schemaVersion: 1,
companyId: event.companyId,
agentId: event.agentId,
runPath,
eventsPath: `${runPath}/events`,
logPath: `${runPath}/log`,
...(event.taskId ? { taskPath: `/api/issues/${encodeURIComponent(event.taskId)}` } : {}),
};
try {
const base = new URL(process.env.PAPERCLIP_PUBLIC_URL ?? "");
if ((base.protocol === "https:" || base.protocol === "http:") &&
!base.username && !base.password && !base.search && !base.hash && base.pathname === "/") {
contexts.paperclip.runUrl = new URL(runPath, base).href;
contexts.paperclip.eventsUrl = new URL(`${runPath}/events`, base).href;
contexts.paperclip.logUrl = new URL(`${runPath}/log`, base).href;
}
} catch {}
}
if (diagnostics) {
contexts.run_execution = { ...diagnostics.execution, truncatedFields: diagnostics.truncatedFields };
if (Object.keys(diagnostics.adapter).length) contexts.adapter_failure = diagnostics.adapter;
if (Object.keys(diagnostics.provider).length) contexts.provider_failure = diagnostics.provider;
diagnostics.exceptions.forEach(({ name, code, status, requestId }, index) => {
contexts[`run_exception_${index}`] = { name, code, status, requestId };
});
}
// Rebuild only sanitized exception fields. Passing a raw SDK Error can
// serialize its request/response, headers, or other enumerable properties.
let cause: Error | undefined;
for (const entry of [...(diagnostics?.exceptions ?? [])].reverse()) {
const error: Error = new Error(entry.message ?? event.errorMessage, cause ? { cause } : undefined);
error.name = entry.name ?? "Error";
error.stack = entry.stack;
cause = error;
}
const exception = cause ?? new Error(event.errorMessage);
if (!cause) {
// A saved adapter result is not a thrown Error. Do not pretend that the
// reporter's own stack is the failure location.
exception.stack = typeof diagnostics?.adapter.stackPreview === "string"
? diagnostics.adapter.stackPreview : undefined;
}
return handle.captureException(exception, {
tags: {
event_kind: "run_failure",
...(event.failureSource ? { failure_source: event.failureSource } : {}),
...(rootErrorCode ? { root_error_code: rootErrorCode } : {}),
...(failurePhase ? { failure_phase: failurePhase } : {}),
run_id: event.runId,
...(event.taskId ? { task_id: event.taskId } : {}),
...(event.companyId ? { company_id: event.companyId } : {}),
...(event.agentId ? { agent_id: event.agentId } : {}),
error_code: errorCode,
agent_adapter: event.agentAdapter,
run_status: event.runStatus,
},
contexts: {
...contexts,
run_failure: {
taskId: event.taskId,
runId: event.runId,
errorMessage: event.errorMessage,
errorCode,
agentAdapter: event.agentAdapter,
exitCode: normalizeRunExitCode(event.exitCode),
signal: normalizeRunSignal(event.signal),
},
},
fingerprint: [errorCode, event.agentAdapter, ...(failurePhase ? [failurePhase] : []), ...(rootErrorCode ? [rootErrorCode] : rootFallback)],
});
} catch (err) {
// eslint-disable-next-line no-console
console.error("[paperclip] Sentry captureRunFailure failed", err);
return null;
}
}
const reportedTaskInterruptions = new Set<string>();
const MAX_REPORTED_TASK_INTERRUPTIONS = 1_000;
const TASK_INTERRUPTION_DELIVERY_RETRY_MS = 60_000;
const pendingTaskInterruptions = new Map<string, { eventId: string; retryAt: number }>();
const pendingTaskInterruptionEvents = new Map<string, string>();
function forgetPendingTaskInterruption(key: string) {
const pending = pendingTaskInterruptions.get(key);
if (pending && pendingTaskInterruptionEvents.get(pending.eventId) === key) {
pendingTaskInterruptionEvents.delete(pending.eventId);
}
pendingTaskInterruptions.delete(key);
}
function confirmTaskInterruptionDelivery(eventId: string | undefined, statusCode: number | undefined) {
if (!eventId || statusCode === undefined || statusCode < 200 || statusCode >= 300) return;
const key = pendingTaskInterruptionEvents.get(eventId);
if (!key) return;
forgetPendingTaskInterruption(key);
reportedTaskInterruptions.add(key);
if (reportedTaskInterruptions.size > MAX_REPORTED_TASK_INTERRUPTIONS) {
reportedTaskInterruptions.delete(reportedTaskInterruptions.values().next().value!);
}
}
export function captureTaskInterruptionFailure(event: Pick<RunFailureEvent,
"companyId" | "agentId" | "taskId" | "runId" | "runStatus" | "agentAdapter"> & { intentId: string | null }): void {
const key = JSON.stringify([event.companyId, event.taskId, event.runId, event.intentId]);
if (reportedTaskInterruptions.has(key)) return;
const now = Date.now();
if ((pendingTaskInterruptions.get(key)?.retryAt ?? 0) > now) return;
for (const [pendingKey, pending] of pendingTaskInterruptions) {
if (pending.retryAt <= now) forgetPendingTaskInterruption(pendingKey);
}
const eventId = captureRunFailureEvent({
companyId: event.companyId,
agentId: event.agentId,
taskId: event.taskId,
runId: event.runId,
runStatus: event.runStatus,
agentAdapter: event.agentAdapter,
errorMessage: "Couldn’t finish this attempt. Your message is saved.",
errorCode: "task_attempt_failed",
failureSource: "task_interruption",
});
if (!eventId) return;
pendingTaskInterruptions.set(key, { eventId, retryAt: now + TASK_INTERRUPTION_DELIVERY_RETRY_MS });
pendingTaskInterruptionEvents.set(eventId, key);
if (pendingTaskInterruptions.size > MAX_REPORTED_TASK_INTERRUPTIONS) {
forgetPendingTaskInterruption(pendingTaskInterruptions.keys().next().value!);
}
}
/**
* 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 }>;
beforeSend: typeof enrichServerEvent;
}
type ServerEvent = Parameters<typeof addSentryRunLinks>[0] & Parameters<typeof addDynamicImportProvenance>[0];
/** The server's `beforeSend`: run navigation links, then dynamic-import provenance. */
export async function enrichServerEvent<T extends ServerEvent>(event: T, hint?: ProvenanceHint): Promise<T> {
return addDynamicImportProvenance(await addSentryRunLinks(event), hint);
}
/**
* 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,
beforeSend: enrichServerEvent,
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));
Sentry.getClient?.()?.on("afterSendEvent", (event: { event_id?: string }, response?: { statusCode?: number }) => {
confirmTaskInterruptionDelivery(event.event_id, response?.statusCode);
});
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,
);
}
}