peekinside 1.2.4 → 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.
@@ -104,6 +104,7 @@ export class SpoolIngester {
104
104
  const exceptions = this.store.appendExceptions(payload.project.id, payload.exceptions).length;
105
105
  this.store.appendJobs(payload.project.id, payload.jobs);
106
106
  this.store.appendLogs(payload.project.id, payload.logs);
107
+ this.store.appendDumps(payload.project.id, payload.dumps);
107
108
  spool.remove(name);
108
109
  return { requests, exceptions };
109
110
  }
package/src/hub/store.js CHANGED
@@ -16,6 +16,7 @@ export const DEFAULT_MAX_REQUESTS = 1000;
16
16
  export const DEFAULT_MAX_EXCEPTIONS = 500;
17
17
  export const DEFAULT_MAX_JOBS = 2000;
18
18
  export const DEFAULT_MAX_LOGS = 10000;
19
+ export const DEFAULT_MAX_DUMPS = 1000;
19
20
 
20
21
  /**
21
22
  * All projects on this machine.
@@ -206,6 +207,47 @@ export class Store {
206
207
  if (project) project.clearLogs();
207
208
  }
208
209
 
210
+ /**
211
+ * Stores new dumps of a project.
212
+ *
213
+ * @param {string} projectId
214
+ * @param {Object[]} records
215
+ * @returns {Object[]}
216
+ */
217
+ appendDumps(projectId, records) {
218
+ const project = this.projects.get(projectId);
219
+ return project && records.length ? project.appendDumps(records) : [];
220
+ }
221
+
222
+ /**
223
+ * @param {string} projectId
224
+ * @param {{ after?: number, before?: number, limit: number }} query
225
+ * @returns {{ dumps: Object[], hasMore: boolean }|null}
226
+ */
227
+ listDumps(projectId, query) {
228
+ const project = this.projects.get(projectId);
229
+ return project ? project.listDumps(query) : null;
230
+ }
231
+
232
+ /**
233
+ * @param {string} projectId
234
+ * @param {string} dumpId
235
+ * @returns {Object|null}
236
+ */
237
+ dumpDetail(projectId, dumpId) {
238
+ const project = this.projects.get(projectId);
239
+ return project ? project.dumpDetail(dumpId) : null;
240
+ }
241
+
242
+ /**
243
+ * @param {string} projectId
244
+ * @returns {void}
245
+ */
246
+ clearDumps(projectId) {
247
+ const project = this.projects.get(projectId);
248
+ if (project) project.clearDumps();
249
+ }
250
+
209
251
  /**
210
252
  * Adds process samples of a project's process (kept in memory, for the Runtime page).
211
253
  *
@@ -398,7 +440,8 @@ export class Store {
398
440
  maxExceptions: this.maxExceptions,
399
441
  exceptionSegmentSize: this.exceptionSegmentSize,
400
442
  maxJobs: DEFAULT_MAX_JOBS,
401
- maxLogs: DEFAULT_MAX_LOGS
443
+ maxLogs: DEFAULT_MAX_LOGS,
444
+ maxDumps: DEFAULT_MAX_DUMPS
402
445
  };
403
446
  }
404
447
  }
@@ -12,12 +12,14 @@
12
12
  *
13
13
  * Outgoing HTTP and console capture are process-wide subscriptions; they record only for code
14
14
  * running inside a request (log lines go to the Logs page from anywhere). Loggers (pino, winston)
15
- * and job libraries (node-cron, cron, BullMQ) are found and patched like the drivers.
15
+ * and job libraries (node-cron, cron, BullMQ) are found and patched like the drivers; NestJS's
16
+ * Logger and @nestjs/schedule are reached through the app instead (see integrations/nest.js).
16
17
  */
17
18
 
18
19
  import { createRequire } from 'module';
19
20
  import fs from 'fs';
20
21
  import path from 'path';
22
+ import { pathToFileURL } from 'url';
21
23
  import { ConsoleInstrumentation } from './console.js';
22
24
  import { HttpInstrumentation } from './http.js';
23
25
  import { JobInstrumentation } from './jobs.js';
