diff --git a/server/src/__tests__/run-failure-sentry.test.ts b/server/src/__tests__/run-failure-sentry.test.ts index ccea8a5550a..b8bcb5de4dc 100644 --- a/server/src/__tests__/run-failure-sentry.test.ts +++ b/server/src/__tests__/run-failure-sentry.test.ts @@ -190,6 +190,60 @@ describe("captureRunFailure", () => { 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["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(); diff --git a/server/src/sentry.ts b/server/src/sentry.ts index 016f13e01be..0fe3360c47b 100644 --- a/server/src/sentry.ts +++ b/server/src/sentry.ts @@ -229,6 +229,21 @@ function captureRunFailureEvent(event: RunFailureEvent): string | null { 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> = {}; @@ -303,7 +318,7 @@ function captureRunFailureEvent(event: RunFailureEvent): string | null { signal: normalizeRunSignal(event.signal), }, }, - fingerprint: [errorCode, event.agentAdapter, ...(failurePhase ? [failurePhase] : []), ...(rootErrorCode ? [rootErrorCode] : [])], + fingerprint: [errorCode, event.agentAdapter, ...(failurePhase ? [failurePhase] : []), ...(rootErrorCode ? [rootErrorCode] : rootFallback)], }); } catch (err) { // eslint-disable-next-line no-console