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 caba99a68fb9..b46624189f5d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts @@ -263,8 +263,12 @@ describe('mysql auto instrumentation', () => { type: 'string', value: 'SELECT 1 + 1 AS solution', }, + 'db.query.summary': { + type: 'string', + value: 'SELECT', + }, }, - name: 'SELECT 1 + 1 AS solution', + name: 'SELECT', ...COMMON_SPAN_PROPS, }, { @@ -274,8 +278,12 @@ describe('mysql auto instrumentation', () => { type: 'string', value: 'SELECT NOW()', }, + 'db.query.summary': { + type: 'string', + value: 'SELECT', + }, }, - name: 'SELECT NOW()', + name: 'SELECT', ...COMMON_SPAN_PROPS, }, ]); diff --git a/packages/server-utils/src/integrations/mysql.ts b/packages/server-utils/src/integrations/mysql.ts index d651e3778d75..ede932d84ba4 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 'node:diagnostics_channel'; import { DB_NAMESPACE, + DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME, DB_USER, @@ -10,10 +11,14 @@ import { } from '@sentry/conventions/attributes'; import type { IntegrationFn, Scope } from '@sentry/core'; import { + _INTERNAL_getSqlQuerySummary, + _INTERNAL_sanitizeSqlQuery, isObjectLike, bindScopeToEmitter, defineIntegration, + getClient, getCurrentScope, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, } from '@sentry/core'; @@ -30,6 +35,8 @@ const INTEGRATION_NAME = 'Mysql' as const; // `@opentelemetry/instrumentation-mysql`'s default shape. const ATTR_DB_CONNECTION_STRING = 'db.connection_string'; +const DB_SYSTEM_NAME_VALUE_MYSQL = 'mysql' as const; + /** * The shape orchestrion's transform attaches to the tracing-channel `context` object. Documented here * rather than imported because orchestrion's runtime doesn't export it. @@ -80,17 +87,26 @@ function instrumentMysql(): void { // handler with the caller's context lost. `deferSpanEnd` replays this scope onto the emitter. data._sentryCallerScope = getCurrentScope(); + const querySummary = sql ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(sql)) : undefined; + + const client = getClient(); + const name = + client && hasSpanStreamingEnabled(client) + ? querySummary || database || DB_SYSTEM_NAME_VALUE_MYSQL + : (sql ?? 'mysql.query'); + return startInactiveSpan({ - name: sql ?? 'mysql.query', + name, op: 'db', attributes: { [SENTRY_KIND]: 'client', - [DB_SYSTEM_NAME]: 'mysql', + [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_MYSQL, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.mysql', [ATTR_DB_CONNECTION_STRING]: getJDBCString(host, portIsNumber ? portNumber : undefined, database), ...(database ? { [DB_NAMESPACE]: database } : {}), ...(user ? { [DB_USER]: user } : {}), ...(sql ? { [DB_QUERY_TEXT]: sql } : {}), + [DB_QUERY_SUMMARY]: querySummary, [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 f39b0613c551..559de1986a12 100644 --- a/packages/server-utils/src/integrations/mysql2/index.ts +++ b/packages/server-utils/src/integrations/mysql2/index.ts @@ -1,7 +1,11 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import type { IntegrationFn, SpanAttributes } from '@sentry/core'; import { + _INTERNAL_getSqlQuerySummary, + _INTERNAL_sanitizeSqlQuery, defineIntegration, + getClient, + hasSpanStreamingEnabled, isObjectLike, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -16,6 +20,7 @@ import { mysql2ModuleNames } from '../../orchestrion/config/mysql2'; import { invokeOrchestrionInstrumentation } from '../../orchestrion/instrumentation'; import { DB_NAMESPACE, + DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME, DB_USER, @@ -78,16 +83,25 @@ function subscribeQueryChannel(channelName: ChannelName): void { diagnosticsChannel.tracingChannel(channelName), data => { const statement = getQueryText(data.arguments); + const connectionAttributes = getConnectionAttributes(data.self?.config); + const querySummary = statement ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(statement)) : undefined; + + const client = getClient(); + const name = + client && hasSpanStreamingEnabled(client) + ? querySummary || (connectionAttributes[DB_NAMESPACE] as string | undefined) || DB_SYSTEM_VALUE_MYSQL + : (statement ?? 'mysql2.query'); return startInactiveSpan({ - name: statement ?? 'mysql2.query', + name, attributes: { [SENTRY_KIND]: 'client', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', [DB_SYSTEM_NAME]: DB_SYSTEM_VALUE_MYSQL, - ...getConnectionAttributes(data.self?.config), [DB_QUERY_TEXT]: statement || undefined, + [DB_QUERY_SUMMARY]: querySummary, + ...connectionAttributes, }, }); }, diff --git a/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts b/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts index 2dcf1849ed89..0e65ed02231e 100644 --- a/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts @@ -2,13 +2,17 @@ import type { TracingChannel } from 'node:diagnostics_channel'; import { DB_NAMESPACE, DB_OPERATION_NAME, + DB_QUERY_SUMMARY, DB_QUERY_TEXT, DB_SYSTEM_NAME, SERVER_ADDRESS, SERVER_PORT, } from '@sentry/conventions/attributes'; import { + _INTERNAL_getSqlQuerySummary, _INTERNAL_sanitizeSqlQuery, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, @@ -100,14 +104,22 @@ function setupQueryChannel(tracingChannel: MySQL2TracingChannelFactory, channelN // literal before it leaves the process; `values` is never attached. const queryText = data.query ? _INTERNAL_sanitizeSqlQuery(data.query) : undefined; const operation = queryText?.match(SQL_OPERATION_RE)?.[1]?.toUpperCase(); + const querySummary = _INTERNAL_getSqlQuerySummary(queryText); + + const client = getClient(); + const streamedName = + client && hasSpanStreamingEnabled(client) + ? querySummary || data.database || DB_SYSTEM_NAME_VALUE_MYSQL + : queryText || 'mysql2.query'; return startInactiveSpan({ - name: queryText || 'mysql2.query', + name: streamedName, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_MYSQL, [DB_QUERY_TEXT]: queryText, + [DB_QUERY_SUMMARY]: querySummary, [DB_OPERATION_NAME]: operation, [DB_NAMESPACE]: data.database || undefined, [SERVER_ADDRESS]: data.serverAddress, diff --git a/packages/server-utils/test/integrations/mysql2/mysql2-dc-subscriber.test.ts b/packages/server-utils/test/integrations/mysql2/mysql2-dc-subscriber.test.ts index 2048edfb5155..3a489ba00999 100644 --- a/packages/server-utils/test/integrations/mysql2/mysql2-dc-subscriber.test.ts +++ b/packages/server-utils/test/integrations/mysql2/mysql2-dc-subscriber.test.ts @@ -41,12 +41,13 @@ class TestClient extends Client { } } -function initTestClient(): void { +function initTestClient(traceLifecycle: 'static' | 'stream' = 'static'): void { initAndBind(TestClient, { dsn: 'https://username@domain/123', integrations: [], sendClientReports: false, stackParser: () => [], + traceLifecycle, tracesSampleRate: 1, transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), }); @@ -199,6 +200,8 @@ describe('subscribeMysql2DiagnosticChannels', () => { expect(json.attributes['sentry.origin']).toBe('auto.db.mysql2.diagnostic_channel'); expect(json.attributes['db.system.name']).toBe('mysql'); expect(json.attributes['db.operation.name']).toBe('SELECT'); + // reported regardless of trace lifecycle, even though only span streaming names the span after it + expect(json.attributes['db.query.summary']).toBe('SELECT maths'); expect(json.attributes['db.namespace']).toBe('test'); expect(json.attributes['server.address']).toBe('127.0.0.1'); expect(json.attributes['server.port']).toBe(3306); @@ -222,6 +225,45 @@ describe('subscribeMysql2DiagnosticChannels', () => { expect(json.name).toBe('SELECT * FROM users WHERE email = ? AND age = ?'); }); + it('names the span after the query summary with span streaming enabled', async () => { + initTestClient('stream'); + + const { span } = await traceOperation( + MYSQL2_DC_CHANNEL_QUERY, + { query: 'SELECT solution FROM maths' }, + { result: [] }, + ); + + const json = spanToJSON(span!); + expect(json.name).toBe('SELECT maths'); + expect(json.attributes['db.query.summary']).toBe('SELECT maths'); + // the statement is still reported, just not as the name + expect(json.attributes['db.query.text']).toBe('SELECT solution FROM maths'); + }); + + it('walks the name conventions when no query summary can be derived', async () => { + initTestClient('stream'); + + const { span } = await traceOperation( + MYSQL2_DC_CHANNEL_QUERY, + { query: '', database: 'test', serverAddress: '127.0.0.1', serverPort: 3306 }, + { result: [] }, + ); + + // no summary and no operation, so `{db.namespace}` is the first template that can be filled + const json = spanToJSON(span!); + expect(json.name).toBe('test'); + expect(json.attributes['db.query.summary']).toBeUndefined(); + }); + + it('falls back to the db system name when nothing else can be filled in', async () => { + initTestClient('stream'); + + const { span } = await traceOperation(MYSQL2_DC_CHANNEL_QUERY, { query: '' }, { result: [] }); + + expect(spanToJSON(span!).name).toBe('mysql'); + }); + it('does not attach raw values to the span', async () => { const { span } = await traceOperation( MYSQL2_DC_CHANNEL_QUERY,