@@ -71,6 +73,10 @@ export class Instrumentation {
71
73
  if (!fromRoot) return patched;
72
74
  if (patchWith(fromRoot, 'pino', LoggerInstrumentation.pino)) patched.push('pino');
73
75
  if (patchWith(fromRoot, 'winston', LoggerInstrumentation.winston)) patched.push('winston');
76
+ // NestJS's ConsoleLogger, for a NestJS project.
77
+ if (declares(root, '@nestjs/common')) {
78
+ void importInstalled(fromRoot, '@nestjs/common', null, (common) => LoggerInstrumentation.nest(exported(common, 'ConsoleLogger')));
79
+ }
74
80
  return patched;
75
81
  }
76
82
 
@@ -92,6 +98,11 @@ export class Instrumentation {
92
98
  const directory = packageDirectory(main, name);
93
99
  if (directory) void JobInstrumentation.patchModuleBuild(directory, patch);
94
100
  }
101
+ // @nestjs/schedule registers a NestJS app's scheduled methods as the app initializes, which a
102
+ // worker (NestFactory.createApplicationContext()) does before it can be attached.
103
+ if (declares(root, '@nestjs/schedule')) {
104
+ void importInstalled(fromRoot, '@nestjs/schedule', path.join('dist', 'schedule.explorer.js'), (explorer) => JobInstrumentation.nestSchedule(exported(explorer, 'ScheduleExplorer')));
105
+ }
95
106
  return patched;
96
107
  }
97
108
 
@@ -217,14 +228,48 @@ function resolve(fromSomewhere, name) {
217
228
  }
218
229
  }
219
230
 
220
- // The folder of the package `name` that contains `file`.
231
+ // The folder of the package `name` (scoped or not) that contains `file`.
221
232
  function packageDirectory(file, name) {
233
+ const [scope, base] = name.includes('/') ? name.split('/') : [null, name];
222
234
  for (let directory = path.dirname(file); ; directory = path.dirname(directory)) {
223
- if (path.basename(directory) === name && fs.existsSync(path.join(directory, 'package.json'))) return directory;
235
+ const named = path.basename(directory) === base && (!scope || path.basename(path.dirname(directory)) === scope);
236
+ if (named && fs.existsSync(path.join(directory, 'package.json'))) return directory;
224
237
  if (path.dirname(directory) === directory) return null;
225
238
  }
226
239
  }
227
240
 
241
+ // Whether the project's package.json depends on a package. Frameworks are only loaded for projects
242
+ // that use them: a monorepo's shared node_modules may hold another app's.
243
+ function declares(root, name) {
244
+ try {
245
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
246
+ return ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'].some((field) => Boolean(pkg[field] && pkg[field][name]));
247
+ } catch (error) {
248
+ return false;
249
+ }
250
+ }
251
+
252
+ // Loads a package installed in the project (or one of its files) the way an import does, which
253
+ // gives the module instance the app runs whether the package ships CommonJS or ES modules, and
254
+ // patches it.
255
+ async function importInstalled(fromRoot, name, file, patch) {
256
+ const main = resolve(fromRoot, name);
257
+ const directory = main && file ? packageDirectory(main, name) : null;
258
+ const target = file ? directory && path.join(directory, file) : main;
259
+ if (!target || !fs.existsSync(target)) return false;
260
+ try {
261
+ return patch(await import(pathToFileURL(target).href));
262
+ } catch (error) {
263
+ return false;
264
+ }
265
+ }
266
+
267
+ // An export of a module, imported as an ES module or as CommonJS (under `default`).
268
+ function exported(module, name) {
269
+ if (!module) return undefined;
270
+ return module[name] || (module.default && module.default[name]);
271
+ }
272
+
228
273
  // The file a module object was loaded from, found in the CommonJS cache.
229
274
  function fileOf(exports) {
230
275
  for (const module of Object.values(requireHere.cache)) {
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * Job runs from the common job libraries, without code changes: node-cron and cron (scheduled
3
- * tasks, with their cron expression, so PeekInside can tell when one misses its run) and BullMQ
3
+ * tasks, with their cron expression, so PeekInside can tell when one misses its run), BullMQ
4
4
  * (queue workers, with the queue, the attempt and the job's data, and the schedule of repeatable
5
- * jobs). Each run goes through JobRunner, exactly as PeekInside.job() would run it.
5
+ * jobs) and @nestjs/schedule (@Cron, @Interval and @Timeout methods, named after their class and
6
+ * method). Each run goes through JobRunner, exactly as PeekInside.job() would run it.
7
+ *
8
+ * A schedule is recorded with its time zone: the one given to the library, else the process's
9
+ * own, which is the one these libraries read a cron expression in without one.
6
10
  *
