From 6acd6333f7ee07b60b2a0e58f5ff5b79a0f18997 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 13 Jul 2026 16:41:33 +0200 Subject: [PATCH 1/5] feat(server-utils): Migrate `@opentelemetry/instrumentation-mysql2` to orchestrion Rewrites the mysql2 instrumentation from an OTel `InstrumentationBase` patcher to a diagnostics-channel listener with orchestrion injecting the channel into the instrumented module, mirroring the mysql/kafkajs migrations. Adds an opt-in `mysql2ChannelIntegration` (registered in `channelIntegrations`, so `@sentry/node`/`@sentry/bun` swap it in for the OTel `Mysql2` integration when diagnostics-channel injection is enabled). It subscribes to the injected `orchestrion:mysql2:query`/`:execute` channels for mysql2 < 3.20.0 and to mysql2's native tracing channels for >= 3.20.0, so the two ranges never overlap. The orchestrion config uses `kind: 'Callback'` rather than `'Auto'`: `Auto`'s no-callback branch probes the return value for `.then`, but mysql2's callback-less `query(sql)` returns a `Query` whose `.then()` throws, which would crash streamed queries. `Callback` traces the callback and promise forms (the common case) and leaves the rarely-used row-streaming form untouched. Fixes #20763 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../suites/tracing/mysql2/test.ts | 13 +- .../integrations/tracing-channel/mysql2.ts | 201 ++++++++++++++++++ .../src/orchestrion/config/mysql2.ts | 48 ++++- .../server-utils/src/orchestrion/index.ts | 3 + 4 files changed, 258 insertions(+), 7 deletions(-) create mode 100644 packages/server-utils/src/integrations/tracing-channel/mysql2.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts index eb63e1cf10b7..0eeceaa519df 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts @@ -1,4 +1,5 @@ import { afterAll, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; describeWithDockerCompose('mysql2 auto instrumentation', { workingDirectory: [__dirname] }, () => { @@ -6,13 +7,17 @@ describeWithDockerCompose('mysql2 auto instrumentation', { workingDirectory: [__ cleanupChildProcesses(); }); + // With orchestrion injection enabled (`INJECT_ORCHESTRION`), the diagnostics-channel integration + // records the spans instead of the OTel patcher, so they carry a different `sentry.origin`. + const ORIGIN = isOrchestrionEnabled() ? 'auto.db.orchestrion.mysql2' : 'auto.db.otel.mysql2'; + const EXPECTED_TRANSACTION = { transaction: 'Test Transaction', spans: expect.arrayContaining([ expect.objectContaining({ description: 'SELECT 1 + 1 AS solution', op: 'db', - origin: 'auto.db.otel.mysql2', + origin: ORIGIN, data: expect.objectContaining({ 'db.system': 'mysql', 'db.statement': 'SELECT 1 + 1 AS solution', @@ -24,7 +29,7 @@ describeWithDockerCompose('mysql2 auto instrumentation', { workingDirectory: [__ expect.objectContaining({ description: 'SELECT NOW()', op: 'db', - origin: 'auto.db.otel.mysql2', + origin: ORIGIN, data: expect.objectContaining({ 'db.system': 'mysql', 'db.statement': 'SELECT NOW()', @@ -37,7 +42,7 @@ describeWithDockerCompose('mysql2 auto instrumentation', { workingDirectory: [__ expect.objectContaining({ description: 'SELECT 42 AS answer', op: 'db', - origin: 'auto.db.otel.mysql2', + origin: ORIGIN, data: expect.objectContaining({ 'db.system': 'mysql', 'db.statement': 'SELECT 42 AS answer', @@ -48,7 +53,7 @@ describeWithDockerCompose('mysql2 auto instrumentation', { workingDirectory: [__ description: 'SELECT * FROM does_not_exist', op: 'db', status: 'internal_error', - origin: 'auto.db.otel.mysql2', + origin: ORIGIN, data: expect.objectContaining({ 'db.system': 'mysql', 'db.statement': 'SELECT * FROM does_not_exist', diff --git a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts new file mode 100644 index 000000000000..b38b0ece2490 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts @@ -0,0 +1,201 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn, SpanAttributes } from '@sentry/core'; +import { + defineIntegration, + isObjectLike, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_KIND, + startInactiveSpan, + waitForTracingChannelBinding, +} from '@sentry/core'; +import { subscribeMysql2DiagnosticChannels } from '../../mysql2/mysql2-dc-subscriber'; +import type { ChannelName } from '../../orchestrion/channels'; +import { CHANNELS } from '../../orchestrion/channels'; +import { bindTracingChannelToSpan } from '../../tracing-channel'; +import { + DB_NAME, + DB_STATEMENT, + DB_SYSTEM, + DB_USER, + NET_PEER_NAME, + NET_PEER_PORT, +} from '@sentry/conventions/attributes'; + +const INTEGRATION_NAME = 'Mysql2' as const; +const ORIGIN = 'auto.db.orchestrion.mysql2'; +const ATTR_DB_CONNECTION_STRING = 'db.connection_string'; +const DB_SYSTEM_VALUE_MYSQL = 'mysql'; + +/** + * 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. + */ +interface Mysql2QueryChannelContext { + // The live args array passed to the wrapped `query`/`execute` call: `arguments[0]` is the SQL (a + // string, `Query`, or `{ sql, values }`), `arguments[1]` is the values array or a callback. + arguments: unknown[]; + self?: Mysql2Connection; + result?: unknown; + error?: unknown; +} + +interface Mysql2ConnectionConfig { + host?: string; + port?: number | string; + database?: string; + user?: string; + // Pool connections nest the real config one level deeper. + connectionConfig?: Mysql2ConnectionConfig; +} + +interface Mysql2Connection { + config?: Mysql2ConnectionConfig; + // mysql2 renders parameterized statements through the connection's own `format` (SqlString.format). + format?: (sql: string, values?: unknown) => string; +} + +/** + * Orchestrion-driven mysql2 integration. + * + * Subscribes to: + * - the `orchestrion:mysql2:query`/`:execute` channels the code transform injects into mysql2's + * `query`/`execute` (mysql2 `< 3.20.0`), and + * - mysql2's native `node:diagnostics_channel` tracing channels (mysql2 `>= 3.20.0`), which the + * transform intentionally leaves alone. + * + * The two version ranges never overlap, so no query is double-counted. Requires the orchestrion + * runtime hook or bundler plugin to be active — wire that up via + * `experimentalUseDiagnosticsChannelInjection`. + */ +function instrumentMysql2(): void { + // mysql2 >= 3.20.0: native diagnostics_channel path (inert on older versions, which never publish). + subscribeMysql2DiagnosticChannels(diagnosticsChannel.tracingChannel); + + // mysql2 < 3.20.0: orchestrion-injected channels (inert on >= 3.20.0, which we don't transform). + subscribeQueryChannel(CHANNELS.MYSQL2_QUERY); + subscribeQueryChannel(CHANNELS.MYSQL2_EXECUTE); +} + +function subscribeQueryChannel(channelName: ChannelName): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channelName), + data => { + const statement = getQueryText(data.self, data.arguments); + + return startInactiveSpan({ + name: statement ?? 'mysql2.query', + kind: SPAN_KIND.CLIENT, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db', + // oxlint-disable-next-line typescript/no-deprecated + [DB_SYSTEM]: DB_SYSTEM_VALUE_MYSQL, + ...getConnectionAttributes(data.self?.config), + // oxlint-disable-next-line typescript/no-deprecated + ...(statement ? { [DB_STATEMENT]: statement } : {}), + }, + }); + }, + { requiresParentSpan: true }, + ); +} + +/** + * Render the `db.statement` from the wrapped call's arguments, inlining any bind values through the + * connection's own `format` (as `@opentelemetry/instrumentation-mysql2` does). Returns the raw SQL if + * formatting isn't possible. + */ +function getQueryText(connection: Mysql2Connection | undefined, args: unknown[]): string | undefined { + const sql = extractSql(args[0]); + if (sql === undefined) { + return undefined; + } + + // `query(sql, values, cb)` → values is `args[1]`; `query(sql, cb)` → no values. + const values = Array.isArray(args[1]) ? args[1] : undefined; + const objectValues = + isObjectLike(args[0]) && 'values' in args[0] ? (args[0] as { values?: unknown }).values : undefined; + const boundValues = values ?? objectValues; + + const format = connection?.format; + if (format && boundValues !== undefined) { + try { + return format.call(connection, sql, boundValues); + } catch { + return sql; + } + } + + return sql; +} + +function extractSql(firstArg: unknown): string | undefined { + if (typeof firstArg === 'string') { + return firstArg; + } + if (isObjectLike(firstArg) && 'sql' in firstArg) { + const sql = (firstArg as { sql?: unknown }).sql; + return typeof sql === 'string' ? sql : undefined; + } + return undefined; +} + +function getConnectionAttributes(config: Mysql2ConnectionConfig | undefined): SpanAttributes { + // Pool connections nest the real config under `.connectionConfig`; single connections expose it + // directly. Matches `@opentelemetry/instrumentation-mysql2`. + const { host, port, database, user } = config?.connectionConfig ?? config ?? {}; + const portNumber = typeof port === 'string' ? parseInt(port, 10) : port; + const portIsNumber = typeof portNumber === 'number' && !isNaN(portNumber); + + return { + [ATTR_DB_CONNECTION_STRING]: getJDBCString(host, portIsNumber ? portNumber : undefined, database), + // oxlint-disable-next-line typescript/no-deprecated + ...(database ? { [DB_NAME]: database } : {}), + ...(user ? { [DB_USER]: user } : {}), + // oxlint-disable-next-line typescript/no-deprecated + ...(host ? { [NET_PEER_NAME]: host } : {}), + // oxlint-disable-next-line typescript/no-deprecated + ...(portIsNumber ? { [NET_PEER_PORT]: portNumber } : {}), + }; +} + +function getJDBCString(host: string | undefined, port: number | undefined, database: string | undefined): string { + let s = `jdbc:mysql://${host || 'localhost'}`; + if (typeof port === 'number') { + s += `:${port}`; + } + if (database) { + s += `/${database}`; + } + return s; +} + +const _mysql2ChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + waitForTracingChannelBinding(() => { + instrumentMysql2(); + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * Orchestrion-driven mysql2 integration. + * + * Adds Sentry tracing instrumentation for the [mysql2](https://www.npmjs.com/package/mysql2) library + * via diagnostics-channel injection. See {@link instrumentMysql2} for how the two mysql2 version + * ranges are covered. + * + * Known limitation vs. the OTel integration it replaces: the callback-less streaming form + * (`connection.query(sql).on('result', ...)`) is not traced — see the `mysql2` orchestrion config for + * why. The callback and promise forms (the common case) are fully instrumented. + */ +export const mysql2ChannelIntegration = defineIntegration(_mysql2ChannelIntegration); diff --git a/packages/server-utils/src/orchestrion/config/mysql2.ts b/packages/server-utils/src/orchestrion/config/mysql2.ts index 43acfbdb62c1..796361164756 100644 --- a/packages/server-utils/src/orchestrion/config/mysql2.ts +++ b/packages/server-utils/src/orchestrion/config/mysql2.ts @@ -1,6 +1,48 @@ import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; -// TODO: Stub for the `mysql2` orchestrion integration (ports `@opentelemetry/instrumentation-mysql2`). -export const mysql2Config: InstrumentationConfig[] = []; +// Ports `@opentelemetry/instrumentation-mysql2` (which patches `query`/`execute` on the connection +// prototype) to orchestrion channel injection. +// +// Two file layouts across the supported range: +// - `< 3.11.5` → `class Connection` with `query`/`execute` in `lib/connection.js` +// - `>= 3.11.5` → the methods moved to `class BaseConnection` in `lib/base/connection.js` +// +// Gated to `< 3.20.0`: from 3.20.0 mysql2 publishes to `node:diagnostics_channel` natively, and +// that path is instrumented by `subscribeMysql2DiagnosticChannels` instead. Injecting there too +// would emit two spans per query. +// +// `Callback` traces the callback shapes: `query(sql, cb)`, `query(sql, values, cb)`, and — since the +// promise API funnels through the callback form — `await connection.query(sql)`. `execute` never +// delegates to `query` internally, so instrumenting both is safe. +// +// NOT `Auto`: `Auto`'s no-callback branch probes the return value for `.then`, but mysql2's callback- +// less `query(sql)` returns a `Query` whose `.then()` throws (mysql2's "use the promise wrapper" +// guard) — so `Auto` would crash streamed queries. `Callback` leaves that shape untouched (a rare, +// row-streaming use that consumes the emitter's events), the tradeoff being it isn't traced. +export const mysql2Config = [ + { + channelName: 'query', + module: { name: 'mysql2', versionRange: '>=1.4.2 <3.11.5', filePath: 'lib/connection.js' }, + functionQuery: { className: 'Connection', methodName: 'query', kind: 'Callback' }, + }, + { + channelName: 'execute', + module: { name: 'mysql2', versionRange: '>=1.4.2 <3.11.5', filePath: 'lib/connection.js' }, + functionQuery: { className: 'Connection', methodName: 'execute', kind: 'Callback' }, + }, + { + channelName: 'query', + module: { name: 'mysql2', versionRange: '>=3.11.5 <3.20.0', filePath: 'lib/base/connection.js' }, + functionQuery: { className: 'BaseConnection', methodName: 'query', kind: 'Callback' }, + }, + { + channelName: 'execute', + module: { name: 'mysql2', versionRange: '>=3.11.5 <3.20.0', filePath: 'lib/base/connection.js' }, + functionQuery: { className: 'BaseConnection', methodName: 'execute', kind: 'Callback' }, + }, +] satisfies InstrumentationConfig[]; -export const mysql2Channels = {} as const; +export const mysql2Channels = { + MYSQL2_QUERY: 'orchestrion:mysql2:query', + MYSQL2_EXECUTE: 'orchestrion:mysql2:execute', +} as const; diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 6cbb67f942d6..112be388a040 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -12,6 +12,7 @@ import { ioredisChannelIntegration } from '../integrations/tracing-channel/iored import { kafkajsChannelIntegration } from '../integrations/tracing-channel/kafkajs'; import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; +import { mysql2ChannelIntegration } from '../integrations/tracing-channel/mysql2'; import { openaiChannelIntegration } from '../integrations/tracing-channel/openai'; import { postgresChannelIntegration } from '../integrations/tracing-channel/postgres'; import { postgresJsChannelIntegration } from '../integrations/tracing-channel/postgres-js'; @@ -34,6 +35,7 @@ export { kafkajsChannelIntegration, lruMemoizerChannelIntegration, mysqlChannelIntegration, + mysql2ChannelIntegration, openaiChannelIntegration, postgresChannelIntegration, postgresJsChannelIntegration, @@ -75,6 +77,7 @@ export const channelIntegrations = { postgresIntegration: postgresChannelIntegration, postgresJsIntegration: postgresJsChannelIntegration, mysqlIntegration: mysqlChannelIntegration, + mysql2Integration: mysql2ChannelIntegration, genericPoolIntegration: genericPoolChannelIntegration, lruMemoizerIntegration: lruMemoizerChannelIntegration, openaiIntegration: openaiChannelIntegration, From 8662b74fc18c703d5ee08662f3902b6178fe2747 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Tue, 14 Jul 2026 09:50:31 +0200 Subject: [PATCH 2/5] remove connection string --- .../src/integrations/tracing-channel/mysql2.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts index b38b0ece2490..c936088c643b 100644 --- a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts +++ b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts @@ -24,7 +24,6 @@ import { const INTEGRATION_NAME = 'Mysql2' as const; const ORIGIN = 'auto.db.orchestrion.mysql2'; -const ATTR_DB_CONNECTION_STRING = 'db.connection_string'; const DB_SYSTEM_VALUE_MYSQL = 'mysql'; /** @@ -149,7 +148,6 @@ function getConnectionAttributes(config: Mysql2ConnectionConfig | undefined): Sp const portIsNumber = typeof portNumber === 'number' && !isNaN(portNumber); return { - [ATTR_DB_CONNECTION_STRING]: getJDBCString(host, portIsNumber ? portNumber : undefined, database), // oxlint-disable-next-line typescript/no-deprecated ...(database ? { [DB_NAME]: database } : {}), ...(user ? { [DB_USER]: user } : {}), @@ -160,17 +158,6 @@ function getConnectionAttributes(config: Mysql2ConnectionConfig | undefined): Sp }; } -function getJDBCString(host: string | undefined, port: number | undefined, database: string | undefined): string { - let s = `jdbc:mysql://${host || 'localhost'}`; - if (typeof port === 'number') { - s += `:${port}`; - } - if (database) { - s += `/${database}`; - } - return s; -} - const _mysql2ChannelIntegration = (() => { return { name: INTEGRATION_NAME, From fb0c81b88d97e7d9f5ba9011c0e73a576986d648 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Tue, 14 Jul 2026 10:36:40 +0200 Subject: [PATCH 3/5] fix --- .../suites/tracing/mysql2/scenario.mjs | 2 ++ .../suites/tracing/mysql2/test.ts | 10 ++++++++++ .../src/integrations/tracing-channel/mysql2.ts | 7 +++++-- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql2/scenario.mjs index b8f1cac9c2f8..6db7df2be2e5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql2/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2/scenario.mjs @@ -26,6 +26,8 @@ async function run() { async _ => { await connection.query('SELECT 1 + 1 AS solution'); await connection.query('SELECT NOW()', ['1', '2']); + // A single, non-array bind value (`query(sql, scalar)`) must still be inlined into `db.statement`. + await connection.query('SELECT ? AS scalar_value', 42); // `execute` is instrumented the same way as `query` await connection.execute('SELECT 42 AS answer'); // a failing query should produce a span with an error status diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts index 0eeceaa519df..e5d2cb53390c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts @@ -38,6 +38,16 @@ describeWithDockerCompose('mysql2 auto instrumentation', { workingDirectory: [__ 'db.user': 'root', }), }), + // a single non-array bind value is inlined into `db.statement` (not left as a `?` placeholder) + expect.objectContaining({ + description: 'SELECT 42 AS scalar_value', + op: 'db', + origin: ORIGIN, + data: expect.objectContaining({ + 'db.system': 'mysql', + 'db.statement': 'SELECT 42 AS scalar_value', + }), + }), // `execute` is instrumented the same way as `query` expect.objectContaining({ description: 'SELECT 42 AS answer', diff --git a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts index c936088c643b..ba7f2484040a 100644 --- a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts +++ b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts @@ -111,8 +111,11 @@ function getQueryText(connection: Mysql2Connection | undefined, args: unknown[]) return undefined; } - // `query(sql, values, cb)` → values is `args[1]`; `query(sql, cb)` → no values. - const values = Array.isArray(args[1]) ? args[1] : undefined; + // `query(sql, values, cb)` → values is `args[1]`. mysql2 also accepts a single non-array bind value + // (`query(sql, scalar, cb)`); a non-array `args[1]` is only a value when a callback follows it, + // otherwise it is the callback itself (`query(sql, cb)`). Matches `@opentelemetry/instrumentation-mysql2`. + const values = Array.isArray(args[1]) ? args[1] : args[2] !== undefined ? [args[1]] : undefined; + const objectValues = isObjectLike(args[0]) && 'values' in args[0] ? (args[0] as { values?: unknown }).values : undefined; const boundValues = values ?? objectValues; From 1b8fe37f3e5e6713920dc33d353c1b98f4a0b18f Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Tue, 14 Jul 2026 13:25:37 +0200 Subject: [PATCH 4/5] fixes --- .../suites/tracing/mysql2/scenario.mjs | 3 +- .../suites/tracing/mysql2/test.ts | 13 +++---- .../integrations/tracing-channel/mysql2.ts | 35 +++---------------- 3 files changed, 12 insertions(+), 39 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql2/scenario.mjs index 6db7df2be2e5..b895589b373f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql2/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2/scenario.mjs @@ -25,8 +25,7 @@ async function run() { }, async _ => { await connection.query('SELECT 1 + 1 AS solution'); - await connection.query('SELECT NOW()', ['1', '2']); - // A single, non-array bind value (`query(sql, scalar)`) must still be inlined into `db.statement`. + await connection.query('SELECT ? as a, ? as b, NOW() as c', ['1', '2']); await connection.query('SELECT ? AS scalar_value', 42); // `execute` is instrumented the same way as `query` await connection.execute('SELECT 42 AS answer'); diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts index e5d2cb53390c..7542374edccc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql2/test.ts @@ -26,26 +26,27 @@ describeWithDockerCompose('mysql2 auto instrumentation', { workingDirectory: [__ 'db.user': 'root', }), }), + // bind values are left as `?` placeholders in `db.statement` (not inlined) expect.objectContaining({ - description: 'SELECT NOW()', + description: 'SELECT ? as a, ? as b, NOW() as c', op: 'db', origin: ORIGIN, data: expect.objectContaining({ 'db.system': 'mysql', - 'db.statement': 'SELECT NOW()', + 'db.statement': 'SELECT ? as a, ? as b, NOW() as c', 'net.peer.name': 'localhost', 'net.peer.port': 3306, 'db.user': 'root', }), }), - // a single non-array bind value is inlined into `db.statement` (not left as a `?` placeholder) + // a single non-array bind value is also left as a `?` placeholder in `db.statement` expect.objectContaining({ - description: 'SELECT 42 AS scalar_value', + description: 'SELECT ? AS scalar_value', op: 'db', origin: ORIGIN, data: expect.objectContaining({ 'db.system': 'mysql', - 'db.statement': 'SELECT 42 AS scalar_value', + 'db.statement': 'SELECT ? AS scalar_value', }), }), // `execute` is instrumented the same way as `query` @@ -73,7 +74,7 @@ describeWithDockerCompose('mysql2 auto instrumentation', { workingDirectory: [__ }; createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => { - test('should auto-instrument `mysql` package without connection.connect()', { timeout: 75_000 }, async () => { + test('should auto-instrument `mysql2` package without connection.connect()', { timeout: 75_000 }, async () => { await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); }); }); diff --git a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts index ba7f2484040a..26fef281191b 100644 --- a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts +++ b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts @@ -50,8 +50,6 @@ interface Mysql2ConnectionConfig { interface Mysql2Connection { config?: Mysql2ConnectionConfig; - // mysql2 renders parameterized statements through the connection's own `format` (SqlString.format). - format?: (sql: string, values?: unknown) => string; } /** @@ -80,7 +78,7 @@ function subscribeQueryChannel(channelName: ChannelName): void { bindTracingChannelToSpan( diagnosticsChannel.tracingChannel(channelName), data => { - const statement = getQueryText(data.self, data.arguments); + const statement = getQueryText(data.arguments); return startInactiveSpan({ name: statement ?? 'mysql2.query', @@ -101,35 +99,10 @@ function subscribeQueryChannel(channelName: ChannelName): void { } /** - * Render the `db.statement` from the wrapped call's arguments, inlining any bind values through the - * connection's own `format` (as `@opentelemetry/instrumentation-mysql2` does). Returns the raw SQL if - * formatting isn't possible. + * Render the `db.statement` from the wrapped call's first argument. */ -function getQueryText(connection: Mysql2Connection | undefined, args: unknown[]): string | undefined { - const sql = extractSql(args[0]); - if (sql === undefined) { - return undefined; - } - - // `query(sql, values, cb)` → values is `args[1]`. mysql2 also accepts a single non-array bind value - // (`query(sql, scalar, cb)`); a non-array `args[1]` is only a value when a callback follows it, - // otherwise it is the callback itself (`query(sql, cb)`). Matches `@opentelemetry/instrumentation-mysql2`. - const values = Array.isArray(args[1]) ? args[1] : args[2] !== undefined ? [args[1]] : undefined; - - const objectValues = - isObjectLike(args[0]) && 'values' in args[0] ? (args[0] as { values?: unknown }).values : undefined; - const boundValues = values ?? objectValues; - - const format = connection?.format; - if (format && boundValues !== undefined) { - try { - return format.call(connection, sql, boundValues); - } catch { - return sql; - } - } - - return sql; +function getQueryText(args: unknown[]): string | undefined { + return extractSql(args[0]); } function extractSql(firstArg: unknown): string | undefined { From fbe65075de2c39b34d0457f157b877ce9bbdcc82 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Wed, 15 Jul 2026 09:08:26 +0200 Subject: [PATCH 5/5] ref --- .../src/integrations/tracing-channel/mysql2.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts index 26fef281191b..9e59f521d363 100644 --- a/packages/server-utils/src/integrations/tracing-channel/mysql2.ts +++ b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts @@ -90,7 +90,7 @@ function subscribeQueryChannel(channelName: ChannelName): void { [DB_SYSTEM]: DB_SYSTEM_VALUE_MYSQL, ...getConnectionAttributes(data.self?.config), // oxlint-disable-next-line typescript/no-deprecated - ...(statement ? { [DB_STATEMENT]: statement } : {}), + [DB_STATEMENT]: statement || undefined, }, }); }, @@ -125,12 +125,12 @@ function getConnectionAttributes(config: Mysql2ConnectionConfig | undefined): Sp return { // oxlint-disable-next-line typescript/no-deprecated - ...(database ? { [DB_NAME]: database } : {}), - ...(user ? { [DB_USER]: user } : {}), + [DB_NAME]: database || undefined, + [DB_USER]: user || undefined, // oxlint-disable-next-line typescript/no-deprecated - ...(host ? { [NET_PEER_NAME]: host } : {}), + [NET_PEER_NAME]: host || undefined, // oxlint-disable-next-line typescript/no-deprecated - ...(portIsNumber ? { [NET_PEER_PORT]: portNumber } : {}), + [NET_PEER_PORT]: portIsNumber ? portNumber : undefined, }; }