diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/test.ts b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/test.ts index 10e550b305b0..0604374c83d6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/test.ts @@ -1,54 +1,124 @@ +import type { SerializedStreamedSpan, SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; +import { conditionalTest } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; afterAll(() => { cleanupChildProcesses(); }); -// Prisma 8 ("Prisma Next") has no tracing surface (its scorecard lists OpenTelemetry spans as not in 8.0), so -// `prismaIntegration` is inert and only the `pg` spans from `postgresIntegration` remain. Pinned here so a -// Prisma release that adds a tracing surface shows up. The runtime runs on every Node version in the matrix; -// only the Prisma 8 CLI needs Node 22.18+, which is why the generated files are committed. +function getSegment(container: SerializedStreamedSpanContainer): SerializedStreamedSpan { + const segment = container.items.find(item => item.is_segment); + expect(segment?.name).toBe('Test Transaction'); + return segment!; +} + +function getQuerySpans(container: SerializedStreamedSpanContainer): SerializedStreamedSpan[] { + const querySpans = container.items.filter( + item => item.attributes['sentry.origin']?.value === 'auto.db.postgres' && item.attributes['db.query.text']?.value, + ); + expect(querySpans.map(span => span.name)).toEqual( + expect.arrayContaining(['INSERT "public"."user"', 'SELECT "public"."user"', 'DELETE "public"."user"']), + ); + querySpans.forEach(span => { + expect(span.attributes['sentry.op']?.value).toBe('db'); + expect(span.attributes['db.system.name']?.value).toBe('postgresql'); + }); + return querySpans; +} + +function getOperationSpans(container: SerializedStreamedSpanContainer): SerializedStreamedSpan[] { + return container.items.filter(item => item.attributes['sentry.origin']?.value === 'auto.db.prisma'); +} + +// Prisma 8 has no tracing surface: the operation spans come from the orchestrion channels the runtime hook +// injects, and the `pg` spans underneath stand in for v7's `db_query` spans. describe('Prisma ORM v8 Tests', () => { describeWithDockerCompose('Prisma ORM v8', { workingDirectory: [__dirname] }, () => { createEsmAndCjsTests( __dirname, 'scenario.mjs', 'instrument.mjs', - (createRunner, test) => { - test('should instrument PostgreSQL queries from Prisma ORM via pg', { timeout: 75_000 }, async () => { - await createRunner() - .unordered() - .expect({ - span: container => { - const segment = container.items.find(item => item.is_segment); - expect(segment?.name).toBe('Test Transaction'); - - const querySpans = container.items.filter( - item => - item.attributes['sentry.origin']?.value === 'auto.db.postgres' && - item.attributes['db.query.text']?.value, - ); - expect(querySpans.map(span => span.name)).toEqual( - expect.arrayContaining([ - 'INSERT "public"."user"', - 'SELECT "public"."user"', - 'DELETE "public"."user"', - ]), - ); - querySpans.forEach(span => { - expect(span.attributes['sentry.op']?.value).toBe('db'); - expect(span.attributes['db.system.name']?.value).toBe('postgresql'); - expect(span.parent_span_id).toBe(segment?.span_id); - }); - - expect( - container.items.filter(item => item.attributes['sentry.origin']?.value === 'auto.db.prisma'), - ).toEqual([]); - }, - }) - .start() - .completed(); + (createRunner, test, mode) => { + const testInstrumentedOperations = (): void => { + test('should instrument Prisma ORM operations and nest their queries', { timeout: 75_000 }, async () => { + await createRunner() + .unordered() + .expect({ + span: container => { + const segment = getSegment(container); + const querySpans = getQuerySpans(container); + const operationSpans = getOperationSpans(container); + + expect(operationSpans.map(span => span.name)).toEqual([ + 'prisma:client:operation', + 'prisma:client:operation', + 'prisma:client:operation', + ]); + expect(operationSpans.map(span => span.attributes['method']?.value)).toEqual([ + 'create', + 'all', + 'delete', + ]); + operationSpans.forEach(span => { + const method = span.attributes['method']?.value; + expect(span.parent_span_id).toBe(segment.span_id); + expect(span.attributes).toMatchObject({ + 'sentry.origin': { value: 'auto.db.prisma', type: 'string' }, + 'sentry.op': { value: 'db', type: 'string' }, + 'db.operation.name': { value: method, type: 'string' }, + 'db.collection.name': { value: 'user', type: 'string' }, + model: { value: 'User', type: 'string' }, + name: { value: `User.${method}`, type: 'string' }, + }); + }); + + const queriesByOperation = (method: string): unknown[] => { + const operation = operationSpans.find(span => span.attributes['method']?.value === method); + return querySpans + .filter(span => span.parent_span_id === operation?.span_id) + .map(span => span.attributes['db.query.text']?.value); + }; + expect(queriesByOperation('create')).toEqual( + expect.arrayContaining([expect.stringMatching(/^INSERT INTO "public"\."user" /)]), + ); + expect(queriesByOperation('all')).toEqual([ + expect.stringMatching(/^SELECT .* FROM "public"\."user"$/), + ]); + expect(queriesByOperation('delete')).toEqual( + expect.arrayContaining([expect.stringMatching(/^DELETE FROM "public"\."user" /)]), + ); + expect(querySpans.filter(span => span.parent_span_id === segment.span_id)).toEqual([]); + }, + }) + .start() + .completed(); + }); + }; + + if (mode === 'esm') { + testInstrumentedOperations(); + return; + } + + // The CJS scenario loads the ESM-only package via `require(esm)`, which Node's module hooks only see + // from Node 22 on; on Node 20 the ORM terminals load uninstrumented. + conditionalTest({ min: 22 })('with require(esm) reaching the module hooks', testInstrumentedOperations); + + conditionalTest({ max: 21 })('with require(esm) bypassing the module hooks', () => { + test('should keep the pg spans on the transaction', { timeout: 75_000 }, async () => { + await createRunner() + .unordered() + .expect({ + span: container => { + const segment = getSegment(container); + expect(getOperationSpans(container)).toEqual([]); + getQuerySpans(container).forEach(span => expect(span.parent_span_id).toBe(segment.span_id)); + }, + }) + .start() + .completed(); + }); }); }, { diff --git a/packages/server-utils/src/integrations/prisma/index.ts b/packages/server-utils/src/integrations/prisma/index.ts index 600198f6caec..c67f13645f54 100644 --- a/packages/server-utils/src/integrations/prisma/index.ts +++ b/packages/server-utils/src/integrations/prisma/index.ts @@ -1,6 +1,9 @@ import type { IntegrationFn } from '@sentry/core'; import { defineIntegration } from '@sentry/core'; +import { prismaModuleNames } from '../../orchestrion/config/prisma'; +import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; import { setGlobalTracingHelper } from './global'; +import { instrumentPrismaV8 } from './orchestrion'; import { ActiveTracingHelper } from './tracing-helper'; const INTEGRATION_NAME = 'Prisma' as const; @@ -43,6 +46,12 @@ const _prismaIntegration = ((options?: PrismaOptions) => { setupOnce() { instrumentPrisma(options); }, + // Prisma 8 has no tracing helper to install; its ORM terminals are instrumented via orchestrion instead. + setup(client) { + invokeOrchestrionInstrumentation(client, prismaModuleNames, instrumentPrismaV8, [ + { ignoreSpanTypes: options?.instrumentationConfig?.ignoreSpanTypes ?? [] }, + ]); + }, }; }) satisfies IntegrationFn; @@ -50,7 +59,9 @@ const _prismaIntegration = ((options?: PrismaOptions) => { * Adds Sentry tracing instrumentation for the [prisma](https://www.npmjs.com/package/prisma) library. * For more information, see the [`prismaIntegration` documentation](https://docs.sentry.io/platforms/javascript/guides/node/configuration/integrations/prisma/). * - * NOTE: This integration works out of the box with Prisma v6, and v7. + * NOTE: This integration works out of the box with Prisma v6, v7 and v8. Prisma v8 ("Prisma Next") + * has no tracing surface of its own, so its ORM calls are instrumented through Sentry's runtime hook + * or bundler plugin, like the other channel-based integrations. * On Prisma versions prior to v6, add `previewFeatures = ["tracing"]` to the client generator block of your Prisma schema: * * ``` diff --git a/packages/server-utils/src/integrations/prisma/orchestrion.ts b/packages/server-utils/src/integrations/prisma/orchestrion.ts new file mode 100644 index 000000000000..41a10ce8ea1b --- /dev/null +++ b/packages/server-utils/src/integrations/prisma/orchestrion.ts @@ -0,0 +1,212 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { Span } from '@sentry/core'; +import { + getActiveSpan, + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanIsIgnored, + startInactiveSpan, + withActiveSpan, +} from '@sentry/core'; +import { DB_COLLECTION_NAME, DB_OPERATION_NAME, SENTRY_OP } from '@sentry/conventions/attributes'; +import { DB } from '@sentry/conventions/op'; +import { prismaChannels, PRISMA_LAZY_TERMINALS } from '../../orchestrion/config/prisma'; +import type { TracingChannelLifeCycleOptions } from '../../tracing-channel'; +import { bindTracingChannelToSpan, safeChannelCallback } from '../../tracing-channel'; +import { shouldIgnoreSpan } from './tracing-helper'; + +// Same span name as Prisma v5–v7's own operation span, so the tree looks the same across majors. +const OPERATION_SPAN_NAME = 'prisma:client:operation'; +const PRISMA_ORIGIN = 'auto.db.prisma'; + +// Terminals call each other (`delete` reads the row through `first`); only the outermost call is the operation. +const operationSpans = new WeakSet(); + +interface PrismaCollection { + modelName?: unknown; + tableName?: unknown; +} + +interface PrismaTerminalChannelContext { + arguments: unknown[]; + self?: PrismaCollection; + moduleVersion?: string; + result?: unknown; + error?: unknown; +} + +// Prisma's `AsyncIterableResult`: `await`, `.then()` and `.first()` all funnel through `toArray()`; only +// `for await` goes through the iterator. +interface LazyResult { + toArray: () => Promise; + [Symbol.asyncIterator]: () => AsyncIterator; +} + +interface PrismaV8InstrumentationOptions { + ignoreSpanTypes: (string | RegExp)[]; +} + +/** + * Opens a `prisma:client:operation` span per ORM call, kept active while the terminal runs so the `pg` + * query spans nest under it like the `db_query` spans did on v5–v7. + */ +export function instrumentPrismaV8(options: PrismaV8InstrumentationOptions): void { + if (shouldIgnoreSpan(OPERATION_SPAN_NAME, options.ignoreSpanTypes)) { + return; + } + + for (const channelName of Object.values(prismaChannels)) { + const method = channelName.slice(channelName.lastIndexOf(':') + 1); + const isLazy = (PRISMA_LAZY_TERMINALS as readonly string[]).includes(method); + + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channelName), + data => startOperationSpan(method, data), + { + // A stray query outside a request must not become a segment of its own. + requiresParentSpan: true, + ...(isLazy ? { deferSpanEnd: deferSpanEndUntilConsumed } : {}), + }, + ); + } +} + +function startOperationSpan(method: string, data: PrismaTerminalChannelContext): Span | undefined { + const parentSpan = getActiveSpan(); + if (parentSpan && operationSpans.has(parentSpan)) { + return undefined; + } + + const model = stringOrUndefined(data.self?.modelName); + const table = stringOrUndefined(data.self?.tableName); + + const span = startInactiveSpan({ + name: OPERATION_SPAN_NAME, + parentSpan, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: PRISMA_ORIGIN, + [SENTRY_OP]: DB, + [DB_OPERATION_NAME]: method, + [DB_COLLECTION_NAME]: table ?? model, + // Prisma v5–v7's own attributes, kept so existing queries keep matching across the major. + method, + model, + name: model ? `${model}.${method}` : method, + }, + }); + operationSpans.add(span); + return span; +} + +// A lazy terminal returns before anything is queried, so the span stays open until the result is consumed, +// with consumption re-routed through the span's context so the driver call lands under it. A result that is +// never consumed never ran a query; its span is simply never ended. +const deferSpanEndUntilConsumed: NonNullable< + TracingChannelLifeCycleOptions['deferSpanEnd'] +> = ({ span, data, end }) => { + const result = data.result; + if (!isLazyResult(result)) { + return false; + } + + // A throw in a channel subscriber is an uncaught exception; an unpatchable (frozen) result ends the span now. + return safeChannelCallback(() => patchConsumption(result, span, end)) ?? false; +}; + +function patchConsumption(result: LazyResult, span: Span, end: (error?: unknown) => void): boolean { + const toArray = result.toArray; + const asyncIterator = result[Symbol.asyncIterator]; + + // Built before either is installed, so a failed assignment can't leave a half-patched result. + const patchedToArray = function (this: LazyResult): Promise { + const rows: Promise = runUnderSpan(span, () => toArray.call(this)); + // Chained, not side-observed, so a rejected fire-and-forget `toArray()` still surfaces as unhandled. + return rows.then( + value => { + end(); + return value; + }, + (error: unknown) => { + end(error); + throw error; + }, + ); + }; + const patchedAsyncIterator = function (this: LazyResult): AsyncIterableIterator { + return wrapIterator( + runUnderSpan(span, () => asyncIterator.call(this)), + span, + end, + ); + }; + + result.toArray = patchedToArray; + result[Symbol.asyncIterator] = patchedAsyncIterator; + + return true; +} + +// An ignored child span is never emitted, so its children must keep parenting to the nearest emitted span +// (same rule as the tracing-channel binding and core `startSpan`). +function runUnderSpan(span: Span, callback: () => T): T { + if (spanIsIgnored(span) && getRootSpan(span) !== span) { + return callback(); + } + return withActiveSpan(span, callback); +} + +function wrapIterator( + iterator: AsyncIterator, + span: Span, + end: (error?: unknown) => void, +): AsyncIterableIterator { + const settle = (step: Promise>): Promise> => + step.then( + result => { + if (result.done) { + end(); + } + return result; + }, + error => { + end(error); + throw error; + }, + ); + + return { + next: (...args) => settle(runUnderSpan(span, () => iterator.next(...args))), + return: value => { + const iteratorReturn = iterator.return?.bind(iterator); + if (!iteratorReturn) { + end(); + return Promise.resolve({ done: true, value }); + } + return settle(runUnderSpan(span, () => iteratorReturn(value))); + }, + throw: error => { + const iteratorThrow = iterator.throw?.bind(iterator); + if (!iteratorThrow) { + end(error); + return Promise.reject(error); + } + return settle(runUnderSpan(span, () => iteratorThrow(error))); + }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} + +function stringOrUndefined(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function isLazyResult(value: unknown): value is LazyResult { + return ( + typeof value === 'object' && + value !== null && + typeof (value as LazyResult).toArray === 'function' && + typeof (value as LazyResult)[Symbol.asyncIterator] === 'function' + ); +} diff --git a/packages/server-utils/src/integrations/prisma/tracing-helper.ts b/packages/server-utils/src/integrations/prisma/tracing-helper.ts index bb17556efea1..0cfe6cf471d0 100644 --- a/packages/server-utils/src/integrations/prisma/tracing-helper.ts +++ b/packages/server-utils/src/integrations/prisma/tracing-helper.ts @@ -398,6 +398,7 @@ function isPromiseLike(value: unknown): value is PromiseLike { return value != null && typeof (value as Record)['then'] === 'function'; } -function shouldIgnoreSpan(spanName: string, ignoreSpanTypes: (string | RegExp)[]): boolean { +/** Whether `spanName` matches one of the configured `ignoreSpanTypes` patterns. */ +export function shouldIgnoreSpan(spanName: string, ignoreSpanTypes: (string | RegExp)[]): boolean { return ignoreSpanTypes.some(pattern => (typeof pattern === 'string' ? pattern === spanName : pattern.test(spanName))); } diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index a88495d6ed8c..52a50ee6a0a2 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -26,6 +26,7 @@ import { nestjsChannels } from './config/nestjs'; import { openaiChannels } from './config/openai'; import { pgChannels } from './config/pg'; import { postgresJsChannels } from './config/postgres'; +import { prismaChannels } from './config/prisma'; import { redisChannels } from './config/redis'; import { remixChannels } from './config/remix'; import { tediousChannels } from './config/tedious'; @@ -77,6 +78,7 @@ export const CHANNELS = { ...openaiChannels, ...pgChannels, ...postgresJsChannels, + ...prismaChannels, ...redisChannels, ...remixChannels, ...tediousChannels, diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts index 881a38da55fd..3325129cb1b5 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -18,6 +18,7 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ { exportName: 'postgresIntegration', modules: ['pg', 'pg-pool'] }, { exportName: 'postgresJsIntegration', modules: ['postgres'] }, + { exportName: 'prismaIntegration', modules: ['@prisma/orm-family-sql'] }, { exportName: 'mysqlIntegration', modules: ['mysql'] }, { exportName: 'mysql2Integration', modules: ['mysql2'] }, { exportName: 'mongoIntegration', modules: ['mongodb'] }, diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 0ed9055935d4..c57412996096 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -31,6 +31,7 @@ import { nestjsConfig } from './nestjs'; import { openaiConfig } from './openai'; import { pgConfig } from './pg'; import { postgresJsConfig } from './postgres'; +import { prismaConfig } from './prisma'; import { redisConfig } from './redis'; import { remixConfig } from './remix'; import { tediousConfig } from './tedious'; @@ -82,6 +83,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...openaiConfig, ...pgConfig, ...postgresJsConfig, + ...prismaConfig, ...redisConfig, ...remixConfig, ...tediousConfig, diff --git a/packages/server-utils/src/orchestrion/config/prisma.ts b/packages/server-utils/src/orchestrion/config/prisma.ts new file mode 100644 index 000000000000..73d6b1a77607 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/prisma.ts @@ -0,0 +1,59 @@ +import type { InstrumentationConfig } from '../apmTypes'; +import { getModuleNames } from './module-names'; + +// Prisma 8 has no tracing surface, so its ORM terminals are wrapped directly. Apps install +// `@prisma/orm-postgres`, but `CollectionImpl` lives in the transitive `@prisma/orm-family-sql`. +const MODULE_NAME = '@prisma/orm-family-sql'; +const ORM_CLIENT_FILE = 'dist/orm-client.mjs'; + +// The range matcher admits every `8.0.0-rc.*`, not just rc.8 and later; the terminals were verified on rc.8. +const VERSION_RANGE = '>=8.0.0-rc.8 <9'; + +export const PRISMA_ASYNC_TERMINALS = [ + 'aggregate', + 'first', + 'create', + 'createAndCount', + 'upsert', + 'update', + 'updateAndCount', + 'delete', + 'deleteAndCount', +] as const; + +// These return a single-use thenable whose query runs on consumption. `Sync` on purpose: the `Async`/`Auto` +// wrapper calls `.then()` on non-native thenables and would consume the result before the app does. +export const PRISMA_LAZY_TERMINALS = ['all', 'createAll', 'updateAll', 'deleteAll'] as const; + +type PrismaTerminal = (typeof PRISMA_ASYNC_TERMINALS)[number] | (typeof PRISMA_LAZY_TERMINALS)[number]; + +function terminalConfig(methodName: PrismaTerminal, kind: 'Async' | 'Sync'): InstrumentationConfig { + return { + channelName: methodName, + module: { name: MODULE_NAME, versionRange: VERSION_RANGE, filePath: ORM_CLIENT_FILE }, + functionQuery: { className: 'CollectionImpl', methodName, kind }, + }; +} + +export const prismaConfig: InstrumentationConfig[] = [ + ...PRISMA_ASYNC_TERMINALS.map(methodName => terminalConfig(methodName, 'Async')), + ...PRISMA_LAZY_TERMINALS.map(methodName => terminalConfig(methodName, 'Sync')), +]; + +export const prismaModuleNames = getModuleNames(prismaConfig); + +export const prismaChannels = { + PRISMA_AGGREGATE: 'orchestrion:@prisma/orm-family-sql:aggregate', + PRISMA_FIRST: 'orchestrion:@prisma/orm-family-sql:first', + PRISMA_CREATE: 'orchestrion:@prisma/orm-family-sql:create', + PRISMA_CREATE_AND_COUNT: 'orchestrion:@prisma/orm-family-sql:createAndCount', + PRISMA_UPSERT: 'orchestrion:@prisma/orm-family-sql:upsert', + PRISMA_UPDATE: 'orchestrion:@prisma/orm-family-sql:update', + PRISMA_UPDATE_AND_COUNT: 'orchestrion:@prisma/orm-family-sql:updateAndCount', + PRISMA_DELETE: 'orchestrion:@prisma/orm-family-sql:delete', + PRISMA_DELETE_AND_COUNT: 'orchestrion:@prisma/orm-family-sql:deleteAndCount', + PRISMA_ALL: 'orchestrion:@prisma/orm-family-sql:all', + PRISMA_CREATE_ALL: 'orchestrion:@prisma/orm-family-sql:createAll', + PRISMA_UPDATE_ALL: 'orchestrion:@prisma/orm-family-sql:updateAll', + PRISMA_DELETE_ALL: 'orchestrion:@prisma/orm-family-sql:deleteAll', +} as const; diff --git a/packages/server-utils/test/integrations/prisma-orchestrion.test.ts b/packages/server-utils/test/integrations/prisma-orchestrion.test.ts new file mode 100644 index 000000000000..78a1e3bf1988 --- /dev/null +++ b/packages/server-utils/test/integrations/prisma-orchestrion.test.ts @@ -0,0 +1,365 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Client, Scope, Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { + addChildSpanToSpan, + getActiveSpan, + getDefaultCurrentScope, + getDefaultIsolationScope, + GLOBAL_OBJ, + SentryNonRecordingSpan, + setAsyncContextStrategy, + withActiveSpan, +} from '@sentry/core'; +import type { MockInstance } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { prismaIntegration } from '../../src/integrations/prisma'; +import { instrumentPrismaV8 } from '../../src/integrations/prisma/orchestrion'; +import { CHANNELS } from '../../src/orchestrion/channels'; +import { + PRISMA_ASYNC_TERMINALS, + PRISMA_LAZY_TERMINALS, + prismaChannels, + prismaConfig, +} from '../../src/orchestrion/config/prisma'; + +interface TestStore { + scope: Scope; + isolationScope: Scope; +} + +// `setup` only subscribes once an async-context strategy exposes `getTracingChannelBinding`. +function installTestAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage(); + + function getScopes(): TestStore { + return asyncStorage.getStore() || { scope: getDefaultCurrentScope(), isolationScope: getDefaultIsolationScope() }; + } + + setAsyncContextStrategy({ + withScope: callback => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withSetScope: (scope, callback) => { + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withIsolationScope: callback => { + const scope = getScopes().scope; + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + withSetIsolationScope: (isolationScope, callback) => { + const scope = getScopes().scope; + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ asyncLocalStorage: asyncStorage }), + }); +} + +function makeSpan(name: string): Span { + return { + end: vi.fn(), + setStatus: vi.fn(), + setAttributes: vi.fn(), + isRecording: () => true, + spanContext: () => ({ spanId: name, traceId: 'trace', traceFlags: 1 }), + } as unknown as Span; +} + +// Stand-in for Prisma's `AsyncIterableResult`: `then` funnels through `toArray`, `query` runs on consumption. +function makeLazyResult(query: () => unknown[]): { + then: Promise['then']; + toArray: () => Promise; + [Symbol.asyncIterator]: () => AsyncGenerator; +} { + return { + toArray() { + return Promise.resolve().then(query); + }, + then(onFulfilled, onRejected) { + return this.toArray().then(onFulfilled, onRejected); + }, + async *[Symbol.asyncIterator]() { + yield* query(); + }, + }; +} + +const PARENT_SPAN = makeSpan('parent'); + +describe('prisma orchestrion config', () => { + it('has one config per terminal, lazy terminals wrapped as Sync', () => { + const byMethod = new Map( + prismaConfig.map(config => [ + (config.functionQuery as { methodName: string }).methodName, + (config.functionQuery as { kind: string }).kind, + ]), + ); + + expect([...byMethod.keys()].sort()).toEqual([...PRISMA_ASYNC_TERMINALS, ...PRISMA_LAZY_TERMINALS].sort()); + PRISMA_ASYNC_TERMINALS.forEach(method => expect(byMethod.get(method)).toBe('Async')); + PRISMA_LAZY_TERMINALS.forEach(method => expect(byMethod.get(method)).toBe('Sync')); + expect(Object.values(prismaChannels).sort()).toEqual( + prismaConfig.map(config => `orchestrion:${config.module.name}:${config.channelName}`).sort(), + ); + }); +}); + +describe('instrumentPrismaV8', () => { + let startInactiveSpanSpy: MockInstance; + let span: Span; + + beforeAll(() => { + installTestAsyncContextStrategy(); + }); + + afterAll(() => { + setAsyncContextStrategy(undefined); + delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; + }); + + beforeEach(() => { + span = makeSpan('operation'); + startInactiveSpanSpy = vi.spyOn(SentryCore, 'startInactiveSpan').mockReturnValue(span); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Subscriptions are process-global, so this has to run before anything subscribes. + it('does not subscribe at all when the operation span type is ignored', () => { + instrumentPrismaV8({ ignoreSpanTypes: [/^prisma:client:operation$/] }); + + expect(tracingChannel(CHANNELS.PRISMA_CREATE).start.hasSubscribers).toBe(false); + expect(tracingChannel(CHANNELS.PRISMA_ALL).start.hasSubscribers).toBe(false); + }); + + it('subscribes through the integration once the Prisma module is injected', () => { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { runtime: ['@prisma/orm-family-sql'] }; + prismaIntegration().setup?.({ on: () => () => undefined } as unknown as Client); + + Object.values(prismaChannels).forEach(channel => { + expect(tracingChannel(channel).start.hasSubscribers).toBe(true); + }); + }); + + it('async terminal: opens an operation span for the call and ends it when the promise settles', async () => { + const ctx = { arguments: [{ email: 'a@b.c' }], self: { modelName: 'User', tableName: 'user' } }; + + let activeDuringCall: Span | undefined; + await withActiveSpan(PARENT_SPAN, () => + tracingChannel(CHANNELS.PRISMA_CREATE).tracePromise(async () => { + activeDuringCall = getActiveSpan(); + return { id: 1 }; + }, ctx), + ); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'prisma:client:operation', + parentSpan: PARENT_SPAN, + attributes: { + 'sentry.origin': 'auto.db.prisma', + 'sentry.op': 'db', + 'db.operation.name': 'create', + 'db.collection.name': 'user', + method: 'create', + model: 'User', + name: 'User.create', + }, + }), + ); + expect(activeDuringCall).toBe(span); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('reuses the outer operation span when a terminal calls another terminal', async () => { + const self = { modelName: 'User' }; + + let activeDuringInnerCall: Span | undefined; + await withActiveSpan(PARENT_SPAN, () => + tracingChannel(CHANNELS.PRISMA_DELETE).tracePromise( + () => + tracingChannel(CHANNELS.PRISMA_FIRST).tracePromise( + async () => { + activeDuringInnerCall = getActiveSpan(); + return { id: 1 }; + }, + { arguments: [], self }, + ), + { arguments: [], self }, + ), + ); + + expect(startInactiveSpanSpy).toHaveBeenCalledTimes(1); + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ attributes: expect.objectContaining({ method: 'delete' }) }), + ); + expect(activeDuringInnerCall).toBe(span); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('does not open a span without a parent', async () => { + await tracingChannel(CHANNELS.PRISMA_CREATE).tracePromise(async () => ({ id: 1 }), { + arguments: [], + self: { modelName: 'User' }, + }); + + expect(startInactiveSpanSpy).not.toHaveBeenCalled(); + }); + + it('async terminal: marks the span as errored when the promise rejects', async () => { + await expect( + withActiveSpan(PARENT_SPAN, () => + tracingChannel(CHANNELS.PRISMA_DELETE).tracePromise( + async () => { + throw new Error('boom'); + }, + { arguments: [], self: { modelName: 'User' } }, + ), + ), + ).rejects.toThrow('boom'); + + expect(span.setStatus).toHaveBeenCalledWith({ code: expect.anything(), message: 'boom' }); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('lazy terminal: keeps the span open until the result is awaited, and runs the query under it', async () => { + let activeDuringQuery: Span | undefined; + const result = withActiveSpan(PARENT_SPAN, () => + tracingChannel(CHANNELS.PRISMA_ALL).traceSync( + () => + makeLazyResult(() => { + activeDuringQuery = getActiveSpan(); + return [{ id: 1 }]; + }), + { arguments: [], self: { modelName: 'User' } }, + ), + ); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ attributes: expect.objectContaining({ method: 'all', model: 'User' }) }), + ); + expect(span.end).not.toHaveBeenCalled(); + + await expect(result).resolves.toEqual([{ id: 1 }]); + + expect(activeDuringQuery).toBe(span); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('lazy terminal: ends the span when a `for await` loop finishes', async () => { + let activeDuringQuery: Span | undefined; + const result = withActiveSpan(PARENT_SPAN, () => + tracingChannel(CHANNELS.PRISMA_ALL).traceSync( + () => + makeLazyResult(() => { + activeDuringQuery = getActiveSpan(); + return [1, 2]; + }), + { arguments: [], self: { modelName: 'User' } }, + ), + ); + + const rows: unknown[] = []; + for await (const row of result) { + rows.push(row); + expect(span.end).not.toHaveBeenCalled(); + } + + expect(rows).toEqual([1, 2]); + expect(activeDuringQuery).toBe(span); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('lazy terminal: ends the span when a `for await` loop breaks early', async () => { + const result = withActiveSpan(PARENT_SPAN, () => + tracingChannel(CHANNELS.PRISMA_ALL).traceSync(() => makeLazyResult(() => [1, 2, 3]), { + arguments: [], + self: { modelName: 'User' }, + }), + ); + + for await (const row of result) { + if (row === 2) { + break; + } + } + + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('lazy terminal: marks the span as errored when consuming the result rejects', async () => { + const result = withActiveSpan(PARENT_SPAN, () => + tracingChannel(CHANNELS.PRISMA_ALL).traceSync( + () => + makeLazyResult(() => { + throw new Error('query failed'); + }), + { arguments: [], self: { modelName: 'User' } }, + ), + ); + + await expect(result).rejects.toThrow('query failed'); + + expect(span.setStatus).toHaveBeenCalledWith({ code: expect.anything(), message: 'query failed' }); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('lazy terminal: does not activate an ignored operation span while the query runs', async () => { + const ignoredSpan = new SentryNonRecordingSpan({ dropReason: 'ignored' }); + addChildSpanToSpan(PARENT_SPAN, ignoredSpan); + const ignoredSpanEnd = vi.spyOn(ignoredSpan, 'end'); + startInactiveSpanSpy.mockReturnValue(ignoredSpan); + + let activeDuringQuery: Span | undefined; + const result = withActiveSpan(PARENT_SPAN, () => + tracingChannel(CHANNELS.PRISMA_ALL).traceSync( + () => + makeLazyResult(() => { + activeDuringQuery = getActiveSpan(); + return [1]; + }), + { arguments: [], self: { modelName: 'User' } }, + ), + ); + + await expect(withActiveSpan(PARENT_SPAN, () => result.toArray())).resolves.toEqual([1]); + expect(activeDuringQuery).toBe(PARENT_SPAN); + expect(ignoredSpanEnd).toHaveBeenCalledTimes(1); + }); + + it('lazy terminal: ends the span right away when the result cannot be patched', async () => { + const result = withActiveSpan(PARENT_SPAN, () => + tracingChannel(CHANNELS.PRISMA_ALL).traceSync(() => Object.freeze(makeLazyResult(() => [1])), { + arguments: [], + self: { modelName: 'User' }, + }), + ); + + expect(span.end).toHaveBeenCalledTimes(1); + await expect(result).resolves.toEqual([1]); + }); + + it('lazy terminal: ends the span right away when the call itself throws', () => { + expect(() => + withActiveSpan(PARENT_SPAN, () => + tracingChannel(CHANNELS.PRISMA_ALL).traceSync( + () => { + throw new Error('bad filter'); + }, + { arguments: [], self: { modelName: 'User' } }, + ), + ), + ).toThrow('bad filter'); + + expect(span.end).toHaveBeenCalledTimes(1); + }); +});