Files
PaperClipAI/server/src/middleware/error-handler.ts
T
DottaandPaperclip 51c81d0129 feat(sentry): link run errors to task and run pages (#16803)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators use Sentry to inspect run and server failures.
> - Reports identify the run and task, but the existing links open API
records.
> - Operators need a direct path from an error to the normal task and
run pages.
> - This change adds navigation links through the shared server Sentry
hook.
> - Saved company and task records determine the links. A failed lookup
preserves the error report.

## Linked Issues or Issue Description

Related work: #16300 added saved run API references.

**What existing behavior does this improve?**

Sentry event context for run failures, Stop timeouts, and server errors
from an authenticated agent run.

**Current behavior**

Reports show task and run UUIDs. The existing run URL opens an API
record. The operator must find the normal task page separately.

**Proposed behavior**

Add `task_url`, `run_url`, and `task_identifier` tags when saved run
metadata is available. Add `taskUrl` and `runPageUrl` to the event
context. Use the company prefix and saved task identifier in normal
board URLs.

**Reason and benefit**

An operator can open the task or run directly from the error report.

## What Changed

- Add a shared Sentry hook for events with a known run identity.
- Read only the saved company prefix, task identifier, and run identity.
Keep the lookup within the recorded company and agent.
- Include authenticated run identity on request errors. Use the existing
run identity on Stop errors.
- Bound optional lookups to one second and eight unfinished reads.
Preserve the original event on failure.
- Keep the existing API references and error grouping.
- Document the fields and add SDK, timeout, and company-boundary
coverage.

## Verification

- Passed: `pnpm -r typecheck` and the final server TypeScript check.
- Passed: `pnpm build`.
- Passed: 332 tests across the affected server suites, plus 25 final
link and real Sentry SDK tests after review fixes. The SDK test checks
outgoing event envelopes for run failures, request errors, and Stop
errors. It checks that unrelated errors receive no links. The database
test verifies saved metadata and company boundaries. It passes with
embedded PostgreSQL and with an explicitly configured disposable test
database.
- Passed in CI on `bec0cdac853efdef71041d59a66261941827653e`: the full
test matrix, typecheck, build, Runner checks, browser suites, and the
real Sentry SDK contract. All 56 active checks pass. Two Storybook jobs
are skipped.
- The full local `pnpm test:run` attempt first failed because PostgreSQL
library links were missing. The package setup script repaired the
install. The affected database suites then passed. The repaired full
local run was stopped after all CI test jobs passed; it did not complete
locally.
- Greptile: 5/5 on the final commit. All three review threads are
resolved.
- Read-only browser inspection confirms that Sentry displays context URL
values with an Open link control.
- No live test event was posted. Validation uses the real SDK with an
in-memory transport.

## Risks

- A slow or unavailable database can prevent optional links. It does not
discard the error. At most eight unfinished lookups can remain active.
- Missing or unsafe public origins omit the links. URLs longer than the
tag limit remain in context.
- This change applies to newly captured events. It does not update
existing Sentry events.
- The change adds no schema, permission, or first-party Telemetry
contract change.

## Model Used

OpenAI Codex, `gpt-6.1-sol`, xhigh reasoning, with tool use and code
execution. The exact context window is not exposed in the session
metadata.

## 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-10-19 06:47:47 -05:00

329 lines
11 KiB
TypeScript

import { AgentLifecycleConflict } from "../modules/agent-lifecycle/index.js";
import type { Request, Response, NextFunction } from "express";
import type { Db } from "@paperclipai/db";
import { ZodError } from "zod";
import { HttpError } from "../errors.js";
import { trackErrorHandlerCrash } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js";
import { captureException } from "../sentry.js";
import { COMPANY_IMPORT_API_PATH } from "../routes/company-import-paths.js";
import { logger } from "./logger.js";
import { isSecretSensitiveHttpRequest } from "./http-log-policy.js";
import {
collectSensitiveStringValues,
redactSensitiveValueOccurrences,
} from "./redact-sensitive.js";
import { recordResponsibleUserDenialOnActiveRun } from "../services/responsible-user-denial-run-outcomes.js";
import { isExpectedMcpConnectionFailure } from "../services/mcp-connection-failure.js";
import { isEndedDbPoolError } from "../database-retry.js";
export interface ErrorContext {
error: {
message: string;
stack?: string;
name?: string;
details?: unknown;
raw?: unknown;
};
method: string;
url: string;
reqBody?: unknown;
reqParams?: unknown;
reqQuery?: unknown;
}
function isRedactedSkillPolicyDenial(details: Record<string, unknown> | null) {
return details?.code === "skill_policy_denied";
}
function readZodIssues(err: unknown): unknown[] | null {
if (err instanceof ZodError) return err.issues;
if (
!err ||
typeof err !== "object" ||
(err as { name?: unknown }).name !== "ZodError"
)
return null;
const issues = (err as { issues?: unknown }).issues;
return Array.isArray(issues) ? issues : null;
}
function attachErrorContext(
req: Request,
res: Response,
payload: ErrorContext["error"],
rawError?: Error,
) {
(res as any).__errorContext = {
error: payload,
method: req.method,
url: req.originalUrl,
reqBody: req.body,
reqParams: req.params,
reqQuery: req.query,
} satisfies ErrorContext;
if (rawError) {
(res as any).err = rawError;
}
}
function sanitizeSecretSensitiveError(req: Request, error: Error): Error {
if (!isSecretSensitiveHttpRequest(req.method, req.originalUrl)) return error;
const sanitized = new Error("Secret-sensitive request failed");
// Both `name` and `message` are attacker/provider-controlled properties on
// JavaScript errors. Do not preserve either on a credential-bearing route.
sanitized.name = "Error";
return sanitized;
}
function sanitizeSecretSensitiveResponse(
req: Request,
value: unknown,
): unknown {
if (!isSecretSensitiveHttpRequest(req.method, req.originalUrl)) return value;
return redactSensitiveValueOccurrences(
value,
collectSensitiveStringValues(req.body),
);
}
/** Report a server-side crash to every error sink. */
function reportCrash(error: Error, req: Request, reportToSentry = true): void {
const tc = getTelemetryClient();
if (tc) trackErrorHandlerCrash(tc, { errorCode: error.name });
if (reportToSentry) {
if (req.actor?.type === "agent" && req.actor.runId && req.actor.companyId && req.actor.agentId) {
captureException(error, { runId: req.actor.runId, companyId: req.actor.companyId, agentId: req.actor.agentId });
} else captureException(error);
}
}
/** Set by the orderly shutdown just before it ends the database pools. */
function isDatabaseClosing(req: Request): boolean {
const locals = req.app?.locals as { paperclipDatabaseClosing?: boolean } | undefined;
return locals?.paperclipDatabaseClosing === true;
}
function getPaperclipDb(req: Request): Db | null {
const locals = req.app?.locals as { paperclipDb?: Db; db?: Db } | undefined;
return locals?.paperclipDb ?? locals?.db ?? null;
}
function recordResponsibleUserDenialFromHttpError(
req: Request,
details: Record<string, unknown> | null,
) {
if (req.actor?.type !== "agent") return;
const db = getPaperclipDb(req);
if (!db) return;
void recordResponsibleUserDenialOnActiveRun(db, {
runId: req.actor.runId ?? null,
agentId: req.actor.agentId ?? null,
companyId: req.actor.companyId ?? null,
code: details?.code,
}).catch((recordErr) => {
logger.warn(
{
err: recordErr,
runId: req.actor?.runId ?? null,
agentId:
req.actor?.type === "agent" ? (req.actor.agentId ?? null) : null,
},
"failed to record responsible-user denial on heartbeat run",
);
});
}
export function errorHandler(
err: unknown,
req: Request,
res: Response,
_next: NextFunction,
) {
if (err instanceof AgentLifecycleConflict) { res.status(409).json({ error: err.message }); return; }
if (err instanceof HttpError) {
const details =
err.details &&
typeof err.details === "object" &&
!Array.isArray(err.details)
? (err.details as Record<string, unknown>)
: null;
const redactedSkillPolicyDenial = isRedactedSkillPolicyDenial(details);
const workspaceRepairPreconditionFailure =
details?.code === "workspace_repair_precondition_failed";
const structuredConnectionError = new Set([
"user_authorization_required",
"organization_authorization_required",
"grant_audience_denied",
"grant_revoked",
"needs_reauthorization",
"installation_required",
"connection_not_installed",
"subject_not_permitted",
"standing_delegation_required",
"grant_owner_membership_inactive",
]).has(typeof details?.code === "string" ? details.code : "");
const responseDetailsValue = sanitizeSecretSensitiveResponse(
req,
err.details,
);
const responseDetails =
responseDetailsValue &&
typeof responseDetailsValue === "object" &&
!Array.isArray(responseDetailsValue)
? (responseDetailsValue as Record<string, unknown>)
: null;
recordResponsibleUserDenialFromHttpError(req, details);
if (err.status >= 500) {
const reportableError = sanitizeSecretSensitiveError(req, err);
attachErrorContext(
req,
res,
isSecretSensitiveHttpRequest(req.method, req.originalUrl)
? { message: reportableError.message, name: reportableError.name }
: {
message: err.message,
stack: err.stack,
name: err.name,
details: err.details,
},
reportableError,
);
reportCrash(reportableError, req, !isExpectedMcpConnectionFailure(err));
}
const secretSensitiveServerError =
err.status >= 500 &&
isSecretSensitiveHttpRequest(req.method, req.originalUrl);
res.status(err.status).json(
secretSensitiveServerError
? { error: "Internal server error" }
: {
error: sanitizeSecretSensitiveResponse(req, err.message),
...(typeof responseDetails?.code === "string"
? { code: responseDetails.code }
: {}),
...(redactedSkillPolicyDenial &&
typeof responseDetails?.reason === "string"
? { reason: responseDetails.reason }
: {}),
...(workspaceRepairPreconditionFailure &&
typeof responseDetails?.reason === "string"
? { reason: responseDetails.reason }
: {}),
...(workspaceRepairPreconditionFailure &&
typeof responseDetails?.repairPhase === "string"
? { repairPhase: responseDetails.repairPhase }
: {}),
...(typeof responseDetails?.remediation === "string" ||
(structuredConnectionError &&
responseDetails?.remediation &&
typeof responseDetails.remediation === "object")
? { remediation: responseDetails.remediation }
: {}),
...(structuredConnectionError && responseDetails?.connection
? { connection: responseDetails.connection }
: {}),
...(structuredConnectionError && responseDetails?.subject
? { subject: responseDetails.subject }
: {}),
...(structuredConnectionError &&
typeof responseDetails?.grantId === "string"
? { grantId: responseDetails.grantId }
: {}),
...(!redactedSkillPolicyDenial &&
!workspaceRepairPreconditionFailure &&
responseDetailsValue
? { details: responseDetailsValue }
: {}),
},
);
return;
}
const zodIssues = readZodIssues(err);
if (zodIssues) {
res.status(400).json({
error: "Validation error",
details: sanitizeSecretSensitiveResponse(req, zodIssues),
});
return;
}
// Only body-parser's malformed-JSON errors are client input failures.
// Parser messages can quote request bytes; return a constant response and
// keep the raw error out of crash reporting and HTTP error context.
if (
err instanceof SyntaxError &&
"status" in err && err.status === 400 &&
"type" in err && err.type === "entity.parse.failed"
) {
res.status(400).json({ error: "Invalid JSON body" });
return;
}
const rootError = err instanceof Error ? err : new Error(String(err));
// The client tore down the connection mid-request (closed tab, dropped
// mobile network, cancelled upload): Node surfaces it as `Error: aborted`
// with ECONNRESET. There is no server fault to report and nobody left to
// answer, so skip the error sinks and just close out the response.
if (
rootError.message === "aborted" &&
(rootError as NodeJS.ErrnoException).code === "ECONNRESET"
) {
if (!res.headersSent) res.status(499);
res.end();
return;
}
// Shutdown drains the HTTP listener before it ends the pool, but a handler
// whose client already disconnected (or whose socket the drain timeout
// closed) keeps running. Its next query then fails with CONNECTION_ENDED
// against the pool this process deliberately ended. That is shutdown, not a
// server fault, so skip the error sinks. Any other error during shutdown is
// still reported.
if (isDatabaseClosing(req) && isEndedDbPoolError(rootError)) {
if (!res.headersSent) {
res.status(503).json({ error: "Server is shutting down" });
} else {
res.end();
}
return;
}
const reportableError = sanitizeSecretSensitiveError(req, rootError);
attachErrorContext(
req,
res,
isSecretSensitiveHttpRequest(req.method, req.originalUrl)
? { message: reportableError.message, name: reportableError.name }
: err instanceof Error
? { message: err.message, stack: err.stack, name: err.name }
: {
message: String(err),
raw: err,
stack: rootError.stack,
name: rootError.name,
},
reportableError,
);
reportCrash(reportableError, req);
res.status(500).json({
error: "Internal server error",
...(shouldExposeTrustedCloudTenantImportError(req)
? { message: rootError.message }
: {}),
});
}
function shouldExposeTrustedCloudTenantImportError(req: Request) {
return (
req.actor?.source === "cloud_tenant" &&
req.method === "POST" &&
req.originalUrl.split("?")[0] === COMPANY_IMPORT_API_PATH
);
}