Skip to content

Commit 2224c4f

Browse files
feat(run): traceperf.config.json and a login step
Options can live in traceperf.config.json (or --config file); the command line wins. A login step runs before the traffic and sends the token from its JSON response (or its cookies) with every request, so a stale token can't turn a run into a benchmark of 401 pages.
1 parent a1a0a95 commit 2224c4f

5 files changed

Lines changed: 246 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- `traceperf/auto` wrote each request tree with its own `appendFileSync`, and under load that showed up as the app's "CPU cost" (`writeFileUtf8`). Writes are batched every 20 ms.
1414

1515
### Added
16+
- `traceperf run` reads `traceperf.config.json` (or `--config file`); command-line options win. A `login` step logs in before the traffic and sends the returned token or cookies with every request; `${NAME}` in the file is read from the environment.
1617
- `npm run eval` scores `traceperf run` on 20 apps in `examples/slow-apps`: 12 with a planted problem, 8 clean ones where the right answer is "no issues". CI fails on any missed problem or false alarm. Before these fixes: 17/17 found, 15 false alarms; after: 17/17, 0.
1718

1819
### Added

‎README.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,25 @@ Checked on real apps, not only on the planted ones: Prisma's REST example, the R
105105
- `--warmup 3`: untimed requests per path first, so cold-start work doesn't look like a problem.
106106
- `--no-profile`: don't sample the CPU.
107107
- Any start command works: `npx traceperf run -- npm start`.
108+
- `--config file.json`: read the options from a file (`traceperf.config.json` is read automatically when present). Options given on the command line win.
109+
110+
**Config file and login.** Put the routes in `traceperf.config.json` so the command is just `npx traceperf run`. A `login` step runs once before the traffic, and its token (or cookie) is sent with every request, so an expired token can't silently turn the run into a benchmark of 401 pages. `${NAME}` is read from the environment:
111+
112+
<!-- docs:skip -->
113+
```json
114+
{
115+
"app": "dist/server.js",
116+
"paths": ["/api/articles", "/api/user"],
117+
"login": {
118+
"path": "/api/users/login",
119+
"body": { "user": { "email": "${TEST_EMAIL}", "password": "${TEST_PASSWORD}" } },
120+
"token": "user.token",
121+
"prefix": "Token "
122+
}
123+
}
124+
```
125+
126+
`token` is the dotted path of the token in the JSON response, sent as `authorization: Bearer <token>` unless `header` / `prefix` say otherwise. Leave `token` out to use the cookies the login sets. `method` defaults to POST.
108127

109128
### In CI: fail a PR that adds queries
110129

