From d0356f646c695f680e4e6d11b10f9dc37e543ac7 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 26 Jun 2026 11:47:54 -0400 Subject: [PATCH 01/12] feat(mysql2): Instrument mysql2 >= 3.20.0 via native tracing channels mysql2 >= 3.20.0 publishes its operations over node:diagnostics_channel (mysql2:query / :execute / :connect / :pool:connect), so the SDK can subscribe to those channels instead of monkey-patching, the same way we did for redis/ioredis and mongoose. The subscription lives in server-utils as mysql2Integration, and the node mysql2Integration extends it (via extendIntegration) while keeping the vendored OTel patcher for mysql2 < 3.20.0, which is now gated to that range so the two paths never double-instrument. The channel path emits the stable DB semconv (db.system.name, db.query.text, db.operation.name, db.namespace, server.address, server.port), so there's the same attribute drift we accepted for redis/mongoose. mysql2 publishes already-formatted SQL, so db.query.text is run through the OTel sanitizer and raw values are never attached. --- .../mysql2-tracing-channel/docker-compose.yml | 15 + .../mysql2-tracing-channel/instrument.mjs | 9 + .../mysql2-tracing-channel/scenario.mjs | 35 ++ .../tracing/mysql2-tracing-channel/test.ts | 104 ++++++ .../src/integrations/tracing/mysql2/index.ts | 9 +- .../mysql2/vendored/instrumentation.ts | 5 +- packages/server-utils/src/index.ts | 1 + packages/server-utils/src/mysql2/index.ts | 31 ++ .../src/mysql2/mysql2-dc-subscriber.ts | 159 +++++++++ .../test/mysql2/mysql2-dc-subscriber.test.ts | 302 ++++++++++++++++++ 10 files changed, 666 insertions(+), 4 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/docker-compose.yml create mode 100644 dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts create mode 100644 packages/server-utils/src/mysql2/index.ts create mode 100644 packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts create mode 100644 packages/server-utils/test/mysql2/mysql2-dc-subscriber.test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/docker-compose.yml b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/docker-compose.yml new file mode 100644 index 000000000000..f3869f6acfbd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/docker-compose.yml @@ -0,0 +1,15 @@ +services: + db: + image: mysql:8 + restart: always + container_name: integration-tests-mysql2-dc + ports: + - '3307:3306' + environment: + MYSQL_ROOT_PASSWORD: password + healthcheck: + test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -ppassword'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/instrument.mjs @@ -0,0 +1,9 @@ +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, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs new file mode 100644 index 000000000000..8f1252c8f081 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs @@ -0,0 +1,35 @@ +import * as Sentry from '@sentry/node'; +import mysql from 'mysql2/promise'; + +async function run() { + // Yield a microtick so the DC subscriber (deferred via Promise.resolve().then) + // is registered before mysql2 publishes on its native TracingChannels. + await Promise.resolve(); + + const connection = await mysql.createConnection({ + user: 'root', + password: 'password', + host: 'localhost', + port: 3307, + }); + + await Sentry.startSpan( + { + op: 'transaction', + name: 'Test Transaction', + }, + async () => { + await connection.query('SELECT 1 + 1 AS solution'); + // A literal value, to assert it is redacted out of `db.query.text`. + await connection.query("SELECT 'super-secret' AS leaked"); + // `execute` keeps `?` placeholders (prepared statements). + await connection.execute('SELECT ? AS answer', [42]); + // A failing query should produce a span with an error status. + await connection.query('SELECT * FROM does_not_exist').catch(() => {}); + }, + ); + + await connection.end(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts new file mode 100644 index 000000000000..0f71a46c8afc --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts @@ -0,0 +1,104 @@ +import { afterAll, expect } from 'vitest'; +import { conditionalTest } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// mysql2 >= 3.20.0 publishes its operations via `node:diagnostics_channel`, so the SDK subscribes +// to those channels (`subscribeMysql2DiagnosticChannels`) instead of monkey-patching. This suite +// pins `^3.20.0` and asserts the diagnostics-channel path: stable OTel DB semconv attributes, +// redacted query text, and that the legacy IITM patcher (gated to `< 3.20.0`) does NOT also fire. +// `TracingChannel` is only reliable on Node >= 20, so this suite is skipped on older Node. +conditionalTest({ min: 20 })('mysql2 tracing channel Test', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const expectedQuerySpan = (queryText: string) => + expect.objectContaining({ + description: queryText, + op: 'db', + origin: 'auto.db.mysql2.diagnostic_channel', + data: expect.objectContaining({ + 'sentry.origin': 'auto.db.mysql2.diagnostic_channel', + 'db.system.name': 'mysql', + 'db.operation.name': 'SELECT', + 'db.query.text': queryText, + 'server.address': 'localhost', + 'server.port': 3307, + }), + }); + + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expectedQuerySpan('SELECT ? + ? AS solution'), + // the inlined literal is redacted out of `db.query.text` + expectedQuerySpan('SELECT ? AS leaked'), + // `execute` keeps the `?` placeholder + expectedQuerySpan('SELECT ? AS answer'), + // a failing query produces a span with an error status + expect.objectContaining({ + description: 'SELECT * FROM does_not_exist', + op: 'db', + status: 'internal_error', + origin: 'auto.db.mysql2.diagnostic_channel', + }), + ]), + }; + + const EXPECTED_CONNECT = { + transaction: 'mysql2.connect', + }; + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('subscribes to mysql2 >= 3.20.0 diagnostics channels with stable semconv attributes', async () => { + await createTestRunner() + .withDockerCompose({ workingDirectory: [__dirname] }) + .expect({ transaction: EXPECTED_CONNECT }) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); + }); + + test('does not double-instrument: the legacy IITM mysql2 patcher does not fire on 3.20.0+', async () => { + await createTestRunner() + .withDockerCompose({ workingDirectory: [__dirname] }) + .expect({ transaction: EXPECTED_CONNECT }) + .expect({ + transaction: event => { + const spans = event.spans || []; + // The monkey-patch path (origin `auto.db.otel.mysql2`) must be inactive on 3.20.0+. + expect(spans.find(span => span.origin === 'auto.db.otel.mysql2')).toBeUndefined(); + // ...while the diagnostics-channel path is active. + expect(spans.find(span => span.origin === 'auto.db.mysql2.diagnostic_channel')).toBeDefined(); + }, + }) + .start() + .completed(); + }); + + test('never leaks raw values into db.query.text', async () => { + await createTestRunner() + .withDockerCompose({ workingDirectory: [__dirname] }) + .expect({ transaction: EXPECTED_CONNECT }) + .expect({ + transaction: event => { + const spans = event.spans || []; + for (const span of spans) { + const queryText = span.data?.['db.query.text']; + if (typeof queryText === 'string') { + expect(queryText).not.toContain('super-secret'); + } + } + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { mysql2: '^3.20.0' } }, + ); +}); diff --git a/packages/node/src/integrations/tracing/mysql2/index.ts b/packages/node/src/integrations/tracing/mysql2/index.ts index e2b6a0c7c12b..f2b59f36ba13 100644 --- a/packages/node/src/integrations/tracing/mysql2/index.ts +++ b/packages/node/src/integrations/tracing/mysql2/index.ts @@ -1,19 +1,22 @@ import { MySQL2Instrumentation } from './vendored/instrumentation'; import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; +import { defineIntegration, extendIntegration } from '@sentry/core'; import { generateInstrumentOnce } from '@sentry/node-core'; +import { mysql2Integration as mysql2ChannelIntegration } from '@sentry/server-utils'; const INTEGRATION_NAME = 'Mysql2' as const; export const instrumentMysql2 = generateInstrumentOnce(INTEGRATION_NAME, () => new MySQL2Instrumentation()); const _mysql2Integration = (() => { - return { + // The diagnostics_channel subscription (mysql2 >= 3.20.0) lives in server-utils so it is shared + // across server runtimes; we extend it here to also run the vendored OTel patcher for mysql2 < 3.20.0. + return extendIntegration(mysql2ChannelIntegration(), { name: INTEGRATION_NAME, setupOnce() { instrumentMysql2(); }, - }; + }); }) satisfies IntegrationFn; /** diff --git a/packages/node/src/integrations/tracing/mysql2/vendored/instrumentation.ts b/packages/node/src/integrations/tracing/mysql2/vendored/instrumentation.ts index 902f110e9275..a9e4e3ba500f 100644 --- a/packages/node/src/integrations/tracing/mysql2/vendored/instrumentation.ts +++ b/packages/node/src/integrations/tracing/mysql2/vendored/instrumentation.ts @@ -29,7 +29,10 @@ import { getConnectionAttributes, getConnectionPrototypeToInstrument, getQueryTe const PACKAGE_NAME = '@sentry/instrumentation-mysql2'; const ORIGIN = 'auto.db.otel.mysql2'; -const supportedVersions = ['>=1.4.2 <4']; +// mysql2 >= 3.20.0 publishes via diagnostics_channel and is instrumented by +// `subscribeMysql2DiagnosticChannels` instead, so this IITM patcher must not +// overlap it — otherwise every query would emit two mysql2 spans. +const supportedVersions = ['>=1.4.2 <3.20.0']; // The raw imported `mysql2` module exposes the `format` helper used to render // parameterized queries. Typed shallowly since it is only read internally. diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 33ca16bf11a6..dbd79e3819fc 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -5,6 +5,7 @@ */ export { mongooseIntegration } from './mongoose'; +export { mysql2Integration } from './mysql2'; export { redisIntegration, type RedisDiagnosticChannelsOptions } from './redis'; export type { RedisDiagnosticChannelResponseHook } from './redis/redis-dc-subscriber'; export { defaultDbStatementSerializer } from './redis/redis-statement-serializer'; diff --git a/packages/server-utils/src/mysql2/index.ts b/packages/server-utils/src/mysql2/index.ts new file mode 100644 index 000000000000..0a2678a3387a --- /dev/null +++ b/packages/server-utils/src/mysql2/index.ts @@ -0,0 +1,31 @@ +import { defineIntegration, type IntegrationFn } from '@sentry/core'; +import * as dc from 'node:diagnostics_channel'; +import { subscribeMysql2DiagnosticChannels } from './mysql2-dc-subscriber'; + +const _mysql2Integration = (() => { + return { + name: 'Mysql2', + setupOnce() { + // Bail on Node <= 18.18.0, where `tracingChannel` does not exist. + if (!dc.tracingChannel) { + return; + } + + // Subscribe to mysql2's native tracing channels (mysql2 >= 3.20.0). + // This is a no-op on versions that don't publish to the channels, so it is always safe to call. + // `bindTracingChannelToSpan` (inside the subscriber) makes the span the active context via + // `bindStore`, which needs the Sentry OTel context manager — `initOpenTelemetry()` registers + // that after `setupOnce`, so defer a tick. + void Promise.resolve().then(() => subscribeMysql2DiagnosticChannels(dc.tracingChannel)); + }, + }; +}) satisfies IntegrationFn; + +/** + * Auto-instrument the [mysql2](https://www.npmjs.com/package/mysql2) library via its native + * `node:diagnostics_channel` tracing channels (mysql2 >= 3.20.0). + * + * On older mysql2 versions the channels are never published to, so this integration is inert and + * the vendored OTel instrumentation (gated to `< 3.20.0`) handles instrumentation instead. + */ +export const mysql2Integration = defineIntegration(_mysql2Integration); diff --git a/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts b/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts new file mode 100644 index 000000000000..c579b54fafe6 --- /dev/null +++ b/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts @@ -0,0 +1,159 @@ +import type { TracingChannel } from 'node:diagnostics_channel'; +import { + DB_NAMESPACE, + DB_OPERATION_NAME, + DB_QUERY_TEXT, + DB_SYSTEM_NAME, + SERVER_ADDRESS, + SERVER_PORT, +} from '@sentry/conventions/attributes'; +import { + _INTERNAL_sanitizeSqlQuery, + debug, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startInactiveSpan, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { bindTracingChannelToSpan } from '../tracing-channel'; + +// Channel names published by mysql2 >= 3.20.0 (see mysql2 `lib/tracing.js`). +// Hardcoded so the subscriber does not have to import mysql2 — the channels +// just have to be subscribed to before the user's mysql2 code publishes. +export const MYSQL2_DC_CHANNEL_QUERY = 'mysql2:query'; +export const MYSQL2_DC_CHANNEL_EXECUTE = 'mysql2:execute'; +export const MYSQL2_DC_CHANNEL_CONNECT = 'mysql2:connect'; +export const MYSQL2_DC_CHANNEL_POOL_CONNECT = 'mysql2:pool:connect'; + +const ORIGIN = 'auto.db.mysql2.diagnostic_channel'; +const DB_SYSTEM_NAME_VALUE_MYSQL = 'mysql'; + +// Leading keyword of a SQL statement (SELECT, INSERT, …) → `db.operation.name`. +const SQL_OPERATION_RE = /^\s*(\w+)/; + +/** + * Shape of the context object mysql2 >= 3.20.0 publishes on its query/execute + * tracing channels (see mysql2 `lib/base/connection.js`). + * + * Node's `traceCallback`/`tracePromise` mutate this same object with + * `result`/`error` once the operation settles, which `bindTracingChannelToSpan` + * reads in its lifecycle handlers — hence both are declared optional here. + * + * `query` is the SQL statement. On the `query` channel mysql2 has already + * inlined `values` into it (`Connection.format`), so it carries raw user data; + * on the `execute` channel it keeps `?` placeholders. Either way we sanitize it + * before emitting `db.query.text` and never attach `values`. + */ +export interface MySQL2QueryData { + query?: string; + values?: unknown; + database?: string; + serverAddress?: string; + /** Absent for unix-socket connections, where `serverAddress` is the socket path. */ + serverPort?: number; + result?: unknown; + error?: Error; +} + +/** + * Shape of the context object mysql2 >= 3.20.0 publishes on its + * `connect`/`pool:connect` channels. + */ +export interface MySQL2ConnectData { + database?: string; + serverAddress?: string; + serverPort?: number; + user?: string; + result?: unknown; + error?: Error; +} + +/** + * Platform-provided factory that creates a native tracing channel for the given name. The + * subscriber binds the span and its lifecycle onto the channel via `bindTracingChannelToSpan`, + * which propagates the active span through the runtime's async context. + * + * Node passes `node:diagnostics_channel`'s `tracingChannel` directly. + */ +export type MySQL2TracingChannelFactory = (name: string) => TracingChannel; + +let subscribed = false; + +/** + * Subscribe Sentry span handlers to mysql2's diagnostics-channel events + * (`mysql2:query`, `:execute`, `:connect`, `:pool:connect`), published by + * mysql2 >= 3.20.0. + * + * On older mysql2 versions the channels are never published to, so the + * subscribers are inert — there is no double-instrumentation against the + * vendored OTel patcher, which is gated to `< 3.20.0`. + * + * Idempotent: subsequent calls are a no-op. + */ +export function subscribeMysql2DiagnosticChannels(tracingChannel: MySQL2TracingChannelFactory): void { + if (subscribed) { + return; + } + subscribed = true; + + try { + setupQueryChannel(tracingChannel, MYSQL2_DC_CHANNEL_QUERY); + setupQueryChannel(tracingChannel, MYSQL2_DC_CHANNEL_EXECUTE); + setupConnectChannel(tracingChannel, MYSQL2_DC_CHANNEL_CONNECT, 'mysql2.connect'); + setupConnectChannel(tracingChannel, MYSQL2_DC_CHANNEL_POOL_CONNECT, 'mysql2.pool.connect'); + } catch { + // The factory relies on `node:diagnostics_channel`, which isn't always + // available. Fail closed; the SDK simply won't emit mysql2 spans here. + DEBUG_BUILD && debug.log('mysql2 node:diagnostics_channel subscription failed.'); + } +} + +function setupQueryChannel(tracingChannel: MySQL2TracingChannelFactory, channelName: string): void { + bindTracingChannelToSpan( + tracingChannel(channelName), + data => { + // mysql2 does not sanitize its channel payload, so the statement may carry + // raw user values (on the `query` channel they are inlined). Strip every + // 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(); + + return startInactiveSpan({ + name: queryText || 'mysql2.query', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', + [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_MYSQL, + ...(queryText != null ? { [DB_QUERY_TEXT]: queryText } : {}), + ...(operation != null ? { [DB_OPERATION_NAME]: operation } : {}), + ...(data.database ? { [DB_NAMESPACE]: data.database } : {}), + ...(data.serverAddress != null ? { [SERVER_ADDRESS]: data.serverAddress } : {}), + ...(data.serverPort != null ? { [SERVER_PORT]: data.serverPort } : {}), + }, + }); + }, + // Query failures are surfaced to (and usually handled by) the caller; only annotate the + // span so we don't emit a duplicate error event for every failed query. + { captureError: false }, + ); +} + +function setupConnectChannel(tracingChannel: MySQL2TracingChannelFactory, channelName: string, spanName: string): void { + bindTracingChannelToSpan( + tracingChannel(channelName), + data => { + return startInactiveSpan({ + name: spanName, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', + [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_MYSQL, + ...(data.database ? { [DB_NAMESPACE]: data.database } : {}), + ...(data.serverAddress != null ? { [SERVER_ADDRESS]: data.serverAddress } : {}), + ...(data.serverPort != null ? { [SERVER_PORT]: data.serverPort } : {}), + }, + }); + }, + { captureError: false }, + ); +} diff --git a/packages/server-utils/test/mysql2/mysql2-dc-subscriber.test.ts b/packages/server-utils/test/mysql2/mysql2-dc-subscriber.test.ts new file mode 100644 index 000000000000..09752cec6aaa --- /dev/null +++ b/packages/server-utils/test/mysql2/mysql2-dc-subscriber.test.ts @@ -0,0 +1,302 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Scope, Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + Client, + createTransport, + getActiveSpan, + getCurrentScope, + getDefaultCurrentScope, + getDefaultIsolationScope, + getGlobalScope, + initAndBind, + resolvedSyncPromise, + setAsyncContextStrategy, + spanToJSON, + startSpan, +} from '@sentry/core'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + MYSQL2_DC_CHANNEL_CONNECT, + MYSQL2_DC_CHANNEL_EXECUTE, + MYSQL2_DC_CHANNEL_POOL_CONNECT, + MYSQL2_DC_CHANNEL_QUERY, + type MySQL2TracingChannelFactory, + subscribeMysql2DiagnosticChannels, +} from '../../src/mysql2/mysql2-dc-subscriber'; + +interface TestStore { + scope: Scope; + isolationScope: Scope; +} + +class TestClient extends Client { + public eventFromException(): PromiseLike { + return resolvedSyncPromise({}); + } + public eventFromMessage(): PromiseLike { + return resolvedSyncPromise({}); + } +} + +function initTestClient(): void { + initAndBind(TestClient, { + dsn: 'https://username@domain/123', + integrations: [], + sendClientReports: false, + stackParser: () => [], + tracesSampleRate: 1, + transport: () => createTransport({ recordDroppedEvent: () => undefined }, () => resolvedSyncPromise({})), + }); +} + +function installTestAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage(); + + function getScopes(): TestStore { + return ( + asyncStorage.getStore() || { + scope: getDefaultCurrentScope(), + isolationScope: getDefaultIsolationScope(), + } + ); + } + + setAsyncContextStrategy({ + withScope: callback => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withSetScope: (scope, callback) => { + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withIsolationScope: callback => { + const scope = getScopes().scope; + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + withSetIsolationScope: (isolationScope, callback) => { + const scope = getScopes().scope; + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + return { scope, isolationScope }; + }, + }), + }); +} + +/** Drives a channel's `tracePromise` and captures the span bound by the subscriber. */ +async function traceOperation( + channelName: string, + data: Record, + outcome: { result?: unknown; error?: Error }, +): Promise<{ span: Span | undefined; childParentSpanId: string | undefined }> { + const channel = tracingChannel(channelName); + let span: Span | undefined; + let childParentSpanId: string | undefined; + + const run = channel.tracePromise(async () => { + span = getActiveSpan(); + startSpan({ name: 'child' }, child => { + childParentSpanId = spanToJSON(child).parent_span_id; + }); + if (outcome.error) { + throw outcome.error; + } + return outcome.result; + }, data); + + await run.catch(() => undefined); + + return { span, childParentSpanId }; +} + +const factory = tracingChannel as MySQL2TracingChannelFactory; + +describe('subscribeMysql2DiagnosticChannels', () => { + let captureExceptionSpy: ReturnType; + + // The subscriber captures the async-context strategy's ALS when it binds, so the strategy must be + // installed before we subscribe — and both must stay fixed for the file. We do that once here, + // mirroring production where `setupOnce` subscribes a single time. Per-test we only reset the client + // and scopes (cleared in `afterEach`), so nothing leaks between tests. + beforeAll(() => { + installTestAsyncContextStrategy(); + subscribeMysql2DiagnosticChannels(factory); + }); + + afterAll(() => { + setAsyncContextStrategy(undefined); + }); + + beforeEach(() => { + initTestClient(); + captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('event-id'); + }); + + afterEach(() => { + getCurrentScope().clear(); + getCurrentScope().setClient(undefined); + getGlobalScope().clear(); + vi.clearAllMocks(); + }); + + describe('query channel', () => { + it('creates a db span with stable semconv attributes', async () => { + const { span } = await traceOperation( + MYSQL2_DC_CHANNEL_QUERY, + { + query: 'SELECT solution FROM maths', + database: 'test', + serverAddress: '127.0.0.1', + serverPort: 3306, + }, + { result: [{ solution: 2 }] }, + ); + + expect(span).toBeDefined(); + const json = spanToJSON(span!); + expect(json.description).toBe('SELECT solution FROM maths'); + expect(json.op).toBe('db'); + expect(json.origin).toBe('auto.db.mysql2.diagnostic_channel'); + expect(json.data['db.system.name']).toBe('mysql'); + expect(json.data['db.operation.name']).toBe('SELECT'); + expect(json.data['db.namespace']).toBe('test'); + expect(json.data['server.address']).toBe('127.0.0.1'); + expect(json.data['server.port']).toBe(3306); + expect(json.timestamp).toBeDefined(); + }); + + it('sanitizes inlined values out of db.query.text and the span name', async () => { + const { span } = await traceOperation( + MYSQL2_DC_CHANNEL_QUERY, + // The `query` channel publishes the already-formatted SQL with values inlined. + { query: "SELECT * FROM users WHERE email = 'a@b.com' AND age = 21" }, + { result: [] }, + ); + + const json = spanToJSON(span!); + const queryText = json.data['db.query.text'] as string; + expect(queryText).toBe('SELECT * FROM users WHERE email = ? AND age = ?'); + expect(queryText).not.toContain('a@b.com'); + expect(queryText).not.toContain('21'); + // the span name is the sanitized statement too — no raw values leak there either + expect(json.description).toBe('SELECT * FROM users WHERE email = ? AND age = ?'); + }); + + it('does not attach raw values to the span', async () => { + const { span } = await traceOperation( + MYSQL2_DC_CHANNEL_QUERY, + { query: 'SELECT * FROM users WHERE id = ?', values: ['secret'] }, + { result: [] }, + ); + + expect(JSON.stringify(spanToJSON(span!).data)).not.toContain('secret'); + }); + + it('sets error status and does NOT capture an exception on failure', async () => { + const { span } = await traceOperation( + MYSQL2_DC_CHANNEL_QUERY, + { query: 'SELECT * FROM does_not_exist' }, + { error: new Error('table missing') }, + ); + + expect(spanToJSON(span!).status).toBe('table missing'); + expect(spanToJSON(span!).timestamp).toBeDefined(); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('parents the mysql2 span to the surrounding span and parents children to it', async () => { + let outerSpanId: string | undefined; + let result: Awaited> | undefined; + + await startSpan({ name: 'outer' }, async outer => { + outerSpanId = outer.spanContext().spanId; + result = await traceOperation(MYSQL2_DC_CHANNEL_QUERY, { query: 'SELECT 1' }, { result: [] }); + }); + + expect(spanToJSON(result!.span!).parent_span_id).toBe(outerSpanId); + expect(result!.childParentSpanId).toBe(result!.span!.spanContext().spanId); + }); + }); + + describe('execute channel', () => { + it('keeps `?` placeholders in db.query.text (prepared statements)', async () => { + const { span } = await traceOperation( + MYSQL2_DC_CHANNEL_EXECUTE, + { query: 'SELECT * FROM users WHERE id = ?', values: [1] }, + { result: [] }, + ); + + const json = spanToJSON(span!); + expect(json.data['db.query.text']).toBe('SELECT * FROM users WHERE id = ?'); + expect(json.data['db.operation.name']).toBe('SELECT'); + }); + }); + + describe('connect channels', () => { + it('creates a connect span without db.query.text', async () => { + const { span } = await traceOperation( + MYSQL2_DC_CHANNEL_CONNECT, + { database: 'test', serverAddress: '127.0.0.1', serverPort: 3306, user: 'root' }, + { result: undefined }, + ); + + const json = spanToJSON(span!); + expect(json.description).toBe('mysql2.connect'); + expect(json.op).toBe('db'); + expect(json.origin).toBe('auto.db.mysql2.diagnostic_channel'); + expect(json.data['db.system.name']).toBe('mysql'); + expect(json.data['db.namespace']).toBe('test'); + expect(json.data['server.address']).toBe('127.0.0.1'); + expect(json.data['server.port']).toBe(3306); + expect(json.data['db.query.text']).toBeUndefined(); + }); + + it('names the pool connect span distinctly', async () => { + const { span } = await traceOperation( + MYSQL2_DC_CHANNEL_POOL_CONNECT, + { database: 'test', serverAddress: '127.0.0.1', serverPort: 3306 }, + { result: undefined }, + ); + + expect(spanToJSON(span!).description).toBe('mysql2.pool.connect'); + }); + + it('omits server.port for unix-socket connections', async () => { + const { span } = await traceOperation( + MYSQL2_DC_CHANNEL_CONNECT, + { database: 'test', serverAddress: '/var/run/mysqld/mysqld.sock', serverPort: undefined }, + { result: undefined }, + ); + + const json = spanToJSON(span!); + expect(json.data['server.address']).toBe('/var/run/mysqld/mysqld.sock'); + expect(json.data['server.port']).toBeUndefined(); + }); + }); + + describe('idempotency', () => { + it('does not throw or double-subscribe on a second call', async () => { + subscribeMysql2DiagnosticChannels(factory); + + const { span } = await traceOperation(MYSQL2_DC_CHANNEL_QUERY, { query: 'SELECT 1' }, { result: [] }); + + // a single subscription means a single span, ended exactly once + expect(span).toBeDefined(); + expect(spanToJSON(span!).timestamp).toBeDefined(); + }); + }); +}); From 007542c8ab9285ee80184214c73c7e1ca07f9650 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 26 Jun 2026 16:17:03 -0400 Subject: [PATCH 02/12] fix(mysql2): Use unique host port 3308 for tracing channel test db The mysql2-tracing-channel docker-compose bound host port 3307, which collides with the knex/mysql2 suite. When both run in parallel the second container fails with 'port is already allocated'. Switch to 3308. --- .../suites/tracing/mysql2-tracing-channel/docker-compose.yml | 2 +- .../suites/tracing/mysql2-tracing-channel/scenario.mjs | 2 +- .../suites/tracing/mysql2-tracing-channel/test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/docker-compose.yml b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/docker-compose.yml index f3869f6acfbd..a3bb1b258f90 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/docker-compose.yml +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/docker-compose.yml @@ -4,7 +4,7 @@ services: restart: always container_name: integration-tests-mysql2-dc ports: - - '3307:3306' + - '3308:3306' environment: MYSQL_ROOT_PASSWORD: password healthcheck: diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs index 8f1252c8f081..42c566769841 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs @@ -10,7 +10,7 @@ async function run() { user: 'root', password: 'password', host: 'localhost', - port: 3307, + port: 3308, }); await Sentry.startSpan( diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts index 0f71a46c8afc..54cb37552f61 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts @@ -23,7 +23,7 @@ conditionalTest({ min: 20 })('mysql2 tracing channel Test', () => { 'db.operation.name': 'SELECT', 'db.query.text': queryText, 'server.address': 'localhost', - 'server.port': 3307, + 'server.port': 3308, }), }); From 36184e820e32abbb77fa6d95904a70dbe27d57f7 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 1 Jul 2026 13:25:47 -0400 Subject: [PATCH 03/12] style(mysql2): Use nullish-coalescing for optional span attributes Replace the conditional-spread pattern for optional attributes with `?? undefined` (and `|| undefined` for the database name, which keeps dropping empty strings). Undefined-valued keys are dropped by the span backends, so this is equivalent while keeping the attribute lines consistent. --- .../src/mysql2/mysql2-dc-subscriber.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts b/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts index c579b54fafe6..82e8e567bdee 100644 --- a/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts +++ b/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts @@ -124,11 +124,11 @@ function setupQueryChannel(tracingChannel: MySQL2TracingChannelFactory, channelN [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_MYSQL, - ...(queryText != null ? { [DB_QUERY_TEXT]: queryText } : {}), - ...(operation != null ? { [DB_OPERATION_NAME]: operation } : {}), - ...(data.database ? { [DB_NAMESPACE]: data.database } : {}), - ...(data.serverAddress != null ? { [SERVER_ADDRESS]: data.serverAddress } : {}), - ...(data.serverPort != null ? { [SERVER_PORT]: data.serverPort } : {}), + [DB_QUERY_TEXT]: queryText ?? undefined, + [DB_OPERATION_NAME]: operation ?? undefined, + [DB_NAMESPACE]: data.database || undefined, + [SERVER_ADDRESS]: data.serverAddress ?? undefined, + [SERVER_PORT]: data.serverPort ?? undefined, }, }); }, @@ -148,9 +148,9 @@ function setupConnectChannel(tracingChannel: MySQL2TracingChannelFactory, channe [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_MYSQL, - ...(data.database ? { [DB_NAMESPACE]: data.database } : {}), - ...(data.serverAddress != null ? { [SERVER_ADDRESS]: data.serverAddress } : {}), - ...(data.serverPort != null ? { [SERVER_PORT]: data.serverPort } : {}), + [DB_NAMESPACE]: data.database || undefined, + [SERVER_ADDRESS]: data.serverAddress ?? undefined, + [SERVER_PORT]: data.serverPort ?? undefined, }, }); }, From eee646f46de8466c8f2e43f445d8b16f6584c9c6 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 1 Jul 2026 13:25:47 -0400 Subject: [PATCH 04/12] refactor(mysql2): Use waitForTracingChannelBinding to defer subscribe Replace the ad-hoc `void Promise.resolve().then(...)` deferral with the shared `waitForTracingChannelBinding` util, matching the mongoose, graphql, vercel-ai and redis integrations. It retries until the async context binding is available, so it also handles custom OTel setups where the binding isn't ready on the first tick. --- packages/server-utils/src/mysql2/index.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/server-utils/src/mysql2/index.ts b/packages/server-utils/src/mysql2/index.ts index 0a2678a3387a..d04758fbc96d 100644 --- a/packages/server-utils/src/mysql2/index.ts +++ b/packages/server-utils/src/mysql2/index.ts @@ -1,4 +1,4 @@ -import { defineIntegration, type IntegrationFn } from '@sentry/core'; +import { defineIntegration, type IntegrationFn, waitForTracingChannelBinding } from '@sentry/core'; import * as dc from 'node:diagnostics_channel'; import { subscribeMysql2DiagnosticChannels } from './mysql2-dc-subscriber'; @@ -13,10 +13,9 @@ const _mysql2Integration = (() => { // Subscribe to mysql2's native tracing channels (mysql2 >= 3.20.0). // This is a no-op on versions that don't publish to the channels, so it is always safe to call. - // `bindTracingChannelToSpan` (inside the subscriber) makes the span the active context via - // `bindStore`, which needs the Sentry OTel context manager — `initOpenTelemetry()` registers - // that after `setupOnce`, so defer a tick. - void Promise.resolve().then(() => subscribeMysql2DiagnosticChannels(dc.tracingChannel)); + waitForTracingChannelBinding(() => { + subscribeMysql2DiagnosticChannels(dc.tracingChannel); + }); }, }; }) satisfies IntegrationFn; From df3f4203102f05ee96828c49bbde8240ad393ff0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 2 Jul 2026 11:50:33 -0400 Subject: [PATCH 05/12] test(mysql2): Harden tracing-channel healthcheck to fix flaky CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite gated readiness on `mysqladmin ping`, which reports "alive" even during MySQL 8's init bootstrap (it treats access-denied as alive). So `docker compose up --wait` could return before the real server and root password were ready, and the scenario's first connection was dropped ("server closed the connection") — crashing the run and timing out the test. Gate on an authenticated `SELECT 1` instead, mirroring the knex/mysql2 sibling suite (#21868). --- .../tracing/mysql2-tracing-channel/docker-compose.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/docker-compose.yml b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/docker-compose.yml index a3bb1b258f90..216783c7731a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/docker-compose.yml +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/docker-compose.yml @@ -8,7 +8,11 @@ services: environment: MYSQL_ROOT_PASSWORD: password healthcheck: - test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -ppassword'] + # `mysqladmin ping` reports "alive" even during MySQL's init bootstrap (it treats + # access-denied as alive), so `--wait` can return before the real server and root + # password are ready, and early connections get dropped ("server closed the + # connection"). Run an authenticated query instead so readiness gates on the real server. + test: ['CMD-SHELL', 'mysql -h 127.0.0.1 -uroot -ppassword -e "SELECT 1"'] interval: 2s timeout: 3s retries: 30 From 97ca338e17da3b162dd73e85cadd32c8fda14da3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 2 Jul 2026 16:17:03 -0400 Subject: [PATCH 06/12] test(mysql2): Retry initial connect to fix tracing-channel CI flake MySQL keeps finalizing for a short window after `docker compose up --wait` reports healthy, and drops early handshakes with "server closed the connection". The scenario connected once, so every CI run failed at `createConnection` and the test timed out. Retry the initial connect in the scenario. Each failed attempt still publishes on mysql2's `connect` tracing channel, so assert the envelopes with `.unordered()` and pin the callback assertions to the query transaction. Bump the per-test timeout to leave room for docker startup plus the retry. --- .../mysql2-tracing-channel/scenario.mjs | 33 +++++++++++++++---- .../tracing/mysql2-tracing-channel/test.ts | 18 ++++++++-- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs index 42c566769841..37f44b98c43c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs @@ -1,17 +1,38 @@ import * as Sentry from '@sentry/node'; import mysql from 'mysql2/promise'; +const CONNECT_CONFIG = { + user: 'root', + password: 'password', + host: 'localhost', + port: 3308, +}; + +// `docker compose up --wait` gates on the healthcheck, but MySQL keeps finalizing +// for a short window afterwards and drops early handshakes ("server closed the +// connection"). Retry the initial connect so the suite doesn't flake on that window. +// A failed attempt still publishes on mysql2's `connect` channel, so the test asserts +// its envelopes with `.unordered()` to tolerate the transient connect transaction. +async function connectWithRetry(attempts = 15, delayMs = 500) { + let lastError; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + return await mysql.createConnection(CONNECT_CONFIG); + } catch (error) { + lastError = error; + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + } + + throw lastError; +} + async function run() { // Yield a microtick so the DC subscriber (deferred via Promise.resolve().then) // is registered before mysql2 publishes on its native TracingChannels. await Promise.resolve(); - const connection = await mysql.createConnection({ - user: 'root', - password: 'password', - host: 'localhost', - port: 3308, - }); + const connection = await connectWithRetry(); await Sentry.startSpan( { diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts index 54cb37552f61..f5518da2e423 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts @@ -59,9 +59,13 @@ conditionalTest({ min: 20 })('mysql2 tracing channel Test', () => { .withDockerCompose({ workingDirectory: [__dirname] }) .expect({ transaction: EXPECTED_CONNECT }) .expect({ transaction: EXPECTED_TRANSACTION }) + // The scenario retries the initial connect (MySQL drops early handshakes right + // after the healthcheck passes), and each failed attempt emits a `mysql2.connect` + // transaction, so envelope order isn't guaranteed. + .unordered() .start() .completed(); - }); + }, 30_000); test('does not double-instrument: the legacy IITM mysql2 patcher does not fire on 3.20.0+', async () => { await createTestRunner() @@ -69,6 +73,9 @@ conditionalTest({ min: 20 })('mysql2 tracing channel Test', () => { .expect({ transaction: EXPECTED_CONNECT }) .expect({ transaction: event => { + // With `.unordered()`, pin the assertion to the query transaction so a stray + // connect transaction (from a retried handshake) can't satisfy it. + expect(event.transaction).toBe('Test Transaction'); const spans = event.spans || []; // The monkey-patch path (origin `auto.db.otel.mysql2`) must be inactive on 3.20.0+. expect(spans.find(span => span.origin === 'auto.db.otel.mysql2')).toBeUndefined(); @@ -76,9 +83,10 @@ conditionalTest({ min: 20 })('mysql2 tracing channel Test', () => { expect(spans.find(span => span.origin === 'auto.db.mysql2.diagnostic_channel')).toBeDefined(); }, }) + .unordered() .start() .completed(); - }); + }, 30_000); test('never leaks raw values into db.query.text', async () => { await createTestRunner() @@ -86,6 +94,9 @@ conditionalTest({ min: 20 })('mysql2 tracing channel Test', () => { .expect({ transaction: EXPECTED_CONNECT }) .expect({ transaction: event => { + // With `.unordered()`, pin the assertion to the query transaction so an empty + // connect transaction (from a retried handshake) can't vacuously satisfy it. + expect(event.transaction).toBe('Test Transaction'); const spans = event.spans || []; for (const span of spans) { const queryText = span.data?.['db.query.text']; @@ -95,9 +106,10 @@ conditionalTest({ min: 20 })('mysql2 tracing channel Test', () => { } }, }) + .unordered() .start() .completed(); - }); + }, 30_000); }, { additionalDependencies: { mysql2: '^3.20.0' } }, ); From 2c5ad18b8d881c7f871584b86224f73992862400 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 4 Jul 2026 00:00:13 -0400 Subject: [PATCH 07/12] ref: remove no-op undefineds --- .../server-utils/src/mysql2/mysql2-dc-subscriber.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts b/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts index 82e8e567bdee..2ac46e7d193c 100644 --- a/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts +++ b/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts @@ -124,11 +124,11 @@ function setupQueryChannel(tracingChannel: MySQL2TracingChannelFactory, channelN [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_MYSQL, - [DB_QUERY_TEXT]: queryText ?? undefined, - [DB_OPERATION_NAME]: operation ?? undefined, + [DB_QUERY_TEXT]: queryText, + [DB_OPERATION_NAME]: operation, [DB_NAMESPACE]: data.database || undefined, - [SERVER_ADDRESS]: data.serverAddress ?? undefined, - [SERVER_PORT]: data.serverPort ?? undefined, + [SERVER_ADDRESS]: data.serverAddress, + [SERVER_PORT]: data.serverPort, }, }); }, @@ -149,8 +149,8 @@ function setupConnectChannel(tracingChannel: MySQL2TracingChannelFactory, channe [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_MYSQL, [DB_NAMESPACE]: data.database || undefined, - [SERVER_ADDRESS]: data.serverAddress ?? undefined, - [SERVER_PORT]: data.serverPort ?? undefined, + [SERVER_ADDRESS]: data.serverAddress, + [SERVER_PORT]: data.serverPort, }, }); }, From 4e97aea63e2d5464ab20ffbd4f0257f19d48d46c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 4 Jul 2026 00:01:51 -0400 Subject: [PATCH 08/12] ref: remove excessive guards --- .../src/mysql2/mysql2-dc-subscriber.ts | 94 ++++++++----------- 1 file changed, 38 insertions(+), 56 deletions(-) diff --git a/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts b/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts index 2ac46e7d193c..16d7465b62a7 100644 --- a/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts +++ b/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts @@ -9,12 +9,10 @@ import { } from '@sentry/conventions/attributes'; import { _INTERNAL_sanitizeSqlQuery, - debug, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, } from '@sentry/core'; -import { DEBUG_BUILD } from '../debug-build'; import { bindTracingChannelToSpan } from '../tracing-channel'; // Channel names published by mysql2 >= 3.20.0 (see mysql2 `lib/tracing.js`). @@ -94,66 +92,50 @@ export function subscribeMysql2DiagnosticChannels(tracingChannel: MySQL2TracingC if (subscribed) { return; } - subscribed = true; - try { - setupQueryChannel(tracingChannel, MYSQL2_DC_CHANNEL_QUERY); - setupQueryChannel(tracingChannel, MYSQL2_DC_CHANNEL_EXECUTE); - setupConnectChannel(tracingChannel, MYSQL2_DC_CHANNEL_CONNECT, 'mysql2.connect'); - setupConnectChannel(tracingChannel, MYSQL2_DC_CHANNEL_POOL_CONNECT, 'mysql2.pool.connect'); - } catch { - // The factory relies on `node:diagnostics_channel`, which isn't always - // available. Fail closed; the SDK simply won't emit mysql2 spans here. - DEBUG_BUILD && debug.log('mysql2 node:diagnostics_channel subscription failed.'); - } + setupQueryChannel(tracingChannel, MYSQL2_DC_CHANNEL_QUERY); + setupQueryChannel(tracingChannel, MYSQL2_DC_CHANNEL_EXECUTE); + setupConnectChannel(tracingChannel, MYSQL2_DC_CHANNEL_CONNECT, 'mysql2.connect'); + setupConnectChannel(tracingChannel, MYSQL2_DC_CHANNEL_POOL_CONNECT, 'mysql2.pool.connect'); + subscribed = true; } function setupQueryChannel(tracingChannel: MySQL2TracingChannelFactory, channelName: string): void { - bindTracingChannelToSpan( - tracingChannel(channelName), - data => { - // mysql2 does not sanitize its channel payload, so the statement may carry - // raw user values (on the `query` channel they are inlined). Strip every - // 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(); + bindTracingChannelToSpan(tracingChannel(channelName), data => { + // mysql2 does not sanitize its channel payload, so the statement may carry + // raw user values (on the `query` channel they are inlined). Strip every + // 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(); - return startInactiveSpan({ - name: 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_OPERATION_NAME]: operation, - [DB_NAMESPACE]: data.database || undefined, - [SERVER_ADDRESS]: data.serverAddress, - [SERVER_PORT]: data.serverPort, - }, - }); - }, - // Query failures are surfaced to (and usually handled by) the caller; only annotate the - // span so we don't emit a duplicate error event for every failed query. - { captureError: false }, - ); + return startInactiveSpan({ + name: 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_OPERATION_NAME]: operation, + [DB_NAMESPACE]: data.database || undefined, + [SERVER_ADDRESS]: data.serverAddress, + [SERVER_PORT]: data.serverPort, + }, + }); + }); } function setupConnectChannel(tracingChannel: MySQL2TracingChannelFactory, channelName: string, spanName: string): void { - bindTracingChannelToSpan( - tracingChannel(channelName), - data => { - return startInactiveSpan({ - name: spanName, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', - [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_MYSQL, - [DB_NAMESPACE]: data.database || undefined, - [SERVER_ADDRESS]: data.serverAddress, - [SERVER_PORT]: data.serverPort, - }, - }); - }, - { captureError: false }, - ); + bindTracingChannelToSpan(tracingChannel(channelName), data => { + return startInactiveSpan({ + name: spanName, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', + [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_MYSQL, + [DB_NAMESPACE]: data.database || undefined, + [SERVER_ADDRESS]: data.serverAddress, + [SERVER_PORT]: data.serverPort, + }, + }); + }); } From f41e1dc9d6c023e7f80bc7853c7f692924db90ba Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 4 Jul 2026 00:04:39 -0400 Subject: [PATCH 09/12] fix: only instrument when there is active spans --- .../server-utils/src/mysql2/mysql2-dc-subscriber.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts b/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts index 16d7465b62a7..f5eb5cd54435 100644 --- a/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts +++ b/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts @@ -9,6 +9,7 @@ import { } from '@sentry/conventions/attributes'; import { _INTERNAL_sanitizeSqlQuery, + getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, @@ -102,6 +103,11 @@ export function subscribeMysql2DiagnosticChannels(tracingChannel: MySQL2TracingC function setupQueryChannel(tracingChannel: MySQL2TracingChannelFactory, channelName: string): void { bindTracingChannelToSpan(tracingChannel(channelName), data => { + // Only instrument when there's an active span + if (!getActiveSpan()) { + return undefined; + } + // mysql2 does not sanitize its channel payload, so the statement may carry // raw user values (on the `query` channel they are inlined). Strip every // literal before it leaves the process; `values` is never attached. @@ -126,6 +132,11 @@ function setupQueryChannel(tracingChannel: MySQL2TracingChannelFactory, channelN function setupConnectChannel(tracingChannel: MySQL2TracingChannelFactory, channelName: string, spanName: string): void { bindTracingChannelToSpan(tracingChannel(channelName), data => { + // Only instrument when there's an active span. + if (!getActiveSpan()) { + return undefined; + } + return startInactiveSpan({ name: spanName, attributes: { From 40ecf319548020a775a0cd27e3a76841e5296212 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 4 Jul 2026 00:14:44 -0400 Subject: [PATCH 10/12] ref: remove uneeded guards --- .../server-utils/src/mysql2/mysql2-dc-subscriber.ts | 9 --------- .../test/mysql2/mysql2-dc-subscriber.test.ts | 12 ------------ 2 files changed, 21 deletions(-) diff --git a/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts b/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts index f5eb5cd54435..bfe952ec6da5 100644 --- a/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts +++ b/packages/server-utils/src/mysql2/mysql2-dc-subscriber.ts @@ -76,8 +76,6 @@ export interface MySQL2ConnectData { */ export type MySQL2TracingChannelFactory = (name: string) => TracingChannel; -let subscribed = false; - /** * Subscribe Sentry span handlers to mysql2's diagnostics-channel events * (`mysql2:query`, `:execute`, `:connect`, `:pool:connect`), published by @@ -86,19 +84,12 @@ let subscribed = false; * On older mysql2 versions the channels are never published to, so the * subscribers are inert — there is no double-instrumentation against the * vendored OTel patcher, which is gated to `< 3.20.0`. - * - * Idempotent: subsequent calls are a no-op. */ export function subscribeMysql2DiagnosticChannels(tracingChannel: MySQL2TracingChannelFactory): void { - if (subscribed) { - return; - } - setupQueryChannel(tracingChannel, MYSQL2_DC_CHANNEL_QUERY); setupQueryChannel(tracingChannel, MYSQL2_DC_CHANNEL_EXECUTE); setupConnectChannel(tracingChannel, MYSQL2_DC_CHANNEL_CONNECT, 'mysql2.connect'); setupConnectChannel(tracingChannel, MYSQL2_DC_CHANNEL_POOL_CONNECT, 'mysql2.pool.connect'); - subscribed = true; } function setupQueryChannel(tracingChannel: MySQL2TracingChannelFactory, channelName: string): void { diff --git a/packages/server-utils/test/mysql2/mysql2-dc-subscriber.test.ts b/packages/server-utils/test/mysql2/mysql2-dc-subscriber.test.ts index 09752cec6aaa..6f4f88a7934a 100644 --- a/packages/server-utils/test/mysql2/mysql2-dc-subscriber.test.ts +++ b/packages/server-utils/test/mysql2/mysql2-dc-subscriber.test.ts @@ -287,16 +287,4 @@ describe('subscribeMysql2DiagnosticChannels', () => { expect(json.data['server.port']).toBeUndefined(); }); }); - - describe('idempotency', () => { - it('does not throw or double-subscribe on a second call', async () => { - subscribeMysql2DiagnosticChannels(factory); - - const { span } = await traceOperation(MYSQL2_DC_CHANNEL_QUERY, { query: 'SELECT 1' }, { result: [] }); - - // a single subscription means a single span, ended exactly once - expect(span).toBeDefined(); - expect(spanToJSON(span!).timestamp).toBeDefined(); - }); - }); }); From d712b030bf12cedd0105dcb60f847fcf4d591a47 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 4 Jul 2026 00:19:25 -0400 Subject: [PATCH 11/12] test(mysql2): cover orphan-query no-span path and drop idempotency test The subscriber now only creates a span inside an enclosing active span, so drive operations through an enclosing span in the test helper and add an explicit orphan-query case. Remove the idempotency test since the internal subscribe function no longer guards against double-subscription. --- .../test/mysql2/mysql2-dc-subscriber.test.ts | 67 ++++++++++++------- 1 file changed, 44 insertions(+), 23 deletions(-) diff --git a/packages/server-utils/test/mysql2/mysql2-dc-subscriber.test.ts b/packages/server-utils/test/mysql2/mysql2-dc-subscriber.test.ts index 6f4f88a7934a..aecbdcd2870d 100644 --- a/packages/server-utils/test/mysql2/mysql2-dc-subscriber.test.ts +++ b/packages/server-utils/test/mysql2/mysql2-dc-subscriber.test.ts @@ -97,30 +97,39 @@ function installTestAsyncContextStrategy(): void { }); } -/** Drives a channel's `tracePromise` and captures the span bound by the subscriber. */ +/** + * Drives a channel's `tracePromise` inside an enclosing span and captures the span bound by the + * subscriber. The subscriber only creates a span when there is an enclosing active span, so the + * helper always establishes one and returns its id for parenting assertions. + */ async function traceOperation( channelName: string, data: Record, outcome: { result?: unknown; error?: Error }, -): Promise<{ span: Span | undefined; childParentSpanId: string | undefined }> { +): Promise<{ span: Span | undefined; childParentSpanId: string | undefined; enclosingSpanId: string | undefined }> { const channel = tracingChannel(channelName); let span: Span | undefined; let childParentSpanId: string | undefined; + let enclosingSpanId: string | undefined; - const run = channel.tracePromise(async () => { - span = getActiveSpan(); - startSpan({ name: 'child' }, child => { - childParentSpanId = spanToJSON(child).parent_span_id; - }); - if (outcome.error) { - throw outcome.error; - } - return outcome.result; - }, data); + await startSpan({ name: 'enclosing' }, async enclosing => { + enclosingSpanId = enclosing.spanContext().spanId; + + const run = channel.tracePromise(async () => { + span = getActiveSpan(); + startSpan({ name: 'child' }, child => { + childParentSpanId = spanToJSON(child).parent_span_id; + }); + if (outcome.error) { + throw outcome.error; + } + return outcome.result; + }, data); - await run.catch(() => undefined); + await run.catch(() => undefined); + }); - return { span, childParentSpanId }; + return { span, childParentSpanId, enclosingSpanId }; } const factory = tracingChannel as MySQL2TracingChannelFactory; @@ -153,6 +162,20 @@ describe('subscribeMysql2DiagnosticChannels', () => { vi.clearAllMocks(); }); + it('does not create a span when there is no enclosing active span', async () => { + const channel = tracingChannel(MYSQL2_DC_CHANNEL_QUERY); + let span: Span | undefined; + + await channel.tracePromise( + async () => { + span = getActiveSpan(); + }, + { query: 'SELECT 1' }, + ); + + expect(span).toBeUndefined(); + }); + describe('query channel', () => { it('creates a db span with stable semconv attributes', async () => { const { span } = await traceOperation( @@ -219,16 +242,14 @@ describe('subscribeMysql2DiagnosticChannels', () => { }); it('parents the mysql2 span to the surrounding span and parents children to it', async () => { - let outerSpanId: string | undefined; - let result: Awaited> | undefined; - - await startSpan({ name: 'outer' }, async outer => { - outerSpanId = outer.spanContext().spanId; - result = await traceOperation(MYSQL2_DC_CHANNEL_QUERY, { query: 'SELECT 1' }, { result: [] }); - }); + const { span, childParentSpanId, enclosingSpanId } = await traceOperation( + MYSQL2_DC_CHANNEL_QUERY, + { query: 'SELECT 1' }, + { result: [] }, + ); - expect(spanToJSON(result!.span!).parent_span_id).toBe(outerSpanId); - expect(result!.childParentSpanId).toBe(result!.span!.spanContext().spanId); + expect(spanToJSON(span!).parent_span_id).toBe(enclosingSpanId); + expect(childParentSpanId).toBe(span!.spanContext().spanId); }); }); From fcc71c4ccccfcb08a8e48fc23701bb75735d30fa Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 7 Jul 2026 12:36:27 -0400 Subject: [PATCH 12/12] test(mysql2): drop connect-transaction expectation that never fires The scenario connects before opening the Test Transaction span, and the subscriber only instruments when there is an active span, so no mysql2.connect transaction is ever emitted. The stale expectation made all variants hang until the 30s timeout. Connect channels remain covered by the unit tests. --- .../tracing/mysql2-tracing-channel/scenario.mjs | 4 ++-- .../tracing/mysql2-tracing-channel/test.ts | 17 ----------------- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs index 37f44b98c43c..3834c3b7ee3d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/scenario.mjs @@ -11,8 +11,8 @@ const CONNECT_CONFIG = { // `docker compose up --wait` gates on the healthcheck, but MySQL keeps finalizing // for a short window afterwards and drops early handshakes ("server closed the // connection"). Retry the initial connect so the suite doesn't flake on that window. -// A failed attempt still publishes on mysql2's `connect` channel, so the test asserts -// its envelopes with `.unordered()` to tolerate the transient connect transaction. +// The connect happens outside an active span, so the subscriber leaves it +// uninstrumented and no connect transaction is emitted. async function connectWithRetry(attempts = 15, delayMs = 500) { let lastError; for (let attempt = 0; attempt < attempts; attempt++) { diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts index f5518da2e423..627ded3164fe 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2-tracing-channel/test.ts @@ -45,10 +45,6 @@ conditionalTest({ min: 20 })('mysql2 tracing channel Test', () => { ]), }; - const EXPECTED_CONNECT = { - transaction: 'mysql2.connect', - }; - createEsmAndCjsTests( __dirname, 'scenario.mjs', @@ -57,12 +53,7 @@ conditionalTest({ min: 20 })('mysql2 tracing channel Test', () => { test('subscribes to mysql2 >= 3.20.0 diagnostics channels with stable semconv attributes', async () => { await createTestRunner() .withDockerCompose({ workingDirectory: [__dirname] }) - .expect({ transaction: EXPECTED_CONNECT }) .expect({ transaction: EXPECTED_TRANSACTION }) - // The scenario retries the initial connect (MySQL drops early handshakes right - // after the healthcheck passes), and each failed attempt emits a `mysql2.connect` - // transaction, so envelope order isn't guaranteed. - .unordered() .start() .completed(); }, 30_000); @@ -70,11 +61,8 @@ conditionalTest({ min: 20 })('mysql2 tracing channel Test', () => { test('does not double-instrument: the legacy IITM mysql2 patcher does not fire on 3.20.0+', async () => { await createTestRunner() .withDockerCompose({ workingDirectory: [__dirname] }) - .expect({ transaction: EXPECTED_CONNECT }) .expect({ transaction: event => { - // With `.unordered()`, pin the assertion to the query transaction so a stray - // connect transaction (from a retried handshake) can't satisfy it. expect(event.transaction).toBe('Test Transaction'); const spans = event.spans || []; // The monkey-patch path (origin `auto.db.otel.mysql2`) must be inactive on 3.20.0+. @@ -83,7 +71,6 @@ conditionalTest({ min: 20 })('mysql2 tracing channel Test', () => { expect(spans.find(span => span.origin === 'auto.db.mysql2.diagnostic_channel')).toBeDefined(); }, }) - .unordered() .start() .completed(); }, 30_000); @@ -91,11 +78,8 @@ conditionalTest({ min: 20 })('mysql2 tracing channel Test', () => { test('never leaks raw values into db.query.text', async () => { await createTestRunner() .withDockerCompose({ workingDirectory: [__dirname] }) - .expect({ transaction: EXPECTED_CONNECT }) .expect({ transaction: event => { - // With `.unordered()`, pin the assertion to the query transaction so an empty - // connect transaction (from a retried handshake) can't vacuously satisfy it. expect(event.transaction).toBe('Test Transaction'); const spans = event.spans || []; for (const span of spans) { @@ -106,7 +90,6 @@ conditionalTest({ min: 20 })('mysql2 tracing channel Test', () => { } }, }) - .unordered() .start() .completed(); }, 30_000);