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)