@@ -130,7 +149,7 @@ It fails when a route makes more calls of any kind per request (database queries
130149
- run: npx traceperf run dist/server.js --path /orders --path /users/1 --baseline perf-baseline.json
131150
```
132151
133-
`examples/slow-apps/` has small apps with one planted problem each (blocking in your code, blocking in built-ins, sequential awaits, N+1, a slow dependency, errors); CI checks that `run` names each one.
152+
`examples/slow-apps/` has 20 small apps: 12 with one planted problem each (blocking in your code and in built-ins, sequential awaits, N+1 over SQL and over HTTP, a slow or flaky dependency, failing requests…) and 8 clean ones where the right answer is "no issues". `npm run eval` scores `run` on them, and CI fails on a missed problem or a false alarm.
134153

135154
The same naming works without `run`: `TRACEPERF_PROFILE_BLOCKING=1 node --require traceperf/auto server.js` adds the function names to the exit summary (sampling costs a few percent CPU, so use it while investigating). In code: `profileSlowCalls({ select: root => blockingRanges(root), mergeInlined: true, onProfile })`, then `explain(roots, { hotspots })`.
136155

‎src/__tests__/run.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,3 +363,52 @@ describe('queueFindings', () => {
363363
expect(busy.fix).toMatch(/throughput limit/);
364364
});
365365
});
366+
367+
describe('config file and login', () => {
368+
const read = (text: string) => (file?: string) => (file === 'missing.json' ? undefined : text);
369+
370+
it('reads options from the file; the command line wins and its --path replaces the file paths', () => {
371+
const config = JSON.stringify({ app: 'server.js', paths: ['/a', '/b'], concurrency: 4, duration: '2s', headers: { 'X-Key': '${KEY}' }, probe: 0 });
372+
const fromFile = parseRunArgs([], read(config), { KEY: 'secret' }) as RunArgs;
373+
expect(fromFile).toMatchObject({ command: ['server.js'], paths: ['/a', '/b'], concurrency: 4, durationMs: 2000, headers: { 'x-key': 'secret' }, probe: 0 });
374+
const overridden = parseRunArgs(['-c', '8', '--path', '/c'], read(config), {}) as RunArgs;
375+
expect(overridden).toMatchObject({ command: ['server.js'], paths: ['/c'], concurrency: 8 });
376+
expect((parseRunArgs(['other.js'], read(config), {}) as RunArgs).command).toEqual(['other.js']);
377+
expect(parseRunArgs([], read('{'), {})).toMatch(/not valid JSON/);
378+
expect(parseRunArgs(['--config', 'missing.json', 'x.js'], read(''), {})).toMatch(/could not read missing.json/);
379+
expect(parseRunArgs([], read('{}'), {})).toMatch(/"app" in the config file/);
380+
});
381+
382+
it('logs in first and sends the token from the JSON response with every request', async () => {
383+
const seen: Array<string | undefined> = [];
384+
const server = http.createServer((req, res) => {
385+
let body = '';
386+
req.on('data', c => { body += c; });
387+
req.on('end', () => {
388+
if (req.url === '/login') {
389+
const { password } = JSON.parse(body);
390+
if (password !== 'pw') { res.statusCode = 401; return res.end('bad password'); }
391+
return res.end(JSON.stringify({ user: { token: 'abc' } }));
392+
}
393+
if (req.url === '/cookie-login') { res.setHeader('set-cookie', ['sid=42; HttpOnly', 'theme=dark']); return res.end('{}'); }
394+
seen.push(req.headers.authorization || req.headers.cookie);
395+
res.end('ok');
396+
});
397+
});
398+
await new Promise<void>(r => server.listen(0, '127.0.0.1', () => r()));
399+
const port = (server.address() as AddressInfo).port;
400+
try {
401+
const args = parseRunArgs(['x.js']) as RunArgs;
402+
const { login } = await import('../run');
403+
expect(await login(http, args, port, { path: '/login', body: { password: 'pw' }, token: 'user.token' })).toEqual({ authorization: 'Bearer abc' });
404+
expect(await login(http, args, port, { path: '/login', body: { password: 'pw' }, token: 'user.token', header: 'X-Token', prefix: '' })).toEqual({ 'x-token': 'abc' });
405+
expect(await login(http, args, port, { path: '/cookie-login' })).toEqual({ cookie: 'sid=42; theme=dark' });
406+
expect(await login(http, args, port, { path: '/login', body: { password: 'no' }, token: 'user.token' })).toMatch(/answered 401: bad password/);
407+
expect(await login(http, args, port, { path: '/login', body: { password: 'pw' }, token: 'missing.path' })).toMatch(/no "missing.path"/);
408+
await sendLoad(http, { ...args, paths: ['/x'], requests: 2, concurrency: 1, headers: { authorization: 'Bearer abc' } }, port);
409+
expect(seen).toEqual(['Bearer abc', 'Bearer abc']);
410+
} finally {
411+
server.close();
412+
}
413+
});
414+
});

‎src/cli.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ import { renderHtmlReport } from './report-html';
1515
import { createWatcher, WatchOptions } from './watch';
1616
import { parseRunArgs, runApp, RUN_USAGE } from './run';
1717

18+
/** --config FILE, or traceperf.config.json in the working directory when there is one */
19+
function readRunConfig(file?: string): string | undefined {
20+
/* eslint-disable @typescript-eslint/no-require-imports */
21+
const fs = require('fs');
22+
const target = require('path').resolve(file || 'traceperf.config.json');
23+
/* eslint-enable @typescript-eslint/no-require-imports */
24+
if (!file && !fs.existsSync(target)) return undefined;
25+
return fs.readFileSync(target, 'utf8');
26+
}
27+
1828
export interface ReportOptions {
1929
top?: number;
2030
/** Return a self-contained HTML report instead of text */
@@ -381,7 +391,7 @@ export function main(argv: string[], io: {
381391
return runWatchOnce(rest, io);
382392
}
383393
if (command === 'run') {
384-
const args = parseRunArgs(rest);
394+
const args = parseRunArgs(rest, readRunConfig, process.env);
385395
io.err(typeof args === 'string' ? `traceperf: ${args}\n\n${RUN_USAGE}` : 'traceperf: run starts an app; use the traceperf command');
386396
return 1;
387397
}
@@ -559,7 +569,7 @@ if (typeof require !== 'undefined' && typeof module !== 'undefined' && require.m
559569
const fs = require('fs');
560570
const argv = process.argv.slice(2);
561571
const watchArgs = argv[0] === 'watch' ? parseWatchArgs(argv.slice(1)) : undefined;
562-
const runArgs = argv[0] === 'run' ? parseRunArgs(argv.slice(1)) : undefined;
572+
const runArgs = argv[0] === 'run' ? parseRunArgs(argv.slice(1), readRunConfig, process.env) : undefined;
563573
if (watchArgs && typeof watchArgs !== 'string' && !watchArgs.once) {
564574
watchLive(watchArgs, fs);
565575
} else if (runArgs && typeof runArgs !== 'string') {

‎src/run.ts‎

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ export interface RunArgs {
4545
profile: boolean;
4646
/** Ms to wait for the app to listen before giving up */
4747
startTimeoutMs: number;
48+
/** From the config file: log in first and send the token with every request */
49+
login?: RunLogin;
4850
}
4951

5052
export const RUN_USAGE = ` traceperf run <app.js> [app args] [options]
@@ -67,6 +69,9 @@ export const RUN_USAGE = ` traceperf run <app.js> [app args] [options]
6769
--baseline f.json compare with it; exit 1 if a route makes more calls
6870
(e.g. an N+1) or a new problem appears. Counts, not
6971
timings: deterministic, safe for CI
72+
--config f.json read these options from a file (default: traceperf.config.json
73+
if present; command-line options win). It can also log in
74+
first: "login": {"path": "/api/login", "body": {...}, "token": "user.token"}
7075
--no-profile don't sample the CPU (blocking code is then named
7176
only by its tracked call)`;
7277

@@ -78,8 +83,113 @@ function parseDuration(value: string | undefined): number | undefined {
7883
}
7984

8085
/** Parse `run` arguments; returns an error message on bad input */
81-
export function parseRunArgs(argv: string[]): RunArgs | string {
82-
const args: RunArgs = {
86+
/** Log in before the traffic and send the token (or cookie) it returns with every request */
87+
export interface RunLogin {
88+
path: string;
89+
/** Default POST */
90+
method?: string;
91+
/** JSON request body; "${NAME}" in strings is replaced by the environment variable */
92+
body?: unknown;
93+
headers?: Record<string, string>;
94+
/** Dotted path of the token in the JSON response, e.g. "user.token"; without it the Set-Cookie header is used */
95+
token?: string;
96+
/** Header to send it in (default authorization) */
97+
header?: string;
98+
/** Text before the token (default "Bearer ") */
99+
prefix?: string;
100+
}
101+
102+
/** traceperf.config.json: the same settings as the command-line options */
103+
export interface RunConfig {
104+
app?: string | string[];
105+
paths?: string[];
106+
concurrency?: number;
107+
duration?: string | number;
108+
requests?: number;
109+
method?: string;
110+
headers?: Record<string, string>;
111+
warmup?: number;
112+
probe?: number;
113+
port?: number;
114+
baseline?: string;
115+
saveBaseline?: string;
116+
html?: string;
117+
login?: RunLogin;
118+
}
119+
120+
/** Replace "${NAME}" with process environment values, in every string of a JSON value */
121+
export function interpolateEnv<T>(value: T, env: Record<string, string | undefined>): T {
122+
if (typeof value === 'string') return value.replace(/\$\{(\w+)\}/g, (_, name) => env[name] ?? '') as unknown as T;
123+
if (Array.isArray(value)) return value.map(v => interpolateEnv(v, env)) as unknown as T;
124+
if (value && typeof value === 'object') {
125+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, interpolateEnv(v, env)])) as T;
126+
}
127+
return value;
128+
}
129+
130+
function configArgv(c: RunConfig): string[] {
131+
const out: string[] = [];
132+
const opt = (flag: string, v: unknown) => { if (v !== undefined) out.push(flag, String(v)); };
133+
for (const p of c.paths || []) out.push('--path', p);
134+
opt('-c', c.concurrency);
135+
opt('-d', typeof c.duration === 'number' ? `${c.duration}ms` : c.duration);
136+
opt('-n', c.requests);
137+
opt('--method', c.method);
138+
for (const [k, v] of Object.entries(c.headers || {})) out.push('-H', `${k}: ${v}`);
139+
opt('--warmup', c.warmup);
140+
opt('--probe', c.probe);
141+
opt('--port', c.port);
142+
opt('--baseline', c.baseline);
143+
opt('--save-baseline', c.saveBaseline);
144+
opt('--html', c.html);
145+
return out;
146+
}
147+
148+
/**
149+
* Read run options; `readConfig(file?)` returns the text of --config FILE, or
150+
* of traceperf.config.json when there is one (undefined: none). Command-line
151+
* options win over the file; --path on the command line replaces its paths.
152+
*/
153+
export function parseRunArgs(argv: string[], readConfig?: (file?: string) => string | undefined, env: Record<string, string | undefined> = {}): RunArgs | string {
154+
const at = argv.indexOf('--config');
155+
const dashdash = argv.indexOf('--');
156+
let configFile: string | undefined;
157+
if (at >= 0 && (dashdash < 0 || at < dashdash)) {
158+
configFile = argv[at + 1];
159+
if (!configFile) return '--config expects a file';
160+
argv = [...argv.slice(0, at), ...argv.slice(at + 2)];
161+
}
162+
let config: RunConfig | undefined;
163+
if (readConfig) {
164+
let text: string | undefined;
165+
try {
166+
text = readConfig(configFile);
167+
} catch (e) {
168+
return `could not read ${configFile}: ${e instanceof Error ? e.message : String(e)}`;
169+
}
170+
if (configFile && text === undefined) return `could not read ${configFile}`;
171+
if (text !== undefined) {
172+
try {
173+
config = interpolateEnv(JSON.parse(text) as RunConfig, env);
174+
} catch (e) {
175+
return `${configFile || 'traceperf.config.json'} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`;
176+
}
177+
}
178+
}
179+
if (!config) return parseArgv(argv);
180+
const base = parseArgv([...configArgv(config), '--', ...(typeof config.app === 'string' ? [config.app] : config.app || [])], true);
181+
if (typeof base === 'string') return `in the config file: ${base}`;
182+
base.login = config.login;
183+
const cli = parseArgv(argv, true, { ...base, paths: [], command: [] });
184+
if (typeof cli === 'string') return cli;
185+
if (!cli.paths.length) cli.paths = base.paths;
186+
if (!cli.command.length) cli.command = base.command;
187+
if (!cli.command.length) return 'run expects an app, e.g. traceperf run server.js (or "app" in the config file)';
188+
return cli;
189+
}
190+
191+
function parseArgv(argv: string[], partial = false, start?: RunArgs): RunArgs | string {
192+
const args: RunArgs = start ? { ...start } : {
83193
command: [], paths: [], concurrency: 10, durationMs: 5000, host: '127.0.0.1',
84194
method: 'GET', warmup: 3, probe: 10, json: false, profile: true, max: 10, startTimeoutMs: 15000,
85195
};
@@ -158,10 +268,50 @@ export function parseRunArgs(argv: string[]): RunArgs | string {
158268
args.command.push(a);
159269
}
160270
}
161-
if (!args.command.length) return 'run expects an app, e.g. traceperf run server.js';
271+
if (!args.command.length && !partial) return 'run expects an app, e.g. traceperf run server.js';
162272
return args;
163273
}
164274

