mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent runs retain execution results and output in the instance. > - Operators can report run failures to Sentry for investigation. > - A backend Sentry DSN currently enables those reports with no separate choice. > - A repair bot also needs the exact instance, company, agent, and saved run. > - This pull request adds an explicit reporting mode and authenticated run references. > - The benefit is that operators can investigate every failure on selected deployments while keeping other deployments quiet. ## Linked Issues or Issue Description **What existing behavior does this improve?** Optional server reporting of terminal agent run failures to Sentry. **Current behavior** A backend DSN enables run failure reports. Known account, quota, and connection failures are excluded. Reports omit company and agent references. Runs without a task are skipped. Tool-name collision errors omit the conflicting bindings. **Proposed behavior** Set `PAPERCLIP_SENTRY_RUN_FAILURES=unexpected` to retain the existing filters. Set it to `all` to report every failed or timed-out run, including known setup and quota failures. The default is `off`. Expected stops stay excluded. Each report supplies identifiers and API paths that a bot can use with its own authorized Paperclip connection. **Reason and benefit** Operators can enable broad failure reporting on one staging deployment. A bot can fetch the saved run, events, and logs instead of relying on a short error label or copying complete transcripts into Sentry. **Breaking changes** A backend DSN alone no longer enables run failure reports. Existing operators must set `PAPERCLIP_SENTRY_RUN_FAILURES=unexpected` to retain the previous policy. Ordinary server exception capture remains enabled by the DSN. Failure grouping gains the phase and original cause code when available. Refs #16281, #16297, #16298. These cover related tool discovery and failure diagnostics. This change retains their existing boundaries and does not depend on their open changes. ## What Changed - Add `off`, `unexpected`, and `all` modes. Missing and invalid values keep reporting off before diagnostic reads. - Keep the Sentry backend DSN and audited optional SDK requirements. - Include company, agent, run, and optional task identifiers. Include versioned paths for the saved run, events, log, and task. Add absolute run URLs only for a valid public HTTP(S) origin. - Report company runs without a task. Prefer the first-class issue binding when selecting the task reference. - Select saved native provider/model settings, timestamps, and original failure codes. Preserve gateway reason codes through redacted exception projection. - Group by error code, adapter, and available phase/cause. Keep run IDs and error text out of grouping. - Include the tool names and connection bindings in collision errors without copying tool schemas or provider metadata. - Document configuration, upgrade behavior, bot access, redaction, retention, and best-effort delivery. ## Verification - All 56 checks passed for `cccb47a721eaa1cc36e63278fc177b9101477541`. Two unrelated Storybook jobs were skipped by their path filters. No checks remain pending. - Complete [Paperclip CI run](https://github.com/paperclipai/paperclip/actions/runs/38591422284): passed. This covers general and serialized tests, Runner checks, browser E2E shards, typecheck, build, release-registry checks, canary dry run, and Docker checks. - Greptile reviewed that exact head: 5/5, with no unresolved threads or actionable findings. Superagent, Socket, and Snyk checks passed. - Local focused reporting, diagnostics, collision, and real SDK contract tests: 410 passed across five suites. This includes the external-wake filter in both reporting modes. - The audited `@sentry/node@10.71.0` tests use an in-memory transport. No events were sent to Sentry. - `pnpm -r typecheck` and `pnpm build`: passed locally after the final rebase. - `node scripts/check-module-boundaries.mjs` and `git diff --check`: passed. - The full local `pnpm test:run` command was stopped to avoid duplicating the complete CI shards. It did not finish locally. The full suite passed in CI. ## Risks - The new default disables existing run failure exports until the operator selects a mode. The upgrade setting is documented. - `all` can increase event volume and include known user-action failures. Secret redaction and diagnostic limits still apply. - Reporting remains asynchronous and best effort. This PR adds no durable delivery queue and does not install a bot or Sentry alert rule. - API references grant no authority. Existing company, private-task, and run-output checks still apply. Complete transcripts stay in Paperclip. - This changes optional Sentry error reporting. It changes neither first-party Telemetry nor OpenTelemetry span exports. It adds no database migration. ## Model Used - OpenAI GPT-6 through Codex, with reasoning, shell, file editing, and test execution. The exact backend deployment ID and context window 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>
303 lines
13 KiB
TypeScript
303 lines
13 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(() => {
|
|
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.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("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();
|
|
});
|
|
});
|