Skip to content

Commit 1a95413

Browse files
Merge pull request #17 from thelastbackspace/feat/cpu-advice
Library-specific CPU advice; README for run's new features
2 parents 12f4c90 + fdb0277 commit 1a95413

5 files changed

Lines changed: 68 additions & 11 deletions

File tree

‎CHANGELOG.md‎

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

1010
### Added
11+
- **What to do about the CPU, per library:** the CPU-cost finding now matches its hottest functions against known library costs and says what helps: Mongoose (`.lean()`), TypeORM (fewer relations, `getRawMany()`), Sequelize (`raw: true`), Prisma, Express's per-response ETag (`app.set('etag', false)`), class-transformer / class-validator, large JSON responses, password hashing, logging, garbage collection. Found on the NestJS app: 26% of its CPU was Express's `res.send`, 40% TypeORM query building.
1112
- **`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`.
1213
- **`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.
1314
- **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".

‎README.md‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,22 @@ The database was fast; `buildReport` was blocking it. That only shows up when re
8585

8686
- **The blocking function is named even though nothing tracks it:** `run` samples the CPU (the V8 profiler) during the synchronous part of each blocking call. Native work is named by the app function that called it (`hashPassword → pbkdf2Sync`), code V8 inlined is credited to the inlined function, and garbage-collection pauses are reported as such. `--no-profile` turns this off.
8787
- **Queueing the app can't see:** a request that arrives while the event loop is blocked waits in the socket before its handler starts. `run` compares what its clients waited with the app's own timings and reports the difference.
88+
- **What each request costs:** `run` measures how busy the event loop was during the load and where the CPU went. When requests are expensive it says what that means and what helps, for the libraries it recognises (Mongoose, TypeORM, Sequelize, Prisma, Express, class-validator…):
89+
90+
```
91+
🔥 Each request keeps the event loop busy ~10.7 ms, so one process tops out near 94 requests/s … CPU time went to:
92+
getProperty (node_modules/mongoose/lib/helpers/get.js:63) 19%, cloneObject (…/clone.js:120) 17%, (garbage collection) 15%
93+
fix: Mongoose is building full documents: add .lean() to read-only queries … and .select() only the fields you send
94+
```
95+
- **Timings you can trust:** after the warm-up, `run` sends a few requests one at a time (`--probe`) and takes the per-request findings (a call taking most of a request, sequential awaits, N+1) from those. Under concurrent load every `await` also waits for the busy event loop, which makes fast queries look slow.
96+
- **Wrong setup is said first:** "2117 of 2117 responses failed (most: 401 Unauthorized)…" instead of timings of error pages.
97+
98+
Checked on real apps, not only on the planted ones: Prisma's REST example, the RealWorld API (Express + Prisma), an Express + Mongoose boilerplate and a NestJS + TypeORM app.
8899

89100
- `--path /x` (repeatable): what to request. Without `--path`, use the app yourself and press Ctrl-C for the report.
90101
- `-c 10` concurrency, `-d 5s` duration or `-n 500` requests, `--method POST`, `--port 3000` (detected by default).
102+
- `-H "Authorization: Bearer $TOKEN"` (repeatable): request headers, for APIs behind a login.
103+
- `--probe 10`: requests per path sent one at a time before the load (0 for none).
91104
- `--html report.html`, `--output calls.jsonl` (keep the trees), `--json`.
92105
- `--warmup 3`: untimed requests per path first, so cold-start work doesn't look like a problem.
93106
- `--no-profile`: don't sample the CPU.
@@ -99,7 +112,7 @@ The same naming works without `run`: `TRACEPERF_PROFILE_BLOCKING=1 node --requir
99112

100113
## No code changes: `traceperf/auto`
101114

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

104117
<!-- docs:skip -->
105118
```bash
@@ -203,10 +216,11 @@ Ready-made helpers for popular clients. Pass the module you already use; tracepe
203216

204217
<!-- docs:skip -->
205218
```javascript
206-
const { instrumentPg, instrumentMysql2, instrumentMongo, instrumentRedis, instrumentAwsClient, instrumentPrisma, instrumentBetterSqlite3, prismaExtension } = require('traceperf');
219+
const { instrumentPg, instrumentMysql2, instrumentMysql, instrumentMongo, instrumentRedis, instrumentAwsClient, instrumentPrisma, instrumentBetterSqlite3, prismaExtension } = require('traceperf');
207220

