From 58cd60ce42e3880362c7baa8e9c220b60b049f27 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 25 Aug 2026 17:37:13 +0200 Subject: [PATCH 1/3] 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 d651e3778d75..e24e97dcf7f4 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 f39b0613c551..9990cf9f0fc8 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, From ffdcc016a0d9eaf6ab0a03c9f046374ec99b87a2 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 26 Aug 2026 15:02:07 +0200 Subject: [PATCH 2/3] deslop --- .../suites/tracing/mysql/test.ts | 3 --- .../server-utils/src/integrations/mysql.ts | 19 ++++++++++--------- .../src/integrations/mysql2/index.ts | 16 ++++++---------- .../mysql2/mysql2-dc-subscriber.ts | 12 ++++-------- 4 files changed, 20 insertions(+), 30 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 22bb7638aeb2..b46624189f5d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts @@ -255,9 +255,6 @@ 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: { diff --git a/packages/server-utils/src/integrations/mysql.ts b/packages/server-utils/src/integrations/mysql.ts index e24e97dcf7f4..ede932d84ba4 100644 --- a/packages/server-utils/src/integrations/mysql.ts +++ b/packages/server-utils/src/integrations/mysql.ts @@ -35,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. @@ -85,21 +87,20 @@ 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; + + const client = getClient(); + const name = + client && hasSpanStreamingEnabled(client) + ? querySummary || database || DB_SYSTEM_NAME_VALUE_MYSQL + : (sql ?? 'mysql.query'); return startInactiveSpan({ - name: streamedName ?? 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 } : {}), diff --git a/packages/server-utils/src/integrations/mysql2/index.ts b/packages/server-utils/src/integrations/mysql2/index.ts index 9990cf9f0fc8..559de1986a12 100644 --- a/packages/server-utils/src/integrations/mysql2/index.ts +++ b/packages/server-utils/src/integrations/mysql2/index.ts @@ -83,29 +83,25 @@ 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 = + + const client = getClient(); + const name = client && hasSpanStreamingEnabled(client) ? querySummary || (connectionAttributes[DB_NAMESPACE] as string | undefined) || DB_SYSTEM_VALUE_MYSQL - : undefined; + : (statement ?? 'mysql2.query'); return startInactiveSpan({ - name: streamedName ?? 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, - ...connectionAttributes, [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 bc1cb5544c46..0e65ed02231e 100644 --- a/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts @@ -104,20 +104,16 @@ 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 client = getClient(); const streamedName = client && hasSpanStreamingEnabled(client) ? querySummary || data.database || DB_SYSTEM_NAME_VALUE_MYSQL - : undefined; + : queryText || 'mysql2.query'; return startInactiveSpan({ - name: streamedName || queryText || 'mysql2.query', + name: streamedName, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', From 3e563749ef3e2807034d749f992ddd0795000f3a Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 28 Aug 2026 14:34:10 +0200 Subject: [PATCH 3/3] rename `streamedName` to `name` --- .../src/integrations/mysql2/mysql2-dc-subscriber.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 0e65ed02231e..88b0a4b4d7ff 100644 --- a/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/mysql2/mysql2-dc-subscriber.ts @@ -107,13 +107,13 @@ function setupQueryChannel(tracingChannel: MySQL2TracingChannelFactory, channelN const querySummary = _INTERNAL_getSqlQuerySummary(queryText); const client = getClient(); - const streamedName = + const name = client && hasSpanStreamingEnabled(client) ? querySummary || data.database || DB_SYSTEM_NAME_VALUE_MYSQL : queryText || 'mysql2.query'; return startInactiveSpan({ - name: streamedName, + name, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db',