peekinside 1.2.5 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,428 @@
1
+ /**
2
+ * The value a dump shows: a typed tree of what the app passed to dump(), read without running the
3
+ * app's code.
4
+ *
5
+ * console.log turns a value into text; a dump keeps its shape, so the dashboard can show it as a
6
+ * tree that opens level by level, with the type of each node: a Map stays a Map, a Date a date, a
7
+ * class instance says its class. Reading is careful: getters aren't called (they could do
8
+ * anything), proxies aren't looked into, and a cycle becomes a reference to the ancestor it loops
9
+ * back to. The tree stops at bounds on depth, items per object, string length and total size, and
10
+ * says what it left out.
11
+ *
12
+ * Nodes, by `t`:
13
+ *
14
+ * str { v, len? } a string; `len` is its full length when it was cut
15
+ * num { v } big { v } numbers and BigInts, as text (NaN, Infinity and -0 survive)
16
+ * bool { v } null undef hole hole: an empty slot of a sparse array
17
+ * sym { v } a symbol, as text
18
+ * fn { name, kind? } a function; kind is "class", "async" or "generator"
19
+ * date { v } re { v } a date (ISO, or "Invalid Date") and a regular expression
20
+ * err { cls, msg, stack?, props } an error, with its own properties (code, status...)
21
+ * arr { cls?, len, items } an array (cls for subclasses and typed arrays)
22
+ * obj { cls, len, props } an object: cls is its class, null for a plain object
23
+ * map { len, entries } set { len, items }
24
+ * buf { cls, len, hex } a Buffer or ArrayBuffer: its size and first bytes
25
+ * box { cls, v } a boxed primitive (new String("x"))
26
+ * get { set? } an own accessor property, not called
27
+ * ref { path } a cycle: the ancestor it points back to ("$.owner")
28
+ * cut { cls?, len? } not read: past the depth or size bounds
29
+ * proxy promise weak { cls } kinds whose contents aren't read
30
+ *
31
+ * `len` of a container counts all its items, even when only the first ones are in the tree.
32
+ */
33
+
34
+ import { types } from 'util';
35
+
36
+ export const DUMP_LIMITS = Object.freeze({
37
+ depth: 10,
38
+ items: 100,
39
+ string: 4000,
40
+ stack: 4000,
41
+ nodes: 5000,
42
+ // Characters of strings in one tree, all together.
43
+ text: 256 * 1024,
44
+ bytes: 48,
45
+ preview: 160
46
+ });
47
+
48
+ /**
49
+ * Reads values into dump trees.
50
+ */
51
+ export class DumpValue {
52
+ /**
53
+ * @param {*} value
54
+ * @returns {{ tree: Object, type: string, preview: string, truncated: boolean }} The tree, a short
55
+ * name of its type ("User", "array", "string"), a one-line preview, and whether bounds cut it
56
+ */
57
+ static capture(value) {
58
+ const reader = new Reader();
59
+ let tree;
60
+ try {
61
+ tree = reader.node(value, 0, '$');
62
+ } catch (error) {
63
+ // Every read is guarded; this is the last line of defense.
64
+ tree = { t: 'cut' };
65
+ reader.truncated = true;
66
+ }
67
+ return { tree, type: DumpValue.typeOf(tree), preview: DumpValue.preview(tree, DUMP_LIMITS.preview), truncated: reader.truncated };
68
+ }
69
+
70
+ /**
71
+ * A short name of a node's type: its class for objects, else its kind.
72
+ *
73
+ * @param {Object} node
74
+ * @returns {string}
75
+ */
76
+ static typeOf(node) {
77
+ switch (node.t) {
78
+ case 'str':
79
+ return 'string';
80
+ case 'num':
81
+ return 'number';
82
+ case 'big':
83
+ return 'bigint';
84
+ case 'bool':
85
+ return 'boolean';
86
+ case 'undef':
87
+ return 'undefined';
88
+ case 'sym':
89
+ return 'symbol';
90
+ case 'fn':
91
+ return node.kind === 'class' ? 'class' : 'function';
92
+ case 're':
93
+ return 'RegExp';
94
+ case 'date':
95
+ return 'Date';
96
+ case 'arr':
97
+ return node.cls || 'array';
98
+ case 'map':
99
+ return 'Map';
100
+ case 'set':
101
+ return 'Set';
102
+ case 'err':
103
+ case 'obj':
104
+ case 'buf':
105
+ case 'box':
106
+ return node.cls || 'object';
107
+ default:
108
+ return node.t;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * The node on one line, as a list shows it: `User { id: 1, name: "Ada", … }`.
114
+ *
115
+ * @param {Object} node
116
+ * @param {number} [max=160] - Characters at most
117
+ * @returns {string}
118
+ */
119
+ static preview(node, max = DUMP_LIMITS.preview) {
120
+ const text = line(node, 2);
121
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
122
+ }
123
+ }
124
+
125
+ class Reader {
126
+ constructor() {
127
+ this.nodes = 0;
128
+ this.text = 0;
129
+ this.truncated = false;
130
+ // The objects being read, from the root down, to their paths: a cycle points back to one.
131
+ this.ancestors = new Map();
132
+ }
133
+
134
+ node(value, depth, path) {
135
+ this.nodes++;
136
+ switch (typeof value) {
137
+ case 'string':
138
+ return this.string(value, DUMP_LIMITS.string);
139
+ case 'number':
140
+ return { t: 'num', v: Object.is(value, -0) ? '-0' : String(value) };
141
+ case 'bigint':
142
+ return { t: 'big', v: value.toString() };
143
+ case 'boolean':
144
+ return { t: 'bool', v: value };
145
+ case 'undefined':
146
+ return { t: 'undef' };
147
+ case 'symbol':
148
+ return { t: 'sym', v: guard(() => value.toString(), 'Symbol()') };
149
+ case 'function':
150
+ return fn(value);
151
+ default:
152
+ return value === null ? { t: 'null' } : this.object(value, depth, path);
153
+ }
154
+ }
155
+
156
+ string(value, max) {
157
+ const room = Math.max(0, Math.min(max, DUMP_LIMITS.text - this.text));
158
+ if (value.length <= room) {
159
+ this.text += value.length;
160
+ return { t: 'str', v: value };
161
+ }
162
+ this.truncated = true;
163
+ const cut = value.slice(0, room);
164
+ this.text += cut.length;
165
+ return { t: 'str', v: cut, len: value.length };
166
+ }
167
+
168
+ object(value, depth, path) {
169
+ if (types.isProxy(value)) return { t: 'proxy' };
170
+ const ancestor = this.ancestors.get(value);
171
+ if (ancestor) return { t: 'ref', path: ancestor };
172
+ const cls = className(value);
173
+ if (types.isDate(value)) return { t: 'date', v: guard(() => (Number.isNaN(value.getTime()) ? 'Invalid Date' : value.toISOString()), 'Invalid Date') };
174
+ if (types.isRegExp(value)) return { t: 're', v: guard(() => RegExp.prototype.toString.call(value), '/(?:)/') };
175
+ if (types.isPromise(value)) return { t: 'promise' };
176
+ if (types.isWeakMap(value) || types.isWeakSet(value)) return { t: 'weak', cls: cls || 'Weak' };
177
+ if (Buffer.isBuffer(value)) return { t: 'buf', cls: 'Buffer', len: value.length, hex: value.subarray(0, DUMP_LIMITS.bytes).toString('hex') };
178
+ if (types.isAnyArrayBuffer(value)) {
179
+ return { t: 'buf', cls: cls || 'ArrayBuffer', len: value.byteLength, hex: Buffer.from(value, 0, Math.min(DUMP_LIMITS.bytes, value.byteLength)).toString('hex') };
180
+ }
181
+ if (types.isBoxedPrimitive(value)) return { t: 'box', cls: cls || 'Object', v: this.node(guard(() => value.valueOf(), undefined), depth + 1, path) };
182
+ if (depth >= DUMP_LIMITS.depth || this.nodes > DUMP_LIMITS.nodes || this.text >= DUMP_LIMITS.text) {
183
+ this.truncated = true;
184
+ return { t: 'cut', ...(cls ? { cls } : {}), ...sizeOf(value) };
185
+ }
186
+
187
+ this.ancestors.set(value, path);
188
+ try {
189
+ if (types.isNativeError(value) || value instanceof Error) return this.error(value, cls, depth, path);
190
+ if (Array.isArray(value)) return this.array(value, cls, depth, path);
191
+ if (types.isTypedArray(value)) return this.typedArray(value, cls);
192
+ if (types.isMap(value)) return this.map(value, depth, path);
193
+ if (types.isSet(value)) return this.set(value, depth, path);
194
+ return { t: 'obj', cls, ...this.props(value, depth, path, null) };
195
+ } finally {
196
+ this.ancestors.delete(value);
197
+ }
198
+ }
199
+
200
+ // Own enumerable properties, string keys then symbols; `skip` leaves some out.
201
+ props(value, depth, path, skip) {
202
+ const keys = guard(() => Reflect.ownKeys(value), []);
203
+ const props = [];
204
+ let len = 0;
205
+ for (const key of keys) {
206
+ if (skip && skip.has(key)) continue;
207
+ const descriptor = guard(() => Object.getOwnPropertyDescriptor(value, key), undefined);
208
+ if (!descriptor || !descriptor.enumerable) continue;
209
+ len++;
210
+ if (props.length >= DUMP_LIMITS.items || this.nodes > DUMP_LIMITS.nodes) {
211
+ this.truncated = true;
212
+ continue;
213
+ }
214
+ const name = typeof key === 'symbol' ? `[${guard(() => key.toString(), 'Symbol()')}]` : key;
215
+ props.push([name, this.property(descriptor, depth, childPath(path, name))]);
216
+ }
217
+ return { len, props };
218
+ }
219
+
220
+ property(descriptor, depth, path) {
221
+ if ('value' in descriptor) return this.node(descriptor.value, depth + 1, path);
222
+ this.nodes++;
223
+ return descriptor.set ? { t: 'get', set: true } : { t: 'get' };
224
+ }
225
+
226
+ array(value, cls, depth, path) {
227
+ const len = value.length;
228
+ const count = Math.min(len, DUMP_LIMITS.items);
229
+ const items = [];
230
+ for (let index = 0; index < count && this.nodes <= DUMP_LIMITS.nodes; index++) {
231
+ const descriptor = guard(() => Object.getOwnPropertyDescriptor(value, index), undefined);
232
+ items.push(descriptor ? this.property(descriptor, depth, `${path}[${index}]`) : (this.nodes++, { t: 'hole' }));
233
+ }
234
+ if (items.length < len) this.truncated = true;
235
+ return { t: 'arr', ...(cls && cls !== 'Array' ? { cls } : {}), len, items };
236
+ }
237
+
238
+ typedArray(value, cls) {
239
+ const len = value.length;
240
+ const count = Math.min(len, DUMP_LIMITS.items);
241
+ const items = [];
242
+ for (let index = 0; index < count; index++) {
243
+ this.nodes++;
244
+ const item = value[index];
245
+ items.push(typeof item === 'bigint' ? { t: 'big', v: item.toString() } : { t: 'num', v: Object.is(item, -0) ? '-0' : String(item) });
246
+ }
247
+ if (count < len) this.truncated = true;
248
+ return { t: 'arr', cls: cls || 'TypedArray', len, items };
249
+ }
250
+
251
+ map(value, depth, path) {
252
+ const len = guard(() => MAP_SIZE.call(value), 0);
253
+ const entries = [];
254
+ for (const [key, item] of guard(() => Map.prototype.entries.call(value), [])) {
255
+ if (entries.length >= DUMP_LIMITS.items || this.nodes > DUMP_LIMITS.nodes) break;
256
+ const keyNode = this.node(key, depth + 1, `${path}.keys()[${entries.length}]`);
257
+ entries.push([keyNode, this.node(item, depth + 1, `${path}.get(${keyText(keyNode)})`)]);
258
+ }
259
+ if (entries.length < len) this.truncated = true;
260
+ return { t: 'map', len, entries };
261
+ }
262
+
263
+ set(value, depth, path) {
264
+ const len = guard(() => SET_SIZE.call(value), 0);
265
+ const items = [];
266
+ for (const item of guard(() => Set.prototype.values.call(value), [])) {
267
+ if (items.length >= DUMP_LIMITS.items || this.nodes > DUMP_LIMITS.nodes) break;
268
+ items.push(this.node(item, depth + 1, `${path}[${items.length}]`));
269
+ }
270
+ if (items.length < len) this.truncated = true;
271
+ return { t: 'set', len, items };
272
+ }
273
+
274
+ error(value, cls, depth, path) {
275
+ const name = ownString(value, 'name') || cls || 'Error';
276
+ const message = ownString(value, 'message');
277
+ // V8 keeps a native error's stack behind its own accessor; reading it runs no app code.
278
+ const stack = types.isNativeError(value) ? guard(() => (typeof value.stack === 'string' ? value.stack : ''), '') : ownString(value, 'stack');
279
+ const { props } = this.props(value, depth, path, ERROR_OWN);
280
+ const cause = guard(() => Object.getOwnPropertyDescriptor(value, 'cause'), undefined);
281
+ if (cause && 'value' in cause) props.push(['cause', this.node(cause.value, depth + 1, `${path}.cause`)]);
282
+ return {
283
+ t: 'err',
284
+ cls: name,
285
+ msg: this.string(message, DUMP_LIMITS.string).v,
286
+ ...(stack ? { stack: this.string(stack, DUMP_LIMITS.stack).v } : {}),
287
+ props
288
+ };
289
+ }
290
+ }
291
+
292
+ const ERROR_OWN = new Set(['name', 'message', 'stack', 'cause']);
293
+ // Map's and Set's own size getters, so an object's own `size` can't stand in.
294
+ const MAP_SIZE = Object.getOwnPropertyDescriptor(Map.prototype, 'size').get;
295
+ const SET_SIZE = Object.getOwnPropertyDescriptor(Set.prototype, 'size').get;
296
+
297
+ function fn(value) {
298
+ if (types.isProxy(value)) return { t: 'proxy' };
299
+ const name = guard(() => {
300
+ const descriptor = Object.getOwnPropertyDescriptor(value, 'name');
301
+ return descriptor && typeof descriptor.value === 'string' ? descriptor.value : '';
302
+ }, '');
303
+ let kind = null;
304
+ if (types.isAsyncFunction(value)) kind = 'async';
305
+ else if (types.isGeneratorFunction(value)) kind = 'generator';
306
+ else if (guard(() => /^class\b/.test(Function.prototype.toString.call(value)), false)) kind = 'class';
307
+ return { t: 'fn', name, ...(kind ? { kind } : {}) };
308
+ }
309
+
310
+ // The class of an object, read without getters: null for plain objects (and those without a
311
+ // prototype).
312
+ function className(value) {
313
+ return guard(() => {
314
+ const proto = Object.getPrototypeOf(value);
315
+ if (proto === null || proto === Object.prototype) return null;
316
+ const constructor = Object.getOwnPropertyDescriptor(proto, 'constructor');
317
+ const name = constructor && typeof constructor.value === 'function' ? Object.getOwnPropertyDescriptor(constructor.value, 'name') : null;
318
+ return name && typeof name.value === 'string' && name.value ? name.value : 'Object';
319
+ }, null);
320
+ }
321
+
322
+ // How big a container left unread is, when that is cheap to know.
323
+ function sizeOf(value) {
324
+ if (Array.isArray(value)) return { len: value.length };
325
+ if (types.isMap(value)) return { len: guard(() => MAP_SIZE.call(value), 0) };
326
+ if (types.isSet(value)) return { len: guard(() => SET_SIZE.call(value), 0) };
327
+ return {};
328
+ }
329
+
330
+ function ownString(value, key) {
331
+ const descriptor = guard(() => Object.getOwnPropertyDescriptor(value, key), undefined);
332
+ if (descriptor && typeof descriptor.value === 'string') return descriptor.value;
333
+ // name and message usually live on the prototype (Error.prototype.name); read as data only.
334
+ for (let proto = guard(() => Object.getPrototypeOf(value), null); proto; proto = guard(() => Object.getPrototypeOf(proto), null)) {
335
+ const inherited = guard(() => Object.getOwnPropertyDescriptor(proto, key), undefined);
336
+ if (inherited) return typeof inherited.value === 'string' ? inherited.value : '';
337
+ }
338
+ return '';
339
+ }
340
+
341
+ function childPath(path, key) {
342
+ return /^[A-Za-z_$][\w$]*$/.test(key) ? `${path}.${key}` : `${path}[${JSON.stringify(key)}]`;
343
+ }
344
+
345
+ function keyText(node) {
346
+ return node.t === 'str' ? JSON.stringify(node.v) : node.t === 'num' || node.t === 'big' || node.t === 'bool' ? String(node.v) : '…';
347
+ }
348
+
349
+ function guard(read, fallback) {
350
+ try {
351
+ return read();
352
+ } catch (error) {
353
+ return fallback;
354
+ }
355
+ }
356
+
357
+ // One node as text, `depth` levels down.
358
+ function line(node, depth) {
359
+ switch (node.t) {
360
+ case 'str':
361
+ return JSON.stringify(node.v.length > 60 ? `${node.v.slice(0, 60)}…` : node.v);
362
+ case 'num':
363
+ case 'sym':
364
+ case 'date':
365
+ case 're':
366
+ return node.v;
367
+ case 'big':
368
+ return `${node.v}n`;
369
+ case 'bool':
370
+ return String(node.v);
371
+ case 'null':
372
+ return 'null';
373
+ case 'undef':
374
+ return 'undefined';
375
+ case 'hole':
376
+ return '<empty>';
377
+ case 'fn':
378
+ return node.kind === 'class' ? `[class ${node.name || '(anonymous)'}]` : `[${node.kind === 'async' ? 'AsyncFunction' : 'Function'}${node.name ? ` ${node.name}` : ''}]`;
379
+ case 'err':
380
+ return node.msg ? `${node.cls}: ${node.msg}` : node.cls;
381
+ case 'buf':
382
+ return `<${node.cls} ${(node.hex.match(/../g) || []).slice(0, 8).join(' ')}${node.len > 8 ? ' …' : ''}>`;
383
+ case 'box':
384
+ return `[${node.cls}: ${line(node.v, 0)}]`;
385
+ case 'get':
386
+ return node.set ? '[Getter/Setter]' : '[Getter]';
387
+ case 'ref':
388
+ return `[Circular ${node.path}]`;
389
+ case 'proxy':
390
+ return '[Proxy]';
391
+ case 'promise':
392
+ return 'Promise';
393
+ case 'weak':
394
+ return node.cls;
395
+ case 'cut':
396
+ return `${node.cls ? `${node.cls} ` : ''}{…}`;
397
+ case 'arr': {
398
+ const head = node.cls ? `${node.cls}(${node.len}) ` : '';
399
+ if (!node.len) return `${head}[]`;
400
+ if (depth <= 0) return `${head}[…]`;
401
+ return `${head}[${list(node.items.map((item) => line(item, depth - 1)), node.len)}]`;
402
+ }
403
+ case 'map': {
404
+ if (!node.len) return 'Map(0) {}';
405
+ if (depth <= 0) return `Map(${node.len}) {…}`;
406
+ return `Map(${node.len}) { ${list(node.entries.map(([key, value]) => `${line(key, 0)} => ${line(value, depth - 1)}`), node.len)} }`;
407
+ }
408
+ case 'set': {
409
+ if (!node.len) return 'Set(0) {}';
410
+ if (depth <= 0) return `Set(${node.len}) {…}`;
411
+ return `Set(${node.len}) { ${list(node.items.map((item) => line(item, depth - 1)), node.len)} }`;
412
+ }
413
+ case 'obj': {
414
+ const head = node.cls ? `${node.cls} ` : '';
415
+ if (!node.len) return `${head}{}`;
416
+ if (depth <= 0) return `${head}{…}`;
417
+ return `${head}{ ${list(node.props.map(([key, value]) => `${/^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key)}: ${line(value, depth - 1)}`), node.len)} }`;
418
+ }
419
+ default:
420
+ return '…';
421
+ }
422
+ }
423
+
424
+ // The first few parts, and an ellipsis for the rest.
425
+ function list(parts, total) {
426
+ const shown = parts.slice(0, 4);
427
+ return total > shown.length ? `${shown.join(', ')}, …` : shown.join(', ');
428
+ }
@@ -13,7 +13,8 @@
13
13
  * - Whether an Express error was handled is only known later: an error middleware that receives
14
14
  * it may still pass it on to Express's final handler. Such records stay open on the request and
15
15
  * are settled when the error reaches the final handler (unhandled) or the request's record is
16
- * built (handled).
16
+ * built (handled). NestJS exception filters and Hono error handlers work the same way: one that
17
+ * takes an error may still hand it to the framework's default answer.
17
18
  *
18
19
  * Nothing here ever throws into the app: a failure to record is a lost exception, never a new one.
19
20
  */
@@ -134,12 +135,12 @@ export class ExceptionCapture {
134
135
  }
135
136
 
136
137
  /**
137
- * An error reached a framework during a request: Express, Koa or Fastify.
138
+ * An error reached a framework during a request: Express, Koa, Fastify, NestJS or Hono.
138
139
  *
139
140
  * @param {RequestContext|null} context - The request's context
140
141
  * @param {*} error
141
142
  * @param {Object} options
142
- * @param {'express'|'koa'|'fastify'} options.mechanism
143
+ * @param {'express'|'koa'|'fastify'|'nest'|'hono'} options.mechanism
143
144
  * @param {boolean} options.handled - Whether the app handled it
144
145
  * @param {boolean} [options.open=false] - The outcome isn't final yet (see the module comment)
145
146
  * @returns {string|null} The exception's id, or null when nothing was recorded
@@ -290,7 +291,7 @@ export class ExceptionCapture {
290
291
  exchange.exceptionCount = own.exceptionCount;
291
292
  exchange.exceptions = own.links.slice();
292
293
  own.completeTimeline(exchange);
293
- if (own.user) exchange.user = own.user;
294
+ own.describe(exchange);
294
295
  exchanges.push(exchange);
295
296
  }
296
297
  }
@@ -14,15 +14,24 @@
14
14
  * The context also keeps the request's exception bookkeeping: which errors were already recorded
15
15
  * (each error object is recorded once per request), the records whose outcome is still open (see
16
16
  * ExceptionCapture), and the count and links that go into the request's record.
17
+ *
18
+ * Integrations that know what the framework matched the request to note it here (noteRoute()):
19
+ * the route pattern, the code that handles it and the route parameters. The request's record and
20
+ * its exceptions carry them.
17
21
  */
18
22
 
19
23
  import { AsyncLocalStorage } from 'async_hooks';
20
24
  import { BodyDecoder } from '../capture/body-decoder.js';
21
- import { parseQuery, splitUrl } from '../capture/http-message.js';
22
- import { LIMITS } from './limits.js';
25
+ import { parseQuery, splitUrl, stringParams } from '../capture/http-message.js';
26
+ import { LIMITS, truncate } from './limits.js';
23
27
 
24
28
  const storage = new AsyncLocalStorage();
25
29
  const kContext = Symbol('peekInside.context');
30
+ // What a route pattern and a handler name may take up.
31
+ const MAX_ROUTE = 512;
32
+ const MAX_HANDLER = 256;
33
+ // Dumps listed in a request's record (the Dumps page has them all).
34
+ const MAX_DUMP_LINKS = 20;
26
35
  // The request an exception last escaped from, until the current job ends.
27
36
  let escaped = null;
28
37
 
@@ -83,7 +92,7 @@ export class RequestContext {
83
92
  * @param {boolean} [fields.blocked=false] - The request reached a server in local mode: neither
84
93
  * it nor its exceptions are recorded
85
94
  * @param {string} [fields.integration='custom'] - Where tracking started: "express", "koa",
86
- * "fastify", "http" (a server's emit()) or "custom" (intercept())
95
+ * "fastify", "nest", "hono", "http" (a server's emit()) or "custom" (intercept())
87
96
  */
88
97
  constructor({ capture, req, recorder = null, target, blocked = false, integration = 'custom' }) {
89
98
  this.capture = capture;
@@ -119,8 +128,26 @@ export class RequestContext {
119
128
  * @type {import('../user.js').User|null}
120
129
  */
121
130
  this.user = null;
131
+ /**
132
+ * The route pattern the framework matched ("/users/:id"), once an integration noted it
133
+ * @type {string|null}
134
+ */
135
+ this.route = null;
136
+ /**
137
+ * The code that handles the request ("UsersController.findOne"), once an integration noted it
138
+ * @type {string|null}
139
+ */
140
+ this.handler = null;
141
+ /**
142
+ * Dumps made during the request (PeekInside.dump()), for its record
143
+ * @type {{ id: string, label: string|null, type: string, preview: string }[]}
144
+ */
145
+ this.dumps = [];
122
146
 
123
147
  this._req = req;
148
+ // Route parameters an integration noted (or a function returning them), for frameworks that
149
+ // don't put them on the Node.js request.
150
+ this._params = undefined;
124
151
  // What remains for exceptions after the request finished (see release()).
125
152
  this._after = null;
126
153
 
@@ -169,12 +196,81 @@ export class RequestContext {
169
196
  }
170
197
 
171
198
  /**
172
- * For the exception list: the request's method and path.
199
+ * Notes what the framework matched the request to, as an integration learns it. Each part that is
200
+ * given replaces the one noted before; null forgets it, and parts left out stay as they are.
201
+ *
202
+ * @param {Object} match
203
+ * @param {string|null} [match.route] - The route pattern, such as "/users/:id"
204
+ * @param {string|null} [match.handler] - The code that handles it, such as "UsersController.findOne"
205
+ * @param {Object|(() => Object)|null} [match.params] - The route parameters, or a function that
206
+ * returns them when they are needed, for frameworks that keep them off the Node.js request
207
+ * @returns {void}
208
+ */
209
+ noteRoute({ route, handler, params } = {}) {
210
+ if (route !== undefined) this.route = typeof route === 'string' && route ? truncate(route, MAX_ROUTE) : null;
211
+ if (handler !== undefined) this.handler = typeof handler === 'string' && handler ? truncate(handler, MAX_HANDLER) : null;
212
+ if (params !== undefined) this._params = params;
213
+ }
214
+
215
+ /**
216
+ * The route parameters an integration noted, as strings.
217
+ *
218
+ * @returns {Object<string, string>|null} null when none were noted
219
+ */
220
+ routeParams() {
221
+ let params = this._params;
222
+ if (typeof params === 'function') {
223
+ try {
224
+ params = params();
225
+ } catch (error) {
226
+ params = null;
227
+ }
228
+ }
229
+ return params && typeof params === 'object' ? stringParams(params) : null;
230
+ }
231
+
232
+ /**
233
+ * For the exception list: the request's method and path, and its route and handler when known.
173
234
  *
174
- * @returns {{ method: string, path: string }}
235
+ * @returns {{ method: string, path: string, route?: string, handler?: string }}
175
236
  */
176
237
  requestLine() {
177
- return { method: this.method, path: this.path };
238
+ return {
239
+ method: this.method,
240
+ path: this.path,
241
+ ...(this.route ? { route: this.route } : {}),
242
+ ...(this.handler ? { handler: this.handler } : {})
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Counts a dump made during the request: listed on its record, and placed on its timeline.
248
+ *
249
+ * @param {import('../dumps/dump-record.js').DumpRecord} record
250
+ * @returns {void}
251
+ */
252
+ noteDump(record) {
253
+ if (this.dumps.length < MAX_DUMP_LINKS) this.dumps.push(record.link());
254
+ if (this.timeline) this.timeline.dump(record);
255
+ }
256
+
257
+ /**
258
+ * Adds what the request's record shows of the request beyond the exchange itself: who it was for,
259
+ * its route and handler, the dumps made during it, and route parameters the Node.js request
260
+ * didn't carry.
261
+ *
262
+ * @param {import('../capture/exchange-recorder.js').Exchange} exchange - Changed in place
263
+ * @returns {void}
264
+ */
265
+ describe(exchange) {
266
+ if (this.user) exchange.user = this.user;
267
+ if (this.route) exchange.route = this.route;
268
+ if (this.handler) exchange.handler = this.handler;
269
+ if (this.dumps.length) exchange.dumps = this.dumps.slice();
270
+ if (this._params !== undefined && (!exchange.params || !Object.keys(exchange.params).length)) {
271
+ const params = this.routeParams();
272
+ if (params) exchange.params = params;
273
+ }
178
274
  }
179
275
 
180
276
  /**
@@ -185,9 +281,11 @@ export class RequestContext {
185
281
  */
186
282
  snapshot() {
187
283
  if (this.recorder && !this.recorder.recorded) {
188
- return { id: this.requestId, ...this.recorder.snapshot(LIMITS.requestBody) };
284
+ const snapshot = { id: this.requestId, ...this.recorder.snapshot(LIMITS.requestBody) };
285
+ if (this._params !== undefined && !Object.keys(snapshot.params).length) snapshot.params = this.routeParams() || {};
286
+ return snapshot;
189
287
  }
190
- const after = this._after || RequestSnapshot.partsOf(this._req);
288
+ const after = this._after || this._parts();
191
289
  return {
192
290
  id: this.requestId,
193
291
  method: this.method,
@@ -223,7 +321,7 @@ export class RequestContext {
223
321
  complete(exchange) {
224
322
  if (this.capture) this.capture.finish(this, exchange);
225
323
  this.completeTimeline(exchange);
226
- if (this.user) exchange.user = this.user;
324
+ this.describe(exchange);
227
325
  this.release();
228
326
  }
229
327
 
@@ -253,11 +351,21 @@ export class RequestContext {
253
351
  */
254
352
  release() {
255
353
  this.finished = true;
256
- this._after = RequestSnapshot.partsOf(this._req);
354
+ this._after = this._parts();
355
+ // A function returning the parameters may hold the framework's request.
356
+ if (typeof this._params === 'function') this._params = this.routeParams();
257
357
  this._req = null;
258
358
  this.recorder = null;
259
359
  this.timeline = null;
260
360
  }
361
+
362
+ // The small parts of the request a snapshot keeps, with the parameters an integration noted when
363
+ // the request has none of its own.
364
+ _parts() {
365
+ const parts = RequestSnapshot.partsOf(this._req);
366
+ if (this._params !== undefined && !Object.keys(parts.params).length) parts.params = this.routeParams() || {};
367
+ return parts;
368
+ }
261
369
  }
262
370
 
263
371
  /**