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..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,7 +25,8 @@ async function run() { }, async _ => { await connection.query('SELECT 1 + 1 AS solution'); - await connection.query('SELECT NOW()', ['1', '2']); + 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'); // 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 eb63e1cf10b7..7542374edccc 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', @@ -21,23 +26,34 @@ 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: 'auto.db.otel.mysql2', + 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 also left as a `?` placeholder in `db.statement` + expect.objectContaining({ + description: 'SELECT ? AS scalar_value', + op: 'db', + origin: ORIGIN, + data: expect.objectContaining({ + 'db.system': 'mysql', + 'db.statement': 'SELECT ? AS scalar_value', + }), + }), // `execute` is instrumented the same way as `query` 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 +64,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', @@ -58,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 new file mode 100644 index 000000000000..9e59f521d363 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/mysql2.ts @@ -0,0 +1,164 @@ +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 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; +} + +/** + * 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.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 + [DB_STATEMENT]: statement || undefined, + }, + }); + }, + { requiresParentSpan: true }, + ); +} + +/** + * Render the `db.statement` from the wrapped call's first argument. + */ +function getQueryText(args: unknown[]): string | undefined { + return extractSql(args[0]); +} + +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 { + // oxlint-disable-next-line typescript/no-deprecated + [DB_NAME]: database || undefined, + [DB_USER]: user || undefined, + // oxlint-disable-next-line typescript/no-deprecated + [NET_PEER_NAME]: host || undefined, + // oxlint-disable-next-line typescript/no-deprecated + [NET_PEER_PORT]: portIsNumber ? portNumber : undefined, + }; +} + +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,