Skip to content

Commit d41824f

Browse files
fix(sentry): group Drizzle errors by underlying cause, not SQL text (#2957)
* fix(sentry): group Drizzle errors by underlying cause, not SQL text Drizzle wraps query errors with a 'Failed query: <unique SQL>' message. beforeSend set a useful fingerprint, but the event's primary exception (used for the issue title) still carried the unique SQL string, so grouping regressed and the root cause (e.g. 'statement timeout') wasn't visible in the title. Rewrite the root exception value (last entry in event.exception.values, per Sentry's oldest-to-newest ordering) so its type/message reflect error.cause, and move the failed query + params into the drizzle_query context so they stay visible on the issue without polluting the title or fingerprint. Non-Drizzle errors and Drizzle errors without a cause are unaffected. * fix(sentry): collapse Drizzle wrapped exceptions * fix(sentry): omit Drizzle query params --------- Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> Co-authored-by: Remon Oldenbeuving <remon@kilocode.ai>
1 parent 9276fe5 commit d41824f

1 file changed

Lines changed: 96 additions & 39 deletions

File tree

apps/web/sentry.server.config.ts

Lines changed: 96 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import { consoleLoggingIntegration, httpIntegration, init } from '@sentry/nextjs
99
type DrizzleQueryError = Error & {
1010
query: string;
1111
params: unknown[];
12-
cause?: { code?: string; message?: string };
12+
cause?: { code?: string; message?: string; name?: string; constructor?: { name?: string } };
1313
};
1414

15+
const GENERIC_ERROR_TYPE_NAMES = new Set(['Error', 'error']);
16+
1517
function isDrizzleQueryError(error: unknown): error is DrizzleQueryError {
1618
return (
1719
error instanceof Error &&
@@ -21,6 +23,36 @@ function isDrizzleQueryError(error: unknown): error is DrizzleQueryError {
2123
);
2224
}
2325

26+
function causeTypeName(cause: NonNullable<DrizzleQueryError['cause']>): string {
27+
if (typeof cause.code === 'string' && /^[A-Z0-9]{5}$/.test(cause.code)) {
28+
return 'PostgresError';
29+
}
30+
31+
if (
32+
typeof cause.name === 'string' &&
33+
cause.name.length > 0 &&
34+
!GENERIC_ERROR_TYPE_NAMES.has(cause.name)
35+
) {
36+
return cause.name;
37+
}
38+
39+
const ctorName = cause.constructor?.name;
40+
if (
41+
typeof ctorName === 'string' &&
42+
ctorName.length > 0 &&
43+
ctorName !== 'Object' &&
44+
!GENERIC_ERROR_TYPE_NAMES.has(ctorName)
45+
) {
46+
return ctorName;
47+
}
48+
49+
return 'DatabaseError';
50+
}
51+
52+
function isDrizzleWrapperException(value: { value?: string }): boolean {
53+
return typeof value.value === 'string' && value.value.startsWith('Failed query:');
54+
}
55+
2456
const TRPC_4XX_CODES = new Set([
2557
'BAD_REQUEST',
2658
'UNAUTHORIZED',
@@ -46,48 +78,73 @@ function isTRPC4xxError(error: unknown): boolean {
4678
);
4779
}
4880

49-
if (process.env.NODE_ENV !== 'development') {
50-
init({
51-
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
81+
init({
82+
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
5283

53-
// Tracing is fully disabled.
54-
tracesSampleRate: 0,
84+
// Tracing is fully disabled.
85+
tracesSampleRate: 0,
5586

56-
// Setting this option to true will print useful information to the console while you're setting up Sentry.
57-
debug: false,
58-
normalizeDepth: 5,
87+
// Setting this option to true will print useful information to the console while you're setting up Sentry.
88+
debug: false,
89+
normalizeDepth: 5,
5990

60-
// Skip Sentry's OTEL setup because we are using Vercel's OTEL with SentrySpanProcessor
61-
skipOpenTelemetrySetup: true,
91+
// Skip Sentry's OTEL setup because we are using Vercel's OTEL with SentrySpanProcessor
92+
skipOpenTelemetrySetup: true,
6293

63-
integrations: [
64-
// Keep Sentry's httpIntegration for correct request isolation, but do not
65-
// emit spans here because tracing spans are produced by Vercel's OTel.
66-
httpIntegration({ spans: false }),
67-
// send console.log, console.error, and console.warn calls as logs to Sentry
68-
consoleLoggingIntegration({ levels: ['log', 'error', 'warn'] }),
69-
],
94+
integrations: [
95+
// Keep Sentry's httpIntegration for correct request isolation, but do not
96+
// emit spans here because tracing spans are produced by Vercel's OTel.
97+
httpIntegration({ spans: false }),
98+
// send console.log, console.error, and console.warn calls as logs to Sentry
99+
consoleLoggingIntegration({ levels: ['log', 'error', 'warn'] }),
100+
],
70101

71-
beforeSend(event, hint) {
72-
const error = hint.originalException;
73-
if (isTRPC4xxError(error)) {
74-
return null;
75-
}
102+
beforeSend(event, hint) {
103+
const error = hint.originalException;
104+
if (isTRPC4xxError(error)) {
105+
return null;
106+
}
76107

77-
// Drizzle Queries are wrapped and that prevents Sentry from properly grouping them
78-
if (isDrizzleQueryError(error)) {
79-
const pgCode = error.cause?.code;
80-
event.fingerprint = [
81-
'drizzle-query-error',
82-
pgCode ?? 'generic',
83-
error.cause?.message ?? 'generic',
84-
];
85-
event.tags = {
86-
...event.tags,
87-
'db.error_code': pgCode,
88-
};
108+
// Drizzle wraps query errors with a `Failed query: <unique SQL>` message,
109+
// which breaks Sentry grouping and hides the real root cause (e.g. a
110+
// "statement timeout" on `error.cause`). Rewrite the primary exception so
111+
// the reported error reflects the underlying cause, and move the failed
112+
// query into a context so it stays visible on the issue without polluting
113+
// the title or fingerprint.
114+
if (isDrizzleQueryError(error)) {
115+
const cause = error.cause;
116+
const pgCode = cause?.code;
117+
event.fingerprint = ['drizzle-query-error', pgCode ?? 'generic', cause?.message ?? 'generic'];
118+
event.tags = {
119+
...event.tags,
120+
'db.error_code': pgCode,
121+
};
122+
event.contexts = {
123+
...event.contexts,
124+
drizzle_query: {
125+
query: error.query,
126+
wrapper_message: error.message,
127+
},
128+
};
129+
130+
if (cause) {
131+
// Prefer the Drizzle wrapper so we keep the stack that points through
132+
// our code, then drop serialized cause entries because they duplicate
133+
// the rewritten primary exception.
134+
const values = event.exception?.values;
135+
if (values && values.length > 0) {
136+
const primaryException =
137+
values.find(isDrizzleWrapperException) ?? values[values.length - 1];
138+
primaryException.type = causeTypeName(cause);
139+
primaryException.value = cause.message ?? 'unknown database error';
140+
event.exception = {
141+
...event.exception,
142+
values: [primaryException],
143+
};
144+
}
89145
}
90-
return event;
91-
},
92-
});
93-
}
146+
}
147+
148+
return event;
149+
},
150+
});

0 commit comments

Comments
 (0)