mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The server sends each unexpected terminal run failure to Sentry. A custom fingerprint groups these events. > - The fingerprint is `[errorCode, agentAdapter, failurePhase?, rootErrorCode?]`. The root code is used only when it is an identifier. > - Many setup failures have no identifier root code. Some have a numeric JSON-RPC code (`-32002`). Some have only an HTTP status (`409`). Some have no code. > - All of these failures get the same fingerprint, `setup_failed / paperclip_runner / setup`. One Sentry group now holds about ten unrelated failure types: a controller lease loss, Daytona create timeouts, sandbox resume timeouts, remote-workspace 409 errors, S3 throttling and more. The group title changes with each new event. A fix for one cause cannot close the group. > - This pull request adds a bounded fallback when there is no usable root code. The fallback is the root exception class name plus its HTTP status or numeric code. > - The benefit is that each failure type gets its own Sentry group, so a fix can be verified and resolved for each cause. ## Linked Issues or Issue Description Refs #16359 Refs #16802 Refs #19156 **What happened?** Unrelated `paperclip_runner` setup failures go into one Sentry issue. The root exceptions include `LegacyControllerLeaseLostError`, `JsonRpcCallError` with code `-32002` (Daytona create timeout), `JsonRpcCallError` with code `-32003` (sandbox resume timeout), plain `Error` with status `409` (remote workspace writer still pending), and AWS `SlowDown`. None of them has an identifier root code, so the fingerprint drops the root element. **Expected behavior** Failures with different root exceptions go into different Sentry issues. Failures with the same root exception and status still group together. The fingerprint does not contain message text. **Steps to reproduce** 1. Report two setup failures for the `paperclip_runner` adapter with error code `setup_failed`. 2. Give the first failure the root exception `LegacyControllerLeaseLostError` with no code. 3. Give the second failure the root exception `JsonRpcCallError` with code `-32002`. 4. Read the two fingerprints. Before this change, they are the same. **Additional context** Only events without an identifier root code get a new fingerprint. Events with an identifier root code (for example `ECONNRESET`) keep their current fingerprint and their current Sentry group. ## What Changed - `server/src/sentry.ts`: when there is no identifier root code, `captureRunFailure` adds fallback fingerprint elements from the deepest recorded exception: - `exception:<Name>` when the name is an identifier. - `status:<n>` when the exception has an HTTP status (already validated as 100–599 by the diagnostics collector), or else `code:<n>` when the code is a number of up to 10 digits. - Names or codes with other shapes are not used, so message text and secrets cannot go into the fingerprint. - The `root_error_code` tag does not change. - `server/src/__tests__/run-failure-sentry.test.ts`: new tests for the fallback groups, for an identifier root code that keeps the current fingerprint, and for rejected unbounded names and codes. ## Verification - `cd server && npx vitest run src/__tests__/run-failure-sentry.test.ts src/__tests__/run-failure-sentry-real-sdk.test.ts src/__tests__/sentry-links.test.ts src/services/__tests__/run-failure-report.test.ts`: 358 tests pass. The real-SDK suite ran against `@sentry/node@10.71.0`. - I reverted only `server/src/sentry.ts` and ran the new grouping test. It failed, because all six fingerprints were the same. With the change, it passes. - `cd server && npx tsc --noEmit -p tsconfig.json`: no errors. - `src/__tests__/sentry.test.ts` did not load in my local environment (`AI_InvalidArgumentError: baseURL must be a non-empty string`). It fails in the same way on unchanged `master`, so I depend on CI for that suite. ## Risks - Events without an identifier root code get a new fingerprint. Sentry puts them in new issues. The old mixed issues stop getting these events. This is the intended result. Alerts or saved searches that point to an old mixed issue must move to the new issues. - The number of groups increases by a small, bounded amount. Exception class names, HTTP statuses and numeric codes have low cardinality. No message, ID or free text goes into the fingerprint. - No change to what the server sends in tags or contexts, and no change to run control flow. > 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 - Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, file edits, test runs, GitHub and Sentry connectors). ## 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>
394 lines
19 KiB
TypeScript
394 lines
19 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,
|
||
getClient: () => ({ on: vi.fn() }),
|
||
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(() => {
|
||
vi.stubEnv("PAPERCLIP_SENTRY_RUN_FAILURES", "unexpected");
|
||
delete process.env[DSN_ENV];
|
||
delete process.env[FRONTEND_DSN_ENV];
|
||
delete process.env[BACKEND_DSN_ENV];
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllEnvs();
|
||
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("reports a saved-message failure with the run-failure tag and the actual run status", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
const event = { companyId: "company", agentId: "agent", taskId: "task", runId: "run",
|
||
agentAdapter: "process", runStatus: "succeeded" as const, intentId: "intent" };
|
||
sentryModule.captureTaskInterruptionFailure(event);
|
||
sentryModule.captureTaskInterruptionFailure(event);
|
||
expect(captureException).toHaveBeenCalledTimes(1);
|
||
expect(captureException.mock.calls[0]![0]).toMatchObject({
|
||
message: "Couldn’t finish this attempt. Your message is saved.",
|
||
});
|
||
expect(captureException.mock.calls[0]![1]).toMatchObject({
|
||
tags: { event_kind: "run_failure", failure_source: "task_interruption", run_id: "run", task_id: "task",
|
||
company_id: "company", agent_id: "agent", run_status: "succeeded", error_code: "task_attempt_failed" },
|
||
contexts: { paperclip: { runPath: "/api/heartbeat-runs/run", taskPath: "/api/issues/task" } },
|
||
fingerprint: ["task_attempt_failed", "process"],
|
||
});
|
||
sentryModule.captureTaskInterruptionFailure({ ...event, intentId: "next-intent" });
|
||
sentryModule.captureTaskInterruptionFailure({ ...event, runId: "next-run" });
|
||
expect(captureException).toHaveBeenCalledTimes(3);
|
||
});
|
||
|
||
it("does not consume a saved-message report while reporting is off or capture fails", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
const event = { companyId: "company", agentId: "agent", taskId: "task", runId: "run",
|
||
agentAdapter: "process", runStatus: "failed" as const, intentId: null };
|
||
vi.stubEnv("PAPERCLIP_SENTRY_RUN_FAILURES", "off");
|
||
sentryModule.captureTaskInterruptionFailure(event);
|
||
expect(captureException).not.toHaveBeenCalled();
|
||
vi.stubEnv("PAPERCLIP_SENTRY_RUN_FAILURES", "unexpected");
|
||
captureException.mockImplementationOnce(() => { throw new Error("Sentry unavailable"); });
|
||
expect(() => sentryModule.captureTaskInterruptionFailure(event)).not.toThrow();
|
||
sentryModule.captureTaskInterruptionFailure(event);
|
||
sentryModule.captureTaskInterruptionFailure(event);
|
||
expect(captureException).toHaveBeenCalledTimes(2);
|
||
});
|
||
|
||
it.each([undefined, "off", "true", "invalid"])("keeps ordinary server errors enabled while run reporting is disabled: %s", async mode => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
vi.stubEnv("PAPERCLIP_SENTRY_RUN_FAILURES", mode);
|
||
if (mode === undefined) delete process.env.PAPERCLIP_SENTRY_RUN_FAILURES;
|
||
sentryModule.captureRunFailure(baseEvent());
|
||
const error = new Error("ordinary server failure");
|
||
sentryModule.captureException(error);
|
||
expect(captureException).toHaveBeenCalledExactlyOnceWith(error);
|
||
});
|
||
|
||
it("provides authenticated run references and groups repeated causes without leaking scope", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip.example.com");
|
||
const event = baseEvent({ companyId: "company", agentId: "agent", taskId: null,
|
||
diagnostics: { execution: { failurePhase: "setup", originalFailureCode: "tool_name_collision" }, adapter: {}, provider: {}, exceptions: [], truncatedFields: [] } });
|
||
sentryModule.captureRunFailure(event);
|
||
sentryModule.captureRunFailure({ ...event, runId: "another-run", errorMessage: "Another tool failed" });
|
||
sentryModule.captureException(new Error("unrelated"));
|
||
const first = captureException.mock.calls[0]![1]!;
|
||
expect(first.tags).toMatchObject({ event_kind: "run_failure", company_id: "company", agent_id: "agent", root_error_code: "tool_name_collision" });
|
||
expect(first.tags).not.toHaveProperty("task_id");
|
||
expect(first.contexts.paperclip).toEqual({ schemaVersion: 1, companyId: "company", agentId: "agent",
|
||
runPath: `/api/heartbeat-runs/${event.runId}`, eventsPath: `/api/heartbeat-runs/${event.runId}/events`, logPath: `/api/heartbeat-runs/${event.runId}/log`,
|
||
runUrl: `https://paperclip.example.com/api/heartbeat-runs/${event.runId}`, eventsUrl: `https://paperclip.example.com/api/heartbeat-runs/${event.runId}/events`,
|
||
logUrl: `https://paperclip.example.com/api/heartbeat-runs/${event.runId}/log` });
|
||
expect(first.fingerprint).toEqual(["process_lost", "claude-code", "setup", "tool_name_collision"]);
|
||
expect(captureException.mock.calls[1]![1]?.fingerprint).toEqual(first.fingerprint);
|
||
expect(captureException.mock.calls[2]![1]).toBeUndefined();
|
||
});
|
||
|
||
it.each([undefined, "invalid", "ftp://example.com", "https://user:password@example.com", "https://example.com?token=private", "https://example.com/#private", "https://example.com/private"])("omits unsafe or absent instance URLs: %s", async value => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
vi.stubEnv("PAPERCLIP_PUBLIC_URL", value);
|
||
sentryModule.captureRunFailure(baseEvent({ companyId: "company", agentId: "agent" }));
|
||
const context = captureException.mock.calls[0]![1]!.contexts.paperclip;
|
||
expect(context).toHaveProperty("runPath");
|
||
expect(context).not.toHaveProperty("runUrl");
|
||
expect(JSON.stringify(context)).not.toMatch(/password|private/);
|
||
});
|
||
|
||
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("separates setup failures without a usable root code by the root exception", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
const setupFailure = (exceptions: NonNullable<RunFailureEvent["diagnostics"]>["exceptions"]) => baseEvent({
|
||
errorCode: "setup_failed", agentAdapter: "paperclip_runner",
|
||
diagnostics: { execution: { failurePhase: "setup" }, adapter: {}, provider: {}, exceptions, truncatedFields: [] },
|
||
});
|
||
|
||
sentryModule.captureRunFailure(setupFailure([{ name: "LegacyControllerLeaseLostError", message: "Legacy controller lease lost" }]));
|
||
sentryModule.captureRunFailure(setupFailure([{ name: "Error", message: "The workspace still has an unfinished writer", status: 409 }]));
|
||
sentryModule.captureRunFailure(setupFailure([{ name: "Error", message: "Workspace ownership changed", status: 409 }]));
|
||
sentryModule.captureRunFailure(setupFailure([
|
||
{ name: "EnvironmentRunError", message: "Failed to acquire lease", code: "lease_acquire_failed" },
|
||
{ name: "JsonRpcCallError", message: "Daytona lease acquisition timed out", code: "-32002" },
|
||
]));
|
||
sentryModule.captureRunFailure(setupFailure([{ name: "JsonRpcCallError", message: "RPC call timed out", code: "-32003" }]));
|
||
sentryModule.captureRunFailure(setupFailure([{ name: "Error", message: "plain failure" }]));
|
||
|
||
const fingerprints = captureException.mock.calls.map(([, context]) => context?.fingerprint);
|
||
const prefix = ["setup_failed", "paperclip_runner", "setup"];
|
||
expect(fingerprints).toEqual([
|
||
[...prefix, "exception:LegacyControllerLeaseLostError"],
|
||
[...prefix, "exception:Error", "status:409"],
|
||
[...prefix, "exception:Error", "status:409"],
|
||
[...prefix, "exception:JsonRpcCallError", "code:-32002"],
|
||
[...prefix, "exception:JsonRpcCallError", "code:-32003"],
|
||
[...prefix, "exception:Error"],
|
||
]);
|
||
expect(JSON.stringify(fingerprints)).not.toMatch(/workspace|timed out|plain failure/i);
|
||
});
|
||
|
||
it("keeps an identifier root code as the only root grouping element", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
|
||
sentryModule.captureRunFailure(baseEvent({
|
||
errorCode: "setup_failed", agentAdapter: "paperclip_runner",
|
||
diagnostics: { execution: { failurePhase: "setup" }, adapter: {}, provider: {},
|
||
exceptions: [{ name: "Error", code: "ECONNRESET", status: 503 }], truncatedFields: [] },
|
||
}));
|
||
|
||
expect(captureException.mock.calls[0]![1]?.fingerprint).toEqual(["setup_failed", "paperclip_runner", "setup", "ECONNRESET"]);
|
||
});
|
||
|
||
it("drops unbounded root exception names and codes from the fallback", async () => {
|
||
const { sentryModule, captureException } = await importFreshSentryWithGateOpen();
|
||
|
||
sentryModule.captureRunFailure(baseEvent({
|
||
errorCode: "setup_failed", agentAdapter: "paperclip_runner",
|
||
diagnostics: { execution: { failurePhase: "setup" }, adapter: {}, provider: {},
|
||
exceptions: [{ name: "Error: token=secret value", code: "not a code 123" }], truncatedFields: [] },
|
||
}));
|
||
|
||
expect(captureException.mock.calls[0]![1]?.fingerprint).toEqual(["setup_failed", "paperclip_runner", "setup"]);
|
||
});
|
||
|
||
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({
|
||
event_kind: "run_failure",
|
||
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();
|
||
});
|
||
});
|