From f4dfcc59389d070edccfb45d14e5afb89498d9fc Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 25 Aug 2026 17:37:53 +0200 Subject: [PATCH 01/10] feat(server-utils)!: Emit low cardinality knex, tedious and prisma db span names With span streaming, these three name their query spans from the span name conventions instead of the SQL statement. They are grouped because each needs a different fallback: knex drops to its existing `{operation} {namespace}.{table}`, tedious has no statement to summarize and keeps `getSpanName`, and prisma resolves its statement from either `db.statement` or `db.query.text` depending on version. knex and prisma also report the new `db.query.summary` attribute. `traceLifecycle: 'static'` keeps the existing names. Refs #23523 Co-Authored-By: Claude Opus 5 (1M context) --- .../server-utils/src/integrations/knex.ts | 23 ++++++++- .../src/integrations/prisma/tracing-helper.ts | 47 ++++++++++++++++--- .../server-utils/src/integrations/tedious.ts | 10 +++- 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/packages/server-utils/src/integrations/knex.ts b/packages/server-utils/src/integrations/knex.ts index d23037b70b0a..7794bed5c64e 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, @@ -169,6 +175,11 @@ function subscribeQuery(): void { 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_OP]: DB, [SENTRY_KIND]: 'client', @@ -183,10 +194,20 @@ function subscribeQuery(): void { [SERVER_PORT]: connection?.port ?? extractPortFromConnectionString(connectionString), [NETWORK_TRANSPORT]: connection?.filename === ':memory:' ? 'inproc' : undefined, [DB_QUERY_TEXT]: dbStatement, + [DB_QUERY_SUMMARY]: querySummary, }; + const sentryClient = getClient(); + // With span streaming, span names have to be low cardinality, so `{db.query.summary}` is used + // instead of the full statement, falling back to `getName`'s `{operation} {namespace}.{table}` + // when there is no statement to summarize. + const streamedName = + sentryClient && hasSpanStreamingEnabled(sentryClient) + ? querySummary || getName(name, operation, table) || DB_SPAN_NAME_FALLBACK + : undefined; + return startInactiveSpan({ - name: dbStatement ?? getName(name, operation, table) ?? 'knex.query', + name: streamedName ?? dbStatement ?? getName(name, operation, table) ?? 'knex.query', parentSpan, attributes, }); diff --git a/packages/server-utils/src/integrations/prisma/tracing-helper.ts b/packages/server-utils/src/integrations/prisma/tracing-helper.ts index 390113843f0f..f74fc2008d51 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 77137b5086fc..150a5ad2b68f 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, @@ -143,8 +145,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, attributes, }); From 6a70bb70c0caf95d122e4c285507c0a8358767c8 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 26 Aug 2026 18:20:43 +0200 Subject: [PATCH 02/10] add integration tests, cleanup --- .../knex/mysql2/instrument-span-streaming.mjs | 11 ++++ .../suites/tracing/knex/mysql2/test.ts | 36 ++++++++++++ .../knex/pg/instrument-span-streaming.mjs | 11 ++++ .../suites/tracing/knex/pg/test.ts | 41 +++++++++++++ .../instrument-span-streaming.mjs | 10 ++++ .../suites/tracing/prisma-orm-v5/test.ts | 49 ++++++++++++++++ .../instrument-span-streaming.mjs | 10 ++++ .../suites/tracing/prisma-orm-v6/test.ts | 43 ++++++++++++++ .../instrument-span-streaming.mjs | 10 ++++ .../suites/tracing/prisma-orm-v7/test.ts | 58 +++++++++++++++++++ .../tedious/instrument-span-streaming.mjs | 10 ++++ .../suites/tracing/tedious/test.ts | 40 +++++++++++++ .../server-utils/src/integrations/knex.ts | 33 ++++++++--- 13 files changed, 353 insertions(+), 9 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/knex/mysql2/instrument-span-streaming.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/knex/pg/instrument-span-streaming.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/prisma-orm-v5/instrument-span-streaming.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/prisma-orm-v6/instrument-span-streaming.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/prisma-orm-v7/instrument-span-streaming.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/tedious/instrument-span-streaming.mjs 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 6c104e7ed8ea..32d77ccb40ed 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 853217824013..f5ddc6b2aea6 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 f7d93c2ca2ca..dc6d93ca8ee0 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 @@ -89,5 +89,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 7794bed5c64e..31b156a6bd9b 100644 --- a/packages/server-utils/src/integrations/knex.ts +++ b/packages/server-utils/src/integrations/knex.ts @@ -171,7 +171,7 @@ 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; @@ -189,7 +189,7 @@ 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, @@ -198,16 +198,13 @@ function subscribeQuery(): void { }; const sentryClient = getClient(); - // With span streaming, span names have to be low cardinality, so `{db.query.summary}` is used - // instead of the full statement, falling back to `getName`'s `{operation} {namespace}.{table}` - // when there is no statement to summarize. - const streamedName = + const spanName = sentryClient && hasSpanStreamingEnabled(sentryClient) - ? querySummary || getName(name, operation, table) || DB_SPAN_NAME_FALLBACK - : undefined; + ? querySummary || getSecondaryStreamName(dbNameSpace, operation, table) + : (dbStatement ?? getName(dbNameSpace, operation, table) ?? 'knex.query'); return startInactiveSpan({ - name: streamedName ?? dbStatement ?? getName(name, operation, table) ?? 'knex.query', + name: spanName, parentSpan, attributes, }); @@ -285,6 +282,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') { From 818c1999b6c5d666868a93009ae821df87c76964 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 27 Aug 2026 14:41:25 +0200 Subject: [PATCH 03/10] fix tedious, add better tests --- .../suites/tracing/tedious/scenario.mjs | 18 +++ .../suites/tracing/tedious/test.ts | 123 +++++++++++++++--- .../server-utils/src/integrations/tedious.ts | 33 ++++- 3 files changed, 153 insertions(+), 21 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/tedious/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/tedious/scenario.mjs index 34c78412bc71..8b5e8ee3ae58 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tedious/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/tedious/scenario.mjs @@ -46,6 +46,14 @@ function query(connection, sql, method = 'execSql') { }); } +function queryWithParameter(connection, sql, name, type, value) { + return new Promise((resolve, reject) => { + const request = new Request(sql, err => (err ? reject(err) : resolve())); + request.addParameter(name, type, value); + connection.execSql(request); + }); +} + function callProcedure(connection) { return new Promise((resolve, reject) => { const request = new Request(PROCEDURE_NAME, err => (err ? reject(err) : resolve())); @@ -118,6 +126,16 @@ async function run() { `if object_id('[dbo].[${BULK_TABLE}]') is null CREATE TABLE [dbo].[${BULK_TABLE}] (c1 int, c2 varchar(30))`, ); await bulkLoad(connection); + + // Reads against real tables: the single- and multi-table shapes a query summary has to resolve. + await query(connection, `SELECT c1, c2 FROM ${PREPARED_TABLE}`); + await query(connection, `SELECT p.c1 FROM ${PREPARED_TABLE} p INNER JOIN [dbo].[${BULK_TABLE}] b ON p.c1 = b.c1`); + + // An inlined literal, the same filter parameterized, and a string literal containing `from` — the + // last one is why the statement is sanitized before it is summarized. + await query(connection, `SELECT c1, c2 FROM ${PREPARED_TABLE} WHERE c1 = 42`); + await queryWithParameter(connection, `SELECT c1, c2 FROM ${PREPARED_TABLE} WHERE c1 = @c1`, 'c1', TYPES.Int, 1); + await query(connection, `SELECT c1, c2 FROM [dbo].[${BULK_TABLE}] WHERE c2 = 'hello from acme'`); }); connection.close(); 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 0d13c0c0023b..9bd7ca0a55e1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts @@ -44,42 +44,135 @@ describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [_ createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => { test('should auto-instrument `tedious` package', async () => { - await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + await createTestRunner() + .expect({ + transaction: transaction => { + expect(transaction.transaction).toBe(EXPECTED_TRANSACTION.transaction); + expect(transaction.spans).toEqual(EXPECTED_TRANSACTION.spans); + + const CREATE_PROCEDURE = + 'CREATE OR ALTER PROCEDURE [dbo].[test_proced] @inputVal varchar(30), @outputCount int OUTPUT AS set @outputCount = LEN(@inputVal);'; + const CREATE_PREPARED_TABLE = + "if object_id('[dbo].[test_prepared]') is null CREATE TABLE [dbo].[test_prepared] (c1 int, c2 int)"; + const CREATE_BULK_TABLE = + "if object_id('[dbo].[test_bulk]') is null CREATE TABLE [dbo].[test_bulk] (c1 int, c2 varchar(30))"; + const INSERT_PREPARED = 'INSERT INTO [dbo].[test_prepared] VALUES (@val1, @val2)'; + const INSERT_BULK = 'insert bulk test_bulk([c1] int, [c2] nvarchar(50)) WITH (KEEP_NULLS)'; + const SELECT_PREPARED = 'SELECT c1, c2 FROM [dbo].[test_prepared]'; + const SELECT_JOIN = + 'SELECT p.c1 FROM [dbo].[test_prepared] p INNER JOIN [dbo].[test_bulk] b ON p.c1 = b.c1'; + const SELECT_INLINE_LITERAL = 'SELECT c1, c2 FROM [dbo].[test_prepared] WHERE c1 = 42'; + const SELECT_PARAMETERIZED = 'SELECT c1, c2 FROM [dbo].[test_prepared] WHERE c1 = @c1'; + const SELECT_STRING_LITERAL = "SELECT c1, c2 FROM [dbo].[test_bulk] WHERE c2 = 'hello from acme'"; + + expect( + (transaction.spans ?? []) + .filter(span => span.origin === ORIGIN) + .map(span => ({ name: span.description, text: span.data?.['db.query.text'] })), + ).toEqual([ + { name: 'SELECT 1 + 1 AS solution', text: 'SELECT 1 + 1 AS solution' }, + { name: 'SELECT 42; SELECT 42;', text: 'SELECT 42; SELECT 42;' }, + { name: 'select !', text: 'select !' }, + { name: CREATE_PROCEDURE, text: CREATE_PROCEDURE }, + { name: '[dbo].[test_proced]', text: '[dbo].[test_proced]' }, + { name: CREATE_PREPARED_TABLE, text: CREATE_PREPARED_TABLE }, + { name: INSERT_PREPARED, text: INSERT_PREPARED }, + { name: INSERT_PREPARED, text: INSERT_PREPARED }, + { name: CREATE_BULK_TABLE, text: CREATE_BULK_TABLE }, + { name: 'execBulkLoad test_bulk master', text: undefined }, + { name: INSERT_BULK, text: INSERT_BULK }, + { name: SELECT_PREPARED, text: SELECT_PREPARED }, + { name: SELECT_JOIN, text: SELECT_JOIN }, + { name: SELECT_INLINE_LITERAL, text: SELECT_INLINE_LITERAL }, + { name: SELECT_PARAMETERIZED, text: SELECT_PARAMETERIZED }, + { name: SELECT_STRING_LITERAL, text: SELECT_STRING_LITERAL }, + ]); + }, + }) + .start() + .completed(); }); }); createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-span-streaming.mjs', (createTestRunner, test) => { - test('should name spans after the operation with span streaming', async () => { + test('should name spans after the query summary 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 !' }, + expect( + dbSpans.map(span => ({ + name: span.name, + summary: span.attributes['db.query.summary']?.value, + text: span.attributes['db.query.text']?.value, + })), + ).toEqual([ + { name: 'SELECT', summary: 'SELECT', text: 'SELECT 1 + 1 AS solution' }, + { name: 'SELECT', summary: 'SELECT', text: 'SELECT 42; SELECT 42;' }, + { name: 'select', summary: 'select', text: 'select !' }, { - name: 'execSql master', + name: 'CREATE', + summary: 'CREATE', 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: 'callProcedure [dbo].[test_proced]', summary: undefined, text: '[dbo].[test_proced]' }, { - name: 'execSql master', + name: 'if', + summary: 'if', 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', + name: 'INSERT [dbo].[test_prepared]', + summary: 'INSERT [dbo].[test_prepared]', + text: 'INSERT INTO [dbo].[test_prepared] VALUES (@val1, @val2)', + }, + { + name: 'INSERT [dbo].[test_prepared]', + summary: 'INSERT [dbo].[test_prepared]', + text: 'INSERT INTO [dbo].[test_prepared] VALUES (@val1, @val2)', + }, + { + name: 'if', + summary: 'if', text: "if object_id('[dbo].[test_bulk]') is null CREATE TABLE [dbo].[test_bulk] (c1 int, c2 varchar(30))", }, { - name: 'execSqlBatch master', + name: 'insert', + summary: 'insert', text: 'insert bulk test_bulk([c1] int, [c2] nvarchar(50)) WITH (KEEP_NULLS)', }, - { name: 'execBulkLoad test_bulk master', text: undefined }, + { name: 'execBulkLoad test_bulk', summary: undefined, text: undefined }, + { + name: 'SELECT [dbo].[test_prepared]', + summary: 'SELECT [dbo].[test_prepared]', + text: 'SELECT c1, c2 FROM [dbo].[test_prepared]', + }, + { + name: 'SELECT [dbo].[test_prepared]', + summary: 'SELECT [dbo].[test_prepared]', + text: 'SELECT c1, c2 FROM [dbo].[test_prepared] WHERE c1 = @c1', + }, + { + // TODO: (check if correct) Both sides of the join survive into the summary. + name: 'SELECT [dbo].[test_prepared] [dbo].[test_bulk]', + summary: 'SELECT [dbo].[test_prepared] [dbo].[test_bulk]', + text: 'SELECT p.c1 FROM [dbo].[test_prepared] p INNER JOIN [dbo].[test_bulk] b ON p.c1 = b.c1', + }, + { + // TODO: (fix) tedious reports the statement as the caller wrote it, so an inlined literal reaches + // `db.query.text` unsanitized. Only the summary is sanitized. + name: 'SELECT [dbo].[test_prepared]', + summary: 'SELECT [dbo].[test_prepared]', + text: 'SELECT c1, c2 FROM [dbo].[test_prepared] WHERE c1 = 42', + }, + { + // TODO: (fix) The `from` inside the string literal must not be read as a table: the statement is + // sanitized before it is summarized, so the summary is just the real table. + name: 'SELECT [dbo].[test_bulk]', + summary: 'SELECT [dbo].[test_bulk]', + text: "SELECT c1, c2 FROM [dbo].[test_bulk] WHERE c2 = 'hello from acme'", + }, ]); }, }) diff --git a/packages/server-utils/src/integrations/tedious.ts b/packages/server-utils/src/integrations/tedious.ts index 150a5ad2b68f..40b0baaa7332 100644 --- a/packages/server-utils/src/integrations/tedious.ts +++ b/packages/server-utils/src/integrations/tedious.ts @@ -6,6 +6,8 @@ import { EventEmitter } from 'node:events'; import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, SpanAttributes } from '@sentry/core'; import { + _INTERNAL_getSqlQuerySummary, + _INTERNAL_sanitizeSqlQuery, defineIntegration, getClient, hasSpanStreamingEnabled, @@ -15,6 +17,7 @@ import { } from '@sentry/core'; import { DB_NAMESPACE, + DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME, DB_USER, @@ -130,6 +133,8 @@ function subscribeQuery(channelName: string, operation: string): void { const databaseName = connection[currentDatabaseSymbol]; const sql = extractSql(request); + const querySummary = + sql && operation !== 'callProcedure' ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(sql)) : undefined; const attributes: SpanAttributes = { [SENTRY_OP]: DB, @@ -140,19 +145,19 @@ function subscribeQuery(channelName: string, operation: string): void { // `>=4` uses the `authentication` object; older versions expose `userName` directly. [DB_USER]: connection.config?.userName ?? connection.config?.authentication?.options?.userName, [DB_QUERY_TEXT]: sql, + [DB_QUERY_SUMMARY]: querySummary, [ATTR_DB_SQL_TABLE]: request.table, [SERVER_ADDRESS]: connection.config?.server, [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: client && hasSpanStreamingEnabled(client) ? spanName : sql || spanName, + name: + client && hasSpanStreamingEnabled(client) + ? (querySummary ?? getLowCardinalitySecondarySpanName(operation, databaseName, sql, request.table)) + : sql || getSecondarySpanName(operation, databaseName, sql, request.table), attributes, }); @@ -207,7 +212,7 @@ function extractSql(request: TediousRequest): string | undefined { * The span name is a low-cardinality label for the operation; the SDK's db-span inference later renames * the span description off `db.query.text` when present. Mirrors the vendored OTel `getSpanName`. */ -function getSpanName( +function getSecondarySpanName( operation: string, db: string | undefined, sql: string | undefined, @@ -224,6 +229,22 @@ function getSpanName( return db ? `${operation} ${db}` : operation; } +function getLowCardinalitySecondarySpanName( + operation: string, + db: string | undefined, + sql: string | undefined, + bulkLoadTable: string | undefined, +): string { + if (operation === 'execBulkLoad' && bulkLoadTable) { + return `${operation} ${bulkLoadTable}`; + } + if (operation === 'callProcedure') { + // `sql` refers to the procedure name for `callProcedure`, so it is low-cardinality in this case. + return `${operation} ${sql}`; + } + return db ? `${operation} ${db}` : operation; +} + function once(fn: (...args: Args) => void): (...args: Args) => void { let called = false; From ca8d0c05174567b0c97f5187f342b35735a2fd65 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 27 Aug 2026 15:37:52 +0200 Subject: [PATCH 04/10] fix build && address review --- .../suites/tracing/tedious/test.ts | 10 +++++----- packages/server-utils/src/integrations/knex.ts | 17 ++++++++++++----- 2 files changed, 17 insertions(+), 10 deletions(-) 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 9bd7ca0a55e1..54d63eea226c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts @@ -148,11 +148,6 @@ describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [_ summary: 'SELECT [dbo].[test_prepared]', text: 'SELECT c1, c2 FROM [dbo].[test_prepared]', }, - { - name: 'SELECT [dbo].[test_prepared]', - summary: 'SELECT [dbo].[test_prepared]', - text: 'SELECT c1, c2 FROM [dbo].[test_prepared] WHERE c1 = @c1', - }, { // TODO: (check if correct) Both sides of the join survive into the summary. name: 'SELECT [dbo].[test_prepared] [dbo].[test_bulk]', @@ -166,6 +161,11 @@ describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [_ summary: 'SELECT [dbo].[test_prepared]', text: 'SELECT c1, c2 FROM [dbo].[test_prepared] WHERE c1 = 42', }, + { + name: 'SELECT [dbo].[test_prepared]', + summary: 'SELECT [dbo].[test_prepared]', + text: 'SELECT c1, c2 FROM [dbo].[test_prepared] WHERE c1 = @c1', + }, { // TODO: (fix) The `from` inside the string literal must not be read as a table: the statement is // sanitized before it is summarized, so the summary is just the real table. diff --git a/packages/server-utils/src/integrations/knex.ts b/packages/server-utils/src/integrations/knex.ts index 31b156a6bd9b..ef82998fbc1d 100644 --- a/packages/server-utils/src/integrations/knex.ts +++ b/packages/server-utils/src/integrations/knex.ts @@ -7,7 +7,6 @@ import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core'; import { _INTERNAL_getSqlQuerySummary, _INTERNAL_sanitizeSqlQuery, - DB_SPAN_NAME_FALLBACK, debug, defineIntegration, getActiveSpan, @@ -173,6 +172,7 @@ function subscribeQuery(): void { const operation = query?.method; const dbNameSpace = connection?.filename || connection?.database || extractDatabaseFromConnectionString(connectionString); + const dbSystem = mapSystem(client?.driverName); 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 @@ -185,7 +185,7 @@ function subscribeQuery(): void { [SENTRY_KIND]: 'client', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, 'knex.version': data.moduleVersion, - [DB_SYSTEM_NAME]: mapSystem(client?.driverName), + [DB_SYSTEM_NAME]: dbSystem, [ATTR_DB_SQL_TABLE]: table, [DB_OPERATION_NAME]: operation, [DB_USER]: connection?.user, @@ -200,7 +200,7 @@ function subscribeQuery(): void { const sentryClient = getClient(); const spanName = sentryClient && hasSpanStreamingEnabled(sentryClient) - ? querySummary || getSecondaryStreamName(dbNameSpace, operation, table) + ? querySummary || getSecondaryStreamName(dbSystem, dbNameSpace, operation, table) : (dbStatement ?? getName(dbNameSpace, operation, table) ?? 'knex.query'); return startInactiveSpan({ @@ -282,7 +282,12 @@ function getName(db: string | undefined, operation?: string, table?: string): st return db; } -function getSecondaryStreamName(dbNameSpace: string | undefined, operation?: string, table?: string): string { +function getSecondaryStreamName( + dbSystem: string | undefined, + dbNameSpace: string | undefined, + operation?: string, + table?: string, +): string { if (operation) { if (table) { return `${operation} ${table}`; @@ -297,7 +302,9 @@ function getSecondaryStreamName(dbNameSpace: string | undefined, operation?: str if (dbNameSpace) { return dbNameSpace; } - return DB_SPAN_NAME_FALLBACK; + // Mirrors the postgres integration, which falls back to `{db.system.name}` rather than to a static + // name. `db.system.name` is only unset when the knex client reports no driver. + return dbSystem ?? 'knex.query'; } function extractTableName(builder: KnexBuilder | undefined): string | undefined { From cfe8c4074c90a70d26d13021a48d2774d473572c Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 27 Aug 2026 16:55:08 +0200 Subject: [PATCH 05/10] fix tests i guess --- .../suites/tracing/prisma-orm-v5/test.ts | 3 +++ .../suites/tracing/prisma-orm-v6/test.ts | 3 +++ .../suites/tracing/prisma-orm-v7/test.ts | 3 +++ 3 files changed, 9 insertions(+) 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 32d77ccb40ed..b5a3ef7dbe7c 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 @@ -142,6 +142,9 @@ describeWithDockerCompose('Prisma ORM v5', { workingDirectory: [__dirname] }, () { timeout: 75_000 }, async () => { await createRunner() + // Prisma's engine startup can outlast the span buffer's flush interval, so the query spans + // are not guaranteed to be in the first span envelope. + .unordered() .expect({ span: container => { // v5 reports the SQL on the deprecated `db.statement` rather than `db.query.text`. 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 f5ddc6b2aea6..0b01d4cbaa3e 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 @@ -129,6 +129,9 @@ describeWithDockerCompose('Prisma ORM v6 Tests', { workingDirectory: [__dirname] (createRunner, test) => { test('should name db query spans after the query summary with span streaming', { timeout: 75_000 }, async () => { await createRunner() + // Prisma's engine startup can outlast the span buffer's flush interval, so the query spans + // are not guaranteed to be in the first span envelope. + .unordered() .expect({ span: container => { const querySpans = container.items.filter(item => item.attributes['db.query.text']); 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 dc6d93ca8ee0..657e10a2acdf 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 @@ -100,6 +100,9 @@ describe('Prisma ORM v7 Tests', () => { { timeout: 75_000 }, async () => { await createRunner() + // Prisma's engine startup can outlast the span buffer's flush interval, so the query spans + // are not guaranteed to be in the first span envelope. + .unordered() .expect({ span: container => { // v7 runs the queries through the `pg` adapter, whose own spans are named after the full From d505ec0de065041b5a7d6ed548a90667ac603fe7 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 28 Aug 2026 12:58:41 +0200 Subject: [PATCH 06/10] test(node-integration-tests): Use renamed `auto.db.prisma` origin in v7 streaming test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Prisma v7 span-streaming test filtered query spans on `auto.db.otel.prisma`, which #23627 renamed to `auto.db.prisma`. The filter matched nothing, so the assertion threw — and because the test runs `.unordered()`, `newEnvelope` swallows assertion errors and keeps waiting for another envelope, turning it into an opaque 75s timeout rather than a diff. Co-Authored-By: Claude Opus 5 (1M context) --- .../suites/tracing/prisma-orm-v7/test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 657e10a2acdf..b436b0f96035 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 @@ -109,8 +109,7 @@ describe('Prisma ORM v7 Tests', () => { // 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'], + item.attributes['sentry.origin']?.value === 'auto.db.prisma' && item.attributes['db.query.text'], ); // `SELECT "public"` is what the core query-summary helper derives from a schema-qualified, From 2789b2bbb40c9e78680ae2b6ce6cd45d6aa56e13 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 28 Aug 2026 13:37:50 +0200 Subject: [PATCH 07/10] guard against undefined sql for secondary low card name --- packages/server-utils/src/integrations/tedious.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server-utils/src/integrations/tedious.ts b/packages/server-utils/src/integrations/tedious.ts index 40b0baaa7332..26a7929b6bda 100644 --- a/packages/server-utils/src/integrations/tedious.ts +++ b/packages/server-utils/src/integrations/tedious.ts @@ -238,7 +238,7 @@ function getLowCardinalitySecondarySpanName( if (operation === 'execBulkLoad' && bulkLoadTable) { return `${operation} ${bulkLoadTable}`; } - if (operation === 'callProcedure') { + if (operation === 'callProcedure' && sql) { // `sql` refers to the procedure name for `callProcedure`, so it is low-cardinality in this case. return `${operation} ${sql}`; } From bfe5158954969e28fd6899769a7d54730e16e2b7 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 28 Aug 2026 15:41:55 +0200 Subject: [PATCH 08/10] same guard for transaction path --- packages/server-utils/src/integrations/tedious.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server-utils/src/integrations/tedious.ts b/packages/server-utils/src/integrations/tedious.ts index 26a7929b6bda..4b16c9a54848 100644 --- a/packages/server-utils/src/integrations/tedious.ts +++ b/packages/server-utils/src/integrations/tedious.ts @@ -221,7 +221,7 @@ function getSecondarySpanName( if (operation === 'execBulkLoad' && bulkLoadTable && db) { return `${operation} ${bulkLoadTable} ${db}`; } - if (operation === 'callProcedure') { + if (operation === 'callProcedure' && sql) { // `sql` refers to the procedure name for `callProcedure`. return db ? `${operation} ${sql} ${db}` : `${operation} ${sql}`; } From 2ac512a6d6dff5147d9f052aa52f395c46d57e85 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 31 Aug 2026 11:03:49 +0200 Subject: [PATCH 09/10] apply review feedback --- packages/server-utils/src/integrations/knex.ts | 6 +++--- .../server-utils/src/integrations/tedious.ts | 18 ++++-------------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/packages/server-utils/src/integrations/knex.ts b/packages/server-utils/src/integrations/knex.ts index ef82998fbc1d..14fa8c578e0e 100644 --- a/packages/server-utils/src/integrations/knex.ts +++ b/packages/server-utils/src/integrations/knex.ts @@ -153,6 +153,7 @@ function subscribeBuilder(channelName: string): void { function subscribeQuery(): void { bindTracingChannelToSpan( diagnosticsChannel.tracingChannel(CHANNELS.KNEX_QUERY), + // oxlint-disable-next-line complexity data => { const runner = data.self; const builder = runner?.builder; @@ -175,10 +176,9 @@ function subscribeQuery(): void { const dbSystem = mapSystem(client?.driverName); 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 dialect = client?.driverName === 'mysql' || client?.driverName === 'mysql2' ? 'mysql' : undefined; const querySummary = dbStatement - ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(dbStatement)) + ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(dbStatement, dialect)) : undefined; const attributes: SpanAttributes = { [SENTRY_OP]: DB, diff --git a/packages/server-utils/src/integrations/tedious.ts b/packages/server-utils/src/integrations/tedious.ts index 4b16c9a54848..071e3c190a76 100644 --- a/packages/server-utils/src/integrations/tedious.ts +++ b/packages/server-utils/src/integrations/tedious.ts @@ -156,8 +156,8 @@ function subscribeQuery(channelName: string, operation: string): void { const span = startInactiveSpan({ name: client && hasSpanStreamingEnabled(client) - ? (querySummary ?? getLowCardinalitySecondarySpanName(operation, databaseName, sql, request.table)) - : sql || getSecondarySpanName(operation, databaseName, sql, request.table), + ? querySummary || getLowCardinalitySecondarySpanName(operation, databaseName, sql, request.table) + : sql || getSecondarySpanName(operation, databaseName, request.table), attributes, }); @@ -209,22 +209,12 @@ function extractSql(request: TediousRequest): string | undefined { } /** - * The span name is a low-cardinality label for the operation; the SDK's db-span inference later renames - * the span description off `db.query.text` when present. Mirrors the vendored OTel `getSpanName`. + * Get a secondary span name for static trace lifecycle (not strictly adhering to sentry-convention span names) */ -function getSecondarySpanName( - operation: string, - db: string | undefined, - sql: string | undefined, - bulkLoadTable: string | undefined, -): string { +function getSecondarySpanName(operation: string, db: string | undefined, bulkLoadTable: string | undefined): string { if (operation === 'execBulkLoad' && bulkLoadTable && db) { return `${operation} ${bulkLoadTable} ${db}`; } - if (operation === 'callProcedure' && sql) { - // `sql` refers to the procedure name for `callProcedure`. - return db ? `${operation} ${sql} ${db}` : `${operation} ${sql}`; - } // Avoid `sql` in the general case because of its high cardinality. return db ? `${operation} ${db}` : operation; } From 2f7b3a407bb7ec1ee1fb41ddd830986098ebc03b Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 2 Sep 2026 09:27:37 +0200 Subject: [PATCH 10/10] fix imports moved from core to core/server --- packages/server-utils/src/integrations/knex.ts | 3 +-- .../server-utils/src/integrations/prisma/tracing-helper.ts | 3 +-- packages/server-utils/src/integrations/tedious.ts | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/server-utils/src/integrations/knex.ts b/packages/server-utils/src/integrations/knex.ts index 14fa8c578e0e..74f925a8d4ca 100644 --- a/packages/server-utils/src/integrations/knex.ts +++ b/packages/server-utils/src/integrations/knex.ts @@ -5,8 +5,6 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, Span, SpanAttributes } from '@sentry/core'; import { - _INTERNAL_getSqlQuerySummary, - _INTERNAL_sanitizeSqlQuery, debug, defineIntegration, getActiveSpan, @@ -35,6 +33,7 @@ import { DB } from '@sentry/conventions/op'; import { DEBUG_BUILD } from '../debug-build'; import { CHANNELS } from '../orchestrion/channels'; import { bindTracingChannelToSpan } from '../tracing-channel'; +import { _INTERNAL_getSqlQuerySummary, _INTERNAL_sanitizeSqlQuery } from '@sentry/core/server'; // NOTE: this uses the same name as the OTel integration by design. `@sentry/node`'s `knexIntegration` // picks this subscriber over the vendored OTel path when orchestrion injection is active. diff --git a/packages/server-utils/src/integrations/prisma/tracing-helper.ts b/packages/server-utils/src/integrations/prisma/tracing-helper.ts index f74fc2008d51..b3e774c96903 100644 --- a/packages/server-utils/src/integrations/prisma/tracing-helper.ts +++ b/packages/server-utils/src/integrations/prisma/tracing-helper.ts @@ -15,8 +15,6 @@ import type { Span, SpanAttributes } from '@sentry/core'; import { - _INTERNAL_getSqlQuerySummary, - _INTERNAL_sanitizeSqlQuery, debug, getActiveSpan, getClient, @@ -37,6 +35,7 @@ import { SENTRY_KIND, SENTRY_OP, } from '@sentry/conventions/attributes'; +import { _INTERNAL_getSqlQuerySummary, _INTERNAL_sanitizeSqlQuery } from '@sentry/core/server'; // 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. diff --git a/packages/server-utils/src/integrations/tedious.ts b/packages/server-utils/src/integrations/tedious.ts index 071e3c190a76..b61e8a935242 100644 --- a/packages/server-utils/src/integrations/tedious.ts +++ b/packages/server-utils/src/integrations/tedious.ts @@ -6,8 +6,6 @@ import { EventEmitter } from 'node:events'; import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, SpanAttributes } from '@sentry/core'; import { - _INTERNAL_getSqlQuerySummary, - _INTERNAL_sanitizeSqlQuery, defineIntegration, getClient, hasSpanStreamingEnabled, @@ -30,6 +28,7 @@ import { DB } from '@sentry/conventions/op'; import { CHANNELS } from '../orchestrion/channels'; import { tediousModuleNames } from '../orchestrion/config/tedious'; import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; +import { _INTERNAL_getSqlQuerySummary, _INTERNAL_sanitizeSqlQuery } from '@sentry/core/server'; // NOTE: this uses the same name as the OTel integration by design. When orchestrion injection is active, // `_init` swaps the OTel `Tedious` integration out of the defaults and appends this one (matched by name).