Skip to content

Commit aa12819

Browse files
chargomeclaude
andauthored
feat(node): Support Prisma 8 in prismaIntegration (#24682)
Wraps Prisma 8's ORM terminals via orchestrion: each ORM call gets a `prisma:client:operation` span (`sentry.op: db`, `db.operation.name`, `db.collection.name`, plus v7's `method`/`model`/`name`) with the `pg` query spans nested underneath. No user code needed. - Patch point is `CollectionImpl` in the transitive `@prisma/orm-family-sql`, pinned `>=8.0.0-rc.8 <9`; verified on rc.8. - Lazy terminals (`all`, `createAll`, `updateAll`, `deleteAll`) return a single-use thenable that orchestrion's promise wrapper would consume, so they're wrapped `Sync` and the subscriber hooks consumption instead. - Nested terminal calls reuse the outer span. Operation spans require a parent. - CJS apps on Node 20 keep `pg`-only spans, since `require(esm)` bypasses the module hooks there. From Node 22 on they're covered; with the async hooks (before Node 24.13 / 25.1) a call in the same tick as the `require` runs before the SDK subscribes. ESM apps are fully covered. Fixes #24237 Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 6ec6e0d commit aa12819

9 files changed

Lines changed: 763 additions & 40 deletions

File tree

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

Lines changed: 108 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,124 @@
1+
import type { SerializedStreamedSpan, SerializedStreamedSpanContainer } from '@sentry/core';
12
import { afterAll, describe, expect } from 'vitest';
3+
import { conditionalTest } from '../../../utils';
24
import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner';
35

