diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts index 2a9cb3c3010a..a83b218ec2da 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts @@ -1,5 +1,6 @@ import { DB_CONNECTION_STRING, + DB_OPERATION_NAME, DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME, @@ -129,6 +130,7 @@ describe('mysql auto instrumentation', () => { ...COMMON_ATTRIBUTES, [DB_QUERY_TEXT]: { type: 'string', value: queryText }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' }, }, ...COMMON_SPAN_PROPS, }); diff --git a/dev-packages/node-integration-tests/suites/tracing/neon/test.ts b/dev-packages/node-integration-tests/suites/tracing/neon/test.ts index 485e6dbfac26..531885d20d3e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/neon/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/neon/test.ts @@ -2,6 +2,7 @@ import type { SerializedStreamedSpan } from '@sentry/core'; import { DB_NAMESPACE, DB_OPERATION_BATCH_SIZE, + DB_OPERATION_NAME, DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME, @@ -34,11 +35,13 @@ const CONNECTION_ATTRIBUTES = { function expectedDbSpan({ name, + operation, statement, status = 'ok', batchSize, }: { name: string; + operation: string; statement: string; status?: 'ok' | 'error'; batchSize?: number; @@ -51,6 +54,7 @@ function expectedDbSpan({ [SENTRY_ORIGIN]: attr(ORIGIN), [DB_QUERY_TEXT]: attr(statement), [DB_QUERY_SUMMARY]: attr(name), + [DB_OPERATION_NAME]: attr(operation), ...(batchSize ? { [DB_OPERATION_BATCH_SIZE]: attr(batchSize, 'integer') } : {}), }), is_segment: false, @@ -87,20 +91,30 @@ describeWithDockerCompose('neon auto instrumentation', { workingDirectory: [__di const neonSpans = getNeonSpans(spans); expect(neonSpans.every(span => span.parent_span_id === segment!.span_id)).toBe(true); expect(neonSpans).toEqual([ - expectedDbSpan({ name: 'CREATE TABLE "User"', statement: CREATE_USER_TABLE_STATEMENT }), - expectedDbSpan({ name: 'INSERT "User"', statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)' }), - expectedDbSpan({ name: 'SELECT "User"', statement: 'SELECT * FROM "User" WHERE "name" = $1' }), + expectedDbSpan({ name: 'CREATE TABLE "User"', operation: 'CREATE', statement: CREATE_USER_TABLE_STATEMENT }), + expectedDbSpan({ + name: 'INSERT "User"', + operation: 'INSERT', + statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)', + }), + expectedDbSpan({ + name: 'SELECT "User"', + operation: 'SELECT', + statement: 'SELECT * FROM "User" WHERE "name" = $1', + }), expectedDbSpan({ name: 'SELECT "User"; SELECT "User"', + operation: 'BATCH SELECT', statement: 'SELECT "email" FROM "User"; SELECT "name" FROM "User"', batchSize: 2, }), expectedDbSpan({ name: 'SELECT "does_not_exist_table"', + operation: 'SELECT', statement: 'SELECT * FROM "does_not_exist_table"', status: 'error', }), - expectedDbSpan({ name: 'DROP TABLE "User"', statement: 'DROP TABLE "User"' }), + expectedDbSpan({ name: 'DROP TABLE "User"', operation: 'DROP', statement: 'DROP TABLE "User"' }), ]); }); }); @@ -121,15 +135,20 @@ describeWithDockerCompose('neon auto instrumentation', { workingDirectory: [__di const neonSpans = getNeonSpans(spans); expect(neonSpans.every(span => span.parent_span_id === segment!.span_id)).toBe(true); expect(neonSpans).toEqual([ - expectedDbSpan({ name: 'CREATE TABLE "User"', statement: CREATE_USER_TABLE_STATEMENT }), - expectedDbSpan({ name: 'INSERT "User"', statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)' }), - expectedDbSpan({ name: 'SELECT "User"', statement: 'SELECT * FROM "User"' }), + expectedDbSpan({ name: 'CREATE TABLE "User"', operation: 'CREATE', statement: CREATE_USER_TABLE_STATEMENT }), + expectedDbSpan({ + name: 'INSERT "User"', + operation: 'INSERT', + statement: 'INSERT INTO "User" ("email", "name") VALUES ($1, $2)', + }), + expectedDbSpan({ name: 'SELECT "User"', operation: 'SELECT', statement: 'SELECT * FROM "User"' }), expectedDbSpan({ name: 'SELECT "does_not_exist_table"', + operation: 'SELECT', statement: 'SELECT * FROM "does_not_exist_table"', status: 'error', }), - expectedDbSpan({ name: 'DROP TABLE "User"', statement: 'DROP TABLE "User"' }), + expectedDbSpan({ name: 'DROP TABLE "User"', operation: 'DROP', statement: 'DROP TABLE "User"' }), ]); }); }); @@ -154,10 +173,15 @@ describeWithDockerCompose('neon auto instrumentation', { workingDirectory: [__di expect(neonSpans).toEqual([ expectedDbSpan({ name: 'SELECT pg_catalog.pg_user', + operation: 'SELECT', statement: 'SELECT "email", "name" FROM pg_catalog.pg_user LIMIT ?', status: 'error', }), - expectedDbSpan({ name: 'SELECT pg_catalog.pg_user', statement: 'SELECT "usename" FROM pg_catalog.pg_user' }), + expectedDbSpan({ + name: 'SELECT pg_catalog.pg_user', + operation: 'SELECT', + statement: 'SELECT "usename" FROM pg_catalog.pg_user', + }), ]); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts index 4dd383254d1f..ae2e256e33b1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres/test.ts @@ -1,6 +1,7 @@ import { DB_CONNECTION_STRING, DB_NAMESPACE, + DB_OPERATION_NAME, DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME, @@ -87,6 +88,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'INSERT "User"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'INSERT' }, }, name: 'INSERT "User"', status: 'ok', @@ -101,6 +103,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "User"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' }, }, name: 'SELECT "User"', status: 'ok', @@ -114,6 +117,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "User"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' }, }), name: 'SELECT "User"', status: 'ok', @@ -126,6 +130,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "does_not_exist_table"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' }, }), name: 'SELECT "does_not_exist_table"', status: 'error', @@ -144,6 +149,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ 'CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))', }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'CREATE TABLE "User"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'CREATE' }, }, }, { @@ -156,6 +162,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN }, [DB_QUERY_TEXT]: { type: 'string', value: 'DROP TABLE "User"' }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'DROP TABLE "User"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'DROP' }, }, }, ]), @@ -218,6 +225,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'INSERT "User"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'INSERT' }, }, name: 'INSERT "User"', status: 'ok', @@ -232,6 +240,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "User"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' }, }, name: 'SELECT "User"', status: 'ok', @@ -250,6 +259,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ 'CREATE TABLE "User" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))', }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'CREATE TABLE "User"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'CREATE' }, }, }, { @@ -262,6 +272,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN }, [DB_QUERY_TEXT]: { type: 'string', value: 'DROP TABLE "User"' }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'DROP TABLE "User"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'DROP' }, }, }, ]), @@ -300,6 +311,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' }, }), name: 'SELECT', status: 'ok', @@ -359,6 +371,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' }, }), name: 'SELECT', status: 'ok', @@ -397,6 +410,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' }, }), name: 'SELECT', status: 'ok', @@ -444,6 +458,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'INSERT "NativeUser"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'INSERT' }, [SERVER_ADDRESS]: { type: 'string', value: '127.0.0.1' }, [DB_CONNECTION_STRING]: { type: 'string', @@ -463,6 +478,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "NativeUser"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' }, [SERVER_ADDRESS]: { type: 'string', value: '127.0.0.1' }, [DB_CONNECTION_STRING]: { type: 'string', @@ -490,6 +506,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ 'CREATE TABLE "NativeUser" ("id" SERIAL NOT NULL,"createdAt" TIMESTAMP(?) NOT NULL DEFAULT CURRENT_TIMESTAMP,"email" TEXT NOT NULL,"name" TEXT,CONSTRAINT "User_pkey" PRIMARY KEY ("id"))', }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'CREATE TABLE "NativeUser"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'CREATE' }, }, }, { @@ -506,6 +523,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: QUERY_ORIGIN }, [DB_QUERY_TEXT]: { type: 'string', value: 'DROP TABLE "NativeUser"' }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'DROP TABLE "NativeUser"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'DROP' }, }, }, ]), @@ -568,6 +586,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'INSERT "User"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'INSERT' }, }), name: 'INSERT "User"', status: 'ok', @@ -581,6 +600,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "User"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' }, }), name: 'SELECT "User"', status: 'ok', @@ -592,6 +612,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "does_not_exist_table"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' }, }), name: 'SELECT "does_not_exist_table"', status: 'error', @@ -629,6 +650,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' }, }), name: 'SELECT', status: 'ok', @@ -700,6 +722,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' }, }), name: 'SELECT', status: 'ok', @@ -747,6 +770,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'INSERT "User"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'INSERT' }, }), name: 'INSERT "User"', status: 'ok', @@ -759,6 +783,7 @@ describeWithDockerCompose('postgres auto instrumentation', { workingDirectory: [ [SENTRY_ORIGIN]: { type: 'string', value: ORIGIN }, [SENTRY_OP]: { type: 'string', value: DB }, [DB_QUERY_SUMMARY]: { type: 'string', value: 'SELECT "User"' }, + [DB_OPERATION_NAME]: { type: 'string', value: 'SELECT' }, }), name: 'SELECT "User"', status: 'ok', diff --git a/packages/nuxt/src/runtime/utils/instrumentDatabase.ts b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts index 4c525e005869..5efa767e5b1d 100644 --- a/packages/nuxt/src/runtime/utils/instrumentDatabase.ts +++ b/packages/nuxt/src/runtime/utils/instrumentDatabase.ts @@ -16,7 +16,13 @@ import { flushIfServerless } from '@sentry/core/server'; import { sanitizeSqlQuery, sanitizeSqlQueryWithSummary, type SqlDialect } from '@sentry/server-utils'; import type { Database, PreparedStatement } from 'db0'; import { type DatabaseConnectionConfig, type DatabaseSpanData, getDatabaseSpanData } from './database-span-data'; -import { DB_NAMESPACE, DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME } from '@sentry/conventions/attributes'; +import { + DB_NAMESPACE, + DB_OPERATION_NAME, + DB_QUERY_SUMMARY, + DB_QUERY_TEXT, + DB_SYSTEM_NAME, +} from '@sentry/conventions/attributes'; type MaybeInstrumentedDatabase = Database & { __sentry_instrumented__?: boolean; @@ -261,7 +267,7 @@ function createStartSpanOptions( data: DatabaseSpanData, dialect: SqlDialect | undefined, ): StartSpanOptions { - const { queryText, querySummary } = sanitizeSqlQueryWithSummary(query, dialect); + const { queryText, querySummary, operationName } = sanitizeSqlQueryWithSummary(query, dialect); const client = getClient(); const name = @@ -274,6 +280,7 @@ function createStartSpanOptions( attributes: { [DB_QUERY_TEXT]: queryText, [DB_QUERY_SUMMARY]: querySummary, + [DB_OPERATION_NAME]: operationName, [SENTRY_ORIGIN]: NUXT_DATABASE_ORIGIN, [SENTRY_OP]: DB_QUERY, ...data, diff --git a/packages/server-utils/src/integrations/mysql.ts b/packages/server-utils/src/integrations/mysql.ts index 1a68350361e6..599edf59392f 100644 --- a/packages/server-utils/src/integrations/mysql.ts +++ b/packages/server-utils/src/integrations/mysql.ts @@ -1,6 +1,7 @@ import * as diagnosticsChannel from '../utils/diagnosticsChannel'; import { DB_NAMESPACE, + DB_OPERATION_NAME, DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME, @@ -81,7 +82,7 @@ function instrumentMysql(): void { // handler with the caller's context lost. `deferSpanEnd` replays this scope onto the emitter. data._sentryCallerScope = getCurrentScope(); - const { queryText, querySummary } = sanitizeSqlQueryWithSummary(sql, 'mysql'); + const { queryText, querySummary, operationName } = sanitizeSqlQueryWithSummary(sql, 'mysql'); const client = getClient(); const name = @@ -102,6 +103,7 @@ function instrumentMysql(): void { [DB_USER]: user, [DB_QUERY_TEXT]: queryText, [DB_QUERY_SUMMARY]: querySummary, + [DB_OPERATION_NAME]: operationName, [SERVER_ADDRESS]: host, [SERVER_PORT]: portIsNumber ? portNumber : undefined, }, diff --git a/packages/server-utils/src/integrations/mysql2/index.ts b/packages/server-utils/src/integrations/mysql2/index.ts index f8aa296266f9..88bc7cb0ab98 100644 --- a/packages/server-utils/src/integrations/mysql2/index.ts +++ b/packages/server-utils/src/integrations/mysql2/index.ts @@ -18,6 +18,7 @@ import { mysql2ModuleNames } from '../../orchestrion/config/mysql2'; import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; import { DB_NAMESPACE, + DB_OPERATION_NAME, DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME, @@ -77,7 +78,7 @@ function subscribeQueryChannel(channelName: ChannelName): void { bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(channelName), data => { const statement = getQueryText(data.arguments); const connectionAttributes = getConnectionAttributes(data.self?.config); - const { queryText, querySummary } = sanitizeSqlQueryWithSummary(statement, 'mysql'); + const { queryText, querySummary, operationName } = sanitizeSqlQueryWithSummary(statement, 'mysql'); const client = getClient(); const name = @@ -95,6 +96,7 @@ function subscribeQueryChannel(channelName: ChannelName): void { [DB_SYSTEM_NAME]: DB_SYSTEM_VALUE_MYSQL, [DB_QUERY_TEXT]: queryText || undefined, [DB_QUERY_SUMMARY]: querySummary, + [DB_OPERATION_NAME]: operationName, ...connectionAttributes, }, }); diff --git a/packages/server-utils/src/integrations/neon.ts b/packages/server-utils/src/integrations/neon.ts index e4130d14942c..349fb7d7ab84 100644 --- a/packages/server-utils/src/integrations/neon.ts +++ b/packages/server-utils/src/integrations/neon.ts @@ -1,6 +1,7 @@ import * as diagnosticsChannel from '../utils/diagnosticsChannel'; import { DB_OPERATION_BATCH_SIZE, + DB_OPERATION_NAME, DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME, @@ -101,6 +102,7 @@ function httpQuerySpanOptions(args: unknown[]): Parameters sanitizeSqlQueryWithSummary(text)); const queryText = joinDefined(statements.map(s => s.queryText)); const querySummary = joinDefined(statements.map(s => s.querySummary)); + const operationName = batchSize === undefined ? statements[0]?.operationName : getBatchOperationName(statements); const name = querySummary || DB_SYSTEM_POSTGRESQL; return { @@ -113,11 +115,19 @@ function httpQuerySpanOptions(args: unknown[]): Parameters` when every statement shares one operation, plain `BATCH` otherwise. +function getBatchOperationName(statements: { operationName: string | undefined }[]): string { + const operations = new Set(statements.map(s => s.operationName?.toUpperCase())); + const [operation] = operations; + return operations.size === 1 && operation ? `BATCH ${operation}` : 'BATCH'; +} + function joinDefined(parts: (string | undefined)[]): string | undefined { const defined = parts.filter((part): part is string => !!part); return defined.length ? defined.join('; ') : undefined; diff --git a/packages/server-utils/src/integrations/postgres.ts b/packages/server-utils/src/integrations/postgres.ts index 3231e0e60a06..ed39a15bf1c7 100644 --- a/packages/server-utils/src/integrations/postgres.ts +++ b/packages/server-utils/src/integrations/postgres.ts @@ -1,6 +1,7 @@ import * as diagnosticsChannel from '../utils/diagnosticsChannel'; import { DB_NAMESPACE, + DB_OPERATION_NAME, DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME, @@ -189,7 +190,7 @@ function querySpanOptions( const params = (ctx.self as { connectionParameters?: PgConnectionParams } | undefined)?.connectionParameters ?? {}; const queryConfig = extractQueryConfig(ctx.arguments); const client = getClient(); - const { queryText, querySummary } = sanitizeSqlQueryWithSummary(queryConfig?.text); + const { queryText, querySummary, operationName } = sanitizeSqlQueryWithSummary(queryConfig?.text); const name = client && hasSpanStreamingEnabled(client) ? querySummary || params.database || DB_SYSTEM_POSTGRESQL @@ -203,6 +204,7 @@ function querySpanOptions( [SENTRY_ORIGIN]: origin, [DB_QUERY_TEXT]: queryText || undefined, [DB_QUERY_SUMMARY]: querySummary, + [DB_OPERATION_NAME]: operationName, [ATTR_PG_PLAN]: typeof queryConfig?.name === 'string' ? queryConfig.name : undefined, }, }; diff --git a/packages/server-utils/src/integrations/prisma/tracing-helper.ts b/packages/server-utils/src/integrations/prisma/tracing-helper.ts index 218e7c788134..8527efee244d 100644 --- a/packages/server-utils/src/integrations/prisma/tracing-helper.ts +++ b/packages/server-utils/src/integrations/prisma/tracing-helper.ts @@ -26,6 +26,7 @@ import { import { DEBUG_BUILD } from '../../debug-build'; import type { EngineSpan, ExtendedSpanOptions, SpanCallback, TracingHelper } from './types'; import { + DB_OPERATION_NAME, DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_STATEMENT, @@ -35,7 +36,13 @@ import { SENTRY_OP, SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; -import { getSqlQuerySummary, sanitizeSqlQuery, type SqlDialect, toSqlDialect } from '../../utils/sql'; +import { + getSqlOperationName, + getSqlQuerySummary, + sanitizeSqlQuery, + type SqlDialect, + toSqlDialect, +} from '../../utils/sql'; // 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. @@ -117,7 +124,9 @@ function buildSpanAttributes(name: string, attributes: Record | 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] = getSqlQuerySummary(sanitizeSqlQuery(statement, getSqlDialect(merged))); + const queryText = sanitizeSqlQuery(statement, getSqlDialect(merged)); + merged[DB_QUERY_SUMMARY] = getSqlQuerySummary(queryText); + merged[DB_OPERATION_NAME] ??= getSqlOperationName(queryText); } return merged; diff --git a/packages/server-utils/src/integrations/tedious.ts b/packages/server-utils/src/integrations/tedious.ts index 825ababebd68..b584d8582699 100644 --- a/packages/server-utils/src/integrations/tedious.ts +++ b/packages/server-utils/src/integrations/tedious.ts @@ -14,6 +14,7 @@ import { } from '@sentry/core'; import { DB_NAMESPACE, + DB_OPERATION_NAME, DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME, @@ -29,7 +30,7 @@ import type { OrchestrionChannelContext } from '../orchestrion/types'; import { CHANNELS } from '../orchestrion/channels'; import { tediousModuleNames } from '../orchestrion/config/tedious'; import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; -import { getSqlQuerySummary, sanitizeSqlQuery } from '../utils/sql'; +import { getSqlOperationName, getSqlQuerySummary, sanitizeSqlQuery } from '../utils/sql'; // 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). @@ -132,7 +133,9 @@ function subscribeQuery(channelName: string, operation: string): void { const databaseName = connection[currentDatabaseSymbol]; const sql = extractSql(request); const queryText = sql ? sanitizeSqlQuery(sql, 'mssql') : undefined; - const querySummary = queryText && operation !== 'callProcedure' ? getSqlQuerySummary(queryText) : undefined; + const isProcedureCall = operation === 'callProcedure'; + const querySummary = queryText && !isProcedureCall ? getSqlQuerySummary(queryText) : undefined; + const operationName = isProcedureCall ? undefined : getSqlOperationName(queryText); const attributes: SpanAttributes = { [SENTRY_OP]: DB, @@ -144,6 +147,7 @@ function subscribeQuery(channelName: string, operation: string): void { [DB_USER]: connection.config?.userName ?? connection.config?.authentication?.options?.userName, [DB_QUERY_TEXT]: queryText, [DB_QUERY_SUMMARY]: querySummary, + [DB_OPERATION_NAME]: operationName, [ATTR_DB_SQL_TABLE]: request.table, [SERVER_ADDRESS]: connection.config?.server, [SERVER_PORT]: connection.config?.options?.port, diff --git a/packages/server-utils/src/utils/sql.ts b/packages/server-utils/src/utils/sql.ts index f8ecdb4a9078..22198b805a67 100644 --- a/packages/server-utils/src/utils/sql.ts +++ b/packages/server-utils/src/utils/sql.ts @@ -103,6 +103,43 @@ export function getSqlQuerySummary(query: string | undefined): string | undefine return truncate(query.trim().split(/\s+/)[0] ?? query); } +const LEADING_KEYWORD_RE = /^\s*(?:\(\s*)*([A-Za-z]+)\b/; +const CTE_MAIN_STATEMENT_RE = /\(|\)|\b(SELECT|INSERT|UPDATE|DELETE|MERGE|VALUES|TABLE)\b/gi; + +/** + * Derives `db.operation.name` from a SQL query: the first operation keyword, in its original case. + * + * @see https://opentelemetry.io/docs/specs/semconv/registry/attributes/db/#db-operation-name + */ +export function getSqlOperationName(query: string | undefined): string | undefined { + const match = query ? LEADING_KEYWORD_RE.exec(query) : null; + const keyword = match?.[1]; + if (!query || !match || !keyword) { + return undefined; + } + // Scanned from after `WITH`, so parentheses wrapping the whole query don't shift the depth. + return keyword.toUpperCase() === 'WITH' ? getCteMainOperationName(query.slice(match[0].length)) : keyword; +} + +/** + * The operation of a `WITH` query is its main statement: the first operation keyword outside the + * CTE bodies, which are all parenthesized. Counting parentheses is safe on a sanitized query, which + * has no literals or comments left. + */ +function getCteMainOperationName(query: string): string | undefined { + let depth = 0; + for (const match of query.matchAll(CTE_MAIN_STATEMENT_RE)) { + if (match[0] === '(') { + depth++; + } else if (match[0] === ')') { + depth--; + } else if (depth === 0) { + return match[1]; + } + } + return undefined; +} + function extractTableNames(sql: string): string[] { const tables: string[] = []; TOKEN_RE.lastIndex = 0; @@ -366,6 +403,7 @@ export function sanitizeSqlQuery(sqlQuery: string | undefined, dialect: SqlDiale // whether it is looking at SQL syntax or at a user-supplied value. stripLiteralsAndComments(sqlQuery, dialect) .replace(/;\s*$/, '') // Remove trailing semicolons + .replace(/^[\s;]+/, '') // Remove leading semicolons (T-SQL's `;WITH`) // Collapse whitespace to a single space (after removing comments) .replace(/\s+/g, ' ') .trim() // Remove extra spaces and trim @@ -385,14 +423,15 @@ export function sanitizeSqlQuery(sqlQuery: string | undefined, dialect: SqlDiale } /** - * Sanitizes a collected SQL statement and derives the matching `db.query.summary`, the pair the SQL - * integrations attach to their spans. Both come back `undefined` when there is no statement, so an - * empty query omits the attributes instead of reporting the sanitizer's fallback text. + * Sanitizes a collected SQL statement and derives the matching `db.query.summary` and + * `db.operation.name`, the attributes the SQL integrations attach to their spans. All come back + * `undefined` when there is no statement, so an empty query omits the attributes instead of + * reporting the sanitizer's fallback text. */ export function sanitizeSqlQueryWithSummary( sqlQuery: string | undefined, dialect?: SqlDialect, -): { queryText: string | undefined; querySummary: string | undefined } { +): { queryText: string | undefined; querySummary: string | undefined; operationName: string | undefined } { const queryText = sqlQuery ? sanitizeSqlQuery(sqlQuery, dialect) : undefined; - return { queryText, querySummary: getSqlQuerySummary(queryText) }; + return { queryText, querySummary: getSqlQuerySummary(queryText), operationName: getSqlOperationName(queryText) }; } diff --git a/packages/server-utils/test/utils/sql.test.ts b/packages/server-utils/test/utils/sql.test.ts index 50ed62d4a50e..dfd1e2cbe814 100644 --- a/packages/server-utils/test/utils/sql.test.ts +++ b/packages/server-utils/test/utils/sql.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { getSqlQuerySummary, sanitizeSqlQuery, sanitizeSqlQueryWithSummary, toSqlDialect } from '../../src/utils/sql'; +import { + getSqlOperationName, + getSqlQuerySummary, + sanitizeSqlQuery, + sanitizeSqlQueryWithSummary, + toSqlDialect, +} from '../../src/utils/sql'; describe('getSqlQuerySummary', () => { it.each([undefined, ''])('returns undefined for %j', input => { @@ -283,6 +289,39 @@ describe('getSqlQuerySummary', () => { }); }); +describe('getSqlOperationName', () => { + it.each([undefined, '', ' ', '?'])('returns undefined for %j', input => { + expect(getSqlOperationName(input)).toBeUndefined(); + }); + + it.each([ + ['SELECT * FROM users WHERE id = ?', 'SELECT'], + ['select count(*) from orders', 'select'], + [' INSERT INTO users (name) VALUES (?)', 'INSERT'], + ['UPDATE users SET name = ? WHERE id = ?', 'UPDATE'], + ['DELETE FROM users WHERE id = ?', 'DELETE'], + ['CREATE TABLE IF NOT EXISTS users (id INT)', 'CREATE'], + ['(SELECT * FROM users) UNION (SELECT * FROM admins)', 'SELECT'], + ['BEGIN', 'BEGIN'], + ['PRAGMA table_info(users)', 'PRAGMA'], + ])('%j => %j', (input, expected) => { + expect(getSqlOperationName(input)).toBe(expected); + }); + + it.each([ + ['WITH active AS (SELECT * FROM users WHERE active = ?) SELECT * FROM active', 'SELECT'], + ['with moved AS (DELETE FROM queue RETURNING *) insert into archive SELECT * FROM moved', 'insert'], + ['WITH RECURSIVE t(n) AS (VALUES (?)) SELECT n FROM t', 'SELECT'], + ['WITH a AS (SELECT ?), b (x, y) AS (SELECT * FROM a) UPDATE t SET x = (SELECT x FROM b)', 'UPDATE'], + ['WITH x AS (VALUES (?)) TABLE x', 'TABLE'], + ['WITH x AS (SELECT ?)', undefined], + ['(WITH x AS (SELECT ?) SELECT * FROM x)', 'SELECT'], + ['( (with x AS (SELECT ?) delete FROM t WHERE id IN (SELECT * FROM x)) )', 'delete'], + ])('takes the main statement of a WITH query: %j => %j', (input, expected) => { + expect(getSqlOperationName(input)).toBe(expected); + }); +}); + describe('sanitizeSqlQuery', () => { describe('passthrough (no literals)', () => { it.each([ @@ -320,13 +359,20 @@ describe('sanitizeSqlQuery', () => { }); }); - describe('trailing semicolon removal', () => { + describe('semicolon removal', () => { it.each([ ['SELECT * FROM users;', 'SELECT * FROM users'], ['SELECT * FROM users; ', 'SELECT * FROM users'], ])('removes trailing semicolon: %p', (input, expected) => { expect(sanitizeSqlQuery(input)).toBe(expected); }); + + it.each([ + [';SELECT * FROM users', 'SELECT * FROM users'], + [' ; ;WITH x AS (SELECT ?) SELECT * FROM x', 'WITH x AS (SELECT ?) SELECT * FROM x'], + ])('removes leading semicolons: %p', (input, expected) => { + expect(sanitizeSqlQuery(input)).toBe(expected); + }); }); describe('$n placeholder preservation (OTEL compliance)', () => { @@ -853,6 +899,7 @@ describe('sanitizeSqlQueryWithSummary', () => { expect(sanitizeSqlQueryWithSummary("SELECT * FROM users WHERE email = 'jane@example.com'")).toEqual({ queryText: 'SELECT * FROM users WHERE email = ?', querySummary: 'SELECT users', + operationName: 'SELECT', }); }); @@ -860,12 +907,30 @@ describe('sanitizeSqlQueryWithSummary', () => { expect(sanitizeSqlQueryWithSummary('SELECT * FROM users WHERE email = "jane@example.com"', 'mysql')).toEqual({ queryText: 'SELECT * FROM users WHERE email = ?', querySummary: 'SELECT users', + operationName: 'SELECT', + }); + }); + + it("names the operation of T-SQL's `;WITH` queries", () => { + expect( + sanitizeSqlQueryWithSummary(";WITH x AS (SELECT name FROM users) SELECT * FROM x WHERE name = 'a'", 'mssql'), + ).toMatchObject({ + queryText: 'WITH x AS (SELECT name FROM users) SELECT * FROM x WHERE name = ?', + operationName: 'SELECT', }); }); - it('returns undefined for both when there is no statement', () => { - expect(sanitizeSqlQueryWithSummary(undefined)).toEqual({ queryText: undefined, querySummary: undefined }); - expect(sanitizeSqlQueryWithSummary('')).toEqual({ queryText: undefined, querySummary: undefined }); + it('returns undefined for all when there is no statement', () => { + expect(sanitizeSqlQueryWithSummary(undefined)).toEqual({ + queryText: undefined, + querySummary: undefined, + operationName: undefined, + }); + expect(sanitizeSqlQueryWithSummary('')).toEqual({ + queryText: undefined, + querySummary: undefined, + operationName: undefined, + }); }); }); @@ -891,6 +956,7 @@ describe('sanitizeSqlQueryWithSummary', () => { expect(sanitizeSqlQueryWithSummary("SELECT * FROM users WHERE email = 'jane@example.com'")).toEqual({ queryText: 'SELECT * FROM users WHERE email = ?', querySummary: 'SELECT users', + operationName: 'SELECT', }); }); @@ -903,6 +969,7 @@ describe('sanitizeSqlQueryWithSummary', () => { expect(sanitizeSqlQueryWithSummary(input, dialect)).toEqual({ queryText: 'SELECT * FROM users WHERE email = ?', querySummary: 'SELECT users', + operationName: 'SELECT', }); }); @@ -910,6 +977,7 @@ describe('sanitizeSqlQueryWithSummary', () => { expect(sanitizeSqlQueryWithSummary("SELECT * FROM [users] WHERE [email] = 'jane@example.com'", 'mssql')).toEqual({ queryText: 'SELECT * FROM [users] WHERE [email] = ?', querySummary: 'SELECT [users]', + operationName: 'SELECT', }); }); @@ -917,11 +985,20 @@ describe('sanitizeSqlQueryWithSummary', () => { expect(sanitizeSqlQueryWithSummary("SELECT * FROM users WHERE bio = 'from secret_table'")).toEqual({ queryText: 'SELECT * FROM users WHERE bio = ?', querySummary: 'SELECT users', + operationName: 'SELECT', }); }); - it('returns undefined for both when there is no statement', () => { - expect(sanitizeSqlQueryWithSummary(undefined)).toEqual({ queryText: undefined, querySummary: undefined }); - expect(sanitizeSqlQueryWithSummary('')).toEqual({ queryText: undefined, querySummary: undefined }); + it('returns undefined for all when there is no statement', () => { + expect(sanitizeSqlQueryWithSummary(undefined)).toEqual({ + queryText: undefined, + querySummary: undefined, + operationName: undefined, + }); + expect(sanitizeSqlQueryWithSummary('')).toEqual({ + queryText: undefined, + querySummary: undefined, + operationName: undefined, + }); }); });