Skip to content

Commit 88eacaf

Browse files
Merge pull request #11 from thelastbackspace/feat/orm
Track Prisma and better-sqlite3 / Drizzle automatically
2 parents 70a5ba2 + 1953632 commit 88eacaf

8 files changed

Lines changed: 189 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **Prisma without code changes:** `traceperf/auto` and `traceperf run` track every Prisma Client call (`prisma.User.findMany`, raw queries as `prisma.raw.$queryRaw SELECT users`), including clients made with `$extends`. `instrumentPrisma(tp, require('@prisma/client'))` does it by hand. `prismaExtension` steps aside when this is active, so calls are not counted twice.
12+
- **better-sqlite3, and so Drizzle on SQLite:** `instrumentBetterSqlite3`; auto-detected. Drizzle on pg or mysql2 was already covered through those drivers.
13+
- N+1 fixes name the Prisma (`include`, `where: { id: { in } }`) and Drizzle (`inArray`) way.
14+
1015
## [1.4.0] - 2026-11-06
1116

1217
### Added

‎README.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
- **Honest numbers.** You get total, self and synchronous time for each call. Detailed mode flags calls that blocked the event loop.
1414
- **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.
1515
- **Gets data out.** Chrome/Perfetto traces, OpenTelemetry spans, JSON lines, or your own `onComplete` hook.
16-
- **Zero-effort coverage.** One line tracks every `fetch`, HTTP request or query, for pg, mysql2, MongoDB/Mongoose, ioredis, Prisma and the AWS SDK.
16+
- **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.
1717
- **Built for teams.** It feeds Prometheus, catches regressions in CI (`traceperf compare --fail`), shows up in Chrome DevTools, and produces HTML flame-chart reports.
1818
- **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.
1919

@@ -99,7 +99,7 @@ The same naming works without `run`: `TRACEPERF_PROFILE_BLOCKING=1 node --requir
9999

100100
## No code changes: `traceperf/auto`
101101

102-
Start your app with traceperf preloaded. Every incoming HTTP request becomes a tree, with the outgoing `http`/`fetch` calls and the `pg`, `mysql2`, MongoDB/Mongoose and `ioredis` queries it made nested under it:
102+
Start your app with traceperf preloaded. Every incoming HTTP request becomes a tree, with the outgoing `http`/`fetch` calls and the `pg`, `mysql2`, MongoDB/Mongoose, `ioredis`, Prisma and `better-sqlite3` queries it made nested under it (Drizzle runs on these drivers, so its queries show up too):
103103

104104
<!-- docs:skip -->
105105
```bash
@@ -125,7 +125,7 @@ Database clients are only instrumented when your `package.json` lists them. Conf
125125
| `TRACEPERF_THRESHOLD=50` | Only report calls taking at least 50 ms |
126126
| `TRACEPERF_SAMPLE_RATE=0.1` | Track 10% of requests |
127127
| `TRACEPERF_IGNORE=^/health` | Regular expression of request paths not to track |
128-
| `TRACEPERF_DISABLE=http,fetch` | Skip targets: `server`, `http`, `fetch`, `pg`, `mysql2`, `mongodb`, `ioredis` |
128+
| `TRACEPERF_DISABLE=http,fetch` | Skip targets: `server`, `http`, `fetch`, `pg`, `mysql2`, `mongodb`, `ioredis`, `prisma`, `better-sqlite3` |
129129
| `TRACEPERF_SILENT=1` / `TRACEPERF_SUMMARY=0` / `TRACEPERF_QUIET=1` | No per-request trees / no exit summary / no startup line |
130130
| `TRACEPERF_ENABLED=0` | Load nothing |
131131

@@ -203,13 +203,15 @@ Ready-made helpers for popular clients. Pass the module you already use; tracepe
203203

