Skip to content

Commit 685f3c5

Browse files
chargomeclaude
andcommitted
feat(node): Support Prisma 8 in prismaIntegration
Prisma 8 has no tracing surface, so wrap its ORM terminals via orchestrion instead. Each ORM call gets a `prisma:client:operation` span (`db.operation.name`, `db.collection.name`, plus v7's `method`, `model` and `name`) with the `pg` query spans nested underneath. No user code is needed. The patch point is `CollectionImpl` in the transitive `@prisma/orm-family-sql` package, pinned to `>=8.0.0-rc.8 <9`. Lazy terminals (`all`, `createAll`, `updateAll`, `deleteAll`) return a single-use thenable that orchestrion's promise wrapper would consume, so they are wrapped `Sync` and the subscriber hooks `toArray` and the async iterator instead. Terminals calling terminals reuse the outer span. Operation spans require a parent span. CommonJS apps load the ESM-only runtime through `require(esm)`, which only the synchronous module hooks (Node 24.13 / 25.1) transform; older Node keeps `pg`-only spans in CJS. ESM apps are fully covered. Fixes #24237 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 5acb09a commit 685f3c5

10 files changed

Lines changed: 715 additions & 13 deletions

File tree

‎.size-limit.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,7 @@ module.exports = [
406406
import: createImport('init'),
407407
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
408408
gzip: true,
409-
limit: '135 KB',
409+
limit: '137 KB',
410410
disablePlugins: ['@size-limit/esbuild'],
411411
},
412412
{
@@ -452,7 +452,7 @@ module.exports = [
452452
path: 'packages/node/build/esm/index.js',
453453
import: createImport('init'),
454454
gzip: true,
455-
limit: '114 KB',
455+
limit: '115 KB',
456456
disablePlugins: ['@size-limit/esbuild'],
457457
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
458458
modifyWebpackConfig: function (config) {

‎dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/test.ts‎

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,24 @@ afterAll(() => {
55
cleanupChildProcesses();
66
});
77

8-
// Prisma 8 ("Prisma Next") has no tracing surface (its scorecard lists OpenTelemetry spans as not in 8.0), so
9-
// `prismaIntegration` is inert and only the `pg` spans from `postgresIntegration` remain. Pinned here so a
10-
// Prisma release that adds a tracing surface shows up. The runtime runs on every Node version in the matrix;
11-
// only the Prisma 8 CLI needs Node 22.18+, which is why the generated files are committed.
8+
// The CJS scenario loads the ESM-only package via `require(esm)`, which only the sync `Module.registerHooks`
9+
// loader (Node 24.13 / 25.1) transforms; on older Node the ORM terminals load uninstrumented.
10+
const [nodeMajor = 0, nodeMinor = 0] = process.versions.node.split('.').map(Number);
11+
const cjsLoadsInstrumentedEsm =
12+
nodeMajor > 25 || (nodeMajor === 25 && nodeMinor >= 1) || (nodeMajor === 24 && nodeMinor >= 13);
13+
14+
// Prisma 8 has no tracing surface: the operation spans come from the orchestrion channels the runtime hook
15+
// injects, and the `pg` spans underneath stand in for v7's `db_query` spans.
1216
describe('Prisma ORM v8 Tests', () => {
1317
describeWithDockerCompose('Prisma ORM v8', { workingDirectory: [__dirname] }, () => {
1418
createEsmAndCjsTests(
1519
__dirname,
1620
'scenario.mjs',
1721
'instrument.mjs',
18-
(createRunner, test) => {
19-
test('should instrument PostgreSQL queries from Prisma ORM via pg', { timeout: 75_000 }, async () => {
22+
(createRunner, test, mode) => {
23+
const expectsOperationSpans = mode === 'esm' || cjsLoadsInstrumentedEsm;
24+
25+
test('should instrument Prisma ORM operations and nest their queries', { timeout: 75_000 }, async () => {
2026
await createRunner()
2127
.unordered()
2228
.expect({
@@ -39,12 +45,55 @@ describe('Prisma ORM v8 Tests', () => {
3945
querySpans.forEach(span => {
4046
expect(span.attributes['sentry.op']?.value).toBe('db');
4147
expect(span.attributes['db.system.name']?.value).toBe('postgresql');
48+
});
49+
50+
const operationSpans = container.items.filter(
51+
item => item.attributes['sentry.origin']?.value === 'auto.db.prisma',
52+
);
53+
54+
if (!expectsOperationSpans) {
55+
expect(operationSpans).toEqual([]);
56+
querySpans.forEach(span => expect(span.parent_span_id).toBe(segment?.span_id));
57+
return;
58+
}
59+
60+
expect(operationSpans.map(span => span.name)).toEqual([
61+
'prisma:client:operation',
62+
'prisma:client:operation',
63+
'prisma:client:operation',
64+
]);
65+
expect(operationSpans.map(span => span.attributes['method']?.value)).toEqual([
66+
'create',
67+
'all',
68+
'delete',
69+
]);
70+
operationSpans.forEach(span => {
71+
const method = span.attributes['method']?.value;
4272
expect(span.parent_span_id).toBe(segment?.span_id);
73+
expect(span.attributes).toMatchObject({
74+
'sentry.origin': { value: 'auto.db.prisma', type: 'string' },
75+
'sentry.op': { value: 'db', type: 'string' },
76+
'db.operation.name': { value: method, type: 'string' },
77+
'db.collection.name': { value: 'user', type: 'string' },
78+
model: { value: 'User', type: 'string' },
79+
name: { value: `User.${method}`, type: 'string' },
80+
});
4381
});
4482

45-
expect(
46-
container.items.filter(item => item.attributes['sentry.origin']?.value === 'auto.db.prisma'),
47-
).toEqual([]);
83+
const queriesByOperation = (method: string): unknown[] => {
84+
const operation = operationSpans.find(span => span.attributes['method']?.value === method);
85+
return querySpans
86+
.filter(span => span.parent_span_id === operation?.span_id)
87+
.map(span => span.attributes['db.query.text']?.value);
88+
};
89+
expect(queriesByOperation('create')).toEqual(
90+
expect.arrayContaining([expect.stringMatching(/^INSERT INTO "public"\."user" /)]),
91+
);
92+
expect(queriesByOperation('all')).toEqual([expect.stringMatching(/^SELECT .* FROM "public"\."user"$/)]);
93+
expect(queriesByOperation('delete')).toEqual(
94+
expect.arrayContaining([expect.stringMatching(/^DELETE FROM "public"\."user" /)]),
95+
);
96+
expect(querySpans.filter(span => span.parent_span_id === segment?.span_id)).toEqual([]);
4897
},
4998
})
5099
.start()

‎packages/server-utils/src/integrations/prisma/index.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { IntegrationFn } from '@sentry/core';
22
import { defineIntegration } from '@sentry/core';
3+
import { prismaModuleNames } from '../../orchestrion/config/prisma';
4+
import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation';
35
import { setGlobalTracingHelper } from './global';
6+
import { instrumentPrismaV8 } from './orchestrion';
47
import { ActiveTracingHelper } from './tracing-helper';
58

69
const INTEGRATION_NAME = 'Prisma' as const;
@@ -43,14 +46,22 @@ const _prismaIntegration = ((options?: PrismaOptions) => {
4346
setupOnce() {
4447
instrumentPrisma(options);
4548
},
49+
// Prisma 8 has no tracing helper to install; its ORM terminals are instrumented via orchestrion instead.
50+
setup(client) {
51+
invokeOrchestrionInstrumentation(client, prismaModuleNames, instrumentPrismaV8, [
52+
{ ignoreSpanTypes: options?.instrumentationConfig?.ignoreSpanTypes ?? [] },
53+
]);
54+
},
4655
};
4756
}) satisfies IntegrationFn;
4857

4958
/**
5059
* Adds Sentry tracing instrumentation for the [prisma](https://www.npmjs.com/package/prisma) library.
5160
* For more information, see the [`prismaIntegration` documentation](https://docs.sentry.io/platforms/javascript/guides/node/configuration/integrations/prisma/).
5261
*
53-
* NOTE: This integration works out of the box with Prisma v6, and v7.
62+
* NOTE: This integration works out of the box with Prisma v6, v7 and v8. Prisma v8 ("Prisma Next")
63+
* has no tracing surface of its own, so its ORM calls are instrumented through Sentry's runtime hook
64+
* or bundler plugin, like the other channel-based integrations.
5465
* On Prisma versions prior to v6, add `previewFeatures = ["tracing"]` to the client generator block of your Prisma schema:
5566
*
5667
* ```
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import * as diagnosticsChannel from 'node:diagnostics_channel';
2+
import type { Span } from '@sentry/core';
3+
import {
4+
getActiveSpan,
5+
getRootSpan,
6+
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
7+
spanIsIgnored,
8+
startInactiveSpan,
9+
withActiveSpan,
10+
} from '@sentry/core';
11+
import { DB_COLLECTION_NAME, DB_OPERATION_NAME, SENTRY_OP } from '@sentry/conventions/attributes';
12+
import { DB } from '@sentry/conventions/op';
13+
import { prismaChannels, PRISMA_LAZY_TERMINALS } from '../../orchestrion/config/prisma';
14+
import type { TracingChannelLifeCycleOptions } from '../../tracing-channel';
15+
import { bindTracingChannelToSpan, safeChannelCallback } from '../../tracing-channel';
16+
import { shouldIgnoreSpan } from './tracing-helper';
17+
18+
// Same span name as Prisma v5–v7's own operation span, so the tree looks the same across majors.
19+
const OPERATION_SPAN_NAME = 'prisma:client:operation';
20+
const PRISMA_ORIGIN = 'auto.db.prisma';
21+
22+
// Terminals call each other (`delete` reads the row through `first`); only the outermost call is the operation.
23+
const operationSpans = new WeakSet<Span>();
24+
25+
interface PrismaCollection {
26+
modelName?: unknown;
27+
tableName?: unknown;
28+
}
29+
30+
interface PrismaTerminalChannelContext {
31+
arguments: unknown[];
32+
self?: PrismaCollection;
33+
moduleVersion?: string;
34+
result?: unknown;
35+
error?: unknown;
36+
}
37+
38+
// Prisma's `AsyncIterableResult`: `await`, `.then()` and `.first()` all funnel through `toArray()`; only
39+
// `for await` goes through the iterator.
40+
interface LazyResult {
41+
toArray: () => Promise<unknown>;
42+
[Symbol.asyncIterator]: () => AsyncIterator<unknown>;
43+
}
44+
45+
interface PrismaV8InstrumentationOptions {
46+
ignoreSpanTypes: (string | RegExp)[];
47+
}
48+
49+
/**
50+
* Opens a `prisma:client:operation` span per ORM call, kept active while the terminal runs so the `pg`
51+
* query spans nest under it like the `db_query` spans did on v5–v7.
52+
*/
53+
export function instrumentPrismaV8(options: PrismaV8InstrumentationOptions): void {
54+
if (shouldIgnoreSpan(OPERATION_SPAN_NAME, options.ignoreSpanTypes)) {
55+
return;
56+
}
57+
58+
for (const channelName of Object.values(prismaChannels)) {
59+
const method = channelName.slice(channelName.lastIndexOf(':') + 1);
60+
const isLazy = (PRISMA_LAZY_TERMINALS as readonly string[]).includes(method);
61+
62+
bindTracingChannelToSpan<PrismaTerminalChannelContext>(
63+
diagnosticsChannel.tracingChannel<PrismaTerminalChannelContext>(channelName),
64+
data => startOperationSpan(method, data),
65+
{
66+
// A stray query outside a request must not become a segment of its own.
67+
requiresParentSpan: true,
68+
...(isLazy ? { deferSpanEnd: deferSpanEndUntilConsumed } : {}),
69+
},
70+
);
71+
}
72+
}
73+
74+
function startOperationSpan(method: string, data: PrismaTerminalChannelContext): Span | undefined {
75+
const parentSpan = getActiveSpan();
76+
if (parentSpan && operationSpans.has(parentSpan)) {
77+
return undefined;
78+
}
79+
80+
const model = stringOrUndefined(data.self?.modelName);
81+
const table = stringOrUndefined(data.self?.tableName);
82+
83+
const span = startInactiveSpan({
84+
name: OPERATION_SPAN_NAME,
85+
parentSpan,
86+
attributes: {
87+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: PRISMA_ORIGIN,
88+
[SENTRY_OP]: DB,
89+
[DB_OPERATION_NAME]: method,
90+
[DB_COLLECTION_NAME]: table ?? model,
91+
// Prisma v5–v7's own attributes, kept so existing queries keep matching across the major.
92+
method,
93+
model,
94+
name: model ? `${model}.${method}` : method,
95+
},
96+
});
97+
operationSpans.add(span);
98+
return span;
99+
}
100+
101+
// A lazy terminal returns before anything is queried, so the span stays open until the result is consumed,
102+
// with consumption re-routed through the span's context so the driver call lands under it. A result that is
103+
// never consumed never ran a query; its span is simply never ended.
104+
const deferSpanEndUntilConsumed: NonNullable<
105+
TracingChannelLifeCycleOptions<PrismaTerminalChannelContext>['deferSpanEnd']
106+
> = ({ span, data, end }) => {
107+
const result = data.result;
108+
if (!isLazyResult(result)) {
109+
return false;
110+
}
111+
112+
// A throw in a channel subscriber is an uncaught exception; an unpatchable (frozen) result ends the span now.
113+
return safeChannelCallback(() => patchConsumption(result, span, end)) ?? false;
114+
};
115+
116+
function patchConsumption(result: LazyResult, span: Span, end: (error?: unknown) => void): boolean {
117+
const toArray = result.toArray;
118+
const asyncIterator = result[Symbol.asyncIterator];
119+
120+
// Built before either is installed, so a failed assignment can't leave a half-patched result.
121+
const patchedToArray = function (this: LazyResult): Promise<unknown> {
122+
const rows: Promise<unknown> = runUnderSpan(span, () => toArray.call(this));
123+
// Chained, not side-observed, so a rejected fire-and-forget `toArray()` still surfaces as unhandled.
124+
return rows.then(
125+
value => {
126+
end();
127+
return value;
128+
},
129+
(error: unknown) => {
130+
end(error);
131+
throw error;
132+
},
133+
);
134+
};
135+
const patchedAsyncIterator = function (this: LazyResult): AsyncIterableIterator<unknown> {
136+
return wrapIterator(
137+
runUnderSpan(span, () => asyncIterator.call(this)),
138+
span,
139+
end,
140+
);
141+
};
142+
143+
result.toArray = patchedToArray;
144+
result[Symbol.asyncIterator] = patchedAsyncIterator;
145+
146+
return true;
147+
}
148+
149+
// An ignored child span is never emitted, so its children must keep parenting to the nearest emitted span
150+
// (same rule as the tracing-channel binding and core `startSpan`).
151+
function runUnderSpan<T>(span: Span, callback: () => T): T {
152+
if (spanIsIgnored(span) && getRootSpan(span) !== span) {
153+
return callback();
154+
}
155+
return withActiveSpan(span, callback);
156+
}
157+
158+
function wrapIterator(
159+
iterator: AsyncIterator<unknown>,
160+
span: Span,
161+
end: (error?: unknown) => void,
162+
): AsyncIterableIterator<unknown> {
163+
const settle = (step: Promise<IteratorResult<unknown>>): Promise<IteratorResult<unknown>> =>
164+
step.then(
165+
result => {
166+
if (result.done) {
167+
end();
168+
}
169+
return result;
170+
},
171+
error => {
172+
end(error);
173+
throw error;
174+
},
175+
);
176+
177+
return {
178+
next: (...args) => settle(runUnderSpan(span, () => iterator.next(...args))),
179+
return: value => {
180+
const iteratorReturn = iterator.return?.bind(iterator);
181+
if (!iteratorReturn) {
182+
end();
183+
return Promise.resolve({ done: true, value });
184+
}
185+
return settle(runUnderSpan(span, () => iteratorReturn(value)));
186+
},
187+
throw: error => {
188+
const iteratorThrow = iterator.throw?.bind(iterator);
189+
if (!iteratorThrow) {
190+
end(error);
191+
return Promise.reject(error);
192+
}
193+
return settle(runUnderSpan(span, () => iteratorThrow(error)));
194+
},
195+
[Symbol.asyncIterator]() {
196+
return this;
197+
},
198+
};
199+
}
200+
201+
function stringOrUndefined(value: unknown): string | undefined {
202+
return typeof value === 'string' ? value : undefined;
203+
}
204+
205+
function isLazyResult(value: unknown): value is LazyResult {
206+
return (
207+
typeof value === 'object' &&
208+
value !== null &&
209+
typeof (value as LazyResult).toArray === 'function' &&
210+
typeof (value as LazyResult)[Symbol.asyncIterator] === 'function'
211+
);
212+
}

‎packages/server-utils/src/integrations/prisma/tracing-helper.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,7 @@ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
398398
return value != null && typeof (value as Record<string, unknown>)['then'] === 'function';
399399
}
400400

401-
function shouldIgnoreSpan(spanName: string, ignoreSpanTypes: (string | RegExp)[]): boolean {
401+
/** Whether `spanName` matches one of the configured `ignoreSpanTypes` patterns. */
402+
export function shouldIgnoreSpan(spanName: string, ignoreSpanTypes: (string | RegExp)[]): boolean {
402403
return ignoreSpanTypes.some(pattern => (typeof pattern === 'string' ? pattern === spanName : pattern.test(spanName)));
403404
}

‎packages/server-utils/src/orchestrion/channels.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { nestjsChannels } from './config/nestjs';
2626
import { openaiChannels } from './config/openai';
2727
import { pgChannels } from './config/pg';
2828
import { postgresJsChannels } from './config/postgres';
29+
import { prismaChannels } from './config/prisma';
2930
import { redisChannels } from './config/redis';
3031
import { remixChannels } from './config/remix';
3132
import { tediousChannels } from './config/tedious';
@@ -77,6 +78,7 @@ export const CHANNELS = {
7778
...openaiChannels,
7879
...pgChannels,
7980
...postgresJsChannels,
81+
...prismaChannels,
8082
...redisChannels,
8183
...remixChannels,
8284
...tediousChannels,

‎packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
export const CHANNEL_INTEGRATION_DEFINITIONS = [
1919
{ exportName: 'postgresIntegration', modules: ['pg', 'pg-pool'] },
2020
{ exportName: 'postgresJsIntegration', modules: ['postgres'] },
21+
{ exportName: 'prismaIntegration', modules: ['@prisma/orm-family-sql'] },
2122
{ exportName: 'mysqlIntegration', modules: ['mysql'] },
2223
{ exportName: 'mysql2Integration', modules: ['mysql2'] },
2324
{ exportName: 'mongoIntegration', modules: ['mongodb'] },

0 commit comments

Comments
 (0)