7
11
  * Libraries that ship separate CommonJS and ES module builds (node-cron, BullMQ) are patched in
8
12
  * both: the CommonJS one right away, the ES module one as soon as it is imported here, which is
@@ -17,6 +21,10 @@ import { JobRunner } from '../jobs/job-runner.js';
17
21
  const kWrapped = Symbol('peekInside.job');
18
22
  // node-cron tasks created through named imports are found by looking again now and then.
19
23
  const RESCAN_MS = 30_000;
24
+ // @nestjs/schedule's SchedulerType.
25
+ const NEST_CRON = 1;
26
+ const NEST_TIMEOUT = 2;
27
+ const NEST_INTERVAL = 3;
20
28
 
21
29
  /**
22
30
  * Patches job libraries.
@@ -91,7 +99,8 @@ export class JobInstrumentation {
91
99
  proto.callProcessJob = function (job) {
92
100
  const args = arguments;
93
101
  const repeat = job && job.opts && job.opts.repeat;
94
- const schedule = repeat ? repeat.pattern || repeat.cron || (Number.isFinite(repeat.every) ? `@every ${repeat.every}ms` : undefined) : undefined;
102
+ const pattern = repeat ? repeat.pattern || repeat.cron : undefined;
103
+ const schedule = repeat ? pattern || (Number.isFinite(repeat.every) ? `@every ${repeat.every}ms` : undefined) : undefined;
95
104
  return JobRunner.run(
96
105
  job && job.name && job.name !== '__default__' ? job.name : this.name,
97
106
  () => original.apply(this, args),
@@ -100,7 +109,7 @@ export class JobInstrumentation {
100
109
  attempt: job && Number.isInteger(job.attemptsMade) ? job.attemptsMade + 1 : 1,
101
110
  data: job ? job.data : undefined,
102
111
  schedule,
103
- timezone: repeat && typeof repeat.tz === 'string' ? repeat.tz : undefined,
112
+ timezone: repeat && typeof repeat.tz === 'string' && repeat.tz ? repeat.tz : pattern ? localTimeZone() : undefined,
104
113
  trigger: schedule ? 'cron' : 'queue'
105
114
  }
106
115
  );
@@ -109,6 +118,67 @@ export class JobInstrumentation {
109
118
  return true;
110
119
  }
111
120
 
121
+ /**
122
+ * @nestjs/schedule: every @Cron, @Interval and @Timeout method runs as a job run, named after the
123
+ * name given to the decorator, else its class and method ("ReportsService.sendDigest"), with its
124
+ * schedule. The scheduler catches what a method throws and only logs it, so a failing method would
125
+ * otherwise look like one that ran fine: the run records the error, then the scheduler logs it as
126
+ * before. Methods are wrapped as the scheduler registers them, when the app initializes.
127
+ *
128
+ * @param {*} explorer - The ScheduleExplorer class, or the app's instance of it
129
+ * @returns {boolean}
130
+ */
131
+ static nestSchedule(explorer) {
132
+ const proto = typeof explorer === 'function' ? explorer.prototype : explorer && typeof explorer === 'object' ? Object.getPrototypeOf(explorer) : null;
133
+ if (!proto || typeof proto.wrapFunctionInTryCatchBlocks !== 'function') return false;
134
+ if (proto[kWrapped]) return true;
135
+ const original = proto.wrapFunctionInTryCatchBlocks;
136
+ proto.wrapFunctionInTryCatchBlocks = function (methodRef, instance) {
137
+ let job = null;
138
+ try {
139
+ job = typeof methodRef === 'function' ? nestJob(this, methodRef, instance) : null;
140
+ } catch (error) {
141
+ job = null;
142
+ }
143
+ if (!job) return original.apply(this, arguments);
144
+ const run = function () {
145
+ const args = arguments;
146
+ return JobRunner.run(job.name, () => methodRef.apply(this, args), job.options);
147
+ };
148
+ // cron fires the wrapper as a CronJob callback: it is a run already.
149
+ return JobInstrumentation.markRun(original.call(this, run, instance));
150
+ };
151
+ Object.defineProperty(proto, kWrapped, { value: true });
152
+ return true;
153
+ }
154
+
155
+ /**
156
+ * Whether a function records its own job runs (see markRun()).
157
+ *
158
+ * @param {*} fn
159
+ * @returns {boolean}
160
+ */
161
+ static isRun(fn) {
162
+ return typeof fn === 'function' && Boolean(fn[kWrapped]);
163
+ }
164
+
165
+ /**
166
+ * Marks a function that records its own job runs, so the cron patch doesn't record it again.
167
+ *
168
+ * @param {*} fn
169
+ * @returns {*} fn
170
+ */
171
+ static markRun(fn) {
172
+ if (typeof fn === 'function' && !fn[kWrapped]) {
173
+ try {
174
+ Object.defineProperty(fn, kWrapped, { value: true });
175
+ } catch (error) {
176
+ // A frozen function is recorded by both, which costs a run, never the job.
177
+ }
178
+ }
179
+ return fn;
180
+ }
181
+
112
182
  /**
113
183
  * Patches the ES module build of a package installed in the project, when it has one.
114
184
  *
@@ -146,7 +216,11 @@ function wrapCronTask(task) {
146
216
  const name = task.name && task.name !== task.id ? task.name : `cron ${expression || 'task'}`;
147
217
  const wrapped = function () {
148
218
  const args = arguments;
149
- return JobRunner.run(name, () => original.apply(this, args), { schedule: expression || undefined, timezone: task.timezone, trigger: 'cron' });
219
+ return JobRunner.run(name, () => original.apply(this, args), {
220
+ schedule: expression || undefined,
221
+ timezone: (typeof task.timezone === 'string' && task.timezone) || localTimeZone(),
222
+ trigger: 'cron'
223
+ });
150
224
  };
151
225
  wrapped[kWrapped] = true;
152
226
  runner.onMatch = wrapped;
@@ -159,7 +233,7 @@ function wrapCronJobCallback(job, callback) {
159
233
  const args = arguments;
160
234
  return JobRunner.run(job.name || `cron ${source || 'job'}`, () => callback.apply(this, args), {
161
235
  schedule: source || undefined,
162
- timezone: job.cronTime && typeof job.cronTime.timeZone === 'string' ? job.cronTime.timeZone : undefined,
236
+ timezone: (job.cronTime && typeof job.cronTime.timeZone === 'string' && job.cronTime.timeZone) || localTimeZone(),
163
237
  trigger: 'cron'
164
238
  });
165
239
  };
@@ -167,6 +241,40 @@ function wrapCronJobCallback(job, callback) {
167
241
  return wrapped;
168
242
  }
169
243
 
244
+ // What a @nestjs/schedule method runs as: its name, and its schedule from the decorator's
245
+ // metadata (a cron expression, or "@every <ms>ms" for an interval; a timeout runs once).
246
+ function nestJob(explorer, methodRef, instance) {
247
+ const metadata = explorer.metadataAccessor;
248
+ const read = (method) => (metadata && typeof metadata[method] === 'function' ? metadata[method](methodRef) : undefined);
249
+ const owner = instance && instance.constructor && typeof instance.constructor.name === 'string' ? instance.constructor.name : '';
250
+ const method = typeof methodRef.name === 'string' ? methodRef.name : '';
251
+ const fallback = owner && method ? `${owner}.${method}` : method || owner || 'job';
252
+ const named = (value) => (typeof value === 'string' && value.trim() ? value : fallback);
253
+ const type = read('getSchedulerType');
254
+ if (type === NEST_CRON) {
255
+ const cron = read('getCronMetadata') || {};
256
+ const schedule = typeof cron.cronTime === 'string' && cron.cronTime.trim() ? cron.cronTime : undefined;
257
+ const timezone = typeof cron.timeZone === 'string' && cron.timeZone ? cron.timeZone : schedule && cron.utcOffset === undefined ? localTimeZone() : undefined;
258
+ return { name: named(cron.name), options: { schedule, timezone, trigger: 'cron' } };
259
+ }
260
+ if (type === NEST_INTERVAL) {
261
+ const interval = read('getIntervalMetadata') || {};
262
+ const every = Number.isFinite(interval.timeout) && interval.timeout > 0 ? `@every ${interval.timeout}ms` : undefined;
263
+ return { name: named(read('getSchedulerName')), options: { schedule: every, trigger: 'cron' } };
264
+ }
265
+ if (type === NEST_TIMEOUT) return { name: named(read('getSchedulerName')), options: { trigger: 'cron' } };
266
+ return { name: fallback, options: { trigger: 'cron' } };
267
+ }
268
+
269
+ // The process's time zone (TZ, else the system's).
270
+ function localTimeZone() {
271
+ try {
272
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
273
+ } catch (error) {
274
+ return undefined;
275
+ }
276
+ }
277
+
170
278
  // The file an `import` of the package loads, when it differs from what require() loads.
171
279
  function esmEntry(packageDir) {
172
280
  try {
@@ -1,12 +1,18 @@
1
1
  /**
2
- * Lines written with pino and winston, like console lines: on the timeline of the request (or job
3
- * run) they belong to, and on the Logs page. Only lines the logger actually writes are recorded
4
- * (a level it has turned off is skipped), and what the logger writes is untouched.
2
+ * Lines written with pino, winston and NestJS's Logger, like console lines: on the timeline of the
3
+ * request (or job run) they belong to, and on the Logs page. Only lines the logger actually writes
4
+ * are recorded (a level it has turned off is skipped), and what the logger writes is untouched.
5
5
  *
6
- * Both are patched on a prototype every logger shares, so loggers created before PeekInside, child
6
+ * Each is patched on a prototype every logger shares, so loggers created before PeekInside, child
7
7
  * loggers and loggers imported as ES modules are all covered.
8
+ *
9
+ * Nest's default logger (ConsoleLogger) writes to process.stdout itself, past the console. Its
10
+ * lines keep the class that wrote them ("[OrdersService] Charging card"), and the redaction the
11
+ * app set up applies to them too. Nest's own startup lines (modules loaded, routes mapped) are
12
+ * left out; its warnings and errors are not.
8
13
  */
