From f7c6bc03adc818af67cae6e1e874f4d2d217c223 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 25 Aug 2026 17:37:13 +0200 Subject: [PATCH] feat(server-utils)!: Emit low cardinality mysql db span names With span streaming, `mysql` and `mysql2` query spans are named after their `db.query.summary` (`SELECT users`) instead of the full SQL statement, and report that summary as a new `db.query.summary` attribute. Both mysql2 paths (the orchestrion channels below 3.20.0 and the native diagnostics channels from 3.20.0 on) are covered, so the name does not depend on the driver version. The statement is sanitized before it is summarized, so a string literal containing `from`/`join` cannot leak a value into the name. `traceLifecycle: 'static'` keeps the existing names. Refs #23523 Co-Authored-By: Claude Opus 5 (1M context) --- .../suites/tracing/mysql/test.ts | 15 ++++++- .../server-utils/src/integrations/mysql.ts | 17 ++++++- .../src/integrations/mysql2/index.ts | 22 +++++++++- .../mysql2/mysql2-dc-subscriber.ts | 18 +++++++- .../mysql2/mysql2-dc-subscriber.test.ts | 44 ++++++++++++++++++- 5 files changed, 109 insertions(+), 7 deletions(-) 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..22bb7638aeb2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts @@ -255,6 +255,9 @@ describe('mysql auto instrumentation', () => { trace_id: expect.stringMatching(/^[\da-f]{32}$/), }; + // With span streaming, both spans are named after `{db.query.summary}`. Neither statement + // selects from a table, so the summary is the bare operation. The full statement is still + // reported via `db.query.text`. expect(dbSpans).toEqual([ { attributes: { @@ -263,8 +266,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 +281,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 69ced51b5dac..44031170c897 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'; @@ -80,8 +85,17 @@ function instrumentMysql(): void { // handler with the caller's context lost. `deferSpanEnd` replays this scope onto the emitter. data._sentryCallerScope = getCurrentScope(); + const client = getClient(); + // 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 = sql ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(sql)) : undefined; + // With span streaming, span names have to be low cardinality, so `{db.query.summary}` is used + // instead of the full statement, falling back to `{db.namespace}` and then `{db.system.name}` + // when there is no statement to summarize. + const streamedName = client && hasSpanStreamingEnabled(client) ? querySummary || database || 'mysql' : undefined; + return startInactiveSpan({ - name: sql ?? 'mysql.query', + name: streamedName ?? sql ?? 'mysql.query', op: 'db', attributes: { [SENTRY_KIND]: 'client', @@ -91,6 +105,7 @@ function instrumentMysql(): void { ...(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 7232b984c889..8bbeb6b06a4d 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,29 @@ function subscribeQueryChannel(channelName: ChannelName): void { diagnosticsChannel.tracingChannel(channelName), data => { const statement = getQueryText(data.arguments); + const client = getClient(); + const connectionAttributes = getConnectionAttributes(data.self?.config); + // 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 = statement ? _INTERNAL_getSqlQuerySummary(_INTERNAL_sanitizeSqlQuery(statement)) : undefined; + // With span streaming, span names have to be low cardinality, so `{db.query.summary}` is used + // instead of the full statement, falling back to `{db.namespace}` and then `{db.system.name}` + // when there is no statement to summarize. + const streamedName = + client && hasSpanStreamingEnabled(client) + ? querySummary || (connectionAttributes[DB_NAMESPACE] as string | undefined) || DB_SYSTEM_VALUE_MYSQL + : undefined; return startInactiveSpan({ - name: statement ?? 'mysql2.query', + name: streamedName ?? statement ?? 'mysql2.query', 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), + ...connectionAttributes, [DB_QUERY_TEXT]: statement || undefined, + [DB_QUERY_SUMMARY]: querySummary, }, }); }, 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..bc1cb5544c46 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,26 @@ 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 client = getClient(); + // `queryText` is already sanitized, so a string literal containing `from`/`join` can't leak a + // value into the summary. + const querySummary = _INTERNAL_getSqlQuerySummary(queryText); + // With span streaming, span names have to be low cardinality, so `{db.query.summary}` is used + // instead of the full statement, falling back to `{db.namespace}` and then `{db.system.name}` + // when there is no statement to summarize. + const streamedName = + client && hasSpanStreamingEnabled(client) + ? querySummary || data.database || DB_SYSTEM_NAME_VALUE_MYSQL + : undefined; return startInactiveSpan({ - name: queryText || 'mysql2.query', + name: streamedName || queryText || 'mysql2.query', 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,