204204
<!-- docs:skip -->
205205
```javascript
206-
const { instrumentPg, instrumentMysql2, instrumentMongo, instrumentRedis, instrumentAwsClient, prismaExtension } = require('traceperf');
206+
const { instrumentPg, instrumentMysql2, instrumentMongo, instrumentRedis, instrumentAwsClient, instrumentPrisma, instrumentBetterSqlite3, prismaExtension } = require('traceperf');
207207

208208
instrumentPg(tp, require('pg')); // "pg SELECT users" (Pool.query included)
209209
instrumentMysql2(tp, require('mysql2')); // "mysql UPDATE orders" (callback and promise APIs)
210210
instrumentMongo(tp, require('mongodb')); // "mongo find users", "mongo insertOne users"
211211
instrumentMongo(tp, require('mongoose').mongo); // Mongoose uses its own copy of the driver
212212
instrumentRedis(tp, require('ioredis')); // "redis GET"
213+
instrumentPrisma(tp, require('@prisma/client')); // "prisma.User.findMany" (every client, $extends included)
214+
instrumentBetterSqlite3(tp, require('better-sqlite3')); // "sqlite SELECT users" (synchronous: slow ones show as blocking)
213215
instrumentAwsClient(tp, s3Client); // "aws S3.GetObject"
214216
const prisma = new PrismaClient().$extends(prismaExtension(tp)); // "prisma.User.findMany"
215217
```

