diff --git a/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/instrument-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/instrument-span-streaming.mjs new file mode 100644 index 000000000000..a7b5194fba83 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/instrument-span-streaming.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.knexIntegration()], + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/test.ts b/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/test.ts index da8ce435380b..d5f615dbaaec 100644 --- a/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/knex/mysql2/test.ts @@ -66,5 +66,41 @@ describeWithDockerCompose('knex auto instrumentation', { workingDirectory: [__di await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); }); }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => { + test('should name spans after the query summary with span streaming', { timeout: 60_000 }, async () => { + await createRunner() + .expect({ + span: container => { + // The `mysql2` driver spans underneath the knex spans come from a different integration and + // are named after the full statement, so they are filtered out here. + const knexSpans = container.items.filter(item => item.attributes['sentry.origin']?.value === ORIGIN); + + expect( + knexSpans.map(span => ({ + name: span.name, + summary: span.attributes['db.query.summary']?.value, + text: span.attributes['db.query.text']?.value, + })), + ).toEqual([ + { + name: 'create table `User`', + summary: 'create table `User`', + text: 'create table `User` (`id` int unsigned not null auto_increment primary key, `createdAt` timestamp(3) not null default CURRENT_TIMESTAMP(3), `email` text not null, `name` text not null)', + }, + { + name: 'insert `User`', + summary: 'insert `User`', + text: 'insert into `User` (`email`, `name`) values (?, ?)', + }, + { name: 'select `User`', summary: 'select `User`', text: 'select * from `User`' }, + { name: 'drop table `User`', summary: 'drop table `User`', text: 'drop table `User`' }, + ]); + }, + }) + .start() + .completed(); + }); + }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/knex/pg/instrument-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/knex/pg/instrument-span-streaming.mjs new file mode 100644 index 000000000000..a7b5194fba83 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/knex/pg/instrument-span-streaming.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.knexIntegration()], + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/knex/pg/test.ts b/dev-packages/node-integration-tests/suites/tracing/knex/pg/test.ts index eb59119619ba..a85a4d0250e3 100644 --- a/dev-packages/node-integration-tests/suites/tracing/knex/pg/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/knex/pg/test.ts @@ -80,5 +80,46 @@ describe('knex auto instrumentation', () => { await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); }); }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => { + test('should name spans after the query summary with span streaming', { timeout: 60_000 }, async () => { + await createRunner() + .expect({ + span: container => { + // The `pg` driver spans underneath the knex spans come from a different integration and + // are named after the full statement, so they are filtered out here. + const knexSpans = container.items.filter(item => item.attributes['sentry.origin']?.value === ORIGIN); + + expect( + knexSpans.map(span => ({ + name: span.name, + summary: span.attributes['db.query.summary']?.value, + text: span.attributes['db.query.text']?.value, + })), + ).toEqual([ + { + name: 'create table "User"', + summary: 'create table "User"', + text: 'create table "User" ("id" serial primary key, "createdAt" timestamptz(3) not null default CURRENT_TIMESTAMP(3), "email" text not null, "name" text not null)', + }, + { + name: 'insert "User"', + summary: 'insert "User"', + text: 'insert into "User" ("email", "name") values (?, ?)', + }, + { name: 'select "User"', summary: 'select "User"', text: 'select * from "User"' }, + { + name: 'select "DoesNotExist"', + summary: 'select "DoesNotExist"', + text: 'select * from "DoesNotExist"', + }, + { name: 'drop table "User"', summary: 'drop table "User"', text: 'drop table "User"' }, + ]); + }, + }) + .start() + .completed(); + }); + }); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/instrument-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/instrument-span-streaming.mjs new file mode 100644 index 000000000000..53b9511a21f0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/instrument-span-streaming.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', +}); 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 e4c4f031dced..f9a3be37ad57 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 @@ -72,6 +72,7 @@ function expectPrismaV5Spans(transaction: TransactionEvent): void { expect.objectContaining({ data: { 'db.statement': expect.stringContaining('INSERT INTO'), + 'db.query.summary': 'INSERT "public"."User"', 'db.system': 'postgresql', 'sentry.kind': 'client', 'sentry.op': 'db', @@ -84,6 +85,7 @@ function expectPrismaV5Spans(transaction: TransactionEvent): void { expect.objectContaining({ data: { 'db.statement': expect.stringContaining('SELECT'), + 'db.query.summary': 'SELECT "public"', 'db.system': 'postgresql', 'sentry.kind': 'client', 'sentry.op': 'db', @@ -96,6 +98,7 @@ function expectPrismaV5Spans(transaction: TransactionEvent): void { expect.objectContaining({ data: { 'db.statement': expect.stringContaining('DELETE'), + 'db.query.summary': 'DELETE "public"."User"', 'db.system': 'postgresql', 'sentry.kind': 'client', 'sentry.op': 'db', @@ -128,5 +131,51 @@ describeWithDockerCompose('Prisma ORM v5', { workingDirectory: [__dirname] }, () copyPaths: ['prisma'], }, ); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument-span-streaming.mjs', + (createRunner, test) => { + test( + 'should name db query spans after the query summary with span streaming', + { timeout: 75_000 }, + async () => { + await createRunner() + .expect({ + span: container => { + // v5 reports the SQL on the deprecated `db.statement` rather than `db.query.text`. + const querySpans = container.items.filter(item => item.attributes['db.statement']); + + expect( + querySpans.map(span => ({ + name: span.name, + summary: span.attributes['db.query.summary']?.value, + })), + ).toEqual([ + { name: 'INSERT "public"."User"', summary: 'INSERT "public"."User"' }, + { name: 'SELECT "public"', summary: 'SELECT "public"' }, + { name: 'BEGIN', summary: 'BEGIN' }, + { name: 'INSERT "public"."User"', summary: 'INSERT "public"."User"' }, + { name: 'SELECT "public"', summary: 'SELECT "public"' }, + { name: 'COMMIT', summary: 'COMMIT' }, + { name: 'DELETE "public"."User"', summary: 'DELETE "public"."User"' }, + ]); + + // The raw engine span name must never leak through. + expect(container.items.map(span => span.name)).not.toContain('prisma:engine:db_query'); + }, + }) + .start() + .completed(); + }, + ); + }, + { + additionalDependencies: ADDITIONAL_DEPENDENCIES, + afterSetupCommand: AFTER_SETUP_COMMAND, + copyPaths: ['prisma'], + }, + ); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/instrument-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/instrument-span-streaming.mjs new file mode 100644 index 000000000000..53b9511a21f0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/instrument-span-streaming.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', +}); 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 28187d820d1a..478ed2efcca0 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 @@ -88,6 +88,7 @@ describeWithDockerCompose('Prisma ORM v6 Tests', { workingDirectory: [__dirname] 'sentry.op': 'db', 'db.query.text': 'SELECT "public"."User"."id", "public"."User"."createdAt", "public"."User"."email", "public"."User"."name" FROM "public"."User" WHERE 1=1 OFFSET $1', + 'db.query.summary': 'SELECT "public"', 'db.system': 'postgresql', 'sentry.kind': 'client', }, @@ -99,6 +100,7 @@ describeWithDockerCompose('Prisma ORM v6 Tests', { workingDirectory: [__dirname] data: { 'sentry.op': 'db', 'db.query.text': 'DELETE FROM "public"."User" WHERE "public"."User"."email"::text LIKE $1', + 'db.query.summary': 'DELETE "public"."User"', 'db.system': 'postgresql', 'sentry.kind': 'client', }, @@ -119,4 +121,45 @@ describeWithDockerCompose('Prisma ORM v6 Tests', { workingDirectory: [__dirname] copyPaths: ['prisma'], }, ); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument-span-streaming.mjs', + (createRunner, test) => { + test('should name db query spans after the query summary with span streaming', { timeout: 75_000 }, async () => { + await createRunner() + .expect({ + span: container => { + const querySpans = container.items.filter(item => item.attributes['db.query.text']); + + // `SELECT "public"` is what the core query-summary helper derives from a schema-qualified, + // quoted table (it stops at the first quoted identifier). + expect( + querySpans.map(span => ({ + name: span.name, + summary: span.attributes['db.query.summary']?.value, + })), + ).toEqual([ + { name: 'INSERT "public"."User"', summary: 'INSERT "public"."User"' }, + { name: 'SELECT "public"', summary: 'SELECT "public"' }, + { name: 'DELETE "public"."User"', summary: 'DELETE "public"."User"' }, + ]); + + // Neither the raw engine span name nor the full statement may end up as a span name. + expect(container.items.map(span => span.name)).not.toContain('prisma:engine:db_query'); + querySpans.forEach(span => { + expect(span.name).not.toBe(span.attributes['db.query.text']?.value); + }); + }, + }) + .start() + .completed(); + }); + }, + { + afterSetupCommand: 'prisma generate --schema prisma/schema.prisma', + copyPaths: ['prisma'], + }, + ); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/instrument-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/instrument-span-streaming.mjs new file mode 100644 index 000000000000..53b9511a21f0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/instrument-span-streaming.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts index 9d7d98d48db1..c0da3f46aca9 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/test.ts @@ -91,5 +91,63 @@ describe('Prisma ORM v7 Tests', () => { copyPaths: ['prisma', 'prisma.config.ts'], }, ); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument-span-streaming.mjs', + (createRunner, test) => { + test( + 'should name db query spans after the query summary with span streaming', + { timeout: 75_000 }, + async () => { + await createRunner() + .expect({ + span: container => { + // v7 runs the queries through the `pg` adapter, whose own spans are named after the full + // statement by a different integration, so they are filtered out here. + const querySpans = container.items.filter( + item => + item.attributes['sentry.origin']?.value === 'auto.db.otel.prisma' && + item.attributes['db.query.text'], + ); + + // `SELECT "public"` is what the core query-summary helper derives from a schema-qualified, + // quoted table (it stops at the first quoted identifier). + expect( + querySpans.map(span => ({ + name: span.name, + summary: span.attributes['db.query.summary']?.value, + })), + ).toEqual([ + { name: 'INSERT "public"."User"', summary: 'INSERT "public"."User"' }, + { name: 'SELECT "public"', summary: 'SELECT "public"' }, + { name: 'DELETE "public"."User"', summary: 'DELETE "public"."User"' }, + ]); + + // Neither the raw client span name nor the full statement may end up as a span name. + expect(container.items.map(span => span.name)).not.toContain('prisma:client:db_query'); + querySpans.forEach(span => { + expect(span.name).not.toBe(span.attributes['db.query.text']?.value); + }); + }, + }) + .start() + .completed(); + }, + ); + }, + { + additionalDependencies: { + '@prisma/adapter-pg': '7.2.0', + '@prisma/client': '7.2.0', + pg: '^8.11.0', + prisma: '7.2.0', + typescript: '^5.9.0', + }, + afterSetupCommand: 'prisma generate --schema prisma/schema.prisma && tsc -p prisma/tsconfig.json', + copyPaths: ['prisma', 'prisma.config.ts'], + }, + ); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/tedious/instrument-span-streaming.mjs b/dev-packages/node-integration-tests/suites/tracing/tedious/instrument-span-streaming.mjs new file mode 100644 index 000000000000..53b9511a21f0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/tedious/instrument-span-streaming.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + traceLifecycle: 'stream', +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts b/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts index 23045b1dd408..0d13c0c0023b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts @@ -47,4 +47,44 @@ describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [_ await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); }); }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createTestRunner, test) => { + test('should name spans after the operation with span streaming', async () => { + await createTestRunner() + .expect({ + span: container => { + const dbSpans = container.items.filter(item => item.attributes['sentry.origin']?.value === ORIGIN); + + // The SQL statement stays on `db.query.text`, but never reaches the span name. + expect(dbSpans.map(span => ({ name: span.name, text: span.attributes['db.query.text']?.value }))).toEqual([ + { name: 'execSql master', text: 'SELECT 1 + 1 AS solution' }, + { name: 'execSqlBatch master', text: 'SELECT 42; SELECT 42;' }, + { name: 'execSql master', text: 'select !' }, + { + name: 'execSql master', + text: 'CREATE OR ALTER PROCEDURE [dbo].[test_proced] @inputVal varchar(30), @outputCount int OUTPUT AS set @outputCount = LEN(@inputVal);', + }, + { name: 'callProcedure [dbo].[test_proced] master', text: '[dbo].[test_proced]' }, + { + name: 'execSql master', + text: "if object_id('[dbo].[test_prepared]') is null CREATE TABLE [dbo].[test_prepared] (c1 int, c2 int)", + }, + { name: 'prepare master', text: 'INSERT INTO [dbo].[test_prepared] VALUES (@val1, @val2)' }, + { name: 'execute master', text: 'INSERT INTO [dbo].[test_prepared] VALUES (@val1, @val2)' }, + { + name: 'execSql master', + text: "if object_id('[dbo].[test_bulk]') is null CREATE TABLE [dbo].[test_bulk] (c1 int, c2 varchar(30))", + }, + { + name: 'execSqlBatch master', + text: 'insert bulk test_bulk([c1] int, [c2] nvarchar(50)) WITH (KEEP_NULLS)', + }, + { name: 'execBulkLoad test_bulk master', text: undefined }, + ]); + }, + }) + .start() + .completed(); + }); + }); }); diff --git a/packages/server-utils/src/integrations/knex.ts b/packages/server-utils/src/integrations/knex.ts index 2928f66bd339..2204ae8b196a 100644 --- a/packages/server-utils/src/integrations/knex.ts +++ b/packages/server-utils/src/integrations/knex.ts @@ -5,9 +5,14 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core'; import { + _INTERNAL_getSqlQuerySummary, + _INTERNAL_sanitizeSqlQuery, + DB_SPAN_NAME_FALLBACK, debug, defineIntegration, getActiveSpan, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startInactiveSpan, @@ -17,6 +22,7 @@ import { import { DB_NAMESPACE, DB_OPERATION_NAME, + DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME, DB_USER, @@ -163,10 +169,15 @@ function subscribeQuery(): void { const connectionString = connection?.connectionString; const table = extractTableName(builder); const operation = query?.method; - const name = + const dbNameSpace = connection?.filename || connection?.database || extractDatabaseFromConnectionString(connectionString); const dbStatement = query?.sql != null ? truncate(query.sql, MAX_QUERY_LENGTH) : undefined; + // The statement is sanitized before it is summarized, so that a string literal containing + // `from`/`join` can't leak a value into the summary. + const querySummary = dbStatement + ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(dbStatement)) + : undefined; const attributes: SpanAttributes = { [SENTRY_KIND]: 'client', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, @@ -175,15 +186,22 @@ function subscribeQuery(): void { [ATTR_DB_SQL_TABLE]: table, [DB_OPERATION_NAME]: operation, [DB_USER]: connection?.user, - [DB_NAMESPACE]: name, + [DB_NAMESPACE]: dbNameSpace, [SERVER_ADDRESS]: connection?.host ?? extractHostFromConnectionString(connectionString), [SERVER_PORT]: connection?.port ?? extractPortFromConnectionString(connectionString), [NETWORK_TRANSPORT]: connection?.filename === ':memory:' ? 'inproc' : undefined, [DB_QUERY_TEXT]: dbStatement, + [DB_QUERY_SUMMARY]: querySummary, }; + const sentryClient = getClient(); + const spanName = + sentryClient && hasSpanStreamingEnabled(sentryClient) + ? querySummary || getSecondaryStreamName(dbNameSpace, operation, table) + : (dbStatement ?? getName(dbNameSpace, operation, table) ?? 'knex.query'); + return startInactiveSpan({ - name: dbStatement ?? getName(name, operation, table) ?? 'knex.query', + name: spanName, op: 'db', parentSpan, attributes, @@ -262,6 +280,24 @@ function getName(db: string | undefined, operation?: string, table?: string): st return db; } +function getSecondaryStreamName(dbNameSpace: string | undefined, operation?: string, table?: string): string { + if (operation) { + if (table) { + return `${operation} ${table}`; + } + if (dbNameSpace) { + return `${operation} ${dbNameSpace}`; + } + } + if (table) { + return table; + } + if (dbNameSpace) { + return dbNameSpace; + } + return DB_SPAN_NAME_FALLBACK; +} + function extractTableName(builder: KnexBuilder | undefined): string | undefined { const table = builder?._single?.table; if (table && typeof table === 'object') { diff --git a/packages/server-utils/src/integrations/prisma/tracing-helper.ts b/packages/server-utils/src/integrations/prisma/tracing-helper.ts index 20f1e3552d18..365ac06caa9c 100644 --- a/packages/server-utils/src/integrations/prisma/tracing-helper.ts +++ b/packages/server-utils/src/integrations/prisma/tracing-helper.ts @@ -15,8 +15,12 @@ import type { Span, SpanAttributes } from '@sentry/core'; import { + _INTERNAL_getSqlQuerySummary, + _INTERNAL_sanitizeSqlQuery, debug, getActiveSpan, + getClient, + hasSpanStreamingEnabled, LRUMap, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, @@ -24,7 +28,15 @@ import { } from '@sentry/core'; import { DEBUG_BUILD } from '../../debug-build'; import type { EngineSpan, ExtendedSpanOptions, SpanCallback, TracingHelper } from './types'; -import { DB_STATEMENT, DB_SYSTEM, DB_SYSTEM_NAME, SENTRY_KIND, SENTRY_OP } from '@sentry/conventions/attributes'; +import { + DB_QUERY_SUMMARY, + DB_QUERY_TEXT, + DB_STATEMENT, + DB_SYSTEM, + DB_SYSTEM_NAME, + SENTRY_KIND, + SENTRY_OP, +} from '@sentry/conventions/attributes'; // Reading `process.env` can throw in runtimes that gate env access (e.g. Deno without `--allow-env`) // and `process` may be absent altogether (edge runtimes), so this degrades to `false` in those cases. @@ -102,24 +114,47 @@ function buildSpanAttributes(name: string, attributes: Record | merged[SENTRY_OP] = 'db'; } + const statement = getSqlStatement(name, merged); + if (statement) { + // Sanitized before summarizing, so that a string literal containing `from`/`join` can't leak a + // value into the summary. + merged[DB_QUERY_SUMMARY] = _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(statement)); + } + return merged; } /** - * Db query spans are named after their SQL text (e.g. `SELECT * FROM "User"`) rather than the generic - * engine name. v5/v6 emit `prisma:engine:db_query`; v7 inlined the engine and emits `prisma:client:db_query`. + * The SQL a span reports, if any. Prisma emits it as the deprecated `db.statement` on older versions + * and as `db.query.text` on the `db_query` spans of newer ones. */ -function buildSpanName(name: string, attributes: SpanAttributes): string { +function getSqlStatement(name: string, attributes: SpanAttributes): string | undefined { // oxlint-disable-next-line typescript/no-deprecated const dbStatement = attributes[DB_STATEMENT]; if (typeof dbStatement === 'string' && dbStatement) { return dbStatement; } - const queryText = attributes['db.query.text']; + const queryText = attributes[DB_QUERY_TEXT]; if ((name === 'prisma:engine:db_query' || name === 'prisma:client:db_query') && typeof queryText === 'string') { return queryText; } - return name; + return undefined; +} + +/** + * Db query spans are named after their SQL text (e.g. `SELECT * FROM "User"`) rather than the generic + * engine name. v5/v6 emit `prisma:engine:db_query`; v7 inlined the engine and emits `prisma:client:db_query`. + */ +function buildSpanName(name: string, attributes: SpanAttributes): string { + const client = getClient(); + + // With span streaming, span names have to be low cardinality, so `{db.query.summary}` is used + // instead of the full statement. Spans that report no SQL keep the engine span name. + if (client && hasSpanStreamingEnabled(client)) { + return (attributes[DB_QUERY_SUMMARY] as string | undefined) || name; + } + + return getSqlStatement(name, attributes) ?? name; } /** diff --git a/packages/server-utils/src/integrations/tedious.ts b/packages/server-utils/src/integrations/tedious.ts index 65f0ea8d2c6b..e4106a5a8baa 100644 --- a/packages/server-utils/src/integrations/tedious.ts +++ b/packages/server-utils/src/integrations/tedious.ts @@ -7,6 +7,8 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, SpanAttributes } from '@sentry/core'; import { defineIntegration, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startInactiveSpan, @@ -140,8 +142,14 @@ function subscribeQuery(channelName: string, operation: string): void { [SERVER_PORT]: connection.config?.options?.port, }; + const client = getClient(); + // `getSpanName` already builds `{db.operation.name}` paired with the bulk-load table, the stored + // procedure or `{db.namespace}`, so with span streaming — where span names have to be low + // cardinality — it is used instead of the SQL statement. + const spanName = getSpanName(operation, databaseName, sql, request.table); + const span = startInactiveSpan({ - name: sql || getSpanName(operation, databaseName, sql, request.table), + name: client && hasSpanStreamingEnabled(client) ? spanName : sql || spanName, op: 'db', attributes, });