46
afterAll(() => {
57
cleanupChildProcesses();
68
});
79

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.
10+
function getSegment(container: SerializedStreamedSpanContainer): SerializedStreamedSpan {
11+
const segment = container.items.find(item => item.is_segment);
12+
expect(segment?.name).toBe('Test Transaction');
13+
return segment!;
14+
}
15+
16+
function getQuerySpans(container: SerializedStreamedSpanContainer): SerializedStreamedSpan[] {
17+
const querySpans = container.items.filter(
18+
item => item.attributes['sentry.origin']?.value === 'auto.db.postgres' && item.attributes['db.query.text']?.value,
19+
);
20+
expect(querySpans.map(span => span.name)).toEqual(
21+
expect.arrayContaining(['INSERT "public"."user"', 'SELECT "public"."user"', 'DELETE "public"."user"']),
22+
);
23+
querySpans.forEach(span => {
24+
expect(span.attributes['sentry.op']?.value).toBe('db');
25+
expect(span.attributes['db.system.name']?.value).toBe('postgresql');
26+
});
27+
return querySpans;
28+
}
29+
30+
function getOperationSpans(container: SerializedStreamedSpanContainer): SerializedStreamedSpan[] {
31+
return container.items.filter(item => item.attributes['sentry.origin']?.value === 'auto.db.prisma');
32+
}
33+
34+
// Prisma 8 has no tracing surface: the operation spans come from the orchestrion channels the runtime hook
35+
// injects, and the `pg` spans underneath stand in for v7's `db_query` spans.
1236
describe('Prisma ORM v8 Tests', () => {
1337
describeWithDockerCompose('Prisma ORM v8', { workingDirectory: [__dirname] }, () => {
1438
createEsmAndCjsTests(
1539
__dirname,
1640
'scenario.mjs',
1741
'instrument.mjs',
18-
(createRunner, test) => {
19-
test('should instrument PostgreSQL queries from Prisma ORM via pg', { timeout: 75_000 }, async () => {
20-
await createRunner()
21-
.unordered()
22-
.expect({
23-
span: container => {
24-
const segment = container.items.find(item => item.is_segment);
25-
expect(segment?.name).toBe('Test Transaction');
26-
27-
const querySpans = container.items.filter(
28-
item =>
29-
item.attributes['sentry.origin']?.value === 'auto.db.postgres' &&
30-
item.attributes['db.query.text']?.value,
31-
);
32-
expect(querySpans.map(span => span.name)).toEqual(
33-
expect.arrayContaining([
34-
'INSERT "public"."user"',
35-
'SELECT "public"."user"',
36-
'DELETE "public"."user"',
37-
]),
38-
);
39-
querySpans.forEach(span => {
40-
expect(span.attributes['sentry.op']?.value).toBe('db');
41-
expect(span.attributes['db.system.name']?.value).toBe('postgresql');
42-
expect(span.parent_span_id).toBe(segment?.span_id);
43-
});
44-
45-
expect(
46-
container.items.filter(item => item.attributes['sentry.origin']?.value === 'auto.db.prisma'),
47-
).toEqual([]);
48-
},
49-
})
50-
.start()
51-
.completed();
42+
(createRunner, test, mode) => {
43+
const testInstrumentedOperations = (): void => {
44+
test('should instrument Prisma ORM operations and nest their queries', { timeout: 75_000 }, async () => {
45+
await createRunner()
46+
.unordered()
47+
.expect({
48+
span: container => {
49+
const segment = getSegment(container);
50+
const querySpans = getQuerySpans(container);
51+
const operationSpans = getOperationSpans(container);
52+
53+
expect(operationSpans.map(span => span.name)).toEqual([
54+
'prisma:client:operation',
55+
'prisma:client:operation',
56+
'prisma:client:operation',
57+
]);
58+
expect(operationSpans.map(span => span.attributes['method']?.value)).toEqual([
59+
'create',
60+
'all',
61+
'delete',
62+
]);
63+
operationSpans.forEach(span => {
64+
const method = span.attributes['method']?.value;
65+
expect(span.parent_span_id).toBe(segment.span_id);
66+
expect(span.attributes).toMatchObject({
67+
'sentry.origin': { value: 'auto.db.prisma', type: 'string' },
68+
'sentry.op': { value: 'db', type: 'string' },
69+
'db.operation.name': { value: method, type: 'string' },
70+
'db.collection.name': { value: 'user', type: 'string' },
71+
model: { value: 'User', type: 'string' },
72+
name: { value: `User.${method}`, type: 'string' },
73+
});
74+
});
75+
76+
const queriesByOperation = (method: string): unknown[] => {
77+
const operation = operationSpans.find(span => span.attributes['method']?.value === method);
78+
return querySpans
79+
.filter(span => span.parent_span_id === operation?.span_id)
80+
.map(span => span.attributes['db.query.text']?.value);
81+
};
82+
expect(queriesByOperation('create')).toEqual(
83+
expect.arrayContaining([expect.stringMatching(/^INSERT INTO "public"\."user" /)]),
84+
);
85+
expect(queriesByOperation('all')).toEqual([
86+
expect.stringMatching(/^SELECT .* FROM "public"\."user"$/),
87+
]);
88+
expect(queriesByOperation('delete')).toEqual(
89+
expect.arrayContaining([expect.stringMatching(/^DELETE FROM "public"\."user" /)]),
90+
);
91+
expect(querySpans.filter(span => span.parent_span_id === segment.span_id)).toEqual([]);
92+
},
93+
})
94+
.start()
95+
.completed();
96+
});
97+
};
98+
99+
if (mode === 'esm') {
100+
testInstrumentedOperations();
101+
return;
102+
}
103+
104+
// The CJS scenario loads the ESM-only package via `require(esm)`, which Node's module hooks only see
105+
// from Node 22 on; on Node 20 the ORM terminals load uninstrumented.
106+
conditionalTest({ min: 22 })('with require(esm) reaching the module hooks', testInstrumentedOperations);
107+
108+
conditionalTest({ max: 21 })('with require(esm) bypassing the module hooks', () => {
109+
test('should keep the pg spans on the transaction', { timeout: 75_000 }, async () => {
110+
await createRunner()
111+
.unordered()
112+
.expect({
113+
span: container => {
114+
const segment = getSegment(container);
115+
expect(getOperationSpans(container)).toEqual([]);
116+
getQuerySpans(container).forEach(span => expect(span.parent_span_id).toBe(segment.span_id));
117+
},
118+
})
119+
.start()
120+
.completed();
121+
});
52122
});
53123
},
54124
{

‎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
* ```

0 commit comments

Comments
 (0)