Skip to content

Commit c519446

Browse files
Merge pull request #12 from thelastbackspace/feat/real-app-fixes
Fixes from running traceperf on a real app (Prisma's REST example)
2 parents 88eacaf + c52ba8a commit c519446

8 files changed

Lines changed: 222 additions & 92 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### 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.
11+
- **Prisma without code changes (5, 6 and 7):** `traceperf/auto` and `traceperf run` track every Prisma Client call (`prisma.User.findMany`, raw queries as `prisma.raw.queryRaw`) through Prisma's own tracing hook, so it works with clients generated anywhere (Prisma 7's `prisma-client`), `$extends` and driver adapters, and keeps `@prisma/instrumentation` (OpenTelemetry) working. `instrumentPrisma(tp)` does it by hand; `prismaExtension` steps aside when it is active.
1212
- **better-sqlite3, and so Drizzle on SQLite:** `instrumentBetterSqlite3`; auto-detected. Drizzle on pg or mysql2 was already covered through those drivers.
13+
### Changed (from running it on Prisma's REST example, not just the planted apps)
14+
- **Grouped findings show the typical case**, not the worst: one cold start no longer makes every request look slow. The worst is mentioned (`typical shown; worst 191 ms`), and a finding seen in under 5% of 20+ requests is dropped as noise; otherwise its share is given (`in 1 of 172 GET /feed`).
15+
- "X took 97% of the request" is no longer said of a request that makes a single call, unless that call takes 100 ms or more.
16+
- Sequential or repeated calls made *inside* a driver or ORM call (Prisma's `include` runs two queries) are not reported: the app can't reorder them.
17+
- Blocking inside a dependency or Node.js internals gets "the time is inside pg (library code)…" instead of "move it to a worker thread".
18+
- `traceperf run`: queueing with nothing blocking is described as the app's throughput limit, with what to do about it.
19+
- SQL labels read quoted names: `"public"."Post"` → `SELECT public.Post` (was `SELECT public`).
20+
21+
### Fixed
22+
- `traceperf run -- npx tsx app.ts` hung after the load and left the app running: only `npx` was stopped. The app now runs in its own process group, which is stopped as a whole (also on Ctrl-C and when `run` itself is terminated).
23+
1324
- N+1 fixes name the Prisma (`include`, `where: { id: { in } }`) and Drizzle (`inArray`) way.
1425

1526
## [1.4.0] - 2026-11-06

‎src/__tests__/drivers.test.ts‎

Lines changed: 46 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ describe('sqlLabel', () => {
4242
['/* hint */ select 1', 'SELECT'],
4343
[{ text: 'DELETE FROM t' }, 'DELETE t'],
4444
[{ sql: 'select a from b join c' }, 'SELECT b'],
45+
['SELECT "public"."Post"."id" FROM "public"."Post" WHERE 1', 'SELECT public.Post'],
46+
['select * from `shop`.`orders`', 'SELECT shop.orders'],
4547
['BEGIN', 'BEGIN'],
4648
[undefined, 'QUERY'],
4749
])('%p -> %p', (sql, label) => {
@@ -252,56 +254,66 @@ describe('instrumentAwsClient (real AWS SDK v3 client, local endpoint)', () => {
252254
});
253255
});
254256

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-
}
257+
describe('instrumentPrisma (client that uses the tracing hook the way Prisma 5-7 does)', () => {
258+
// Checked by hand against @prisma/client 6.19 and 7.0 (generated client, pg adapter)
259+
const g = globalThis as any;
260+
const helper = () => g.V7_PRISMA_INSTRUMENTATION?.helper ?? g.PRISMA_INSTRUMENTATION?.helper ??
261+
{ runInChildSpan: (_o: unknown, fn: () => unknown) => fn() };
262+
const fakePrisma = {
263+
_op(model: string | undefined, method: string, fail = false) {
264+
return helper().runInChildSpan({ name: 'operation', attributes: { method, model } }, () =>
265+
helper().runInChildSpan({ name: 'serialize' }, () =>
266+
new Promise((resolve, reject) => setTimeout(() => (fail ? reject(new Error('P2002')) : resolve([])), 2))));
267+
},
268+
user: { findMany: (fail?: boolean) => fakePrisma._op('User', 'findMany', fail) },
269+
$queryRaw: () => fakePrisma._op(undefined, 'queryRaw'),
270+
};
266271

267-
test('model calls, raw queries, extended clients and failures', async () => {
272+
test('model calls, raw queries and failures; uninstall restores the hook', async () => {
268273
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();
274+
const uninstall = instrumentPrisma(tp);
275+
expect(instrumentPrisma(tp)).toBeInstanceOf(Function); // idempotent
272276
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);
277+
await fakePrisma.user.findMany();
278+
await fakePrisma.$queryRaw();
279+
await fakePrisma.user.findMany(true).catch(() => undefined);
276280
}, { label: 'GET /users' });
277-
expect(labels(roots[0])).toEqual(['prisma.User.findMany', 'prisma.raw.$queryRawUnsafe SELECT User', 'prisma.User.findMany']);
281+
expect(labels(roots[0])).toEqual(['prisma.User.findMany', 'prisma.raw.queryRaw', 'prisma.User.findMany']);
278282
expect(roots[0].children[2].error).toBeInstanceOf(Error);
279283
uninstall();
284+
expect('PRISMA_INSTRUMENTATION' in g).toBe(false);
280285
roots.length = 0;
281-
await tp.track(() => prisma.user.findMany(), { label: 'after' });
286+
await tp.track(() => fakePrisma.user.findMany(), { label: 'after' });
282287
expect(labels(roots[0])).toEqual([]);
283288
});
284289

285-
test('prismaExtension stands aside while the client is patched (no double counting)', async () => {
290+
test('OpenTelemetry instrumentation that is already there keeps getting its spans', async () => {
291+
const seen: string[] = [];
292+
const otel = { helper: {
293+
isEnabled: () => true, getTraceParent: () => 'tp', dispatchEngineSpans: () => undefined, getActiveContext: () => undefined,
294+
runInChildSpan: (o: any, fn: () => unknown) => { seen.push(o.name); return fn(); },
295+
} };
296+
g.V7_PRISMA_INSTRUMENTATION = otel;
286297
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' });
298+
const uninstall = instrumentPrisma(tp);
299+
expect(helper().isEnabled()).toBe(true);
300+
await tp.track(() => fakePrisma.user.findMany(), { label: 'r' });
301+
expect(labels(roots[0])).toEqual(['prisma.User.findMany']);
302+
expect(seen).toEqual(['operation', 'serialize']);
291303
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']]]);
304+
expect(g.V7_PRISMA_INSTRUMENTATION).toBe(otel);
305+
delete g.V7_PRISMA_INSTRUMENTATION;
294306
});
295307

296-
test('a client instance works too; unknown objects are ignored', async () => {
308+
test('prismaExtension stands aside while the hook is installed (no double counting)', async () => {
297309
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']);
310+
const ext = prismaExtension(tp).query.$allModels.$allOperations;
311+
const call = () => ext({ model: 'User', operation: 'findMany', args: {}, query: async () => [] });
312+
const uninstall = instrumentPrisma(tp);
313+
await tp.track(() => call(), { label: 'hooked' });
302314
uninstall();
303-
expect(() => instrumentPrisma(tp, {})()).not.toThrow();
304-
expect(() => instrumentPrisma(tp, undefined)()).not.toThrow();
315+
await tp.track(() => call(), { label: 'extension only' });
316+
expect(roots.map(r => [r.label, labels(r)])).toEqual([['hooked', []], ['extension only', ['prisma.User.findMany']]]);
305317
});
306318
});
307319

‎src/__tests__/explain-auto-profile.test.ts‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ describe('explain()', () => {
7272
expect(formatFindings([blocking])).toMatch(/\n {3}fix: hashPassword/);
7373
expect(explain([hash]).find(f => f.kind === 'blocking')!.fix).toMatch(/worker_threads/);
7474
expect(explain([node('boom', 0, 10, { error: new Error('x') })])[0].fix).toBeUndefined();
75+
const lib = { login: [{ functionName: 'submit → Writable.uncork', location: 'node_modules/pg/lib/query.js:177', ms: 16 }] };
76+
expect(explain([hash], { hotspots: lib }).find(f => f.kind === 'blocking')!.fix).toMatch(/^the time is inside pg \(library code\)/);
77+
});
78+
79+
test("a library's own sequential or repeated calls are not reported", () => {
80+
const include = node('GET /feed', 0, 40, { children: [
81+
node('prisma.Post.findMany', 0, 40, { children: [node('pg SELECT public.Post', 0, 20), node('pg SELECT public.User', 20, 20)] }),
82+
] });
83+
expect(explain([include]).filter(f => f.kind === 'sequential' || f.kind === 'repeated')).toEqual([]);
7584
});
7685

7786
test('blocking is attributed to the deepest call that did it', () => {
@@ -88,7 +97,7 @@ describe('explain()', () => {
8897
const page = node('page', 0, 60, { children: seq, selfDuration: 0 });
8998
const queries = Array.from({ length: 6 }, (_, i) => node('pg SELECT items', i * 2, 2));
9099
const list = node('list', 0, 20, { children: queries, selfDuration: 8 });
91-
const slow = node('report', 0, 100, { children: [node('render', 0, 90)], selfDuration: 10 });
100+
const slow = node('report', 0, 100, { children: [node('render', 0, 90), node('cache.get', 90, 5)], selfDuration: 5 });
92101
const failed = node('save', 0, 3, { children: [node('pg INSERT', 0, 2, { error: new Error('duplicate key') })], error: new Error('duplicate key') });
93102
const findings = explain([page, list, slow, failed]);
94103
const by = (kind: string) => findings.filter(f => f.kind === kind);
@@ -110,7 +119,24 @@ describe('explain()', () => {
110119
}));
111120
const seq = explain(roots).filter(f => f.kind === 'sequential');
112121
expect(seq).toHaveLength(1);
113-
expect(seq[0].message).toMatch(/seen 5 times, worst shown/);
122+
expect(seq[0].message).toMatch(/seen 5 times, typical shown\)$/);
123+
});
124+
125+
test('grouped findings show the typical case: one cold start does not make every request look slow', () => {
126+
// A request that makes one call is always mostly that call: not worth saying unless it is slow
127+
expect(explain([node('GET /a', 0, 21, { selfDuration: 1, children: [node('db', 0, 20)] })])).toEqual([]);
128+
expect(explain([node('GET /a', 0, 151, { selfDuration: 1, children: [node('db', 0, 150)] })])[0].kind).toBe('dominant');
129+
// 9 requests where the query is 4 ms of 5 ms, one where the first connection made it 110 ms
130+
const roots = [110, ...Array(9).fill(4)].map(ms => node('GET /users', 0, ms + 1, { selfDuration: 0.5, children: [node('db', 0, ms), node('auth', ms, 0.5)] }));
131+
const [once] = explain(roots).filter(f => f.kind === 'dominant');
132+
expect(once.message).toMatch(/\(in 1 of 10 GET \/users\)$/);
133+
// ... and once in 40 requests is noise
134+
const many = [110, ...Array(39).fill(4)].map(ms => node('GET /users', 0, ms + 1, { selfDuration: 0.5, children: [node('db', 0, ms), node('auth', ms, 0.5)] }));
135+
expect(explain(many).filter(f => f.kind === 'dominant')).toEqual([]);
136+
const slowRoots = [110, 40, 42, 41, 39].map(ms => node('GET /users', 0, ms + 1, { selfDuration: 0.5, children: [node('db', 0, ms), node('auth', ms, 0.5)] }));
137+
const [f] = explain(slowRoots).filter(x => x.kind === 'dominant');
138+
expect(f.ms).toBe(41);
139+
expect(f.message).toMatch(/\(41 ms of 42 ms\) \(seen 5 times, typical shown; worst 110 ms\)$/);
114140
});
115141

116142
test('nothing to say', () => {

‎src/__tests__/run.test.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,11 @@ describe('queueFindings', () => {
250250
];
251251
const findings = queueFindings(load, trees, 'GET');
252252
expect(findings).toHaveLength(1);
253-
expect(findings[0].message).toContain('GET /ping took 0.2 ms in the app (median) but clients waited 190 ms');
253+
expect(findings[0].message).toContain('GET /ping took 0.2 ms in the app (median) but clients waited 190 ms'); expect(findings[0].message).toMatch(/see blocking code above/);
254+
expect(findings[0].fix).toBeUndefined();
255+
// Nothing blocked: the app is simply at its throughput limit
256+
const [busy] = queueFindings({ ...load, requests: 800, durationMs: 1000 }, trees, 'GET', false);
257+
expect(busy.message).toMatch(/waited their turn .* \(800 requests\/s here\)$/);
258+
expect(busy.fix).toMatch(/throughput limit/);
254259
});
255260
});

‎src/auto.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export function autoInstrument(tp: OptimizedPerformanceMonitor, options: AutoIns
9595
if (deps.names.has('pg')) add('pg', () => instrumentPg(tp, load('pg')));
9696
if (deps.names.has('mysql2')) add('mysql2', () => instrumentMysql2(tp, load('mysql2')));
9797
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')));
98+
if (deps.names.has('@prisma/client')) add('prisma', () => instrumentPrisma(tp));
9999
if (deps.names.has('ioredis')) add('ioredis', () => instrumentRedis(tp, load('ioredis')));
100100
if (deps.names.has('mongodb')) add('mongodb', () => instrumentMongo(tp, load('mongodb')));
101101
else if (deps.names.has('mongoose')) {

0 commit comments

Comments
 (0)