diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/scenario.mjs index 1c9511f52913..8b88396a686c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/scenario.mjs @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; import { PrismaClient } from '@prisma/client'; import * as Sentry from '@sentry/node'; import { randomBytes } from 'crypto'; @@ -11,6 +12,11 @@ async function run() { op: 'transaction', }, async span => { + await client.$connect(); + await Sentry.withActiveSpan(null, async () => { + assert.deepEqual(await client.$queryRawUnsafe('SELECT 1 AS before_parent'), [{ before_parent: 1 }]); + }); + await client.user.create({ data: { name: 'Tilda', @@ -40,6 +46,10 @@ async function run() { }, }); + await Sentry.withActiveSpan(null, async () => { + assert.deepEqual(await client.$queryRawUnsafe('SELECT 3 AS after_parent'), [{ after_parent: 3 }]); + }); + setTimeout(async () => { span.end(); await client.$disconnect(); diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/test.ts index 4c528b7c3aa5..474f3b68a1b0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/test.ts @@ -23,6 +23,7 @@ const ADDITIONAL_DEPENDENCIES = { function expectPrismaV5Spans(container: SerializedStreamedSpanContainer): void { const segment = container.items.find(span => span.is_segment); expect(segment?.name).toBe('Test Transaction'); + expect(container.items.filter(span => span.is_segment)).toHaveLength(1); const spans = container.items.filter(span => !span.is_segment); expect(spans.length).toBeGreaterThanOrEqual(5); @@ -148,7 +149,7 @@ describeWithDockerCompose('Prisma ORM v5', { workingDirectory: [__dirname] }, () 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - test('should instrument PostgreSQL queries from Prisma ORM', { timeout: 75_000 }, async () => { + test('only instruments PostgreSQL queries with an active parent span', { timeout: 75_000 }, async () => { await createRunner().expect({ span: expectPrismaV5Spans }).start().completed(); }); }, diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/scenario.mjs index 29862d9fad4e..b2fbfbb40120 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/scenario.mjs @@ -1,3 +1,4 @@ +import assert from 'node:assert/strict'; import { PrismaClient } from '@prisma/client'; import * as Sentry from '@sentry/node'; import { randomBytes } from 'crypto'; @@ -11,6 +12,11 @@ async function run() { async span => { const client = new PrismaClient(); + await client.$connect(); + await Sentry.withActiveSpan(null, async () => { + assert.deepEqual(await client.$queryRawUnsafe('SELECT 1 AS before_parent'), [{ before_parent: 1 }]); + }); + await client.user.create({ data: { name: 'Tilda', @@ -28,6 +34,10 @@ async function run() { }, }); + await Sentry.withActiveSpan(null, async () => { + assert.deepEqual(await client.$queryRawUnsafe('SELECT 3 AS after_parent'), [{ after_parent: 3 }]); + }); + setTimeout(async () => { span.end(); await client.$disconnect(); diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/test.ts index 18f44727b1bb..427d147b6a62 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/test.ts @@ -21,12 +21,13 @@ describeWithDockerCompose('Prisma ORM v6 Tests', { workingDirectory: [__dirname] 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - test('should instrument PostgreSQL queries from Prisma ORM', { timeout: 75_000 }, async () => { + test('only instruments PostgreSQL queries with an active parent span', { timeout: 75_000 }, async () => { await createRunner() .expect({ span: container => { const segment = container.items.find(span => span.is_segment); expect(segment?.name).toBe('Test Transaction'); + expect(container.items.filter(span => span.is_segment)).toHaveLength(1); const spans = container.items.filter(span => !span.is_segment); expect(spans.length).toBeGreaterThanOrEqual(5); diff --git a/packages/server-utils/src/integrations/prisma/tracing-helper.ts b/packages/server-utils/src/integrations/prisma/tracing-helper.ts index 218e7c788134..1d7bd4d284c4 100644 --- a/packages/server-utils/src/integrations/prisma/tracing-helper.ts +++ b/packages/server-utils/src/integrations/prisma/tracing-helper.ts @@ -242,6 +242,10 @@ export class ActiveTracingHelper implements TracingHelper { } public dispatchEngineSpans(spans: EngineSpan[]): void { + if (!getActiveSpan()) { + return; + } + const linkIds = new Map(); const roots = spans.filter(span => span.parentId === null); @@ -291,6 +295,9 @@ export class ActiveTracingHelper implements TracingHelper { } const parentSpan = getActiveSpan(); + if (!parentSpan) { + return callback(); + } const attributes = buildSpanAttributes(name, options.attributes); const spanOptions = { diff --git a/packages/server-utils/test/integrations/prisma.test.ts b/packages/server-utils/test/integrations/prisma.test.ts index 2d02718b6d1e..e9b739e8f216 100644 --- a/packages/server-utils/test/integrations/prisma.test.ts +++ b/packages/server-utils/test/integrations/prisma.test.ts @@ -1,8 +1,17 @@ -import type { Span } from '@sentry/core'; -import { Client, createTransport, getCurrentScope, initAndBind, resolvedSyncPromise, spanToJSON } from '@sentry/core'; -import { afterEach, describe, expect, it } from 'vitest'; +import type { Client, Span, SpanAttributes } from '@sentry/core'; +import { + createTransport, + getCurrentScope, + initAndBind, + resolvedSyncPromise, + spanToJSON, + startSpan, + withActiveSpan, +} from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { instrumentPrisma } from '../../src/integrations/prisma'; -import type { TracingHelper } from '../../src/integrations/prisma/types'; +import type { EngineSpan, TracingHelper } from '../../src/integrations/prisma/types'; +import { TestClient } from '../mocks/client'; type PrismaGlobal = typeof globalThis & { PRISMA_INSTRUMENTATION?: { helper?: TracingHelper }; @@ -13,18 +22,9 @@ function getHelper(): (TracingHelper & { createEngineSpan?: unknown }) | undefin return (globalThis as PrismaGlobal).PRISMA_INSTRUMENTATION?.helper; } -class TestClient extends Client { - public eventFromException(): PromiseLike { - return resolvedSyncPromise({}); - } - public eventFromMessage(): PromiseLike { - return resolvedSyncPromise({}); - } -} - -function initTestClient(): void { +function initTestClient(): Client { getCurrentScope().setClient(undefined); - initAndBind(TestClient, { + return initAndBind(TestClient, { dsn: 'https://username@domain/123', integrations: [], sendClientReports: false, @@ -35,16 +35,19 @@ function initTestClient(): void { } /** Runs a `db_query` span through the installed helper and returns the span it created. */ -function runDbQuerySpan(attributes: Record): Span { +function runDbQuerySpan(attributes: SpanAttributes): Span { let span: Span | undefined; - getHelper()?.runInChildSpan({ name: 'db_query', attributes }, createdSpan => { - span = createdSpan; + startSpan({ name: 'parent' }, () => { + getHelper()?.runInChildSpan({ name: 'db_query', attributes }, createdSpan => { + span = createdSpan; + }); }); return span!; } describe('instrumentPrisma', () => { afterEach(() => { + getCurrentScope().setClient(undefined); const g = globalThis as PrismaGlobal; g.PRISMA_INSTRUMENTATION = undefined; g.V7_PRISMA_INSTRUMENTATION = undefined; @@ -71,6 +74,72 @@ describe('instrumentPrisma', () => { expect(helper?.isEnabled()).toBe(true); }); + describe('engine spans', () => { + const engineSpans: EngineSpan[] = [ + { + id: 'engine-query', + parentId: null, + name: 'prisma:engine:query', + kind: 'internal', + startTime: [1, 0], + endTime: [1, 1_000_000], + }, + { + id: 'db-query', + parentId: 'engine-query', + name: 'prisma:engine:db_query', + kind: 'client', + startTime: [1, 0], + endTime: [1, 1_000_000], + attributes: { + 'db.query.text': 'SELECT * FROM "User"', + 'db.system.name': 'postgresql', + }, + }, + ]; + + it('runs the callback without creating client or engine spans when no parent is active', () => { + const client = initTestClient(); + instrumentPrisma(); + const spanStart = vi.fn(); + client.on('spanStart', spanStart); + const helper = getHelper()!; + const callback = vi.fn(() => { + helper.dispatchEngineSpans(engineSpans); + return 'query result'; + }); + + const result = withActiveSpan(null, () => helper.runInChildSpan('operation', callback)); + + expect(result).toBe('query result'); + expect(callback).toHaveBeenCalledExactlyOnceWith(); + expect(spanStart).not.toHaveBeenCalled(); + }); + + it('attaches the engine batch to the active parent and preserves its child relationships', () => { + const client = initTestClient(); + instrumentPrisma(); + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + const helper = getHelper()!; + + startSpan({ name: 'parent' }, parent => { + helper.dispatchEngineSpans(engineSpans); + + const engineSpan = endedSpans.find(span => spanToJSON(span).name === 'prisma:engine:query')!; + expect( + endedSpans.map(span => ({ + name: spanToJSON(span).name, + parentSpanId: spanToJSON(span).parent_span_id, + })), + ).toEqual([ + { name: 'SELECT "User"', parentSpanId: engineSpan.spanContext().spanId }, + { name: 'prisma:engine:query', parentSpanId: parent.spanContext().spanId }, + ]); + }); + }); + }); + describe('db.query.summary', () => { it('summarizes a standard-dialect statement', () => { initTestClient();