‎src/__tests__/drivers.test.ts‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import {
1515
createTracePerf,
1616
ExecutionRecord,
1717
instrumentAwsClient,
18+
instrumentBetterSqlite3,
19+
instrumentPrisma,
20+
prismaExtension,
1821
instrumentMysql2,
1922
instrumentPg,
2023
instrumentRedis,
@@ -248,3 +251,94 @@ describe('instrumentAwsClient (real AWS SDK v3 client, local endpoint)', () => {
248251
expect(labels(roots[0])).toEqual(['aws S3.ListBuckets']);
249252
});
250253
});
254+
255+
describe('instrumentPrisma (client shaped like Prisma 6: every call goes through _request)', () => {
256+
// Checked by hand against @prisma/client 6.19 (model calls, $extends clients, raw queries)
257+
class FakePrisma {
258+
_originalClient = this;
259+
_request(params: any): Promise<unknown> {
260+
return new Promise((resolve, reject) => setTimeout(() => (params.args?.fail ? reject(new Error('P2002')) : resolve([])), 2));
261+
}
262+
get user() { return { findMany: (args?: any) => this._request({ model: 'User', action: 'findMany', clientMethod: 'user.findMany', args }) }; }
263+
$queryRawUnsafe(sql: string) { return this._request({ action: 'queryRaw', clientMethod: '$queryRawUnsafe', args: [sql] }); }
264+
$extends() { return Object.create(this._originalClient, { _appliedParent: { value: this } }); }
265+
}
266+
267+
test('model calls, raw queries, extended clients and failures', async () => {
268+
const { tp, roots } = setup();
269+
const uninstall = instrumentPrisma(tp, { PrismaClient: FakePrisma });
270+
expect(instrumentPrisma(tp, FakePrisma)).toBeInstanceOf(Function); // idempotent
271+
const prisma = new FakePrisma().$extends();
272+
await tp.track(async () => {
273+
await prisma.user.findMany();
274+
await prisma.$queryRawUnsafe('SELECT * FROM "User" WHERE id = 1');
275+
await prisma.user.findMany({ fail: true }).catch(() => undefined);
276+
}, { label: 'GET /users' });
277+
expect(labels(roots[0])).toEqual(['prisma.User.findMany', 'prisma.raw.$queryRawUnsafe SELECT User', 'prisma.User.findMany']);
278+
expect(roots[0].children[2].error).toBeInstanceOf(Error);
279+
uninstall();
280+
roots.length = 0;
281+
await tp.track(() => prisma.user.findMany(), { label: 'after' });
282+
expect(labels(roots[0])).toEqual([]);
283+
});
284+
285+
test('prismaExtension stands aside while the client is patched (no double counting)', async () => {
286+
const { tp, roots } = setup();
287+
const ext = prismaExtension(tp).query.$allModels.$allOperations;
288+
const call = () => ext({ model: 'User', operation: 'findMany', args: {}, query: async () => [] });
289+
const uninstall = instrumentPrisma(tp, FakePrisma);
290+
await tp.track(() => call(), { label: 'patched' });
291+
uninstall();
292+
await tp.track(() => call(), { label: 'extension only' });
293+
expect(roots.map(r => [r.label, labels(r)])).toEqual([['patched', []], ['extension only', ['prisma.User.findMany']]]);
294+
});
295+
296+
test('a client instance works too; unknown objects are ignored', async () => {
297+
const { tp, roots } = setup();
298+
const client = new FakePrisma();
299+
const uninstall = instrumentPrisma(tp, client);
300+
await tp.track(() => client.user.findMany(), { label: 'r' });
301+
expect(labels(roots[0])).toEqual(['prisma.User.findMany']);
302+
uninstall();
303+
expect(() => instrumentPrisma(tp, {})()).not.toThrow();
304+
expect(() => instrumentPrisma(tp, undefined)()).not.toThrow();
305+
});
306+
});
307+
308+
describe('instrumentBetterSqlite3 (database shaped like better-sqlite3: Statement class not exported)', () => {
309+
// Checked by hand against better-sqlite3 12 with Drizzle
310+
class Statement {
311+
constructor(public source: string) {}
312+
run(..._a: unknown[]) { return { changes: 1 }; }
313+
get(..._a: unknown[]) { return { id: 1 }; }
314+
all() {
315+
if (this.source.includes('missing')) throw new Error('no such table: missing');
316+
return [{ id: 1 }];
317+
}
318+
iterate() { return [][Symbol.iterator](); }
319+
raw() { return this; }
320+
}
321+
class Database {
322+
prepare(sql: string) { return new Statement(sql); }
323+
exec(sql: string) { return sql ? this : this; }
324+
}
325+
326+
test('statements and exec are tracked as synchronous calls', () => {
327+
const { tp, roots } = setup();
328+
const uninstall = instrumentBetterSqlite3(tp, Database);
329+
const db = new Database();
330+
tp.track(() => {
331+
db.prepare('SELECT * FROM users').all();
332+
db.prepare('INSERT INTO posts (t) VALUES (?)').run('a');
333+
db.prepare('select id from users where id = ?').raw().get(1);
334+
db.exec('CREATE TABLE t (a)');
335+
try { db.prepare('SELECT * FROM missing').all(); } catch { /* expected */ }
336+
}, { label: 'job' });
337+
expect(labels(roots[0])).toEqual(['sqlite SELECT users', 'sqlite INSERT posts', 'sqlite SELECT users', 'sqlite CREATE t', 'sqlite SELECT missing']);
338+
expect(roots[0].children[4].error).toBeInstanceOf(Error);
339+
uninstall();
340+
roots.length = 0;
341+
tp.track(() => { db.prepare('SELECT * FROM users').all(); }, { label: 'after' });
342+
expect(labels(roots[0])).toEqual([]);
343+
});
344+
});

‎src/auto.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@
1212
import type { OptimizedPerformanceMonitor } from './core/optimized-performance-monitor';
1313
import { loadNodeBuiltin } from './core/env';
1414
import { HttpServerInstrumentOptions, instrumentFetch, instrumentHttp, instrumentHttpServer, Uninstall } from './instrument';
15-
import { instrumentMongo, instrumentMysql2, instrumentPg, instrumentRedis } from './drivers';
15+
import { instrumentBetterSqlite3, instrumentMongo, instrumentMysql2, instrumentPg, instrumentPrisma, instrumentRedis } from './drivers';
1616

17-
export type AutoTarget = 'server' | 'http' | 'fetch' | 'pg' | 'mysql2' | 'mongodb' | 'ioredis';
17+
export type AutoTarget = 'server' | 'http' | 'fetch' | 'pg' | 'mysql2' | 'mongodb' | 'ioredis' | 'prisma' | 'better-sqlite3';
1818