275+
/** Read a dotted path ("user.token", "data.0.jwt") from a JSON value */
276+
function dig(value: unknown, dotted: string): unknown {
277+
return dotted.split('.').reduce<unknown>((v, key) => (v !== null && typeof v === 'object' ? (v as Record<string, unknown>)[key] : undefined), value);
278+
}
279+
280+
/** Run the login request; returns the headers to send from now on, or an error message */
281+
export function login(http: any, args: RunArgs, port: number, spec: RunLogin): Promise<Record<string, string> | string> {
282+
const body = spec.body === undefined ? undefined : JSON.stringify(spec.body);
283+
return new Promise(resolve => {
284+
const req = http.request({
285+
host: args.host, port, path: spec.path, method: (spec.method || 'POST').toUpperCase(),
286+
headers: { ...(body ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } : {}), ...(spec.headers || {}) },
287+
}, (res: any) => {
288+
let text = '';
289+
res.setEncoding('utf8');
290+
res.on('data', (chunk: string) => { text += chunk; });
291+
res.on('end', () => {
292+
const where = `login ${spec.method || 'POST'} ${spec.path}`;
293+
if (res.statusCode >= 400) return resolve(`${where} answered ${res.statusCode}: ${text.slice(0, 200)}`);
294+
if (!spec.token) {
295+
const cookies: string[] = [].concat(res.headers['set-cookie'] || []);
296+
if (!cookies.length) return resolve(`${where} set no cookie; give "token": the path of the token in its JSON response`);
297+
return resolve({ cookie: cookies.map(c => c.split(';')[0]).join('; ') });
298+
}
299+
let token: unknown;
300+
try {
301+
token = dig(JSON.parse(text), spec.token);
302+
} catch {
303+
return resolve(`${where} did not answer JSON: ${text.slice(0, 200)}`);
304+
}
305+
if (typeof token !== 'string' && typeof token !== 'number') return resolve(`${where}: no "${spec.token}" in the response: ${text.slice(0, 200)}`);
306+
resolve({ [(spec.header || 'authorization').toLowerCase()]: `${spec.prefix ?? 'Bearer '}${token}` });
307+
});
308+
});
309+
req.on('error', (e: Error) => resolve(`login failed: ${e.message}`));
310+
if (body) req.write(body);
311+
req.end();
312+
});
313+
}
314+
165315
export interface LoadResult {
166316
requests: number;
167317
errors: number;
@@ -576,6 +726,17 @@ export async function runApp(args: RunArgs, deps: RunDeps): Promise<number> {
576726
return 1;
577727
}
578728
deps.err(`traceperf: sending ${args.requests ? `${args.requests} requests` : `${args.durationMs / 1000} s of traffic`} to ${args.paths.join(', ')} on port ${port}, ${args.concurrency} at a time`);
729+
if (args.login) {
730+
const got = await login(deps.http, args, port, args.login);
731+
if (typeof got === 'string') {
732+
deps.err(`traceperf: ${got}`);
733+
await stopAndWait();
734+
return 1;
735+
}
736+
// Later headers from -H win over the login's
737+
args = { ...args, headers: { ...got, ...(args.headers || {}) } };
738+
deps.err(`traceperf: logged in (${Object.keys(got).join(', ')} set)`);
739+
}
579740
if (args.warmup > 0) {
580741
await sendLoad(deps.http, { ...args, concurrency: 1, requests: args.warmup * args.paths.length }, port);
581742
skipBytes = await sizeNow();

0 commit comments

Comments
 (0)