9
14
 
15
+ import { inspect } from 'util';
10
16
  import { LogCapture } from '../logs/log-capture.js';
11
17
  import { Timeline } from '../timeline/timeline.js';
12
18
 
@@ -16,6 +22,10 @@ const PINO_LEVELS = { trace: 'debug', debug: 'debug', info: 'info', warn: 'warn'
16
22
  const WINSTON_LEVELS = { error: 'error', warn: 'warn', info: 'info', http: 'info', verbose: 'debug', debug: 'debug', silly: 'debug' };
17
23
  // Keys of a winston `info` that aren't the line's own data.
18
24
  const WINSTON_OWN = new Set(['level', 'message', 'timestamp', 'splat']);
25
+ const NEST_LEVELS = { log: 'info', error: 'error', warn: 'warn', debug: 'debug', verbose: 'debug', fatal: 'error' };
26
+ // Nest's own classes that log while the app starts: their info lines are startup noise.
27
+ const NEST_STARTUP = new Set(['NestFactory', 'InstanceLoader', 'RoutesResolver', 'RouterExplorer', 'NestApplication', 'NestMicroservice', 'WebSocketsController']);
28
+ const INSPECT = { depth: 4, breakLength: Infinity, compact: true };
19
29
 
20
30
  /**
21
31
  * Patches logging libraries.
@@ -88,6 +98,70 @@ export class LoggerInstrumentation {
88
98
  Object.defineProperty(proto, kPatched, { value: true });
89
99
  return true;
90
100
  }
101
+
102
+ /**
103
+ * Patches NestJS's ConsoleLogger, the logger behind Nest's Logger unless the app set its own:
104
+ * every line goes through printMessages(), after the level check.
105
+ *
106
+ * @param {*} target - A ConsoleLogger (or subclass) instance or class
107
+ * @returns {boolean} Whether it is (now) patched
108
+ */
109
+ static nest(target) {
110
+ let proto = typeof target === 'function' ? target.prototype : target;
111
+ while (proto && !(Object.prototype.hasOwnProperty.call(proto, 'printMessages') && typeof proto.formatPid === 'function')) {
112
+ proto = Object.getPrototypeOf(proto);
113
+ }
114
+ if (!proto || typeof proto.printMessages !== 'function') return false;
115
+ if (proto[kPatched]) return true;
116
+ const original = proto.printMessages;
117
+ proto.printMessages = function (messages) {
118
+ const args = Array.prototype.slice.call(arguments);
119
+ try {
120
+ // Nest 12 resolves lazy messages (functions) while printing: they are resolved once here,
121
+ // and the logger prints the values.
122
+ if (Array.isArray(messages) && typeof this.resolveMessage === 'function') {
123
+ args[0] = messages.map((message) => (typeof message === 'function' ? this.resolveMessage(message) : message));
124
+ }
125
+ nestLine(this, args[0], args[1], args[2], args[4], args[5]);
126
+ } catch (error) {
127
+ // The line is written all the same.
128
+ }
129
+ return original.apply(this, args);
130
+ };
131
+ Object.defineProperty(proto, kPatched, { value: true });
132
+ return true;
133
+ }
134
+ }
135
+
136
+ // One call of ConsoleLogger#printMessages(messages, context, logLevel, writeStreamType, errorStack,
137
+ // params): a line per message, as Nest prints them.
138
+ function nestLine(logger, messages, context, logLevel, errorStack, params) {
139
+ // With forceConsole, Nest prints through the console, which records the lines.
140
+ if (!Array.isArray(messages) || (logger.options && logger.options.forceConsole)) return;
141
+ const level = NEST_LEVELS[logLevel] || 'info';
142
+ const where = typeof context === 'string' ? context : '';
143
+ if (NEST_STARTUP.has(where) && (level === 'info' || level === 'debug')) return;
144
+ const timeline = Timeline.current();
145
+ if (!(timeline && timeline.settings.captureLogs) && !LogCapture.wants(level)) return;
146
+
147
+ const redact = logger.redactor && typeof logger.redact === 'function' ? (value) => logger.redact(value) : (value) => value;
148
+ const fields = {};
149
+ if (params && typeof params === 'object') Object.assign(fields, redact(params));
150
+ if (where) fields.context = where;
151
+ if (typeof errorStack === 'string' && errorStack) fields.stack = errorStack;
152
+ for (const message of messages) {
153
+ if (typeof message === 'function') continue;
154
+ const value = redact(message);
155
+ const own = value instanceof Error ? { ...fields, err: errorFields(value), ...(fields.stack ? {} : { stack: String(value.stack || '') }) } : fields;
156
+ const text = nestText(value);
157
+ capture(level, where ? `[${where}] ${text}` : text, Object.keys(own).length ? own : undefined, 'nest');
158
+ }
159
+ }
160
+
161
+ function nestText(value) {
162
+ if (typeof value === 'string') return value;
163
+ if (value instanceof Error) return `${value.name}: ${value.message}`;
164
+ return inspect(value, INSPECT);
91
165
  }
