Skip to content

Commit d34b699

Browse files
authored
fix(webapp): capture Prisma infra errors and obfuscate leaked messages (#3960)
## Summary Prisma infrastructure failures (P1xxx-class: database unreachable, timed out, connection dropped, engine init/panic) carry the database hostname in their `.message`. This captures them centrally for observability and ensures they never reach API clients verbatim. ## Design A `$allOperations` client extension on the writer and replica clients logs infrastructure errors with the originating model and operation, then rethrows the **original** error unchanged — call sites that branch on `error.code` (unique-violation idempotency, not-found handling) and transaction retries keep working. Only infrastructure errors are logged; routine query/validation errors (P2xxx) are left alone. `$allOperations` can't see the transaction boundary (`$transaction` is a client method, not an operation), so infrastructure errors surfacing from `$transaction()` without a Prisma code — e.g. `PrismaClientInitializationError` — are logged separately at the transaction wrapper, where the existing coded-error path would otherwise miss them. `clientSafeErrorMessage()` swaps an infrastructure error's message for `"Internal Server Error"` at the API routes that previously returned `error.message` raw. Status codes, headers, and every non-infrastructure message are unchanged. ## Test plan - [x] P2002 / P2025 rethrow with code intact and are not logged - [x] Statement errors inside `$transaction` keep their code (retry logic intact) - [x] Raw queries wrapped without crashing on the undefined model - [x] A genuine connectivity failure is logged with model/operation/code - [x] `clientSafeErrorMessage` obfuscates infra messages, preserves all others - [x] `pnpm run typecheck --filter webapp` (12/12) ## Note Overlaps with #3391 (Prisma 7 migration) on `apps/webapp/app/db.server.ts` — coordinate rebasing.
1 parent 6bdf800 commit d34b699

11 files changed

Lines changed: 376 additions & 33 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Log Prisma infrastructure errors (P1xxx) centrally and obfuscate their messages (which carry the DB hostname) on API responses that previously returned the raw message, without changing status codes or headers.

‎apps/webapp/app/db.server.ts‎

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import { z } from "zod";
1212
import { env } from "./env.server";
1313
import { logger } from "./services/logger.server";
1414
import { isValidDatabaseUrl } from "./utils/db";
15+
import {
16+
captureInfrastructureErrors,
17+
infraErrorAlreadyLogged,
18+
logTransactionInfrastructureError,
19+
} from "./utils/prismaErrors";
1520
import { singleton } from "./utils/singleton";
1621
import { DATASOURCE_CONTEXT_KEY, startActiveSpan } from "./v3/tracer.server";
1722
import { context, Span, trace } from "@opentelemetry/api";
@@ -24,6 +29,22 @@ export type {
2429
PrismaReplicaClient,
2530
};
2631

32+
// Boundary logger for transac(): skips an error the client extension already
33+
// logged (and tagged) at the statement level, so a single failure is logged
34+
// once. Shared by both $transaction overloads so the guard can't drift.
35+
function logTransactionPrismaError(error: Prisma.PrismaClientKnownRequestError) {
36+
if (infraErrorAlreadyLogged(error)) {
37+
return;
38+
}
39+
logger.error("prisma.$transaction error", {
40+
code: error.code,
41+
meta: error.meta,
42+
stack: error.stack,
43+
message: error.message,
44+
name: error.name,
45+
});
46+
}
47+
2748
export async function $transaction<R>(
2849
prisma: PrismaClientOrTransaction,
2950
name: string,
@@ -40,6 +61,22 @@ export async function $transaction<R>(
4061
fnOrName: ((prisma: PrismaTransactionClient) => Promise<R>) | string,
4162
fnOrOptions?: ((prisma: PrismaTransactionClient) => Promise<R>) | PrismaTransactionOptions,
4263
options?: PrismaTransactionOptions
64+
): Promise<R | undefined> {
65+
try {
66+
return await $transactionInner(prisma, fnOrName, fnOrOptions, options);
67+
} catch (error) {
68+
// transac()'s callback only logs coded Prisma errors; infra errors such as
69+
// PrismaClientInitializationError reach the boundary without a `.code`.
70+
logTransactionInfrastructureError(error);
71+
throw error;
72+
}
73+
}
74+
75+
async function $transactionInner<R>(
76+
prisma: PrismaClientOrTransaction,
77+
fnOrName: ((prisma: PrismaTransactionClient) => Promise<R>) | string,
78+
fnOrOptions?: ((prisma: PrismaTransactionClient) => Promise<R>) | PrismaTransactionOptions,
79+
options?: PrismaTransactionOptions
4380
): Promise<R | undefined> {
4481
if (typeof fnOrName === "string") {
4582
return await startActiveSpan(fnOrName, async (span) => {
@@ -63,34 +100,13 @@ export async function $transaction<R>(
63100

64101
const fn = fnOrOptions as (prisma: PrismaTransactionClient, span: Span) => Promise<R>;
65102

66-
return transac(
67-
prisma,
68-
(client) => fn(client, span),
69-
(error) => {
70-
logger.error("prisma.$transaction error", {
71-
code: error.code,
72-
meta: error.meta,
73-
stack: error.stack,
74-
message: error.message,
75-
name: error.name,
76-
});
77-
},
78-
options
79-
);
103+
return transac(prisma, (client) => fn(client, span), logTransactionPrismaError, options);
80104
});
81105
} else {
82106
return transac(
83107
prisma,
84108
fnOrName,
85-
(error) => {
86-
logger.error("prisma.$transaction error", {
87-
code: error.code,
88-
meta: error.meta,
89-
stack: error.stack,
90-
message: error.message,
91-
name: error.name,
92-
});
93-
},
109+
logTransactionPrismaError,
94110
typeof fnOrOptions === "function" ? undefined : fnOrOptions
95111
);
96112
}
@@ -116,11 +132,13 @@ function tagDatasource<T extends PrismaClient>(
116132
}) as unknown as T;
117133
}
118134

119-
export const prisma = singleton("prisma", () => tagDatasource("writer", getClient()));
135+
export const prisma = singleton("prisma", () =>
136+
captureInfrastructureErrors(tagDatasource("writer", getClient()))
137+
);
120138

121139
export const $replica: PrismaReplicaClient = singleton("replica", () => {
122140
const replica = getReplicaClient();
123-
return replica ? tagDatasource("replica", replica) : prisma;
141+
return replica ? captureInfrastructureErrors(tagDatasource("replica", replica)) : prisma;
124142
});
125143

126144
function getClient() {

‎apps/webapp/app/routes/api.v1.runs.$runParam.replay.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { logger } from "~/services/logger.server";
88
import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server";
99
import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server";
1010
import { sanitizeTriggerSource } from "~/utils/triggerSource";
11+
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
1112

1213
const ParamsSchema = z.object({
1314
/* This is the run friendly ID */
@@ -145,7 +146,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
145146
},
146147
run: runParam,
147148
});
148-
return json({ error: error.message }, { status: 400 });
149+
return json({ error: clientSafeErrorMessage(error) }, { status: 400 });
149150
} else {
150151
logger.error("Failed to replay run", { error: JSON.stringify(error), run: runParam });
151152
return json({ error: JSON.stringify(error) }, { status: 400 });

‎apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { json } from "@remix-run/server-runtime";
33
import { ScheduleObject, UpdateScheduleOptions } from "@trigger.dev/core/v3";
44
import { z } from "zod";
55
import { Prisma, prisma } from "~/db.server";
6+
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
67
import { scheduleUniqWhereClause } from "~/models/schedules.server";
78
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
89
import { authenticateApiRequest } from "~/services/apiAuth.server";
@@ -54,7 +55,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
5455
// Check if it's a Prisma error
5556
if (error instanceof Prisma.PrismaClientKnownRequestError) {
5657
return json(
57-
{ error: error.code === "P2025" ? "Schedule not found" : error.message },
58+
{ error: error.code === "P2025" ? "Schedule not found" : clientSafeErrorMessage(error) },
5859
{ status: error.code === "P2025" ? 404 : 422 }
5960
);
6061
} else {

‎apps/webapp/app/routes/api.v1.tasks.$taskId.batch.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { BatchTriggerTaskRequestBody, BatchTriggerTaskV2RequestBody } from "@tri
44
import { z } from "zod";
55
import { fromZodError } from "zod-validation-error";
66
import { MAX_BATCH_TRIGGER_ITEMS } from "~/consts";
7+
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
78
import { env } from "~/env.server";
89
import { authenticateApiRequest } from "~/services/apiAuth.server";
910
import { logger } from "~/services/logger.server";
@@ -125,7 +126,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
125126
);
126127
} catch (error) {
127128
if (error instanceof Error) {
128-
return json({ error: error.message }, { status: 400 });
129+
return json({ error: clientSafeErrorMessage(error) }, { status: 400 });
129130
}
130131

131132
return json({ error: "Something went wrong" }, { status: 500 });

‎apps/webapp/app/routes/api.v1.token.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import { generateErrorMessage } from "zod-error";
88
import { logger } from "~/services/logger.server";
99
import { getPersonalAccessTokenFromAuthorizationCode } from "~/services/personalAccessToken.server";
10+
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
1011

1112
export async function action({ request }: ActionFunctionArgs) {
1213
logger.info("Getting PersonalAccessToken from AuthorizationCode", { url: request.url });
@@ -45,7 +46,7 @@ export async function action({ request }: ActionFunctionArgs) {
4546
logger.error("Error getting PersonalAccessToken from AuthorizationCode", fields);
4647
}
4748

48-
return json({ error: error.message }, { status: 400 });
49+
return json({ error: clientSafeErrorMessage(error) }, { status: 400 });
4950
}
5051

5152
return json({ error: "Something went wrong" }, { status: 400 });

‎apps/webapp/app/routes/api.v2.tasks.batch.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ServiceValidationError } from "~/v3/services/baseService.server";
2121
import { BatchProcessingStrategy } from "~/v3/services/batchTriggerV3.server";
2222
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
2323
import { sanitizeTriggerSource } from "~/utils/triggerSource";
24+
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
2425
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
2526
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
2627
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
@@ -175,7 +176,7 @@ const { action, loader } = createActionApiRoute(
175176

176177
if (error instanceof Error) {
177178
return json(
178-
{ error: error.message },
179+
{ error: clientSafeErrorMessage(error) },
179180
{ status: 500, headers: { "x-should-retry": "false" } }
180181
);
181182
}

‎apps/webapp/app/routes/api.v3.batches.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { ServiceValidationError } from "~/v3/services/baseService.server";
1515
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
1616
import { sanitizeTriggerSource } from "~/utils/triggerSource";
17+
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
1718
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
1819
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
1920
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
@@ -190,7 +191,7 @@ const { action, loader } = createActionApiRoute(
190191

191192
if (error instanceof Error) {
192193
return json(
193-
{ error: error.message },
194+
{ error: clientSafeErrorMessage(error) },
194195
{ status: 500, headers: { "x-should-retry": "false" } }
195196
);
196197
}

‎apps/webapp/app/routes/engine.v1.worker-actions.runs.$runFriendlyId.snapshots.$snapshotFriendlyId.continue.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { WorkerApiContinueRunExecutionRequestBody } from "@trigger.dev/core/v3/w
33
import { z } from "zod";
44
import { logger } from "~/services/logger.server";
55
import { createLoaderWorkerApiRoute } from "~/services/routeBuilders/apiBuilder.server";
6+
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
67

78
export const loader = createLoaderWorkerApiRoute(
89
{
@@ -31,7 +32,7 @@ export const loader = createLoaderWorkerApiRoute(
3132
} catch (error) {
3233
logger.warn("Failed to suspend run", { runFriendlyId, snapshotFriendlyId, error });
3334
if (error instanceof Error) {
34-
throw json({ error: error.message }, { status: 422 });
35+
throw json({ error: clientSafeErrorMessage(error) }, { status: 422 });
3536
}
3637

3738
throw json({ error: "Failed to continue run execution" }, { status: 422 });

‎apps/webapp/app/utils/prismaErrors.ts‎

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { Prisma } from "@trigger.dev/database";
1+
import { Prisma, type PrismaClient, isPrismaKnownError } from "@trigger.dev/database";
2+
import { logger } from "~/services/logger.server";
3+
4+
// Minimal structural logger so this stays decoupled from the concrete Logger
5+
// (and lets tests pass a capturing logger).
6+
type ErrorLogger = { error: (message: string, fields?: Record<string, unknown>) => void };
27

38
// Prisma connectivity / infrastructure error codes — engine- and
49
// connection-level failures, not query- or validation-level ones. When the
@@ -37,3 +42,97 @@ export function isInfrastructureError(error: unknown): boolean {
3742

3843
return false;
3944
}
45+
46+
// One-shot marker so a single infra error is logged exactly once: the client
47+
// extension (statement level) tags it, and the $transaction-boundary loggers
48+
// skip a tagged error rather than logging the same failure a second time.
49+
const INFRA_ERROR_LOGGED: unique symbol = Symbol("prismaInfraErrorLogged");
50+
51+
function markInfraErrorLogged(error: unknown): void {
52+
if (typeof error !== "object" || error === null) {
53+
return;
54+
}
55+
try {
56+
// Non-enumerable so error-spreads/serializers can't copy the marker onto a
57+
// different error; try/catch so a frozen error object can't make this throw
58+
// and mask the original error as it propagates out of the catch.
59+
Object.defineProperty(error, INFRA_ERROR_LOGGED, {
60+
value: true,
61+
enumerable: false,
62+
configurable: true,
63+
writable: true,
64+
});
65+
} catch {
66+
// best-effort: a sealed/frozen error simply won't be deduped.
67+
}
68+
}
69+
70+
export function infraErrorAlreadyLogged(error: unknown): boolean {
71+
return (
72+
typeof error === "object" &&
73+
error !== null &&
74+
(error as Record<symbol, unknown>)[INFRA_ERROR_LOGGED] === true
75+
);
76+
}
77+
78+
// Logs infrastructure failures (P1xxx-class, see isInfrastructureError) and
79+
// rethrows the ORIGINAL error: callers branch on error.code, and this fires
80+
// per-statement inside transactions, so converting it would break that.
81+
export function captureInfrastructureErrors<T extends PrismaClient>(
82+
client: T,
83+
log: ErrorLogger = logger
84+
): T {
85+
return client.$extends({
86+
name: "infrastructure-error-capture",
87+
query: {
88+
$allOperations: async ({ model, operation, args, query }) => {
89+
try {
90+
return await query(args);
91+
} catch (error) {
92+
if (isInfrastructureError(error)) {
93+
log.error("prisma infrastructure error", {
94+
model,
95+
operation,
96+
code: error instanceof Prisma.PrismaClientKnownRequestError ? error.code : undefined,
97+
meta: error instanceof Prisma.PrismaClientKnownRequestError ? error.meta : undefined,
98+
message: error instanceof Error ? error.message : String(error),
99+
stack: error instanceof Error ? error.stack : undefined,
100+
});
101+
markInfraErrorLogged(error);
102+
}
103+
104+
throw error;
105+
}
106+
},
107+
},
108+
}) as unknown as T;
109+
}
110+
111+
// Logs infrastructure errors that reach the $transaction boundary WITHOUT a
112+
// Prisma error code (e.g. PrismaClientInitializationError). Coded errors there
113+
// are already logged by transac()'s callback, and errors that bubbled up from a
114+
// statement were already logged (and tagged) by the client extension — both are
115+
// skipped here to avoid double-logging. Returns whether it logged.
116+
export function logTransactionInfrastructureError(
117+
error: unknown,
118+
log: ErrorLogger = logger
119+
): boolean {
120+
if (!isInfrastructureError(error) || isPrismaKnownError(error) || infraErrorAlreadyLogged(error)) {
121+
return false;
122+
}
123+
124+
log.error("prisma.$transaction infrastructure error", {
125+
message: error instanceof Error ? error.message : String(error),
126+
name: error instanceof Error ? error.name : undefined,
127+
stack: error instanceof Error ? error.stack : undefined,
128+
});
129+
130+
return true;
131+
}
132+
133+
// Replaces a Prisma infrastructure error's message (which carries the DB
134+
// hostname) with a generic one before it reaches an API client. Any other
135+
// error's message is returned unchanged. Status codes/headers are unaffected.
136+
export function clientSafeErrorMessage(error: Error): string {
137+
return isInfrastructureError(error) ? "Internal Server Error" : error.message;
138+
}

0 commit comments

Comments
 (0)