diff --git a/lib/base/connection.js b/lib/base/connection.js index 4d4fcc74d5..ca50873743 100644 --- a/lib/base/connection.js +++ b/lib/base/connection.js @@ -28,6 +28,15 @@ const Packets = require('../packets/index.js'); const Commands = require('../commands/index.js'); const ConnectionConfig = require('../connection_config.js'); const CharsetToEncoding = require('../constants/charset_encodings.js'); +const { + traceCallback, + tracePromise, + getServerContext, + shouldTrace, + queryChannel, + executeChannel, + connectChannel, +} = require('../tracing.js'); let _connectionId = 0; @@ -141,6 +150,41 @@ class BaseConnection extends EventEmitter { this._notifyError(err); }); this.addCommand(handshakeCommand); + + // Trace the connection handshake + if (shouldTrace(connectChannel)) { + const config = this.config; + tracePromise( + connectChannel, + () => + new Promise((resolve, reject) => { + /* eslint-disable prefer-const */ + let onConnect, onError; + onConnect = (param) => { + this.removeListener('error', onError); + resolve(param); + }; + onError = (err) => { + this.removeListener('connect', onConnect); + reject(err); + }; + /* eslint-enable prefer-const */ + this.once('connect', onConnect); + this.once('error', onError); + }), + () => { + const server = getServerContext(config); + return { + database: config.database || '', + serverAddress: server.serverAddress, + serverPort: server.serverPort, + user: config.user || '', + }; + } + ).catch(() => { + // errors are already handled by the handshake error listener + }); + } } // in case there was no initial handshake but we need to read sting, assume it utf-8 // most common example: "Too many connections" error ( packet is sent immediately on connection attempt, we don't know server encoding yet) @@ -601,7 +645,62 @@ class BaseConnection extends EventEmitter { cmdQuery.values !== undefined ? cmdQuery.values : [] ); cmdQuery.sql = rawSql; - return this.addCommand(cmdQuery); + + if (!shouldTrace(queryChannel)) { + return this.addCommand(cmdQuery); + } + + const config = this.config; + const origCb = cmdQuery.onResult; + + if (origCb) { + // Callback mode: use traceCallback for synchronous wrapping + traceCallback( + queryChannel, + (wrappedCb) => { + cmdQuery.onResult = wrappedCb; + this.addCommand(cmdQuery); + }, + 0, + () => { + const server = getServerContext(config); + return { + query: cmdQuery.sql, + values: cmdQuery.values, + database: config.database || '', + serverAddress: server.serverAddress, + serverPort: server.serverPort, + }; + }, + null, + origCb + ); + } else { + // Event-emitter mode: use tracePromise since there is no callback + tracePromise( + queryChannel, + () => + new Promise((resolve, reject) => { + cmdQuery.once('error', reject); + cmdQuery.once('end', () => resolve()); + this.addCommand(cmdQuery); + }), + () => { + const server = getServerContext(config); + return { + query: cmdQuery.sql, + values: cmdQuery.values, + database: config.database || '', + serverAddress: server.serverAddress, + serverPort: server.serverPort, + }; + } + ).catch(() => { + // errors are already emitted on the command + }); + } + + return cmdQuery; } pause() { @@ -702,25 +801,108 @@ class BaseConnection extends EventEmitter { }); } const executeCommand = new Commands.Execute(options, cb); - const prepareCommand = new Commands.Prepare(options, (err, stmt) => { - if (err) { - // skip execute command if prepare failed, we have main - // combined callback here - executeCommand.start = function () { - return null; - }; - if (cb) { - cb(err); - } else { - executeCommand.emit('error', err); + + if (!shouldTrace(executeChannel)) { + const prepareCommand = new Commands.Prepare(options, (err, stmt) => { + if (err) { + // skip execute command if prepare failed, we have main + // combined callback here + executeCommand.start = function () { + return null; + }; + if (cb) { + cb(err); + } else { + executeCommand.emit('error', err); + } + executeCommand.emit('end'); + return; } - executeCommand.emit('end'); - return; - } - executeCommand.statement = stmt; - }); - this.addCommand(prepareCommand); - this.addCommand(executeCommand); + executeCommand.statement = stmt; + }); + this.addCommand(prepareCommand); + this.addCommand(executeCommand); + return executeCommand; + } + + const config = this.config; + const origExecCb = executeCommand.onResult; + + if (origExecCb) { + // Callback mode: use traceCallback for synchronous wrapping + traceCallback( + executeChannel, + (wrappedCb) => { + const prepareCommand = new Commands.Prepare(options, (err, stmt) => { + if (err) { + executeCommand.start = function () { + return null; + }; + executeCommand.emit('end'); + wrappedCb(err); + return; + } + executeCommand.statement = stmt; + }); + executeCommand.onResult = wrappedCb; + this.addCommand(prepareCommand); + this.addCommand(executeCommand); + }, + 0, + () => { + const server = getServerContext(config); + return { + query: options.sql, + values: options.values, + database: config.database || '', + serverAddress: server.serverAddress, + serverPort: server.serverPort, + }; + }, + null, + origExecCb + ); + } else { + // Event-emitter mode: use tracePromise since there is no callback + tracePromise( + executeChannel, + () => + new Promise((resolve, reject) => { + const prepareCommand = new Commands.Prepare( + options, + (err, stmt) => { + if (err) { + executeCommand.start = function () { + return null; + }; + executeCommand.emit('error', err); + executeCommand.emit('end'); + reject(err); + return; + } + executeCommand.statement = stmt; + } + ); + executeCommand.once('error', reject); + executeCommand.once('end', () => resolve()); + this.addCommand(prepareCommand); + this.addCommand(executeCommand); + }), + () => { + const server = getServerContext(config); + return { + query: options.sql, + values: options.values, + database: config.database || '', + serverAddress: server.serverAddress, + serverPort: server.serverPort, + }; + } + ).catch(() => { + // errors are already emitted on the command + }); + } + return executeCommand; } diff --git a/lib/base/pool.js b/lib/base/pool.js index 0d5bf804ae..45f86840f6 100644 --- a/lib/base/pool.js +++ b/lib/base/pool.js @@ -7,6 +7,12 @@ const PoolConnection = require('../pool_connection.js'); const Queue = require('denque'); const BaseConnection = require('./connection.js'); const Errors = require('../constants/errors.js'); +const { + traceCallback, + getServerContext, + shouldTrace, + poolConnectChannel, +} = require('../tracing.js'); // Source: https://github.com/go-sql-driver/mysql/blob/76c00e35a8d48f8f70f0e7dffe584692bd3fa612/packets.go#L598-L613 function isReadOnlyError(err) { @@ -49,6 +55,28 @@ class BasePool extends EventEmitter { } getConnection(cb) { + if (!shouldTrace(poolConnectChannel)) { + return this._getConnection(cb); + } + const config = this.config.connectionConfig; + traceCallback( + poolConnectChannel, + this._getConnection.bind(this), + 0, + () => { + const server = getServerContext(config); + return { + database: config.database || '', + serverAddress: server.serverAddress, + serverPort: server.serverPort, + }; + }, + null, + cb + ); + } + + _getConnection(cb) { if (this._closed) { return process.nextTick(() => cb(new Error('Pool is closed.'))); } diff --git a/lib/tracing.d.ts b/lib/tracing.d.ts new file mode 100644 index 0000000000..3f5a52ec19 --- /dev/null +++ b/lib/tracing.d.ts @@ -0,0 +1,71 @@ +import type { TracingChannel } from 'node:diagnostics_channel'; + +export interface QueryTraceContext { + query: string; + values: any; + database: string; + serverAddress: string; + serverPort: number | undefined; +} + +export interface ExecuteTraceContext { + query: string; + values: any; + database: string; + serverAddress: string; + serverPort: number | undefined; +} + +export interface ConnectTraceContext { + database: string; + serverAddress: string; + serverPort: number | undefined; + user: string; +} + +export interface PoolConnectTraceContext { + database: string; + serverAddress: string; + serverPort: number | undefined; +} + +export declare const dc: typeof import('node:diagnostics_channel') | undefined; +export declare const hasTracingChannel: boolean; + +export declare function shouldTrace( + channel: TracingChannel | undefined | null +): boolean; + +export declare function traceCallback( + channel: TracingChannel | undefined | null, + fn: (...args: any[]) => any, + position: number, + contextFactory: () => T, + thisArg: any, + ...args: any[] +): any; + +export declare function tracePromise( + channel: TracingChannel | undefined | null, + fn: () => Promise, + contextFactory: () => T +): Promise; + +export declare const queryChannel: + | TracingChannel + | undefined; +export declare const executeChannel: + | TracingChannel + | undefined; +export declare const connectChannel: + | TracingChannel + | undefined; +export declare const poolConnectChannel: + | TracingChannel + | undefined; + +export declare function getServerContext(config: { + socketPath?: string; + host?: string; + port?: number; +}): { serverAddress: string; serverPort: number | undefined }; diff --git a/lib/tracing.js b/lib/tracing.js new file mode 100644 index 0000000000..200ab27f33 --- /dev/null +++ b/lib/tracing.js @@ -0,0 +1,81 @@ +'use strict'; + +const process = require('process'); + +// Safe load: use getBuiltinModule if available, fallback to require, catch if unavailable +const dc = (() => { + try { + return 'getBuiltinModule' in process + ? process.getBuiltinModule('node:diagnostics_channel') + : require('node:diagnostics_channel'); + } catch { + return undefined; + } +})(); + +const hasTracingChannel = typeof dc?.tracingChannel === 'function'; + +const queryChannel = hasTracingChannel + ? dc.tracingChannel('mysql2:query') + : undefined; + +const executeChannel = hasTracingChannel + ? dc.tracingChannel('mysql2:execute') + : undefined; + +const connectChannel = hasTracingChannel + ? dc.tracingChannel('mysql2:connect') + : undefined; + +const poolConnectChannel = hasTracingChannel + ? dc.tracingChannel('mysql2:pool:connect') + : undefined; + +function getServerContext(config) { + if (config.socketPath) { + return { serverAddress: config.socketPath, serverPort: undefined }; + } + return { + serverAddress: config.host || 'localhost', + serverPort: config.port || 3306, + }; +} + +// Node 20+: TracingChannel has an aggregated hasSubscribers getter. +// Node 18.x: that getter is missing (undefined), fall back to start sub-channel. +function shouldTrace(channel) { + if (channel === undefined || channel === null) { + return false; + } + return channel.hasSubscribers ?? channel.start?.hasSubscribers ?? false; +} + +// Generic traceCallback wrapper — calls fn synchronously, wraps the callback +// at args[position] to emit asyncStart/asyncEnd/error. No promises involved. +function traceCallback(channel, fn, position, context, thisArg, ...args) { + if (shouldTrace(channel)) { + return channel.traceCallback(fn, position, context(), thisArg, ...args); + } + return fn.apply(thisArg, args); +} + +// tracePromise for operations that are inherently async (connection handshake) +function tracePromise(channel, fn, contextFactory) { + if (shouldTrace(channel)) { + return channel.tracePromise(fn, contextFactory()); + } + return fn(); +} + +module.exports = { + dc, + hasTracingChannel, + shouldTrace, + queryChannel, + executeChannel, + connectChannel, + poolConnectChannel, + getServerContext, + traceCallback, + tracePromise, +}; diff --git a/test/integration/tracing-channel.test.mts b/test/integration/tracing-channel.test.mts new file mode 100644 index 0000000000..843175abec --- /dev/null +++ b/test/integration/tracing-channel.test.mts @@ -0,0 +1,334 @@ +import type { + ConnectTraceContext, + ExecuteTraceContext, + PoolConnectTraceContext, + QueryTraceContext, +} from '../../lib/tracing.js'; +import type { RowDataPacket } from '../../promise.js'; +import diagnostics_channel from 'node:diagnostics_channel'; +import { assert, describe, it, skip } from 'poku'; +import { config, createConnection, createPool } from '../common.test.mjs'; + +const hasTracingChannel = + typeof diagnostics_channel.tracingChannel === 'function'; + +if (!hasTracingChannel) { + skip('TracingChannel requires Node 19.9+ / 20+'); +} + +interface TraceEvent { + type: string; + ctx: T; +} + +function collectEvents(events: TraceEvent[]) { + return { + start(ctx: object) { + events.push({ type: 'start', ctx: ctx as T }); + }, + end(ctx: object) { + events.push({ type: 'end', ctx: ctx as T }); + }, + asyncStart(ctx: object) { + events.push({ type: 'asyncStart', ctx: ctx as T }); + }, + asyncEnd(ctx: object) { + events.push({ type: 'asyncEnd', ctx: ctx as T }); + }, + error(ctx: object) { + events.push({ type: 'error', ctx: ctx as T }); + }, + }; +} + +function assertEvent(events: TraceEvent[], type: string): TraceEvent { + const event = events.find((e) => e.type === type); + if (!event) { + throw new Error(`expected '${type}' event to fire`); + } + return event; +} + +describe('TracingChannel', () => { + describe('mysql2:query', () => { + it('should trace a successful query with callback', async () => { + const events: TraceEvent[] = []; + const subscribers = collectEvents(events); + + diagnostics_channel.tracingChannel('mysql2:query').subscribe(subscribers); + try { + const conn = createConnection(); + await new Promise((resolve, reject) => { + conn.query( + 'SELECT 1 + 1 AS result', + (err: Error | null, results: RowDataPacket[]) => { + if (err) return reject(err); + assert.strictEqual(results[0].result, 2); + resolve(); + } + ); + }); + conn.end(); + + const start = assertEvent(events, 'start'); + assert( + start.ctx.query.includes('SELECT 1 + 1'), + 'should have query text' + ); + assert.strictEqual(start.ctx.database, config.database); + assert.strictEqual(start.ctx.serverAddress, config.host || 'localhost'); + assert.strictEqual(start.ctx.serverPort, config.port || 3306); + + assertEvent(events, 'asyncEnd'); + } finally { + diagnostics_channel + .tracingChannel('mysql2:query') + .unsubscribe(subscribers); + } + }); + + it('should trace a failed query', async () => { + const events: TraceEvent[] = []; + const subscribers = collectEvents(events); + + diagnostics_channel.tracingChannel('mysql2:query').subscribe(subscribers); + try { + const conn = createConnection(); + await new Promise((resolve) => { + conn.query( + 'SELECT * FROM nonexistent_table_xyz', + (err: Error | null) => { + assert(err, 'should receive an error'); + resolve(); + } + ); + }); + conn.end(); + + assertEvent(events, 'error'); + } finally { + diagnostics_channel + .tracingChannel('mysql2:query') + .unsubscribe(subscribers); + } + }); + + it('should trace query in event-emitter mode', async () => { + const events: TraceEvent[] = []; + const subscribers = collectEvents(events); + + diagnostics_channel.tracingChannel('mysql2:query').subscribe(subscribers); + try { + const conn = createConnection(); + await new Promise((resolve, reject) => { + const query = conn.query('SELECT 1 AS val'); + query.on('error', reject); + query.on('end', () => resolve()); + }); + conn.end(); + + const start = assertEvent(events, 'start'); + assert(start.ctx.query.includes('SELECT 1'), 'should have query text'); + } finally { + diagnostics_channel + .tracingChannel('mysql2:query') + .unsubscribe(subscribers); + } + }); + }); + + describe('mysql2:execute', () => { + it('should trace a successful prepared statement execution', async () => { + const events: TraceEvent[] = []; + const subscribers = collectEvents(events); + + diagnostics_channel + .tracingChannel('mysql2:execute') + .subscribe(subscribers); + try { + const conn = createConnection(); + await new Promise((resolve, reject) => { + conn.execute( + 'SELECT ? + ? AS result', + [1, 2], + (err: Error | null, results: RowDataPacket[]) => { + if (err) return reject(err); + assert.strictEqual(results[0].result, 3); + resolve(); + } + ); + }); + conn.end(); + + const start = assertEvent(events, 'start'); + assert.strictEqual(start.ctx.query, 'SELECT ? + ? AS result'); + assert.deepStrictEqual(start.ctx.values, [1, 2]); + assert.strictEqual(start.ctx.database, config.database); + + assertEvent(events, 'asyncEnd'); + } finally { + diagnostics_channel + .tracingChannel('mysql2:execute') + .unsubscribe(subscribers); + } + }); + + it('should trace a failed prepared statement', async () => { + const events: TraceEvent[] = []; + const subscribers = collectEvents(events); + + diagnostics_channel + .tracingChannel('mysql2:execute') + .subscribe(subscribers); + try { + const conn = createConnection(); + await new Promise((resolve) => { + conn.execute( + 'SELECT * FROM nonexistent_table_xyz', + (err: Error | null) => { + assert(err, 'should receive an error'); + resolve(); + } + ); + }); + conn.end(); + + assertEvent(events, 'error'); + } finally { + diagnostics_channel + .tracingChannel('mysql2:execute') + .unsubscribe(subscribers); + } + }); + }); + + describe('mysql2:connect', () => { + it('should trace a successful connection', async () => { + const events: TraceEvent[] = []; + const subscribers = collectEvents(events); + + diagnostics_channel + .tracingChannel('mysql2:connect') + .subscribe(subscribers); + try { + const conn = createConnection(); + await new Promise((resolve, reject) => { + conn.connect((err: Error | null) => { + if (err) return reject(err); + resolve(); + }); + }); + conn.end(); + + const start = assertEvent(events, 'start'); + assert.strictEqual(start.ctx.database, config.database); + assert.strictEqual(start.ctx.serverAddress, config.host || 'localhost'); + assert.strictEqual(start.ctx.serverPort, config.port || 3306); + assert(typeof start.ctx.user === 'string', 'should have user field'); + + assertEvent(events, 'asyncEnd'); + } finally { + diagnostics_channel + .tracingChannel('mysql2:connect') + .unsubscribe(subscribers); + } + }); + + it('should trace a failed connection', async () => { + const events: TraceEvent[] = []; + const subscribers = collectEvents(events); + + diagnostics_channel + .tracingChannel('mysql2:connect') + .subscribe(subscribers); + try { + const conn = createConnection({ port: 1, connectTimeout: 1000 }); + await new Promise((resolve) => { + conn.on('error', () => { + resolve(); + }); + }); + + assertEvent(events, 'error'); + } finally { + diagnostics_channel + .tracingChannel('mysql2:connect') + .unsubscribe(subscribers); + } + }); + }); + + describe('mysql2:pool:connect', () => { + it('should trace pool getConnection', async () => { + const events: TraceEvent[] = []; + const subscribers = collectEvents(events); + + diagnostics_channel + .tracingChannel('mysql2:pool:connect') + .subscribe(subscribers); + try { + const pool = createPool({ connectionLimit: 1 }); + await new Promise((resolve, reject) => { + pool.getConnection((err, conn) => { + if (err) return reject(err); + conn.release(); + resolve(); + }); + }); + await new Promise((resolve) => pool.end(() => resolve())); + + const start = assertEvent(events, 'start'); + assert.strictEqual(start.ctx.database, config.database); + assert.strictEqual(start.ctx.serverAddress, config.host || 'localhost'); + + assertEvent(events, 'asyncEnd'); + } finally { + diagnostics_channel + .tracingChannel('mysql2:pool:connect') + .unsubscribe(subscribers); + } + }); + + it('should trace pool.query() implicitly', async () => { + const events: TraceEvent[] = []; + const subscribers = collectEvents(events); + + diagnostics_channel + .tracingChannel('mysql2:pool:connect') + .subscribe(subscribers); + try { + const pool = createPool({ connectionLimit: 1 }); + await new Promise((resolve, reject) => { + pool.query('SELECT 1', (err: Error | null) => { + if (err) return reject(err); + resolve(); + }); + }); + await new Promise((resolve) => pool.end(() => resolve())); + + assertEvent(events, 'start'); + } finally { + diagnostics_channel + .tracingChannel('mysql2:pool:connect') + .unsubscribe(subscribers); + } + }); + }); + + describe('no subscribers', () => { + it('should work normally without any tracing subscribers', async () => { + const conn = createConnection(); + await new Promise((resolve, reject) => { + conn.query( + 'SELECT 1 + 1 AS result', + (err: Error | null, results: RowDataPacket[]) => { + if (err) return reject(err); + assert.strictEqual(results[0].result, 2); + resolve(); + } + ); + }); + conn.end(); + }); + }); +}); diff --git a/test/unit/test-tracing.test.mts b/test/unit/test-tracing.test.mts new file mode 100644 index 0000000000..3058f156d1 --- /dev/null +++ b/test/unit/test-tracing.test.mts @@ -0,0 +1,79 @@ +import { describe, it, strict } from 'poku'; +import tracing from '../../lib/tracing.js'; + +const { dc, hasTracingChannel } = tracing; + +await describe('Tracing module', async () => { + await it('should load diagnostics_channel', () => { + // dc should be defined on Node.js versions that support diagnostics_channel + strict.ok(dc !== undefined, 'diagnostics_channel should be available'); + }); + + await it('should detect TracingChannel support', () => { + if (typeof dc?.tracingChannel === 'function') { + strict.ok( + hasTracingChannel, + 'hasTracingChannel should be true when tracingChannel exists' + ); + strict.ok( + tracing.queryChannel !== undefined, + 'queryChannel should be defined' + ); + strict.ok( + tracing.executeChannel !== undefined, + 'executeChannel should be defined' + ); + strict.ok( + tracing.connectChannel !== undefined, + 'connectChannel should be defined' + ); + strict.ok( + tracing.poolConnectChannel !== undefined, + 'poolConnectChannel should be defined' + ); + } else { + strict.ok( + !hasTracingChannel, + 'hasTracingChannel should be false when tracingChannel is unavailable' + ); + strict.strictEqual( + tracing.queryChannel, + undefined, + 'queryChannel should be undefined' + ); + strict.strictEqual( + tracing.executeChannel, + undefined, + 'executeChannel should be undefined' + ); + strict.strictEqual( + tracing.connectChannel, + undefined, + 'connectChannel should be undefined' + ); + strict.strictEqual( + tracing.poolConnectChannel, + undefined, + 'poolConnectChannel should be undefined' + ); + } + }); + + await it('should return server context for host/port', () => { + const ctx = tracing.getServerContext({ host: '127.0.0.1', port: 3307 }); + strict.strictEqual(ctx.serverAddress, '127.0.0.1'); + strict.strictEqual(ctx.serverPort, 3307); + }); + + await it('should return server context for socketPath', () => { + const ctx = tracing.getServerContext({ socketPath: '/tmp/mysql.sock' }); + strict.strictEqual(ctx.serverAddress, '/tmp/mysql.sock'); + strict.strictEqual(ctx.serverPort, undefined); + }); + + await it('should default to localhost:3306', () => { + const ctx = tracing.getServerContext({}); + strict.strictEqual(ctx.serverAddress, 'localhost'); + strict.strictEqual(ctx.serverPort, 3306); + }); +}); diff --git a/typings/mysql/index.d.ts b/typings/mysql/index.d.ts index ffa912e5ec..8448bb4d78 100644 --- a/typings/mysql/index.d.ts +++ b/typings/mysql/index.d.ts @@ -82,3 +82,10 @@ export interface ConnectionConfig extends ConnectionOptions { } export function createServer(handler: (conn: BaseConnection) => any): Server; + +export type { + QueryTraceContext, + ExecuteTraceContext, + ConnectTraceContext, + PoolConnectTraceContext, +} from '../../lib/tracing.js'; diff --git a/website/docs/documentation/tracing-channels.mdx b/website/docs/documentation/tracing-channels.mdx new file mode 100644 index 0000000000..d978988272 --- /dev/null +++ b/website/docs/documentation/tracing-channels.mdx @@ -0,0 +1,333 @@ +--- +tags: [Tracing, Diagnostics, TracingChannel, APM, Observability] +--- + +# Tracing Channels + +mysql2 provides built-in instrumentation via Node.js [`TracingChannel`](https://nodejs.org/api/diagnostics_channel.html#class-tracingchannel) from the `node:diagnostics_channel` module. This allows APM tools and custom instrumentation to subscribe to query, execute, connect, and pool lifecycle events natively. + +:::info Requires Node.js 19.9+ or 20+ + +`TracingChannel` is available in Node.js 19.9+, 20+, and was backported to later Node.js 18.x releases. On older versions where `TracingChannel` is not available, tracing is silently disabled with no overhead. + +::: + +## Channels + +mysql2 emits events on four tracing channels: + +| Channel | Fires when | Context type | +| --------------------- | ------------------------------------------------------ | ------------------------- | +| `mysql2:query` | `connection.query()` is called | `QueryTraceContext` | +| `mysql2:execute` | `connection.execute()` is called (prepared statements) | `ExecuteTraceContext` | +| `mysql2:connect` | A new connection handshake completes (or fails) | `ConnectTraceContext` | +| `mysql2:pool:connect` | `pool.getConnection()` is called | `PoolConnectTraceContext` | + +Each channel emits the standard `TracingChannel` lifecycle events: `start`, `end`, `asyncStart`, `asyncEnd`, and `error`. + +## Context Types + +All context types are exported from the `mysql2` package for TypeScript consumers. + +### QueryTraceContext + +```ts +interface QueryTraceContext { + query: string; // The SQL query text + values: any; // Bind parameter values + database: string; // Target database name + serverAddress: string; // Server host or socket path + serverPort: number | undefined; // Server port (undefined for unix sockets) +} +``` + +### ExecuteTraceContext + +```ts +interface ExecuteTraceContext { + query: string; // The prepared statement SQL + values: any; // Bind parameter values + database: string; // Target database name + serverAddress: string; // Server host or socket path + serverPort: number | undefined; // Server port (undefined for unix sockets) +} +``` + +### ConnectTraceContext + +```ts +interface ConnectTraceContext { + database: string; // Target database name + serverAddress: string; // Server host or socket path + serverPort: number | undefined; // Server port (undefined for unix sockets) + user: string; // MySQL user +} +``` + +### PoolConnectTraceContext + +```ts +interface PoolConnectTraceContext { + database: string; // Target database name + serverAddress: string; // Server host or socket path + serverPort: number | undefined; // Server port (undefined for unix sockets) +} +``` + +## Basic Usage + +Subscribe to a channel using the standard `node:diagnostics_channel` API: + +```js +const diagnostics_channel = require('node:diagnostics_channel'); + +const queryChannel = diagnostics_channel.tracingChannel('mysql2:query'); + +queryChannel.subscribe({ + start(ctx) { + console.log(`Query started: ${ctx.query}`); + console.log(` database: ${ctx.database}`); + console.log(` server: ${ctx.serverAddress}:${ctx.serverPort}`); + }, + end() {}, + asyncStart() {}, + asyncEnd(ctx) { + console.log(`Query completed: ${ctx.query}`); + }, + error(ctx) { + console.log(`Query failed: ${ctx.query}`, ctx.error.message); + }, +}); +``` + +## Tracing Queries + +```js +const diagnostics_channel = require('node:diagnostics_channel'); +const mysql = require('mysql2'); + +// Subscribe to query events +const queryChannel = diagnostics_channel.tracingChannel('mysql2:query'); + +queryChannel.subscribe({ + start(ctx) { + // Called synchronously when query() is invoked + // ctx.query - SQL text + // ctx.values - bind parameters + ctx.startTime = Date.now(); + }, + end() {}, + asyncStart() {}, + asyncEnd(ctx) { + // Called when the query callback fires successfully + const duration = Date.now() - ctx.startTime; + console.log(`[${duration}ms] ${ctx.query}`); + }, + error(ctx) { + // Called when the query fails + const duration = Date.now() - ctx.startTime; + console.error(`[${duration}ms] FAILED: ${ctx.query}`, ctx.error.message); + }, +}); + +// Queries are now automatically traced +const connection = mysql.createConnection({ host: 'localhost', user: 'root' }); + +connection.query('SELECT 1 + 1 AS result', (err, rows) => { + // The subscriber above logs: "[2ms] SELECT 1 + 1 AS result" +}); +``` + +## Tracing Prepared Statements + +```js +const diagnostics_channel = require('node:diagnostics_channel'); + +const executeChannel = diagnostics_channel.tracingChannel('mysql2:execute'); + +executeChannel.subscribe({ + start(ctx) { + console.log(`Execute: ${ctx.query} values=${JSON.stringify(ctx.values)}`); + }, + end() {}, + asyncStart() {}, + asyncEnd(ctx) { + console.log(`Execute completed: ${ctx.query}`); + }, + error(ctx) { + console.error(`Execute failed: ${ctx.query}`, ctx.error.message); + }, +}); +``` + +## Tracing Connections + +```js +const diagnostics_channel = require('node:diagnostics_channel'); + +const connectChannel = diagnostics_channel.tracingChannel('mysql2:connect'); + +connectChannel.subscribe({ + start(ctx) { + console.log( + `Connecting to ${ctx.serverAddress}:${ctx.serverPort} as ${ctx.user}` + ); + }, + end() {}, + asyncStart() {}, + asyncEnd(ctx) { + console.log(`Connected to ${ctx.serverAddress}:${ctx.serverPort}`); + }, + error(ctx) { + console.error(`Connection failed:`, ctx.error.message); + }, +}); +``` + +## Tracing Pool Connections + +```js +const diagnostics_channel = require('node:diagnostics_channel'); + +const poolChannel = diagnostics_channel.tracingChannel('mysql2:pool:connect'); + +poolChannel.subscribe({ + start(ctx) { + console.log( + `Pool acquiring connection to ${ctx.serverAddress}:${ctx.serverPort}` + ); + }, + end() {}, + asyncStart() {}, + asyncEnd(ctx) { + console.log(`Pool connection acquired`); + }, + error(ctx) { + console.error(`Pool connection failed:`, ctx.error.message); + }, +}); +``` + +## Building Custom Spans + +A common use case is creating spans for APM tools. Here's a complete example using only the `node:diagnostics_channel` API to build a simple query logger with timing: + +```js +const diagnostics_channel = require('node:diagnostics_channel'); +const mysql = require('mysql2'); + +// Track all active spans +const activeSpans = new WeakMap(); + +function subscribeChannel(name) { + const channel = diagnostics_channel.tracingChannel(name); + + channel.subscribe({ + start(ctx) { + activeSpans.set(ctx, { + channel: name, + query: ctx.query || null, + database: ctx.database, + server: `${ctx.serverAddress}:${ctx.serverPort}`, + startTime: process.hrtime.bigint(), + }); + }, + end() {}, + asyncStart() {}, + asyncEnd(ctx) { + const span = activeSpans.get(ctx); + if (span) { + const duration = Number(process.hrtime.bigint() - span.startTime) / 1e6; + console.log( + `[${span.channel}] ${span.query || 'connect'} - ${duration.toFixed(1)}ms` + ); + activeSpans.delete(ctx); + } + }, + error(ctx) { + const span = activeSpans.get(ctx); + if (span) { + const duration = Number(process.hrtime.bigint() - span.startTime) / 1e6; + console.error( + `[${span.channel}] FAILED ${span.query || 'connect'} - ${duration.toFixed(1)}ms: ${ctx.error.message}` + ); + activeSpans.delete(ctx); + } + }, + }); +} + +// Subscribe to all channels +subscribeChannel('mysql2:query'); +subscribeChannel('mysql2:execute'); +subscribeChannel('mysql2:connect'); +subscribeChannel('mysql2:pool:connect'); + +// All mysql2 operations are now traced +const connection = mysql.createConnection({ + host: 'localhost', + user: 'root', + database: 'test', +}); + +connection.query('SELECT * FROM users WHERE id = ?', [1], (err, rows) => { + // Logs: [mysql2:query] SELECT * FROM users WHERE id = ? - 3.2ms +}); +``` + +## Zero-Cost When Unused + +When no subscribers are attached to a channel, mysql2 skips the tracing code path entirely. There is no performance overhead for applications that don't use tracing. + +On Node.js 20+, the `hasSubscribers` check on `TracingChannel` enables this zero-cost optimization. On Node.js 18.x, tracing channels are supported but the optimization is not available — operations go through the tracing path unconditionally (with negligible overhead when no subscribers are listening). + +## Cleanup + +Always unsubscribe when you're done to prevent memory leaks: + +```js +const subscribers = { + start(ctx) { + /* ... */ + }, + end() {}, + asyncStart() {}, + asyncEnd(ctx) { + /* ... */ + }, + error(ctx) { + /* ... */ + }, +}; + +const channel = diagnostics_channel.tracingChannel('mysql2:query'); +channel.subscribe(subscribers); + +// Later, when done: +channel.unsubscribe(subscribers); +``` + +## TypeScript + +Context types are exported from the `mysql2` package: + +```ts +import type { + QueryTraceContext, + ExecuteTraceContext, + ConnectTraceContext, + PoolConnectTraceContext, +} from 'mysql2'; +import diagnostics_channel from 'node:diagnostics_channel'; + +const channel = diagnostics_channel.tracingChannel('mysql2:query'); +channel.subscribe({ + start(ctx: QueryTraceContext) { + console.log(ctx.query, ctx.database); + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, +}); +```