From 76558beb5f0ee0f51f0820676384fb692bffc497 Mon Sep 17 00:00:00 2001 From: thelastbackspace <23155651+thelastbackspace@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:46:38 +0100 Subject: [PATCH] feat: track the classic mysql driver (connections and pools) --- CHANGELOG.md | 1 + README.md | 4 +- package-lock.json | 98 +++++++++++++++++++++++++++++++++++ package.json | 1 + src/__tests__/drivers.test.ts | 27 ++++++++++ src/auto.ts | 7 +-- src/drivers.ts | 37 +++++++++++++ src/register.ts | 2 +- 8 files changed, 171 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27169f9..5ad1f79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **`mysql` (the classic driver)**, used by TypeORM, Knex and many older apps: `instrumentMysql(tp, require('mysql'))`, auto-detected. Connections and pools (`pool.query` includes waiting for a free connection, and failures to get one), labelled like mysql2: `mysql SELECT users`. - **`traceperf run` measures in two phases.** After the warm-up it sends `--probe` requests per path (default 10) one at a time, then the concurrent load. Per-request findings (a dominant call, sequential awaits, N+1) come from the one-at-a-time phase: under load every await also waits for the busy event loop, so a 1 ms query on a 60-row table measured 12 ms and was reported as "check this query's plan". Blocking, queueing, errors and CPU cost still come from the load. - **Failed responses are called out first:** "2117 of 2117 responses failed (most: 2117× 401 Unauthorized), so the timings below describe error responses", with a hint (`-H` for 401/403, `--path` for 404). An expired token used to give "no issues found". diff --git a/README.md b/README.md index b5c2610..b5be2f7 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ - **Honest numbers.** You get total, self and synchronous time for each call. Detailed mode flags calls that blocked the event loop. - **Production-ready.** Stats are bounded per label (p50/p95/p99), sampling is built in, and `enabled: false` makes tracking a pass-through costing ~40 ns. - **Gets data out.** Chrome/Perfetto traces, OpenTelemetry spans, JSON lines, or your own `onComplete` hook. -- **Zero-effort coverage.** One line tracks every `fetch`, HTTP request or query, for pg, mysql2, MongoDB/Mongoose, ioredis, Prisma, better-sqlite3 (and Drizzle on any of these drivers) and the AWS SDK. +- **Zero-effort coverage.** One line tracks every `fetch`, HTTP request or query, for pg, mysql2, mysql, MongoDB/Mongoose, ioredis, Prisma, better-sqlite3 (and Drizzle on any of these drivers) and the AWS SDK. - **Built for teams.** It feeds Prometheus, catches regressions in CI (`traceperf compare --fail`), shows up in Chrome DevTools, and produces HTML flame-chart reports. - **Fits your stack.** Express, Fastify, Hono, Koa, NestJS, Next.js route handlers, decorators, CommonJS, ESM, TypeScript, browsers, Node.js 14+, Bun, Deno, Cloudflare Workers and Vercel Edge. There are no runtime dependencies. @@ -125,7 +125,7 @@ Database clients are only instrumented when your `package.json` lists them. Conf | `TRACEPERF_THRESHOLD=50` | Only report calls taking at least 50 ms | | `TRACEPERF_SAMPLE_RATE=0.1` | Track 10% of requests | | `TRACEPERF_IGNORE=^/health` | Regular expression of request paths not to track | -| `TRACEPERF_DISABLE=http,fetch` | Skip targets: `server`, `http`, `fetch`, `pg`, `mysql2`, `mongodb`, `ioredis`, `prisma`, `better-sqlite3` | +| `TRACEPERF_DISABLE=http,fetch` | Skip targets: `server`, `http`, `fetch`, `pg`, `mysql2`, `mongodb`, `ioredis`, `prisma`, `better-sqlite3`, `mysql` | | `TRACEPERF_SILENT=1` / `TRACEPERF_SUMMARY=0` / `TRACEPERF_QUIET=1` | No per-request trees / no exit summary / no startup line | | `TRACEPERF_ENABLED=0` | Load nothing | diff --git a/package-lock.json b/package-lock.json index 97074ab..0ad76e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "jest": "^29.7.0", "koa": "^3.2.1", "mongodb": "^7.7.0", + "mysql": "^2.18.1", "mysql2": "^3.24.6", "pg": "^8.23.1", "prettier": "^3.5.3", @@ -3636,6 +3637,16 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/bignumber.js": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.0.tgz", + "integrity": "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/body-parser": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", @@ -4126,6 +4137,13 @@ "node": ">= 0.8" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -5853,6 +5871,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -7344,6 +7369,22 @@ "node": ">= 0.6" } }, + "node_modules/mysql": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/mysql/-/mysql-2.18.1.tgz", + "integrity": "sha512-Bca+gk2YWmqp2Uf6k5NFEurwY/0td0cpebAucFpY/3jhrwrVGuxU2uQFCHjU19SJfje0yQvi+rVWdq78hR5lig==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "9.0.0", + "readable-stream": "2.3.7", + "safe-buffer": "5.1.2", + "sqlstring": "2.3.1" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mysql2": { "version": "3.24.6", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.6.tgz", @@ -8084,6 +8125,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/process-warning": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", @@ -8279,6 +8327,22 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, + "node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/real-require": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", @@ -8487,6 +8551,13 @@ "node": ">=6" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-regex2": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.2.tgz", @@ -8819,6 +8890,16 @@ "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" } }, + "node_modules/sqlstring": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.1.tgz", + "integrity": "sha512-ooAzh/7dxIG5+uDik1z/Rd1vli0+38izZhGzSa34FwR7IbelPWCCKSNIl8jlL/F7ERvy8CB2jNeM1E9i9mXMAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -8866,6 +8947,16 @@ "node": ">=10.0.0" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -9470,6 +9561,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/package.json b/package.json index e979614..625805c 100644 --- a/package.json +++ b/package.json @@ -142,6 +142,7 @@ "jest": "^29.7.0", "koa": "^3.2.1", "mongodb": "^7.7.0", + "mysql": "^2.18.1", "mysql2": "^3.24.6", "pg": "^8.23.1", "prettier": "^3.5.3", diff --git a/src/__tests__/drivers.test.ts b/src/__tests__/drivers.test.ts index a97c201..22b4af2 100644 --- a/src/__tests__/drivers.test.ts +++ b/src/__tests__/drivers.test.ts @@ -18,6 +18,7 @@ import { instrumentBetterSqlite3, instrumentPrisma, prismaExtension, + instrumentMysql, instrumentMysql2, instrumentPg, instrumentRedis, @@ -191,6 +192,32 @@ describe('instrumentMysql2 (real mysql2 client)', () => { }); }); +describe('instrumentMysql (real mysql client)', () => { + test('connection and pool failures are tracked as failed queries', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mysql = require('mysql'); + const { tp, roots } = setup(); + const uninstall = instrumentMysql(tp, mysql); + expect(instrumentMysql(tp, mysql)).toBeInstanceOf(Function); // idempotent + try { + const conn = mysql.createConnection({ host: '127.0.0.1', port: 1, user: 'x', connectTimeout: 2000 }); + conn.on('error', () => undefined); + const pool = mysql.createPool({ host: '127.0.0.1', port: 1, user: 'x', connectTimeout: 2000 }); + await tp.track(async () => { + await new Promise(resolve => conn.query('SELECT * FROM users WHERE id = ?', [1], () => resolve())); + await new Promise(resolve => pool.query('INSERT INTO orders (a) VALUES (1)', () => resolve())); + }, { label: 'job' }); + pool.end(() => undefined); + } finally { + uninstall(); + } + const job = roots.find(r => r.label === 'job')!; + expect(labels(job)[0]).toBe('mysql SELECT users'); + expect(labels(job)).toContain('mysql INSERT orders'); + job.children.forEach(c => expect(c.error).toBeDefined()); + }); +}); + const MYSQL = process.env.TRACEPERF_MYSQL_URL; (MYSQL ? describe : describe.skip)('instrumentMysql2 (real MySQL)', () => { test('query/execute through connections, pools and the promise API; failures; nesting', async () => { diff --git a/src/auto.ts b/src/auto.ts index 982182c..d12cdbf 100644 --- a/src/auto.ts +++ b/src/auto.ts @@ -12,11 +12,11 @@ import type { OptimizedPerformanceMonitor } from './core/optimized-performance-monitor'; import { loadNodeBuiltin } from './core/env'; import { HttpServerInstrumentOptions, instrumentFetch, instrumentHttp, instrumentHttpServer, Uninstall } from './instrument'; -import { instrumentBetterSqlite3, instrumentMongo, instrumentMysql2, instrumentPg, instrumentPrisma, instrumentRedis } from './drivers'; +import { instrumentBetterSqlite3, instrumentMongo, instrumentMysql, instrumentMysql2, instrumentPg, instrumentPrisma, instrumentRedis } from './drivers'; -export type AutoTarget = 'server' | 'http' | 'fetch' | 'pg' | 'mysql2' | 'mongodb' | 'ioredis' | 'prisma' | 'better-sqlite3'; +export type AutoTarget = 'server' | 'http' | 'fetch' | 'pg' | 'mysql2' | 'mongodb' | 'ioredis' | 'prisma' | 'better-sqlite3' | 'mysql'; -export const AUTO_TARGETS: AutoTarget[] = ['server', 'http', 'fetch', 'pg', 'mysql2', 'mongodb', 'ioredis', 'prisma', 'better-sqlite3']; +export const AUTO_TARGETS: AutoTarget[] = ['server', 'http', 'fetch', 'pg', 'mysql2', 'mongodb', 'ioredis', 'prisma', 'better-sqlite3', 'mysql']; export interface AutoInstrumentOptions { /** Targets to skip, e.g. ['http'] */ @@ -94,6 +94,7 @@ export function autoInstrument(tp: OptimizedPerformanceMonitor, options: AutoIns const load = (name: string) => appRequire(name); if (deps.names.has('pg')) add('pg', () => instrumentPg(tp, load('pg'))); if (deps.names.has('mysql2')) add('mysql2', () => instrumentMysql2(tp, load('mysql2'))); + if (deps.names.has('mysql')) add('mysql', () => instrumentMysql(tp, load('mysql'))); if (deps.names.has('better-sqlite3')) add('better-sqlite3', () => instrumentBetterSqlite3(tp, load('better-sqlite3'))); if (deps.names.has('@prisma/client')) add('prisma', () => { // Prisma 4/5 need the module; 6+ use the tracing hook (and Prisma 7's module may not load on its own) diff --git a/src/drivers.ts b/src/drivers.ts index ab168f4..b0935aa 100644 --- a/src/drivers.ts +++ b/src/drivers.ts @@ -146,6 +146,43 @@ export function instrumentMysql2(tp: OptimizedPerformanceMonitor, mysql2: any, o return () => restores.forEach(r => r()); } +/** + * mysql (the classic driver, e.g. under TypeORM or Knex): every query, + * "mysql SELECT users", for connections and pools (pools use connection.query). + * + * instrumentMysql(tp, require('mysql')); + */ +export function instrumentMysql(tp: OptimizedPerformanceMonitor, mysql: any, options: DriverOptions = {}): Uninstall { + const restores: Uninstall[] = []; + let proto: any; + try { + // The Connection class is not exported; an unconnected connection shows its prototype + proto = mysql && typeof mysql.createConnection === 'function' && Object.getPrototypeOf(mysql.createConnection({})); + } catch { + proto = undefined; + } + const POOLED = Symbol.for('traceperf.mysql.pooled'); + patchMethod(proto, 'query', original => function (this: unknown, ...args: unknown[]) { + // pool.query hands its own Query object to a connection: tracked by the pool patch + const first = args[0] as any; + if (first && typeof first === 'object' && first[POOLED]) return original.apply(this, args); + return trackCall(tp, `mysql ${sqlLabel(first)}`, options, this, original, args); + }, restores); + let poolProto: any; + try { + poolProto = typeof mysql.createPool === 'function' && Object.getPrototypeOf(mysql.createPool({})); + } catch { + poolProto = undefined; + } + // Pools: includes waiting for a free connection, and failures to get one + patchMethod(poolProto, 'query', original => function (this: unknown, ...args: unknown[]) { + const result: any = trackCall(tp, `mysql ${sqlLabel(args[0])}`, options, this, original, args); + if (result && typeof result === 'object') result[POOLED] = true; + return result; + }, restores); + return () => restores.forEach(r => r()); +} + const MONGO_COLLECTION_METHODS = [ 'insertOne', 'insertMany', 'findOne', 'updateOne', 'updateMany', 'replaceOne', 'deleteOne', 'deleteMany', 'countDocuments', 'estimatedDocumentCount', 'distinct', 'findOneAndUpdate', 'findOneAndDelete', diff --git a/src/register.ts b/src/register.ts index 18a8b9f..fded59f 100644 --- a/src/register.ts +++ b/src/register.ts @@ -13,7 +13,7 @@ * TRACEPERF_SILENT=1 no per-call console trees * TRACEPERF_SUMMARY=0 no stats + explanations on exit (default on) * TRACEPERF_DISABLE=http,fetch skip targets (server, http, fetch, pg, mysql2, mongodb, ioredis, - * prisma, better-sqlite3) + * prisma, better-sqlite3, mysql) * TRACEPERF_IGNORE=^/health regular expression of request paths not to track * TRACEPERF_QUIET=1 no "traceperf: tracking ..." line at startup * TRACEPERF_PROFILE_BLOCKING=1 sample the CPU (V8 profiler) to name the