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..a3bb1b258f90 --- /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: + - '3308: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..42c566769841 --- /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: 3308, + }); + + 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..54cb37552f61 --- /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': 3308, + }), + }); + + 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 e3072ee37890..e99c64f9e75f 100644 --- a/packages/node/src/integrations/tracing/mysql2/vendored/instrumentation.ts +++ b/packages/node/src/integrations/tracing/mysql2/vendored/instrumentation.ts @@ -28,7 +28,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 b66a789f53d3..374368965b4a 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -4,6 +4,7 @@ * @module */ +export { mysql2Integration } from './mysql2'; export { IOREDIS_DC_CHANNEL_COMMAND, IOREDIS_DC_CHANNEL_CONNECT, 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(); + }); + }); +});