92
166
 
93
167
  function capture(level, message, fields, source) {
@@ -10,6 +10,7 @@ import { FrameworkAdapter } from './framework-adapter.js';
10
10
  * Records requests from a Fastify onRequest hook, and errors from an onError hook.
11
11
  *
12
12
  * onRequest runs before Fastify reads the body, so bodies are captured as Fastify parses them.
13
+ * It runs once Fastify has routed the request, so the route pattern and parameters are known.
13
14
  * Hooks must be added before the app is ready, so attach before fastify.listen().
14
15
  */
15
16
  export class FastifyAdapter extends FrameworkAdapter {
@@ -36,7 +37,10 @@ export class FastifyAdapter extends FrameworkAdapter {
36
37
  attach(fastify) {
37
38
  fastify.addHook('onRequest', (request, reply, done) => {
38
39
  ExchangeRecorder.setBodySource(request.raw, () => request.body);
39
- this.peekInside._intercept(request.raw, reply.raw, done, 'fastify');
40
+ this.peekInside._intercept(request.raw, reply.raw, () => {
41
+ FastifyAdapter._noteRoute(request);
42
+ done();
43
+ }, 'fastify');
40
44
  });
41
45
  if (this.peekInside.options.captureExceptions !== false) {
42
46
  fastify.addHook('onError', (request, reply, error, done) => {
@@ -49,6 +53,19 @@ export class FastifyAdapter extends FrameworkAdapter {
49
53
  }
50
54
  }
51
55
 
56
+ // The route Fastify matched (routerPath before Fastify 4.10); parameters stay on its request.
57
+ static _noteRoute(request) {
58
+ try {
59
+ const context = RequestContext.of(request.raw);
60
+ if (!context) return;
61
+ const options = request.routeOptions;
62
+ const route = options && typeof options === 'object' ? options.url : request.routerPath;
63
+ context.noteRoute({ route: typeof route === 'string' ? route : null, params: () => request.params });
64
+ } catch (error) {
65
+ // The request is recorded without them.
66
+ }
67
+ }
68
+
52
69
  static _noteError(request, error) {
53
70
  try {
54
71
  const context = RequestContext.of(request.raw);