From 04f44c6c87229c3e1db3af2efe6c0ff6fc1b9991 Mon Sep 17 00:00:00 2001 From: Sohan Kshirsagar Date: Mon, 16 Mar 2026 13:44:48 -0700 Subject: [PATCH 1/3] fixed instrumentation to be compatible with v3.20 --- .github/workflows/e2e.yml | 1 + src/core/tracing/SpanUtils.ts | 5 + .../libraries/mysql2/Instrumentation.ts | 242 +++++++++++++++--- .../cjs-mysql2/src/test_requests.mjs | 62 ++--- .../esm-mysql2/src/test_requests.mjs | 62 ++--- 5 files changed, 275 insertions(+), 97 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 51e54d0e..349db301 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -17,6 +17,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 strategy: + fail-fast: false max-parallel: 6 matrix: library: diff --git a/src/core/tracing/SpanUtils.ts b/src/core/tracing/SpanUtils.ts index 95fac1b6..3fb76b06 100644 --- a/src/core/tracing/SpanUtils.ts +++ b/src/core/tracing/SpanUtils.ts @@ -169,6 +169,11 @@ export class SpanUtils { logger.debug( `[SpanUtils] Stopping recording of child spans for span ${spanContext.spanId}, packageName: ${options.packageName}, instrumentationName: ${options.instrumentationName}`, ); + if (mode === TuskDriftMode.REPLAY) { + throw new Error( + `Unexpected child span in replay mode for span ${spanContext.spanId}, packageName: ${options.packageName}, instrumentationName: ${options.instrumentationName}`, + ); + } return originalFunctionCall(); } } diff --git a/src/instrumentation/libraries/mysql2/Instrumentation.ts b/src/instrumentation/libraries/mysql2/Instrumentation.ts index e6796b66..1f120863 100644 --- a/src/instrumentation/libraries/mysql2/Instrumentation.ts +++ b/src/instrumentation/libraries/mysql2/Instrumentation.ts @@ -23,6 +23,7 @@ import { TdMysql2ConnectionMock } from "./mocks/TdMysql2ConnectionMock"; import { TdMysql2QueryMock } from "./mocks/TdMysql2QueryMock"; import { captureStackTrace } from "src/instrumentation/core/utils"; import { TdMysql2ConnectionEventMock } from "./mocks/TdMysql2ConnectionEventMock"; +import * as diagnosticsChannel from "node:diagnostics_channel"; import { EventEmitter } from "events"; // Version ranges for mysql2 @@ -34,8 +35,13 @@ export class Mysql2Instrumentation extends TdInstrumentationBase { private readonly INSTRUMENTATION_NAME = "Mysql2Instrumentation"; private readonly CONTEXT_BOUND_CONNECTION = Symbol("mysql2-context-bound-connection"); private readonly CONTEXT_BOUND_PARENT_CONTEXT = Symbol("mysql2-bound-parent-context"); + private readonly MYSQL2_NATIVE_QUERY_CHANNEL = + typeof diagnosticsChannel.tracingChannel === "function" + ? diagnosticsChannel.tracingChannel("mysql2:query") + : undefined; private mode: TuskDriftMode; private queryMock: TdMysql2QueryMock; + private baseConnectionCreateQuery?: Function; constructor(config: Mysql2InstrumentationConfig = {}) { super("mysql2", config); @@ -104,6 +110,10 @@ export class Mysql2Instrumentation extends TdInstrumentationBase { return BaseConnectionClass; } + if (typeof BaseConnectionClass.createQuery === "function") { + this.baseConnectionCreateQuery = BaseConnectionClass.createQuery.bind(BaseConnectionClass); + } + // Wrap BaseConnection.prototype.query if (BaseConnectionClass.prototype && BaseConnectionClass.prototype.query) { if (!isWrapped(BaseConnectionClass.prototype.query)) { @@ -1427,6 +1437,65 @@ export class Mysql2Instrumentation extends TdInstrumentationBase { return sql; } + private _shouldEmitMysql2NativeQueryEvents(): boolean { + const channel: any = this.MYSQL2_NATIVE_QUERY_CHANNEL; + if (!channel) { + return false; + } + + return channel.hasSubscribers ?? channel.start?.hasSubscribers ?? false; + } + + private _buildMysql2NativeQueryTraceContext(connectionContext: any, cmdQuery: any) { + const config = connectionContext?.config || {}; + + if (config.socketPath) { + return { + query: cmdQuery.sql, + values: cmdQuery.values, + database: config.database || "", + serverAddress: config.socketPath, + serverPort: undefined, + }; + } + + return { + query: cmdQuery.sql, + values: cmdQuery.values, + database: config.database || "", + serverAddress: config.host || "localhost", + serverPort: config.port || 3306, + }; + } + + private _addMysql2CommandWithNativeTracing(connectionContext: any, cmdQuery: any): any { + const channel: any = this.MYSQL2_NATIVE_QUERY_CHANNEL; + + if (!this._shouldEmitMysql2NativeQueryEvents() || typeof channel?.tracePromise !== "function") { + return connectionContext.addCommand(cmdQuery); + } + + const traceContext = this._buildMysql2NativeQueryTraceContext(connectionContext, cmdQuery); + const result = connectionContext.addCommand(cmdQuery); + + if (result && typeof result.once === "function") { + void channel + .tracePromise( + () => + new Promise((resolve, reject) => { + result.once("error", reject); + result.once("end", () => resolve()); + }), + traceContext, + ) + .catch(() => { + // Query errors are already surfaced via mysql2's emitter/callback contract. + }); + } + + return result; + } + private _handleRecordQueryInSpan( spanInfo: SpanInfo, originalQuery: Function, @@ -1500,30 +1569,130 @@ export class Mysql2Instrumentation extends TdInstrumentationBase { return invokeOriginal(args); } else { - // Promise-based query or streaming query (no callback) - const result = invokeOriginal(args); - - // For streaming queries (event emitters), attach event listeners - // In mysql2, streaming queries are identified by checking if result has 'on' method - // and we're NOT using it as a promise (not calling .then() explicitly) - if (result && typeof result.on === "function") { - // Collect data for streaming queries - const streamResults: any[] = []; + const connectionContext = context as any; + const canConstructQueryDirectly = + !!connectionContext && + typeof connectionContext.addCommand === "function" && + typeof connectionContext.format === "function" && + typeof connectionContext._resolveNamedPlaceholders === "function" && + typeof this.baseConnectionCreateQuery === "function"; + + let result: any; + + if (canConstructQueryDirectly) { + try { + /** + * mysql2 3.20 introduced native TracingChannel support inside BaseConnection.query(). + * That means callback-less queries can now route through additional internal tracing + * branches before the command is added to the connection. Our SDK also monkey-patches + * query() and expects the no-callback form to behave like the pre-3.20 path: build the + * Query command, enqueue it, then observe the returned EventEmitter. + * + * The `/test/stream-query` E2E endpoint depends on that exact EventEmitter lifecycle: + * it only sends the HTTP response when the returned query emits `error` or `end`. + * When we delegate to mysql2's newer callback-less query path, we become coupled to its + * internal tracing implementation and the endpoint can hang before `end` reaches the app. + * + * To keep our instrumentation stable across mysql2 versions, we intentionally recreate + * the old query() steps for connection-style, callback-less queries: + * 1. Reuse an existing Query command if one was provided. + * 2. Otherwise, construct the Query command with BaseConnection.createQuery captured when + * mysql2's base connection class was patched. We intentionally use the unwrapped base + * helper here because wrapped connection constructors may not preserve static helpers + * like createQuery on the instance constructor. + * 3. Resolve named placeholders and format SQL exactly like mysql2 query() does. + * 4. Add the command directly to the connection and observe the returned emitter. + * 5. If some other tool is subscribed to mysql2's native `mysql2:query` + * TracingChannel, emit equivalent lifecycle events around the direct command path + * so native subscribers (for example OTEL/APM integrations) still observe the query. + * + * This avoids mysql2's internal tracing branch for the emitter-style path while still + * recording the query correctly from the command lifecycle. Callback-based queries stay + * on the normal upstream path because they are not affected by this regression. + */ + const firstArg = args[0]; + const isExistingQueryCommand = + !!firstArg && + typeof firstArg === "object" && + typeof firstArg.on === "function" && + (typeof firstArg.onResult === "function" || firstArg.constructor?.name === "Query"); + + const cmdQuery = isExistingQueryCommand + ? firstArg + : this.baseConnectionCreateQuery!( + args[0], + args[1], + undefined, + connectionContext.config, + ); + + connectionContext._resolveNamedPlaceholders(cmdQuery); + const rawSql = connectionContext.format( + cmdQuery.sql, + cmdQuery.values !== undefined ? cmdQuery.values : [], + ); + cmdQuery.sql = rawSql; + result = this._addMysql2CommandWithNativeTracing(connectionContext, cmdQuery); + } catch (error) { + logger.debug( + `[Mysql2Instrumentation] direct query construction failed, falling back to original query path`, + error, + ); + result = invokeOriginal(args); + } + } else { + result = invokeOriginal(args); + } + + const isEmitterLike = + !!result && + typeof result.on === "function" && + typeof result.once === "function" && + typeof result.emit === "function"; + + // mysql2 3.20 can return callback-less query objects that are both thenable + // and EventEmitter-like. For stream-style queries the application relies on + // the emitter contract, so prefer the emitter path whenever those methods exist. + if (isEmitterLike) { let streamFields: any = null; + const streamResults: any[] = []; + let finished = false; + + const finalize = (error?: Error) => { + if (finished) return; + finished = true; + + setImmediate(() => { + if (error) { + logger.debug( + `[Mysql2Instrumentation] MySQL2 stream query error: ${error.message} (${SpanUtils.getTraceInfo()})`, + ); + try { + SpanUtils.endSpan(spanInfo.span, { + code: SpanStatusCode.ERROR, + message: error.message, + }); + } catch (endError) { + logger.error(`[Mysql2Instrumentation] error ending span:`, endError); + } + return; + } - result - .on("error", (error: Error) => { logger.debug( - `[Mysql2Instrumentation] MySQL2 stream query error: ${error.message} (${SpanUtils.getTraceInfo()})`, + `[Mysql2Instrumentation] MySQL2 stream query completed (${SpanUtils.getTraceInfo()})`, ); try { - SpanUtils.endSpan(spanInfo.span, { - code: SpanStatusCode.ERROR, - message: error.message, - }); - } catch (error) { - logger.error(`[Mysql2Instrumentation] error ending span:`, error); + this._addOutputAttributesToSpan(spanInfo, streamResults, streamFields); + SpanUtils.endSpan(spanInfo.span, { code: SpanStatusCode.OK }); + } catch (endError) { + logger.error(`[Mysql2Instrumentation] error ending span:`, endError); } + }); + }; + + result + .once("error", (error: Error) => { + finalize(error); }) .on("fields", (fields: any) => { streamFields = fields; @@ -1531,16 +1700,8 @@ export class Mysql2Instrumentation extends TdInstrumentationBase { .on("result", (row: any) => { streamResults.push(row); }) - .on("end", () => { - logger.debug( - `[Mysql2Instrumentation] MySQL2 stream query completed (${SpanUtils.getTraceInfo()})`, - ); - try { - this._addOutputAttributesToSpan(spanInfo, streamResults, streamFields); - SpanUtils.endSpan(spanInfo.span, { code: SpanStatusCode.OK }); - } catch (error) { - logger.error(`[Mysql2Instrumentation] error ending span:`, error); - } + .once("end", () => { + finalize(); }); } @@ -1614,7 +1775,9 @@ export class Mysql2Instrumentation extends TdInstrumentationBase { return otelContext.with(parentContext, () => callback(error, scopedConnection)); }; - return otelContext.with(parentContext, () => originalGetConnection.call(context, wrappedCallback)); + return otelContext.with(parentContext, () => + originalGetConnection.call(context, wrappedCallback), + ); } else { // Promise-based getConnection const promise = otelContext.with(parentContext, () => originalGetConnection.call(context)); @@ -1635,7 +1798,10 @@ export class Mysql2Instrumentation extends TdInstrumentationBase { }); SpanUtils.endSpan(spanInfo.span, { code: SpanStatusCode.OK }); } catch (error) { - logger.error(`[Mysql2Instrumentation] error processing getConnection response:`, error); + logger.error( + `[Mysql2Instrumentation] error processing getConnection response:`, + error, + ); } return scopedConnection; }); @@ -1733,15 +1899,21 @@ export class Mysql2Instrumentation extends TdInstrumentationBase { // Create the patched constructor function function TdPatchedConnection(this: any, ...args: any[]) { + // Capture new.target so that subclasses (e.g. PoolConnection) get the + // correct prototype chain when we delegate to OriginalConnection. + // Without this, methods like _removeFromPool and _realEnd that live on + // PoolConnection.prototype would be missing from the returned instance. + const constructTarget = new.target || TdPatchedConnection; + const inputValue = { method: "createConnection" }; // RECORD mode: create real connection and record connect/error events if (self.mode === TuskDriftMode.RECORD) { return handleRecordMode({ - originalFunctionCall: () => new OriginalConnection(...args), + originalFunctionCall: () => Reflect.construct(OriginalConnection, args, constructTarget), recordModeHandler: ({ isPreAppStart }) => { return SpanUtils.createAndExecuteSpan( self.mode, - () => new OriginalConnection(...args), + () => Reflect.construct(OriginalConnection, args, constructTarget), { name: `mysql2.connection.create`, kind: SpanKind.CLIENT, @@ -1753,7 +1925,7 @@ export class Mysql2Instrumentation extends TdInstrumentationBase { isPreAppStart, }, (spanInfo) => { - const connection = new OriginalConnection(...args); + const connection = Reflect.construct(OriginalConnection, args, constructTarget); // Listen for successful connection - record via span connection.on("connect", (connectionObj: any) => { @@ -1801,7 +1973,7 @@ export class Mysql2Instrumentation extends TdInstrumentationBase { replayModeHandler: () => { return SpanUtils.createAndExecuteSpan( self.mode, - () => new OriginalConnection(...args), + () => Reflect.construct(OriginalConnection, args, constructTarget), { name: `mysql2.connection.create`, kind: SpanKind.CLIENT, @@ -1956,7 +2128,7 @@ export class Mysql2Instrumentation extends TdInstrumentationBase { } // Fallback for disabled mode - return new OriginalConnection(...args); + return Reflect.construct(OriginalConnection, args, constructTarget); } // Copy static properties from original class diff --git a/src/instrumentation/libraries/mysql2/e2e-tests/cjs-mysql2/src/test_requests.mjs b/src/instrumentation/libraries/mysql2/e2e-tests/cjs-mysql2/src/test_requests.mjs index 6bb49696..6dd45a84 100644 --- a/src/instrumentation/libraries/mysql2/e2e-tests/cjs-mysql2/src/test_requests.mjs +++ b/src/instrumentation/libraries/mysql2/e2e-tests/cjs-mysql2/src/test_requests.mjs @@ -1,34 +1,34 @@ -import { makeRequest, printRequestSummary } from '/app/test-utils.mjs'; +import { makeRequest, printRequestSummary } from "/app/test-utils.mjs"; -await makeRequest('GET', '/health'); -await makeRequest('GET', '/test/connection-query'); -await makeRequest('POST', '/test/connection-parameterized', { body: { userId: 1 } }); -await makeRequest('GET', '/test/connection-execute'); -await makeRequest('POST', '/test/connection-execute-params', { body: { userId: 2 } }); -await makeRequest('GET', '/test/pool-query'); -await makeRequest('POST', '/test/pool-parameterized', { body: { userId: 1 } }); -await makeRequest('GET', '/test/pool-execute'); -await makeRequest('POST', '/test/pool-execute-params', { body: { userId: 2 } }); -await makeRequest('GET', '/test/pool-getConnection'); -await makeRequest('GET', '/test/connection-connect'); -await makeRequest('GET', '/test/connection-ping'); -await makeRequest('GET', '/test/stream-query'); -await makeRequest('GET', '/test/sequelize-authenticate'); -await makeRequest('GET', '/test/sequelize-findall'); -await makeRequest('POST', '/test/sequelize-findone', { body: { userId: 1 } }); -await makeRequest('GET', '/test/sequelize-complex'); -await makeRequest('GET', '/test/sequelize-raw'); -await makeRequest('POST', '/test/sequelize-transaction'); -await makeRequest('GET', '/test/promise-connection-query'); -await makeRequest('GET', '/test/promise-pool-query'); -await makeRequest('GET', '/test/promise-pool-getconnection'); -await makeRequest('GET', '/test/transaction-methods'); -await makeRequest('GET', '/test/prepare-statement'); -await makeRequest('GET', '/test/change-user'); -await makeRequest('GET', '/test/nested-null-values'); -await makeRequest('GET', '/test/binary-data'); -await makeRequest('GET', '/test/knex-raw-query'); -await makeRequest('POST', '/test/knex-savepoint'); -await makeRequest('GET', '/test/knex-streaming'); +await makeRequest("GET", "/health"); +await makeRequest("GET", "/test/connection-query"); +await makeRequest("POST", "/test/connection-parameterized", { body: { userId: 1 } }); +await makeRequest("GET", "/test/connection-execute"); +await makeRequest("POST", "/test/connection-execute-params", { body: { userId: 2 } }); +await makeRequest("GET", "/test/pool-query"); +await makeRequest("POST", "/test/pool-parameterized", { body: { userId: 1 } }); +await makeRequest("GET", "/test/pool-execute"); +await makeRequest("POST", "/test/pool-execute-params", { body: { userId: 2 } }); +await makeRequest("GET", "/test/pool-getConnection"); +await makeRequest("GET", "/test/connection-connect"); +await makeRequest("GET", "/test/connection-ping"); +await makeRequest("GET", "/test/stream-query"); +await makeRequest("GET", "/test/sequelize-authenticate"); +await makeRequest("GET", "/test/sequelize-findall"); +await makeRequest("POST", "/test/sequelize-findone", { body: { userId: 1 } }); +await makeRequest("GET", "/test/sequelize-complex"); +await makeRequest("GET", "/test/sequelize-raw"); +await makeRequest("POST", "/test/sequelize-transaction"); +await makeRequest("GET", "/test/promise-connection-query"); +await makeRequest("GET", "/test/promise-pool-query"); +await makeRequest("GET", "/test/promise-pool-getconnection"); +await makeRequest("GET", "/test/transaction-methods"); +await makeRequest("GET", "/test/prepare-statement"); +await makeRequest("GET", "/test/change-user"); +await makeRequest("GET", "/test/nested-null-values"); +await makeRequest("GET", "/test/binary-data"); +await makeRequest("GET", "/test/knex-raw-query"); +await makeRequest("POST", "/test/knex-savepoint"); +await makeRequest("GET", "/test/knex-streaming"); printRequestSummary(); diff --git a/src/instrumentation/libraries/mysql2/e2e-tests/esm-mysql2/src/test_requests.mjs b/src/instrumentation/libraries/mysql2/e2e-tests/esm-mysql2/src/test_requests.mjs index 6bb49696..6dd45a84 100644 --- a/src/instrumentation/libraries/mysql2/e2e-tests/esm-mysql2/src/test_requests.mjs +++ b/src/instrumentation/libraries/mysql2/e2e-tests/esm-mysql2/src/test_requests.mjs @@ -1,34 +1,34 @@ -import { makeRequest, printRequestSummary } from '/app/test-utils.mjs'; +import { makeRequest, printRequestSummary } from "/app/test-utils.mjs"; -await makeRequest('GET', '/health'); -await makeRequest('GET', '/test/connection-query'); -await makeRequest('POST', '/test/connection-parameterized', { body: { userId: 1 } }); -await makeRequest('GET', '/test/connection-execute'); -await makeRequest('POST', '/test/connection-execute-params', { body: { userId: 2 } }); -await makeRequest('GET', '/test/pool-query'); -await makeRequest('POST', '/test/pool-parameterized', { body: { userId: 1 } }); -await makeRequest('GET', '/test/pool-execute'); -await makeRequest('POST', '/test/pool-execute-params', { body: { userId: 2 } }); -await makeRequest('GET', '/test/pool-getConnection'); -await makeRequest('GET', '/test/connection-connect'); -await makeRequest('GET', '/test/connection-ping'); -await makeRequest('GET', '/test/stream-query'); -await makeRequest('GET', '/test/sequelize-authenticate'); -await makeRequest('GET', '/test/sequelize-findall'); -await makeRequest('POST', '/test/sequelize-findone', { body: { userId: 1 } }); -await makeRequest('GET', '/test/sequelize-complex'); -await makeRequest('GET', '/test/sequelize-raw'); -await makeRequest('POST', '/test/sequelize-transaction'); -await makeRequest('GET', '/test/promise-connection-query'); -await makeRequest('GET', '/test/promise-pool-query'); -await makeRequest('GET', '/test/promise-pool-getconnection'); -await makeRequest('GET', '/test/transaction-methods'); -await makeRequest('GET', '/test/prepare-statement'); -await makeRequest('GET', '/test/change-user'); -await makeRequest('GET', '/test/nested-null-values'); -await makeRequest('GET', '/test/binary-data'); -await makeRequest('GET', '/test/knex-raw-query'); -await makeRequest('POST', '/test/knex-savepoint'); -await makeRequest('GET', '/test/knex-streaming'); +await makeRequest("GET", "/health"); +await makeRequest("GET", "/test/connection-query"); +await makeRequest("POST", "/test/connection-parameterized", { body: { userId: 1 } }); +await makeRequest("GET", "/test/connection-execute"); +await makeRequest("POST", "/test/connection-execute-params", { body: { userId: 2 } }); +await makeRequest("GET", "/test/pool-query"); +await makeRequest("POST", "/test/pool-parameterized", { body: { userId: 1 } }); +await makeRequest("GET", "/test/pool-execute"); +await makeRequest("POST", "/test/pool-execute-params", { body: { userId: 2 } }); +await makeRequest("GET", "/test/pool-getConnection"); +await makeRequest("GET", "/test/connection-connect"); +await makeRequest("GET", "/test/connection-ping"); +await makeRequest("GET", "/test/stream-query"); +await makeRequest("GET", "/test/sequelize-authenticate"); +await makeRequest("GET", "/test/sequelize-findall"); +await makeRequest("POST", "/test/sequelize-findone", { body: { userId: 1 } }); +await makeRequest("GET", "/test/sequelize-complex"); +await makeRequest("GET", "/test/sequelize-raw"); +await makeRequest("POST", "/test/sequelize-transaction"); +await makeRequest("GET", "/test/promise-connection-query"); +await makeRequest("GET", "/test/promise-pool-query"); +await makeRequest("GET", "/test/promise-pool-getconnection"); +await makeRequest("GET", "/test/transaction-methods"); +await makeRequest("GET", "/test/prepare-statement"); +await makeRequest("GET", "/test/change-user"); +await makeRequest("GET", "/test/nested-null-values"); +await makeRequest("GET", "/test/binary-data"); +await makeRequest("GET", "/test/knex-raw-query"); +await makeRequest("POST", "/test/knex-savepoint"); +await makeRequest("GET", "/test/knex-streaming"); printRequestSummary(); From f1257f7735831d7dbf6f385f7f34b7ccbcc73537 Mon Sep 17 00:00:00 2001 From: Sohan Kshirsagar Date: Mon, 16 Mar 2026 13:52:40 -0700 Subject: [PATCH 2/3] add integration test --- .../mysql2/integration-tests/mysql2.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/instrumentation/libraries/mysql2/integration-tests/mysql2.test.ts b/src/instrumentation/libraries/mysql2/integration-tests/mysql2.test.ts index 03ad0670..e2e2eb89 100644 --- a/src/instrumentation/libraries/mysql2/integration-tests/mysql2.test.ts +++ b/src/instrumentation/libraries/mysql2/integration-tests/mysql2.test.ts @@ -11,6 +11,7 @@ TuskDrift.markAppAsReady(); import test from "ava"; import { SpanKind } from "@opentelemetry/api"; +import diagnosticsChannel from "node:diagnostics_channel"; import { SpanUtils } from "../../../../core/tracing/SpanUtils"; import { TuskDriftMode } from "../../../../core/TuskDrift"; import { @@ -588,6 +589,72 @@ test.serial("should handle streaming queries", async (t) => { t.true(mysql2Spans.length > 0); }); +test.serial("should emit native mysql2 tracing events for streaming queries", async (t) => { + if (typeof diagnosticsChannel.tracingChannel !== "function") { + t.pass(); + return; + } + + const events: Array<{ type: string; ctx: any }> = []; + const queryChannel = diagnosticsChannel.tracingChannel("mysql2:query"); + const subscriber = { + start(ctx: object) { + events.push({ type: "start", ctx }); + }, + end(ctx: object) { + events.push({ type: "end", ctx }); + }, + asyncStart(ctx: object) { + events.push({ type: "asyncStart", ctx }); + }, + asyncEnd(ctx: object) { + events.push({ type: "asyncEnd", ctx }); + }, + error(ctx: object) { + events.push({ type: "error", ctx }); + }, + }; + + queryChannel.subscribe(subscriber); + + try { + const rows: any[] = []; + + await new Promise((resolve, reject) => { + withRootSpan(() => { + const query = connection.query("SELECT * FROM test_users ORDER BY id"); + + query + .on("error", (err: any) => { + reject(err); + }) + .on("result", (row: any) => { + rows.push(row); + }) + .on("end", () => { + resolve(); + }); + }); + }); + + t.is(rows.length, 2); + + const eventTypes = events.map((event) => event.type); + t.true(eventTypes.includes("start")); + t.true(eventTypes.includes("asyncEnd")); + t.false(eventTypes.includes("error")); + + const startEvent = events.find((event) => event.type === "start"); + t.truthy(startEvent); + t.true(String(startEvent?.ctx?.query || "").includes("SELECT * FROM test_users")); + t.is(startEvent?.ctx?.database, TEST_MYSQL_CONFIG.database); + t.is(startEvent?.ctx?.serverAddress, TEST_MYSQL_CONFIG.host); + t.is(startEvent?.ctx?.serverPort, TEST_MYSQL_CONFIG.port); + } finally { + queryChannel.unsubscribe(subscriber); + } +}); + test.serial("should handle connection.ping", async (t) => { await new Promise((resolve, reject) => { withRootSpan(() => { From ebc2cbe8e193c0e18487e7bfafa2f1866e1f7d99 Mon Sep 17 00:00:00 2001 From: Sohan Kshirsagar Date: Mon, 16 Mar 2026 14:40:29 -0700 Subject: [PATCH 3/3] copy inherited static methods to patched mysql2 Connection constructor --- .../libraries/mysql2/Instrumentation.ts | 24 +++++++++++++------ .../mysql2/e2e-tests/cjs-mysql2/src/index.ts | 20 ++++++++++++++++ .../cjs-mysql2/src/test_requests.mjs | 1 + .../mysql2/e2e-tests/esm-mysql2/src/index.ts | 20 ++++++++++++++++ .../esm-mysql2/src/test_requests.mjs | 1 + 5 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/instrumentation/libraries/mysql2/Instrumentation.ts b/src/instrumentation/libraries/mysql2/Instrumentation.ts index 1f120863..8fd9eedc 100644 --- a/src/instrumentation/libraries/mysql2/Instrumentation.ts +++ b/src/instrumentation/libraries/mysql2/Instrumentation.ts @@ -1464,7 +1464,7 @@ export class Mysql2Instrumentation extends TdInstrumentationBase { values: cmdQuery.values, database: config.database || "", serverAddress: config.host || "localhost", - serverPort: config.port || 3306, + serverPort: config.port ?? 3306, }; } @@ -2131,12 +2131,22 @@ export class Mysql2Instrumentation extends TdInstrumentationBase { return Reflect.construct(OriginalConnection, args, constructTarget); } - // Copy static properties from original class - const staticProps = Object.getOwnPropertyNames(OriginalConnection).filter( - (key) => !["length", "name", "prototype"].includes(key), - ); - for (const staticProp of staticProps) { - (TdPatchedConnection as any)[staticProp] = OriginalConnection[staticProp]; + // Copy static properties from original class (including inherited ones) + // Walk the prototype chain to pick up statics defined on parent classes + // (e.g., BaseConnection.statementKey inherited by Connection) + let currentProto: any = OriginalConnection; + const copiedKeys = new Set(); + while (currentProto && currentProto !== Function.prototype) { + for (const key of Object.getOwnPropertyNames(currentProto)) { + if (!copiedKeys.has(key) && !["length", "name", "prototype", "constructor"].includes(key)) { + const descriptor = Object.getOwnPropertyDescriptor(currentProto, key); + if (descriptor) { + Object.defineProperty(TdPatchedConnection, key, descriptor); + } + copiedKeys.add(key); + } + } + currentProto = Object.getPrototypeOf(currentProto); } // Set prototype chain diff --git a/src/instrumentation/libraries/mysql2/e2e-tests/cjs-mysql2/src/index.ts b/src/instrumentation/libraries/mysql2/e2e-tests/cjs-mysql2/src/index.ts index 9fa86e76..bc62d2ec 100644 --- a/src/instrumentation/libraries/mysql2/e2e-tests/cjs-mysql2/src/index.ts +++ b/src/instrumentation/libraries/mysql2/e2e-tests/cjs-mysql2/src/index.ts @@ -1092,6 +1092,26 @@ const server = http.createServer(async (req, res) => { return; } + if (url === "/test/pool-execute-singleton-values" && method === "GET") { + pool.execute("SELECT * FROM test_users WHERE id = ?", [1], (error, results) => { + if (error) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: false, error: error.message })); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + success: true, + data: results, + rowCount: Array.isArray(results) ? results.length : 0, + queryType: "pool-execute-singleton-values", + }), + ); + }); + return; + } + // 404 for unknown routes res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" })); diff --git a/src/instrumentation/libraries/mysql2/e2e-tests/cjs-mysql2/src/test_requests.mjs b/src/instrumentation/libraries/mysql2/e2e-tests/cjs-mysql2/src/test_requests.mjs index 6dd45a84..78fab31f 100644 --- a/src/instrumentation/libraries/mysql2/e2e-tests/cjs-mysql2/src/test_requests.mjs +++ b/src/instrumentation/libraries/mysql2/e2e-tests/cjs-mysql2/src/test_requests.mjs @@ -30,5 +30,6 @@ await makeRequest("GET", "/test/binary-data"); await makeRequest("GET", "/test/knex-raw-query"); await makeRequest("POST", "/test/knex-savepoint"); await makeRequest("GET", "/test/knex-streaming"); +await makeRequest("GET", "/test/pool-execute-singleton-values"); printRequestSummary(); diff --git a/src/instrumentation/libraries/mysql2/e2e-tests/esm-mysql2/src/index.ts b/src/instrumentation/libraries/mysql2/e2e-tests/esm-mysql2/src/index.ts index 6214b5bb..25d3bb0d 100644 --- a/src/instrumentation/libraries/mysql2/e2e-tests/esm-mysql2/src/index.ts +++ b/src/instrumentation/libraries/mysql2/e2e-tests/esm-mysql2/src/index.ts @@ -1052,6 +1052,26 @@ const server = http.createServer(async (req, res) => { return; } + if (url === "/test/pool-execute-singleton-values" && method === "GET") { + pool.execute("SELECT * FROM test_users WHERE id = ?", [1], (error, results) => { + if (error) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: false, error: error.message })); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + success: true, + data: results, + rowCount: Array.isArray(results) ? results.length : 0, + queryType: "pool-execute-singleton-values", + }), + ); + }); + return; + } + // 404 for unknown routes res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" })); diff --git a/src/instrumentation/libraries/mysql2/e2e-tests/esm-mysql2/src/test_requests.mjs b/src/instrumentation/libraries/mysql2/e2e-tests/esm-mysql2/src/test_requests.mjs index 6dd45a84..78fab31f 100644 --- a/src/instrumentation/libraries/mysql2/e2e-tests/esm-mysql2/src/test_requests.mjs +++ b/src/instrumentation/libraries/mysql2/e2e-tests/esm-mysql2/src/test_requests.mjs @@ -30,5 +30,6 @@ await makeRequest("GET", "/test/binary-data"); await makeRequest("GET", "/test/knex-raw-query"); await makeRequest("POST", "/test/knex-savepoint"); await makeRequest("GET", "/test/knex-streaming"); +await makeRequest("GET", "/test/pool-execute-singleton-values"); printRequestSummary();