19-
export const AUTO_TARGETS: AutoTarget[] = ['server', 'http', 'fetch', 'pg', 'mysql2', 'mongodb', 'ioredis'];
19+
export const AUTO_TARGETS: AutoTarget[] = ['server', 'http', 'fetch', 'pg', 'mysql2', 'mongodb', 'ioredis', 'prisma', 'better-sqlite3'];
2020

2121
export interface AutoInstrumentOptions {
2222
/** Targets to skip, e.g. ['http'] */
@@ -94,6 +94,8 @@ export function autoInstrument(tp: OptimizedPerformanceMonitor, options: AutoIns
9494
const load = (name: string) => appRequire(name);
9595
if (deps.names.has('pg')) add('pg', () => instrumentPg(tp, load('pg')));
9696
if (deps.names.has('mysql2')) add('mysql2', () => instrumentMysql2(tp, load('mysql2')));
97+
if (deps.names.has('better-sqlite3')) add('better-sqlite3', () => instrumentBetterSqlite3(tp, load('better-sqlite3')));
98+
if (deps.names.has('@prisma/client')) add('prisma', () => instrumentPrisma(tp, load('@prisma/client')));
9799
if (deps.names.has('ioredis')) add('ioredis', () => instrumentRedis(tp, load('ioredis')));
98100
if (deps.names.has('mongodb')) add('mongodb', () => instrumentMongo(tp, load('mongodb')));
99101
else if (deps.names.has('mongoose')) {

‎src/core/explain.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,8 @@ function suggestFix(f: Finding, hotspots?: Record<string, BlockingHotspot[]>): s
385385
}
386386
case 'repeated': {
387387
const label = labels[0] || '';
388+
if (/^prisma\./.test(label)) return `load them in one call: include the relation in the parent query (include: { ... }), or findMany({ where: { id: { in: ids } } }) and match results in memory`;
389+
if (/^sqlite /.test(label)) return `load them in one query: WHERE id IN (...) (in Drizzle: inArray(column, ids), or db.query with: { ... }); then match results in memory`;
388390
return SQL.test(label)
389391
? `load them in one query: WHERE id IN (...) (or = ANY($1) in Postgres), or your ORM's include/join; then match results in memory`
390392
: `make one batched call instead of one per item (a bulk API, or DataLoader to collect the calls of one tick)`;

‎src/drivers.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,76 @@ export function instrumentAwsClient(tp: OptimizedPerformanceMonitor, client: any
219219
delete client[PATCHED];
220220
};
221221
}
222+
223+
/** The prototype that holds Prisma Client's internal `_request`, from a module, class or client */
224+
function prismaPrototype(target: any): any {
225+
if (!target) return undefined;
226+
const candidates = [
227+
target.PrismaClient && target.PrismaClient.prototype, // require('@prisma/client')
228+
typeof target === 'function' ? target.prototype : undefined, // the PrismaClient class
229+
target._originalClient && Object.getPrototypeOf(target._originalClient), // a client (extended or not)
230+
typeof target === 'object' ? Object.getPrototypeOf(target) : undefined,
231+
];
232+
return candidates.find(p => p && typeof p._request === 'function');
233+
}
234+
235+
/**
236+
* Prisma Client (4 to 6, and newer clients that keep the same runtime):
237+
* every model call and raw query, "prisma.User.findMany",
238+
* "prisma.raw.$queryRaw SELECT users". Pass the module, the PrismaClient class
239+
* or a client; clients made with $extends are covered too.
240+
*
241+
* instrumentPrisma(tp, require('@prisma/client'));
242+
*/
243+
export function instrumentPrisma(tp: OptimizedPerformanceMonitor, prisma: any, options: DriverOptions = {}): Uninstall {
244+
const restores: Uninstall[] = [];
245+
patchMethod(prismaPrototype(prisma), '_request', original => function (this: unknown, params: any, ...rest: unknown[]) {
246+
const p = params || {};
247+
let label: string;
248+
// Same labels as prismaExtension: "prisma.User.findMany", "prisma.raw.$queryRaw SELECT users"
249+
if (p.model) label = `prisma.${p.model}.${p.action}`;
250+
else {
251+
const method = String(p.clientMethod || p.action || 'request');
252+
const sql = Array.isArray(p.args) ? p.args[0] : p.args;
253+
label = /Raw/.test(method) && sql ? `prisma.raw.${method} ${sqlLabel(sql)}` : `prisma.raw.${method}`;
254+
}
255+
return trackCall(tp, label, options, this, original, [params, ...rest], -1);
256+
}, restores);
257+
if (restores.length === 0) return () => undefined;
258+
const g = globalThis as any;
259+
const key = Symbol.for('traceperf.prisma.patched');
260+
g[key] = (g[key] || 0) + 1;
261+
return () => {
262+
g[key]--;
263+
restores.forEach(r => r());
264+
};
265+
}
266+
267+
/**
268+
* better-sqlite3 (and Drizzle on it): every statement, "sqlite SELECT users".
269+
* These calls are synchronous, so slow ones show up as blocking.
270+
*
271+
* instrumentBetterSqlite3(tp, require('better-sqlite3'));
272+
*/
273+
export function instrumentBetterSqlite3(tp: OptimizedPerformanceMonitor, Database: any, options: DriverOptions = {}): Uninstall {
274+
const restores: Uninstall[] = [];
275+
const proto = Database && Database.prototype;
276+
const patchStatement = (statement: any) => {
277+
// The Statement class is not exported: patch its prototype on first prepare()
278+
const sp = statement && Object.getPrototypeOf(statement);
279+
for (const method of ['run', 'get', 'all', 'iterate']) {
280+
patchMethod(sp, method, original => function (this: any, ...args: unknown[]) {
281+
return trackCall(tp, `sqlite ${sqlLabel(this && this.source)}`, options, this, original, args, -1);
282+
}, restores);
283+
}
284+
};
285+
patchMethod(proto, 'prepare', original => function (this: unknown, ...args: unknown[]) {
286+
const statement = original.apply(this, args);
287+
patchStatement(statement);
288+
return statement;
289+
}, restores);
290+
patchMethod(proto, 'exec', original => function (this: unknown, ...args: unknown[]) {
291+
return trackCall(tp, `sqlite ${sqlLabel(args[0])}`, options, this, original, args, -1);
292+
}, restores);
293+
return () => restores.forEach(r => r());
294+
}

‎src/instrument.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,8 @@ export function prismaExtension(tp: OptimizedPerformanceMonitor, options: TrackO
197197
query: {
198198
$allModels: {
199199
$allOperations(params: { model?: string; operation: string; args: unknown; query: (args: unknown) => Promise<unknown> }) {
200+
// Already tracked by instrumentPrisma / traceperf/auto: don't count it twice
201+
if ((globalThis as any)[Symbol.for('traceperf.prisma.patched')] > 0) return params.query(params.args);
200202
const label = options.label ?? `prisma.${params.model ?? 'raw'}.${params.operation}`;
201203
return tp.track(() => params.query(params.args), { ...options, label });
202204
},

‎src/register.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
* TRACEPERF_SAMPLE_RATE=0.1 track 10% of top-level calls
1313
* TRACEPERF_SILENT=1 no per-call console trees
1414
* TRACEPERF_SUMMARY=0 no stats + explanations on exit (default on)
15-
* TRACEPERF_DISABLE=http,fetch skip targets (server, http, fetch, pg, mysql2, mongodb, ioredis)
15+
* TRACEPERF_DISABLE=http,fetch skip targets (server, http, fetch, pg, mysql2, mongodb, ioredis,
16+
* prisma, better-sqlite3)
1617
* TRACEPERF_IGNORE=^/health regular expression of request paths not to track
1718
* TRACEPERF_QUIET=1 no "traceperf: tracking ..." line at startup
1819
* TRACEPERF_PROFILE_BLOCKING=1 sample the CPU (V8 profiler) to name the

0 commit comments

Comments
 (0)