208221
instrumentPg(tp, require('pg')); // "pg SELECT users" (Pool.query included)
209222
instrumentMysql2(tp, require('mysql2')); // "mysql UPDATE orders" (callback and promise APIs)
223+
instrumentMysql(tp, require('mysql')); // the classic driver: connections and pools
210224
instrumentMongo(tp, require('mongodb')); // "mongo find users", "mongo insertOne users"
211225
instrumentMongo(tp, require('mongoose').mongo); // Mongoose uses its own copy of the driver
212226
instrumentRedis(tp, require('ioredis')); // "redis GET"

‎src/__tests__/prometheus.test.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,18 @@ function parse(text: string): Record<string, number> {
1616
}
1717

1818
test('histogram buckets, sum, count and errors per label', () => {
19-
const prom = prometheusExporter({ buckets: [0.005, 0.05] });
19+
const prom = prometheusExporter({ buckets: [0.02, 0.2] });
2020
const tp = createTracePerf({ silent: true, trackMemory: false, plugins: [prom] });
2121
const query = tp.createTrackable((ms: number) => busyWait(ms), { label: 'db.query' });
22-
tp.track(() => { query(0); query(20); }, { label: 'GET /x' });
22+
// A call doing nothing must land in the first bucket even on a busy machine (wide margin)
23+
tp.track(() => { query(0); query(40); }, { label: 'GET /x' });
2324
expect(() => tp.track(() => { throw new Error('x'); }, { label: 'job "a"\nb' })).toThrow();
2425
const m = parse(prom.metrics());
25-
expect(m['traceperf_call_duration_seconds_bucket{name="db.query",le="0.005"}']).toBe(1);
26-
expect(m['traceperf_call_duration_seconds_bucket{name="db.query",le="0.05"}']).toBe(2);
26+
expect(m['traceperf_call_duration_seconds_bucket{name="db.query",le="0.02"}']).toBe(1);
27+
expect(m['traceperf_call_duration_seconds_bucket{name="db.query",le="0.2"}']).toBe(2);
2728
expect(m['traceperf_call_duration_seconds_bucket{name="db.query",le="+Inf"}']).toBe(2);
2829
expect(m['traceperf_call_duration_seconds_count{name="db.query"}']).toBe(2);
29-
expect(m['traceperf_call_duration_seconds_sum{name="db.query"}']).toBeGreaterThanOrEqual(0.019);
30+
expect(m['traceperf_call_duration_seconds_sum{name="db.query"}']).toBeGreaterThanOrEqual(0.039);
3031
expect(m['traceperf_call_duration_seconds_count{name="GET /x"}']).toBe(1);
3132
expect(m['traceperf_call_errors_total{name="job \\"a\\"\\nb"}']).toBe(1);
3233
expect(prom.metrics()).toMatch(/# TYPE traceperf_call_duration_seconds histogram/);

‎src/__tests__/run.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,24 @@ describe('statusFinding', () => {
274274
});
275275
});
276276

277+
describe('cpuAdvice', () => {
278+
const { cpuAdvice } = jest.requireActual('../run');
279+
const spot = (functionName: string, location: string, ms: number) => ({ functionName, location, ms });
280+
it('names what to do for the libraries using the CPU', () => {
281+
const nest = [
282+
spot('send → end', 'node_modules/express/lib/response.js:170', 26),
283+
spot('DriverUtils.buildAlias', 'node_modules/typeorm/driver/DriverUtils.js:100', 17),
284+
spot('escapeRegExp', 'node_modules/typeorm/query-builder/QueryBuilder.js:445', 10),
285+
spot('render', 'src/view.js:3', 47),
286+
];
287+
const advice = cpuAdvice(nest, 100);
288+
expect(advice).toMatch(/^Express computes an ETag .*; TypeORM spends CPU .*; beyond that, run more processes/);
289+
expect(advice.match(/TypeORM/g)).toHaveLength(1);
290+
// Under 10% of the time: not worth a library-specific hint
291+
expect(cpuAdvice([spot('x', 'node_modules/mongoose/lib/document.js:1', 5), spot('y', 'src/a.js:1', 95)], 100)).toMatch(/^cut the CPU each request needs/);
292+
});
293+
});
294+
277295
describe('busyFinding', () => {
278296
const { busyFinding } = jest.requireActual('../run');
279297
const load = { requests: 400, errors: 0, statuses: {}, durationMs: 4000, latency: {} };

‎src/run.ts‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,32 @@ export function loadCost(samples: LoadSample[], load: LoadResult, window: { star
267267
return { busy: active / (active + idle), perRequest: rate > 0 ? (activeRate * 1000) / rate : 0, rate };
268268
}
269269

270+
/** Known CPU costs of popular libraries, matched against the hottest functions, and what to do */
271+
const CPU_ADVICE: Array<{ test: RegExp; advice: string }> = [
272+
{ test: /mongoose[\\/]lib[\\/](document|helpers|schema|types)/, advice: 'Mongoose is building full documents: add .lean() to read-only queries (plain objects, often several times less CPU) and .select() only the fields you send' },
273+
{ test: /typeorm[\\/](query-builder|driver[\\/]DriverUtils|metadata|entity-manager|persistence)/, advice: 'TypeORM spends CPU building queries and mapping rows to entities: on hot read paths load fewer relations and columns, or use getRawMany() / a raw query' },
274+
{ test: /sequelize[\\/]lib[\\/](model|instance|utils|dialects)/, advice: 'Sequelize is building model instances: use raw: true on hot read queries and limit attributes / includes' },
275+
{ test: /@prisma[\\/]client[\\/]runtime/, advice: 'Prisma\'s client is serialising queries and results: select only the fields you send and avoid deep include trees on hot paths' },
276+
{ test: /express[\\/]lib[\\/]response\.js|[\\/]etag[\\/]/, advice: "Express computes an ETag (a hash of the body) for every response: app.set('etag', false) if clients don't send conditional requests, and send smaller bodies" },
277+
{ test: /class-transformer|class-validator/, advice: 'class-transformer / class-validator run on every request (NestJS pipes and serialisers): skip them on hot read routes or validate less deeply' },
278+
{ test: /JSON\.stringify|fast-json-stringify|safe-stable-stringify/, advice: 'serialising large responses: paginate, send fewer fields, or use a schema-based serialiser (fast-json-stringify)' },
279+
{ test: /bcrypt|argon2|scrypt|pbkdf2/i, advice: 'password hashing is CPU by design: keep it to login routes, use the async API, and do not hash per request' },
280+
{ test: /winston|pino|morgan|debug[\\/]src|console/, advice: 'logging costs CPU per request: log less on hot routes, avoid pretty formatting in production, prefer pino' },
281+
{ test: /\(garbage collection\)/, advice: 'much of it is garbage collection: allocate less per request (smaller objects and arrays, no needless copies)' },
282+
];
283+
284+
/** Advice for the hottest functions (those with at least 10% of the CPU time), then the general fix */
285+
export function cpuAdvice(spots: Array<{ functionName: string; location: string; ms: number }>, total: number): string {
286+
const hints: string[] = [];
287+
for (const h of spots) {
288+
if (h.ms < 0.1 * total || hints.length >= 2) continue;
289+
const rule = CPU_ADVICE.find(r => r.test.test(`${h.functionName} ${h.location}`));
290+
if (rule && !hints.includes(rule.advice)) hints.push(rule.advice);
291+
}
292+
const general = 'run more processes (cluster or more instances) so requests stop waiting for each other';
293+
return hints.length ? `${hints.join('; ')}; beyond that, ${general}` : `cut the CPU each request needs (start with the functions above), or ${general}`;
294+
}
295+
270296
/**
271297
* The app was saturated *and* each request is expensive (5 ms of CPU or
272298
* more, a ceiling near 200/s per process): a load test saturates any one
@@ -283,10 +309,7 @@ export function busyFinding(samples: LoadSample[], load: LoadResult, window: { s
283309
const shown = spots.filter(h => h.ms >= 0.04 * total).slice(0, 4)
284310
.map(h => `${h.functionName}${h.location ? ` (${h.location})` : ''} ${Math.round((h.ms / Math.max(total, 1e-9)) * 100)}%`);
285311
const where = shown.length ? `; CPU time went to: ${shown.join(', ')}` : '';
286-
const mongooseDocs = spots.slice(0, 5).some(h => /mongoose[\\/]lib[\\/](document|helpers|schema)/.test(h.location));
287-
const fix = mongooseDocs
288-
? 'Mongoose is building full documents: add .lean() to read-only queries (plain objects, often several times less CPU) and select only the fields you send; beyond that, run more processes (cluster or more instances)'
289-
: 'cut the CPU each request needs (start with the functions above), or run more processes (cluster or more instances) so requests stop waiting for each other';
312+
const fix = cpuAdvice(spots, total);
290313
return {
291314
kind: 'busy',
292315
root: 'process',

0 commit comments

Comments
 (0)