Files
PaperClipAI/server/src/__tests__/run-failure-sentry.test.ts
T
DottaandPaperclip b3eb03fcba fix(sentry): preserve run failure stacks and diagnostic context (#14585)
Builds on merged #14575 and targets `master`.

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators use optional Sentry reports to diagnose failed agent runs.
> - The shared reporter currently converts each saved error message into
a new exception.
> - This loses the original stack and cause chain, and omits available
adapter and provider diagnostics.
> - This pull request selects, redacts, and bounds useful diagnostics
before it sends them to Sentry.
> - Operators can locate the failing operation and correlate upstream
failures without copying arbitrary run data.

## Linked Issues or Issue Description

Refs #14573. That change preserves ACP provider failure details in the
run record.
Refs #14575. That merged PR adds recorded exit codes and signals. This
PR covers stacks, causes, and structured diagnostics without duplicating
those fields.

A thrown setup or adapter error currently appears in Sentry with the
reporter's stack. A saved provider failure can contain useful details
that never reach the Sentry event. Both cases use the same shared
reporting path.

## What Changed

- Pass caught setup and execution exceptions, and structured adapter
error metadata, through successful terminal status transitions.
- Snapshot a fixed set of execution, adapter, provider, and exception
fields. Preserve up to four exceptions in the cause chain.
- Remove registered secret values, declared runtime environment
credentials, unknown inherited environment values and encoded credential
forms, and credential patterns before truncation. Mark truncated fields
and bound provider details and stacks.
- Rebuild sanitized Sentry exceptions with the original stacks and
causes. Use an adapter stack preview when available. Omit a fabricated
reporting stack when the source has no stack.
- Keep contexts local to each event. Preserve the optional DSN gate and
existing error-code/adapter fingerprint.
- Document the fields, limits, and omitted data.
- Settle leftover chat fixture outbox rows only after assertions and
worker shutdown, so subsequent tests cannot claim earlier cases’ pending
actions or provider I/O. This fixes the CI shard contamination exposed
during verification; production chat behavior and test timeouts are
unchanged.

## Verification

- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- 86 focused diagnostic, Sentry, and startup tests passed after the
rebase, including the real `@sentry/node@10.71.0` SDK with an in-memory
transport.
- Tests cover cause chains, HTTP status and request IDs, long provider
details, secret redaction, failed secret resolution, cyclic causes, size
limits, and context isolation.
- Five targeted heartbeat integration cases passed, including thrown and
returned errors containing an opaque environment-bound credential. The
earlier full heartbeat integration run also passed its integration
cases.
- The focused suite also passed with an unknown inherited environment
value set to `1`; reporting tests use a controlled environment and
separately verify short-secret redaction.
- Before the fixture cleanup, the full chat shard reproduced the CI
Telegram timeout at `pending recovery before restart` (354 passed, 1
failed). After the cleanup, the same shard passed all 355 tests. No
timeout or production behavior changed.
- Latest-head CI (`0ae9e70df321d66dda025c3a7ba4787e169e0a4f`) passed
typecheck, build, all 12 server shards, all 3 chat shards, workspace and
serialized suites, browser shards, Runner checks, canary validation, the
real Sentry SDK contract, and security checks. Required `ci / verify`
and `ci / e2e` passed.
- The branch has been rebased onto `master` after #14575 merged. All 55
applicable checks passed on this head (2 unrelated Storybook checks
skipped). Greptile re-reviewed the current 11-file diff at 5/5 with no
findings or unresolved review threads.
- Local monolithic full-suite attempts were interrupted to apply fixes;
full-suite success is not claimed from those runs.

## Risks

- Error messages and stacks can contain credentials. The reporter uses
existing redactors and the run's encrypted secret registry, reads only
known fields, and skips capture if registered-secret resolution fails.
- Unknown environment values remain private by default. Unrecognized
short values can mask benign matches; known public settings are
explicitly allowed.
- Diagnostic text is bounded and can be truncated. Truncation is
explicit. Data discarded upstream cannot be recovered.
- These additional fields go to the operator's configured Sentry
endpoint. Arbitrary request/response objects, headers, configuration,
prompts, and stdout/stderr are not copied.

## Model Used

OpenAI GPT-6 through Codex, with tool use and code execution. The exact
serving model ID, context window, and configured reasoning level are not
exposed in this session.

## 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>
2026-09-29 12:54:12 -05:00

260 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RunFailureEvent } from "../sentry.js";
/**
* Tests for `captureRunFailure`, the Sentry report for a terminal run
* failure. `@sentry/node` is an optional runtime dependency and is not
* installed in this test environment, so each test mocks a fake copy of the
* package the same way `sentry.test.ts` does, then imports a fresh copy of
* `../sentry.js` with a backend DSN set. That opens the gate and lets the
* fake package's `captureException` spy records the real call
* shape.
*/
const DSN_ENV = "SENTRY_DSN";
const FRONTEND_DSN_ENV = "SENTRY_DSN_FRONTEND";
const BACKEND_DSN_ENV = "SENTRY_DSN_BACKEND";
const originalDsn = process.env[DSN_ENV];
const originalFrontendDsn = process.env[FRONTEND_DSN_ENV];
const originalBackendDsn = process.env[BACKEND_DSN_ENV];
function baseEvent(overrides: Partial<RunFailureEvent> = {}): RunFailureEvent {
return {
taskId: "11111111-1111-1111-1111-111111111111",
runId: "22222222-2222-2222-2222-222222222222",
errorMessage: "the provider process exited with code 1",
errorCode: "process_lost",
agentAdapter: "claude-code",
runStatus: "failed",
...overrides,
};
}
interface CaptureContext {
tags: Record<string, string>;
contexts: Record<string, Record<string, unknown>>;
fingerprint: string[];
}
/** Mirrors `sentry.test.ts`'s `mockSentryPackage`. */
function mockSentryPackage() {
const init = vi.fn();
const captureException = vi.fn((_error: unknown, _context?: CaptureContext) => "event-id");
const close = vi.fn(async () => true);
const httpIntegration = vi.fn((options: unknown) => ({ name: "Http", ...(options as object) }));
const onUnhandledRejectionIntegration = vi.fn((options: unknown) => ({
name: "OnUnhandledRejection",
...(options as object),
}));
vi.doMock("@sentry/node", () => ({
init,
captureException,
close,
httpIntegration,
onUnhandledRejectionIntegration,
}));
vi.doMock("../peer-version-check.js", () => ({
checkExactPeerVersions: () => ({ ok: true }),
}));
return { init, captureException, close };
}
async function importFreshSentryWithGateOpen() {
process.env[BACKEND_DSN_ENV] = "https://public@sentry.example.com/1";
const mocks = mockSentryPackage();
vi.resetModules();
const sentryModule = await import("../sentry.js");
await sentryModule.sentryReady;
return { sentryModule, ...mocks };
}
beforeEach(() => {
delete process.env[DSN_ENV];
delete process.env[FRONTEND_DSN_ENV];
delete process.env[BACKEND_DSN_ENV];
});
afterEach(() => {
if (originalDsn === undefined) delete process.env[DSN_ENV];
else process.env[DSN_ENV] = originalDsn;
if (originalFrontendDsn === undefined) delete process.env[FRONTEND_DSN_ENV];
else process.env[FRONTEND_DSN_ENV] = originalFrontendDsn;
if (originalBackendDsn === undefined) delete process.env[BACKEND_DSN_ENV];
else process.env[BACKEND_DSN_ENV] = originalBackendDsn;
vi.restoreAllMocks();
vi.doUnmock("@sentry/node");
vi.doUnmock("../peer-version-check.js");
});
describe("captureRunFailure", () => {
it("sets the fingerprint [errorCode, agentAdapter] in that order", async () => {
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
sentryModule.captureRunFailure(baseEvent({ errorCode: "timeout", agentAdapter: "codex" }));
expect(captureException.mock.calls[0]![1]?.fingerprint).toEqual(["timeout", "codex"]);
});
it("keeps the error message out of the fingerprint for two runs with the same code and adapter but different messages", async () => {
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
sentryModule.captureRunFailure(
baseEvent({ errorMessage: "message one", errorCode: "timeout", agentAdapter: "codex" }),
);
sentryModule.captureRunFailure(
baseEvent({ errorMessage: "message two", errorCode: "timeout", agentAdapter: "codex" }),
);
expect(captureException.mock.calls[0]![1]?.fingerprint).toEqual(["timeout", "codex"]);
expect(captureException.mock.calls[1]![1]?.fingerprint).toEqual(["timeout", "codex"]);
});
it("sets the fingerprint element \"unknown\" when the error code is absent", async () => {
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
sentryModule.captureRunFailure(baseEvent({ errorCode: null }));
expect(captureException.mock.calls[0]![1]?.fingerprint).toEqual(["unknown", "claude-code"]);
});
it("keeps missing process exit evidence null on the run_failure context", async () => {
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
const event = baseEvent();
sentryModule.captureRunFailure(event);
expect(captureException.mock.calls[0]![1]?.contexts.run_failure).toEqual({
taskId: event.taskId,
runId: event.runId,
errorMessage: event.errorMessage,
errorCode: event.errorCode,
agentAdapter: event.agentAdapter,
exitCode: null,
signal: null,
});
});
it.each([
{ exitCode: 1, signal: null },
{ exitCode: 0, signal: null },
{ exitCode: -1, signal: null },
{ exitCode: -2147483648, signal: null },
{ exitCode: 2147483647, signal: null },
{ exitCode: null, signal: "SIGTERM" },
{ exitCode: null, signal: "SIGKILL" },
])("retains process exit evidence without changing grouping: %j", async (processExit) => {
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
const event = baseEvent({ ...processExit, errorMessage: "Adapter failed", errorCode: "adapter_failed" });
sentryModule.captureRunFailure(event);
const [error, context] = captureException.mock.calls[0]!;
expect((error as Error).message).toBe("Adapter failed");
expect(context?.contexts.run_failure).toMatchObject(processExit);
expect(context?.fingerprint).toEqual(["adapter_failed", event.agentAdapter]);
expect(context?.tags).not.toHaveProperty("exitCode");
expect(context?.tags).not.toHaveProperty("signal");
});
it.each([
["private-exit-payload", "SIGTERM private-signal-payload"],
["1", "constructor"],
[true, "__proto__"],
[1.5, "SIGCUSTOM"],
[NaN, ""],
[Infinity, 9],
[2147483648, { private: "signal-payload" }],
[-2147483649, ["SIGTERM"]],
])("rejects malformed process exit fields (%j, %j)", async (exitCode, signal) => {
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
sentryModule.captureRunFailure({ ...baseEvent(), exitCode, signal } as RunFailureEvent);
expect(captureException.mock.calls[0]![1]?.contexts.run_failure).toMatchObject({
exitCode: null,
signal: "unknown",
});
expect(JSON.stringify(captureException.mock.calls)).not.toContain("private-");
});
it("does not set an instance key on the run_failure context", async () => {
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
sentryModule.captureRunFailure(baseEvent());
const context = captureException.mock.calls[0]![1]?.contexts.run_failure;
expect(context).not.toHaveProperty("instance");
});
it("sets run_id, task_id, error_code, agent_adapter, and run_status as tags", async () => {
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
const event = baseEvent();
sentryModule.captureRunFailure(event);
expect(captureException.mock.calls[0]![1]?.tags).toEqual({
run_id: event.runId,
task_id: event.taskId,
error_code: event.errorCode,
agent_adapter: event.agentAdapter,
run_status: event.runStatus,
});
});
it("captures the redacted error message as the exception message", async () => {
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
const event = baseEvent({ errorMessage: "boom" });
sentryModule.captureRunFailure(event);
expect(captureException).toHaveBeenCalledTimes(1);
const [received] = captureException.mock.calls[0]!;
expect(received).toBeInstanceOf(Error);
expect((received as Error).message).toBe("boom");
});
it("uses sanitized original stacks and causes instead of the reporting stack", async () => {
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
sentryModule.captureRunFailure(baseEvent({ diagnostics: {
execution: { runtimeMode: "native", failurePhase: "setup" },
adapter: {}, provider: {}, truncatedFields: [],
exceptions: [
{ name: "TypeError", message: "setup failed", stack: "TypeError: setup failed\n at originalSetup (/app/setup.js:42:7)" },
{ name: "Error", message: "connection reset", stack: "Error: connection reset\n at socketRead (/app/network.js:9:4)", code: "ECONNRESET", requestId: "req-123" },
],
} }));
const [error, context] = captureException.mock.calls[0]!;
expect(error).toBeInstanceOf(Error);
expect(error).toMatchObject({ name: "TypeError", message: "setup failed", cause: { message: "connection reset" } });
expect((error as Error).stack).toContain("originalSetup");
expect((error as Error).stack).not.toContain("captureRunFailure");
expect(context?.contexts.run_exception_1).toMatchObject({ code: "ECONNRESET", requestId: "req-123" });
expect(context?.contexts.run_execution).toMatchObject({ failurePhase: "setup" });
});
it("does not invent a reporter stack for a saved result with no original exception", async () => {
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
sentryModule.captureRunFailure(baseEvent());
expect((captureException.mock.calls[0]![0] as Error).stack).toBeUndefined();
});
it("does not throw and captures nothing when the gate is closed", async () => {
vi.resetModules();
const sentryModule = await import("../sentry.js");
await sentryModule.sentryReady;
expect(() => sentryModule.captureRunFailure(baseEvent())).not.toThrow();
});
it("does not throw when the client throws", async () => {
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
captureException.mockImplementation(() => {
throw new Error("sentry client is down");
});
expect(() => sentryModule.captureRunFailure(baseEvent())).not.toThrow();
});
});