Files
PaperClipAI/server/src/__tests__/run-failure-sentry.test.ts
T
Nicky LeachandPaperclip c276d3fdc3 feat(observability): report terminal run failures to Sentry (#13446)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The server records the state of each agent run.
> - Terminal run failures need clear error tracking for operators.
> - The server did not report terminal `failed` or `timed_out`
transitions to Sentry.
> - This pull request reports each genuine terminal failure transition
with safe diagnostic data.
> - The benefit is faster diagnosis without changing run control flow or
exposing credentials.

## Linked Issues or Issue Description

**What happened?**

The server wrote terminal run failures but did not report them to
Sentry. Operators could not see these failures in error tracking.

**Expected behavior**

The server should report each genuine transition to `failed` or
`timed_out` to Sentry.

**Steps to reproduce**

1. Run an agent task that reaches a terminal failure state.
2. Inspect the Sentry events for the server.
3. Observe that the terminal run failure has no matching Sentry event.

**Paperclip version or commit**

The change targets the current `master` branch.

No public GitHub issue or pull request covers this change.

## What Changed

- Add `captureRunFailure()` as a fail-open Sentry entry point.
- Add `reportRunFailure()` to filter status, resolve the adapter, redact
text, and report the failure.
- Call `reportRunFailure()` beside each of the eight terminal status
writers.
- Report six diagnostic values: the instance host, task identifier, run
identifier, error message, error code, and agent adapter.
- Group events by error code and agent adapter while keeping the
redacted message in the event.
- Report only genuine transitions and avoid duplicate finalization
events.
- Keep Sentry failures outside run control flow.

## Verification

- `pnpm vitest run
server/src/services/__tests__/run-failure-report.test.ts
server/src/__tests__/run-failure-sentry.test.ts
server/src/__tests__/native-session-resumption.test.ts`
- `pnpm vitest run
server/src/services/execution-control-reconciliation.test.ts`
- `pnpm --filter @paperclipai/server typecheck`
- The full continuous-integration suite must run on this pull request.

## Risks

- The report path can add diagnostic events when Sentry is configured.
- The report path returns without action when Sentry is not configured.
- Redaction runs before length limits and before the event leaves the
process.
- The change has no migration and no schema change.

## Model Used

OpenAI Codex, GPT-5, tool use and code review support. The exact context
window and reasoning configuration are not exposed by the runtime.

## 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-14 23:32:15 -07:00

189 lines
6.9 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 `withScope`/`captureException` spies record 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,
};
}
/** Mirrors `sentry.test.ts`'s `mockSentryPackage`, plus a `withScope` spy. */
function mockSentryPackage() {
const scope = {
setTag: vi.fn(),
setContext: vi.fn(),
setFingerprint: vi.fn(),
};
const init = vi.fn();
const captureException = vi.fn(() => "event-id");
const withScope = vi.fn((callback: (s: typeof scope) => void) => callback(scope));
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,
withScope,
close,
httpIntegration,
onUnhandledRejectionIntegration,
}));
vi.doMock("../peer-version-check.js", () => ({
checkExactPeerVersions: () => ({ ok: true }),
}));
return { scope, init, captureException, withScope, 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, scope } = await importFreshSentryWithGateOpen();
sentryModule.captureRunFailure(baseEvent({ errorCode: "timeout", agentAdapter: "codex" }));
expect(scope.setFingerprint).toHaveBeenCalledWith(["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, scope } = await importFreshSentryWithGateOpen();
sentryModule.captureRunFailure(
baseEvent({ errorMessage: "message one", errorCode: "timeout", agentAdapter: "codex" }),
);
sentryModule.captureRunFailure(
baseEvent({ errorMessage: "message two", errorCode: "timeout", agentAdapter: "codex" }),
);
expect(scope.setFingerprint).toHaveBeenNthCalledWith(1, ["timeout", "codex"]);
expect(scope.setFingerprint).toHaveBeenNthCalledWith(2, ["timeout", "codex"]);
});
it("sets the fingerprint element \"unknown\" when the error code is absent", async () => {
const { sentryModule, scope } = await importFreshSentryWithGateOpen();
sentryModule.captureRunFailure(baseEvent({ errorCode: null }));
expect(scope.setFingerprint).toHaveBeenCalledWith(["unknown", "claude-code"]);
});
it("sets the five diagnostic values on the run_failure context", async () => {
const { sentryModule, scope } = await importFreshSentryWithGateOpen();
const event = baseEvent();
sentryModule.captureRunFailure(event);
expect(scope.setContext).toHaveBeenCalledWith("run_failure", {
taskId: event.taskId,
runId: event.runId,
errorMessage: event.errorMessage,
errorCode: event.errorCode,
agentAdapter: event.agentAdapter,
});
});
it("does not set an instance key on the run_failure context", async () => {
const { sentryModule, scope } = await importFreshSentryWithGateOpen();
sentryModule.captureRunFailure(baseEvent());
const [, context] = scope.setContext.mock.calls[0]!;
expect(context).not.toHaveProperty("instance");
});
it("sets run_id, task_id, error_code, agent_adapter, and run_status as tags", async () => {
const { sentryModule, scope } = await importFreshSentryWithGateOpen();
const event = baseEvent();
sentryModule.captureRunFailure(event);
expect(scope.setTag).toHaveBeenCalledWith("run_id", event.runId);
expect(scope.setTag).toHaveBeenCalledWith("task_id", event.taskId);
expect(scope.setTag).toHaveBeenCalledWith("error_code", event.errorCode);
expect(scope.setTag).toHaveBeenCalledWith("agent_adapter", event.agentAdapter);
expect(scope.setTag).toHaveBeenCalledWith("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("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, withScope } = await importFreshSentryWithGateOpen();
withScope.mockImplementation(() => {
throw new Error("sentry client is down");
});
expect(() => sentryModule.captureRunFailure(baseEvent())).not.toThrow();
});
});