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..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 @@ -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,54 @@ 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() + // 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`. + 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..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 @@ -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,48 @@ 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() + // 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']); + + // `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..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 @@ -89,5 +89,65 @@ 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() + // 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 + // statement by a different integration, so they are filtered out here. + const querySpans = container.items.filter( + item => + 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, + // 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/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 23045b1dd408..54d63eea226c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts @@ -44,7 +44,140 @@ 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 query summary with span streaming', async () => { + await createTestRunner() + .expect({ + span: container => { + const dbSpans = container.items.filter(item => item.attributes['sentry.origin']?.value === ORIGIN); + + 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: '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]', summary: undefined, text: '[dbo].[test_proced]' }, + { + name: 'if', + summary: 'if', + text: "if object_id('[dbo].[test_prepared]') is null CREATE TABLE [dbo].[test_prepared] (c1 int, c2 int)", + }, + { + 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: 'insert', + summary: 'insert', + text: 'insert bulk test_bulk([c1] int, [c2] nvarchar(50)) WITH (KEEP_NULLS)', + }, + { 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]', + }, + { + // 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', + }, + { + 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. + name: 'SELECT [dbo].[test_bulk]', + summary: 'SELECT [dbo].[test_bulk]', + text: "SELECT c1, c2 FROM [dbo].[test_bulk] WHERE c2 = 'hello from acme'", + }, + ]); + }, + }) + .start() + .completed(); }); }); }); diff --git a/packages/server-utils/src/integrations/knex.ts b/packages/server-utils/src/integrations/knex.ts index d23037b70b0a..74f925a8d4ca 100644 --- a/packages/server-utils/src/integrations/knex.ts +++ b/packages/server-utils/src/integrations/knex.ts @@ -8,6 +8,8 @@ import { debug, defineIntegration, getActiveSpan, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startInactiveSpan, @@ -17,6 +19,7 @@ import { import { DB_NAMESPACE, DB_OPERATION_NAME, + DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME, DB_USER, @@ -30,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. @@ -148,6 +152,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; @@ -165,28 +170,40 @@ 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 dbSystem = mapSystem(client?.driverName); const dbStatement = query?.sql != null ? truncate(query.sql, MAX_QUERY_LENGTH) : undefined; + const dialect = client?.driverName === 'mysql' || client?.driverName === 'mysql2' ? 'mysql' : undefined; + const querySummary = dbStatement + ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(dbStatement, dialect)) + : undefined; const attributes: SpanAttributes = { [SENTRY_OP]: DB, [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, - [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(dbSystem, dbNameSpace, operation, table) + : (dbStatement ?? getName(dbNameSpace, operation, table) ?? 'knex.query'); + return startInactiveSpan({ - name: dbStatement ?? getName(name, operation, table) ?? 'knex.query', + name: spanName, parentSpan, attributes, }); @@ -264,6 +281,31 @@ function getName(db: string | undefined, operation?: string, table?: string): st return db; } +function getSecondaryStreamName( + dbSystem: string | undefined, + 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; + } + // 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 { 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 390113843f0f..b3e774c96903 100644 --- a/packages/server-utils/src/integrations/prisma/tracing-helper.ts +++ b/packages/server-utils/src/integrations/prisma/tracing-helper.ts @@ -17,6 +17,8 @@ import type { Span, SpanAttributes } from '@sentry/core'; import { debug, getActiveSpan, + getClient, + hasSpanStreamingEnabled, LRUMap, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, @@ -24,7 +26,16 @@ 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'; +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. @@ -102,24 +113,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..b61e8a935242 100644 --- a/packages/server-utils/src/integrations/tedious.ts +++ b/packages/server-utils/src/integrations/tedious.ts @@ -7,12 +7,15 @@ 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, } from '@sentry/core'; import { DB_NAMESPACE, + DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME, DB_USER, @@ -25,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). @@ -128,6 +132,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, @@ -138,13 +144,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(); + const span = startInactiveSpan({ - name: sql || getSpanName(operation, databaseName, sql, request.table), + name: + client && hasSpanStreamingEnabled(client) + ? querySummary || getLowCardinalitySecondarySpanName(operation, databaseName, sql, request.table) + : sql || getSecondarySpanName(operation, databaseName, request.table), attributes, }); @@ -196,23 +208,29 @@ 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 getSpanName( +function getSecondarySpanName(operation: string, db: string | undefined, bulkLoadTable: string | undefined): string { + if (operation === 'execBulkLoad' && bulkLoadTable && db) { + return `${operation} ${bulkLoadTable} ${db}`; + } + // Avoid `sql` in the general case because of its high cardinality. + return db ? `${operation} ${db}` : operation; +} + +function getLowCardinalitySecondarySpanName( operation: string, db: string | undefined, sql: string | undefined, bulkLoadTable: string | undefined, ): string { - if (operation === 'execBulkLoad' && bulkLoadTable && db) { - return `${operation} ${bulkLoadTable} ${db}`; + if (operation === 'execBulkLoad' && bulkLoadTable) { + return `${operation} ${bulkLoadTable}`; } - if (operation === 'callProcedure') { - // `sql` refers to the procedure name for `callProcedure`. - return db ? `${operation} ${sql} ${db}` : `${operation} ${sql}`; + if (operation === 'callProcedure' && sql) { + // `sql` refers to the procedure name for `callProcedure`, so it is low-cardinality in this case. + return `${operation} ${sql}`; } - // Avoid `sql` in the general case because of its high cardinality. return db ? `${operation} ${db}` : operation; }