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.
package/README.md CHANGED
@@ -5,6 +5,7 @@ Debug Node.js from the inside: every error with the request that set it off, the
5
5
  [![npm](https://img.shields.io/npm/v/peekinside?color=18b69b)](https://www.npmjs.com/package/peekinside)
6
6
  ![License](https://img.shields.io/badge/license-MIT-18b69b)
7
7
  ![Node](https://img.shields.io/badge/node-%3E%3D18-18b69b)
8
+ [![Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-FFDD00?logo=buymeacoffee&logoColor=000000)](https://buymeacoffee.com/thejesbin)
8
9
 
9
10
  ## Install
10
11
 
@@ -20,11 +21,13 @@ const app = express();
20
21
  PeekInside.attach(app);
21
22
  ```
22
23
 
23
- Start your app, and PeekInside prints the link to its dashboard. It works with Express, Koa, Fastify and the native http module.
24
+ Start your app, and PeekInside prints the link to its dashboard. It works with Express, Koa, Fastify, NestJS, Hono and the native http module.
24
25
 
25
26
  Check the setup before anything breaks on its own: `npx peekinside test` sends a test error from your project.
26
27
 
27
- Also recorded: background jobs (node-cron, cron, BullMQ, or `PeekInside.job()`, with an email when a scheduled job misses its run), log lines from the console, pino and winston, event-loop blocks and memory, the deploy each error came from, the user it happened to (`PeekInside.setUser()`), and TypeScript lines through your source maps.
28
+ While you develop, `PeekInside.dump(value)` shows any value in the local dashboard instead of your terminal: as a tree you can open, under the request it belongs to, with the line that dumped it.
29
+
30
+ Also recorded: background jobs (node-cron, cron, BullMQ, @nestjs/schedule, or `PeekInside.job()`, with an email when a scheduled job misses its run), log lines from the console, pino, winston and NestJS's Logger, event-loop blocks and memory, the deploy each error came from, the user it happened to (`PeekInside.setUser()`), and TypeScript lines through your source maps.
28
31
 
29
32
  ## Documentation
30
33
 
@@ -37,10 +40,18 @@ Everything else is in the docs: **[peekinside.dev/docs](https://peekinside.dev/d
37
40
  - [Timeline and queries](https://peekinside.dev/docs/timeline)
38
41
  - [Logs](https://peekinside.dev/docs/logs)
39
42
  - [Jobs and schedules](https://peekinside.dev/docs/jobs)
43
+ - [Dumps](https://peekinside.dev/docs/dumps)
40
44
  - [Runtime](https://peekinside.dev/docs/runtime)
45
+ - [Alerts](https://peekinside.dev/docs/alerts)
41
46
  - [Configuration](https://peekinside.dev/docs/configuration)
42
47
  - [API](https://peekinside.dev/docs/api)
43
48
 
49
+ ## Support
50
+
51
+ If PeekInside saves you a debugging session, you can buy me a coffee:
52
+
53
+ [![Buy me a coffee](https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=&slug=thejesbin&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff)](https://buymeacoffee.com/thejesbin)
54
+
44
55
  ## License
45
56
 
46
57
  MIT
package/index.d.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  * and the exceptions that happen while handling them. In development they go to the free local
4
4
  * dashboard; with an API key they are encrypted and sent to PeekInside Cloud.
5
5
  *
6
- * The signatures are structural, so Express, Connect, Koa, Fastify and node:http objects fit
7
- * without PeekInside depending on any of those packages.
6
+ * The signatures are structural, so Express, Connect, Koa, Fastify, NestJS, Hono and node:http
7
+ * objects fit without PeekInside depending on any of those packages.
8
8
  */
9
9
 
10
10
  import type { IncomingMessage, Server, ServerResponse } from "node:http";
@@ -67,8 +67,8 @@ export interface PeekInsideOptions {
67
67
  projectRoot?: string;
68
68
 
69
69
  /**
70
- * Records exceptions automatically: errors that reach Express, Koa or Fastify error handling,
71
- * uncaught exceptions and unhandled rejections. `report()` works either way.
70
+ * Records exceptions automatically: errors that reach Express, Koa, Fastify, NestJS or Hono error
71
+ * handling, uncaught exceptions and unhandled rejections. `report()` works either way.
72
72
  * @default true
73
73
  */
74
74
  captureExceptions?: boolean;
@@ -109,8 +109,8 @@ export interface PeekInsideOptions {
109
109
  captureOutgoing?: boolean;
110
110
 
111
111
  /**
112
- * Records log lines from the console, pino and winston: on the timeline of the request (or job
113
- * run) they are written in, and on the Logs page, from anywhere in the app.
112
+ * Records log lines from the console, pino, winston and NestJS's Logger: on the timeline of the
113
+ * request (or job run) they are written in, and on the Logs page, from anywhere in the app.
114
114
  * @default true
115
115
  */
116
116
  captureLogs?: boolean;
@@ -122,7 +122,8 @@ export interface PeekInsideOptions {
122
122
  logLevel?: "debug" | "info" | "warn" | "error";
123
123
 
124
124
  /**
125
- * Records runs of node-cron, cron and BullMQ jobs automatically (see `job()` for other jobs).
125
+ * Records runs of node-cron, cron, BullMQ and @nestjs/schedule jobs automatically (see `job()`
126
+ * for other jobs).
126
127
  * @default true
127
128
  */
128
129
  captureJobs?: boolean;
@@ -208,6 +209,8 @@ export interface PeekInsideUser {
208
209
  id?: string | number;
209
210
  email?: string;
210
211
  name?: string;
212
+ /** As your app writes it, such as "+91 98765 43210". */
213
+ phone?: string | number;
211
214
  }
212
215
 
213
216
  /** Options that can still change after the instance is created (see `attachTo`). */
@@ -215,9 +218,14 @@ export type CaptureOptions = Pick<PeekInsideOptions, "maxBodySize" | "ignorePath
215
218
 
216
219
  /**
217
220
  * What `PeekInside.attach()` understands: an Express or Connect app, a Koa app, a Fastify
218
- * instance, or a node:http (or https) server.
221
+ * instance, a NestJS app or application context, a Hono app, or a node:http (or https) server.
219
222
  */
220
- export type AttachTarget = Server | { use(...args: any[]): unknown } | { addHook(...args: any[]): unknown };
223
+ export type AttachTarget =
224
+ | Server
225
+ | { use(...args: any[]): unknown }
226
+ | { addHook(...args: any[]): unknown }
227
+ | { select(...args: any[]): unknown; useLogger(...args: any[]): unknown }
228
+ | { fetch(...args: any[]): unknown; onError(...args: any[]): unknown };
221
229
 
222
230
  /** Express and Connect style middleware. */
223
231
  export type NodeMiddleware = (req: IncomingMessage, res: ServerResponse, next: (error?: unknown) => void) => void;
@@ -234,7 +242,7 @@ export type KoaMiddleware = (
234
242
  * @example
235
243
  * import PeekInside from "peekinside";
236
244
  *
237
- * PeekInside.attach(app); // Express, Koa, Fastify or an http.Server
245
+ * PeekInside.attach(app); // Express, Koa, Fastify, NestJS, Hono or an http.Server
238
246
  */
239
247
  declare class PeekInside {
240
248
  /**
@@ -253,9 +261,11 @@ declare class PeekInside {
253
261
  readonly release: string | null;
254
262
 
255
263
  /**
256
- * One-line setup: records every request the app handles.
264
+ * One-line setup: records every request the app handles. Attach a NestJS app before
265
+ * `app.listen()`, a Hono app before `serve()`, and a Fastify app before `fastify.listen()`.
257
266
  *
258
- * @param app - Express or Connect app, Koa app, Fastify instance, or http.Server
267
+ * @param app - Express or Connect app, Koa app, Fastify instance, NestJS app or application
268
+ * context, Hono app, or http.Server
259
269
  */
260
270
  static attach(app: AttachTarget, options?: PeekInsideOptions): PeekInside;
261
271
 
@@ -299,6 +309,27 @@ declare class PeekInside {
299
309
  */
300
310
  static measure<T>(name: string, fn: () => T): T;
301
311
 
312
+ /**
313
+ * Shows a value in the local dashboard instead of your terminal: on the Dumps page and under the
314
+ * request (or job run) it was dumped in, as a tree that opens level by level, with the line that
315
+ * dumped it. Returns the value, so it can wrap an expression in place.
316
+ *
317
+ * Dumps are for development: they go to the local dashboard only. With PeekInside Cloud, or when
318
+ * nothing is recorded, `dump()` just returns the value.
319
+ *
320
+ * @param label - Shown with the dump, such as `"cart before checkout"`
321
+ *
322
+ * @example
323
+ * const order = PeekInside.dump(await buildOrder(cart), "order");
324
+ */
325
+ dump<T>(value: T, label?: string): T;
326
+
327
+ /**
328
+ * `dump()` with the instance that tracks the current request, or else the most recently created
329
+ * instance that records.
330
+ */
331
+ static dump<T>(value: T, label?: string): T;
332
+
302
333
  /**
303
334
  * The id of the request the calling code runs for, as the dashboard knows it: put it in your
304
335
  * own logs to find the request from a log line. `null` outside a recorded request.
@@ -315,7 +346,7 @@ declare class PeekInside {
315
346
  *
316
347
  * @example
317
348
  * app.use((req, res, next) => {
318
- * if (req.user) PeekInside.setUser({ id: req.user.id, email: req.user.email });
349
+ * if (req.user) PeekInside.setUser({ id: req.user.id, email: req.user.email, name: req.user.name, phone: req.user.phone });
319
350
  * next();
320
351
  * });
321
352
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peekinside",
3
- "version": "1.2.4",
3
+ "version": "1.3.0",
4
4
  "description": "Debug Node.js from the inside: errors with the requests, queries, logs and jobs behind them. A free local dashboard, and encrypted monitoring for production",
5
5
  "main": "index.js",
6
6
  "types": "./index.d.ts",
@@ -57,6 +57,12 @@
57
57
  },
58
58
  "homepage": "https://peekinside.dev",
59
59
  "devDependencies": {
60
+ "@hono/node-server": "2.1.3",
61
+ "@nestjs/common": "12.1.3",
62
+ "@nestjs/core": "12.1.3",
63
+ "@nestjs/platform-express": "12.1.3",
64
+ "@nestjs/platform-fastify": "12.1.3",
65
+ "@nestjs/schedule": "12.0.2",
60
66
  "@types/express": "4.17.25",
61
67
  "@types/koa": "2.15.0",
62
68
  "@types/node": "24.10.1",
@@ -65,6 +71,7 @@
65
71
  "express": "4.22.3",
66
72
  "express5": "npm:express@5.1.0",
67
73
  "fastify": "5.12.5",
74
+ "hono": "4.13.13",
68
75
  "koa": "2.16.4",
69
76
  "mongodb": "6.21.0",
70
77
  "mongodb-memory-server-core": "11.3.0",
@@ -73,6 +80,8 @@
73
80
  "node-cron": "4.6.0",
74
81
  "pg": "8.23.1",
75
82
  "pino": "10.4.0",
83
+ "reflect-metadata": "0.2.2",
84
+ "rxjs": "7.8.2",
76
85
  "typescript": "5.9.3",
77
86
  "winston": "3.19.0"
78
87
  }
package/src/PeekInside.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Records every request and response your app handles. During development they go to the free
5
5
  * local dashboard, which is shared by all your projects and keeps its data on this machine.
6
6
  * With an API key they are encrypted and sent to PeekInside Cloud instead.
7
- * Works with Express, Koa, Fastify and the native http module.
7
+ * Works with Express, Koa, Fastify, NestJS, Hono and the native http module.
8
8
  *
9
9
  * Exceptions are recorded too: errors that reach the framework's error handling, crashes
10
10
  * (uncaught exceptions and unhandled rejections), and errors the app reports. Each is linked to the
@@ -13,6 +13,9 @@
13
13
  * Each request also gets a timeline: the database queries, outgoing HTTP calls, console lines and
14
14
  * exceptions that happened while it was handled (see src/timeline and src/instrumentation).
15
15
  *
16
+ * In development, dump() shows values in the local dashboard, under the request they were dumped
17
+ * in (see src/dumps).
18
+ *
16
19
  * This class is the public API. It decides where requests go and which requests are recorded,
17
20
  * and leaves the work to the capture (src/capture), exception (src/exceptions), framework
18
21
  * (src/integrations) and transport (src/transport) classes.
@@ -26,6 +29,7 @@ import { isIgnoredPath } from './capture/ignored-paths.js';
26
29
  import { LOG_PREFIX, VERSION, settings } from './config.js';
27
30
  import { TrafficClassifier } from './deployment.js';
28
31
  import { ExceptionCapture } from './exceptions/exception-capture.js';
32
+ import { DumpCapture } from './dumps/dump-capture.js';
29
33
  import { ProcessMonitor } from './exceptions/process-monitor.js';
30
34
  import { RequestContext } from './exceptions/request-context.js';
31
35
  import { Instrumentation } from './instrumentation/index.js';
@@ -33,8 +37,10 @@ import { isNativePromise } from './instrumentation/patch.js';
33
37
  import { TIMELINE_DEFAULTS, Timeline, TimelineSettings } from './timeline/timeline.js';
34
38
  import { ExpressAdapter } from './integrations/express.js';
35
39
  import { FastifyAdapter } from './integrations/fastify.js';
40
+ import { HonoAdapter } from './integrations/hono.js';
36
41
  import { HttpServerAdapter } from './integrations/http-server.js';
37
42
  import { KoaAdapter } from './integrations/koa.js';
43
+ import { NestAdapter } from './integrations/nest.js';
38
44
  import { ServerBinder } from './integrations/server-binder.js';
39
45
  import { ProjectIdentity } from './project.js';
40
46
  import { Release } from './release.js';
@@ -42,6 +48,7 @@ import { JobRunner } from './jobs/job-runner.js';
42
48
  import { LogCapture } from './logs/log-capture.js';
43
49
  import { LoopMonitor } from './runtime/loop-monitor.js';
44
50
  import { RuntimeSampler } from './runtime/sampler.js';
51
+ import { RequestCounter } from './runtime/request-counter.js';
45
52
  import { UserInfo } from './user.js';
46
53
  import { CloudTransport } from './transport/cloud.js';
47
54
  import { LocalTransport } from './transport/local.js';
@@ -59,9 +66,10 @@ const DEFAULT_OPTIONS = {
59
66
  ...TIMELINE_DEFAULTS
60
67
  };
61
68
 
62
- // Tried in this order by attachTo(): Fastify and Koa apps also have use(), and anything with
63
- // use() is handled as middleware before falling back to a plain server.
64
- const ADAPTERS = [FastifyAdapter, KoaAdapter, ExpressAdapter, HttpServerAdapter];
69
+ // Tried in this order by attachTo(): Nest apps (which pass Express's or Fastify's methods
70
+ // through), Hono, Fastify and Koa apps also have use(), and anything with use() is handled as
71
+ // middleware before falling back to a plain server.
72
+ const ADAPTERS = [NestAdapter, HonoAdapter, FastifyAdapter, KoaAdapter, ExpressAdapter, HttpServerAdapter];
65
73
 
66
74
  // Marks requests already being recorded, so a request that passes through several integrations
67
75
  // (or PeekInside instances) is recorded once.
@@ -72,7 +80,7 @@ const kTracked = Symbol('peekInside.tracked');
72
80
  *
73
81
  * @class PeekInside
74
82
  * @example
75
- * // Express, Koa, Fastify or an http.Server
83
+ * // Express, Koa, Fastify, NestJS, Hono or an http.Server
76
84
  * PeekInside.attach(app);
77
85
  *
78
86
  * @example
@@ -103,8 +111,8 @@ class PeekInside {
103
111
  * "v2.4.0". Defaults to PEEKINSIDE_RELEASE, then the commit your host deployed (Vercel, Render,
104
112
  * Railway, Heroku and others set it), then the commit checked out in the project folder.
105
113
  * @param {boolean} [options.captureExceptions=true] - Record exceptions automatically: errors that
106
- * reach Express, Koa or Fastify error handling, uncaught exceptions and unhandled rejections.
107
- * report() works either way.
114
+ * reach Express, Koa, Fastify, NestJS or Hono error handling, uncaught exceptions and unhandled
115
+ * rejections. report() works either way.
108
116
  * @param {boolean} [options.sourceContext=true] - Include the source lines around the app's own
109
117
  * stack frames in exception records
110
118
  * @param {boolean} [options.sourceMaps=true] - Map stack frames of compiled code (TypeScript
@@ -113,11 +121,12 @@ class PeekInside {
113
121
  * queries, outgoing calls, console lines and exceptions
114
122
  * @param {boolean} [options.captureQueries=true] - pg, mysql2, MongoDB (and Mongoose), Prisma
115
123
  * @param {boolean} [options.captureOutgoing=true] - node:http/https requests and fetch
116
- * @param {boolean} [options.captureLogs=true] - Log lines (the console, pino, winston): on request
117
- * timelines and the Logs page
124
+ * @param {boolean} [options.captureLogs=true] - Log lines (the console, pino, winston, NestJS's
125
+ * Logger): on request timelines and the Logs page
118
126
  * @param {'debug'|'info'|'warn'|'error'} [options.logLevel='info'] - The lowest level the Logs
119
127
  * page records
120
- * @param {boolean} [options.captureJobs=true] - Runs of node-cron, cron and BullMQ jobs
128
+ * @param {boolean} [options.captureJobs=true] - Runs of node-cron, cron, BullMQ and
129
+ * @nestjs/schedule jobs
121
130
  * @param {boolean} [options.queryParams=true] - Keep the (masked) values sent with queries
122
131
  * @param {number} [options.slowQueryMs=100] - Queries this slow or slower are flagged
123
132
  * @param {number} [options.nPlusOneThreshold=5] - A statement run this many times in one request
@@ -159,9 +168,12 @@ class PeekInside {
159
168
  // Whether this instance uses the event-loop monitor, and its process sampler.
160
169
  this._loopMonitor = false;
161
170
  this._sampler = null;
162
- // Log lines for the Logs page, and job runs.
171
+ // The requests this instance serves, counted for its process samples.
172
+ this._requests = null;
173
+ // Log lines for the Logs page, job runs, and dumps (local mode only).
163
174
  this._logs = null;
164
175
  this._jobs = null;
176
+ this._dumps = null;
165
177
  this._servers = new ServerBinder(this, { onListening: (port) => this._onAppListening(port) });
166
178
 
167
179
  const environment = this.options.environment || settings.environment();
@@ -218,9 +230,11 @@ class PeekInside {
218
230
  }
219
231
  if (this.options.runtime !== false) {
220
232
  const transport = this._transport;
233
+ this._requests = new RequestCounter();
221
234
  this._sampler = new RuntimeSampler({
222
235
  intervalMs: this._cloud ? 60_000 : 10_000,
223
236
  monitor: LoopMonitor.shared,
237
+ requests: this._requests,
224
238
  onSample: (sample) => transport.addSample(sample)
225
239
  }).start();
226
240
  }
@@ -232,6 +246,9 @@ class PeekInside {
232
246
  if (logs) {
233
247
  this._logs = new LogCapture({ level: LogCapture.level(this.options.logLevel), sink: () => this._transport, isEnabled: () => this.isEnabled });
234
248
  }
249
+ if (this._local) {
250
+ this._dumps = new DumpCapture({ capture: this._exceptions, sink: () => this._transport, isEnabled: () => this.isEnabled });
251
+ }
235
252
  this._jobs = new JobRunner({
236
253
  capture: this._exceptions,
237
254
  sink: () => this._transport,
@@ -359,6 +376,48 @@ class PeekInside {
359
376
  return instance ? instance.measure(name, fn) : fn();
360
377
  }
361
378
 
379
+ /**
380
+ * Shows a value in the local dashboard instead of your terminal: on the Dumps page and under the
381
+ * request (or job run) it was dumped in, as a tree that opens level by level, with the line that
382
+ * dumped it. Returns the value, so it can wrap an expression in place.
383
+ *
384
+ * Dumps are for development: they go to the local dashboard only. With PeekInside Cloud, or when
385
+ * nothing is recorded, dump() just returns the value.
386
+ *
387
+ * @template T
388
+ * @param {T} value - Anything: objects, arrays, Maps, class instances, errors...
389
+ * @param {string} [label] - Shown with the dump, such as "cart before checkout"
390
+ * @returns {T} The same value
391
+ *
392
+ * @example
393
+ * const order = PeekInside.dump(await buildOrder(cart), 'order');
394
+ */
395
+ dump(value, label) {
396
+ if (this._dumps) this._dumps.record(value, label, PeekInside.prototype.dump);
397
+ return value;
398
+ }
399
+
400
+ /**
401
+ * PeekInside#dump() with the instance that tracks the current request, or else the most recently
402
+ * created instance that records.
403
+ *
404
+ * @template T
405
+ * @param {T} value
406
+ * @param {string} [label]
407
+ * @returns {T} The same value
408
+ */
409
+ static dump(value, label) {
410
+ try {
411
+ const context = RequestContext.current();
412
+ const capture = context && context.capture && context.capture.recording ? context.capture : ExceptionCapture.latest();
413
+ const owner = capture ? capture.owner : null;
414
+ if (owner && owner._dumps) owner._dumps.record(value, label, PeekInside.dump);
415
+ } catch (error) {
416
+ // The value comes back all the same.
417
+ }
418
+ return value;
419
+ }
420
+
362
421
  /**
363
422
  * The id of the request the calling code runs for: the id its record has in the dashboard, to
364
423
  * put in the app's own logs.
@@ -427,13 +486,14 @@ class PeekInside {
427
486
  * customer ran into. In PeekInside Cloud the user is encrypted with the rest of the request.
428
487
  * Call it once the user is known, such as in your authentication middleware.
429
488
  *
430
- * @param {{ id?: string|number, email?: string, name?: string }|string|number|null} user - The
431
- * user, or just their id; null forgets it
489
+ * @param {{ id?: string|number, email?: string, name?: string, phone?: string|number }|string|number|null} user -
490
+ * The user, or just their id; null forgets it. The phone is kept as written (phoneNumber,
491
+ * phone_number and mobile work too).
432
492
  * @returns {boolean} Whether a request was running to attach it to
433
493
  *
434
494
  * @example
435
495
  * app.use((req, res, next) => {
436
- * if (req.user) PeekInside.setUser({ id: req.user.id, email: req.user.email });
496
+ * if (req.user) PeekInside.setUser({ id: req.user.id, email: req.user.email, name: req.user.name, phone: req.user.phone });
437
497
  * next();
438
498
  * });
439
499
  */
@@ -553,7 +613,9 @@ class PeekInside {
553
613
  /**
554
614
  * Attach to an application and record its traffic
555
615
  *
556
- * Detects Fastify, Koa, Express-like apps (Express, Connect) and native `http.Server`s.
616
+ * Detects NestJS apps (and application contexts), Hono apps, Fastify, Koa, Express-like apps
617
+ * (Express, Connect) and native `http.Server`s. Attach a NestJS app before app.listen(), a Hono
618
+ * app before serve(), and a Fastify app before fastify.listen().
557
619
  *
558
620
  * @param {Object} app - Application or server instance
559
621
  * @param {Object} [options] - `maxBodySize` and `ignorePaths` to merge into the configuration
@@ -567,7 +629,7 @@ class PeekInside {
567
629
  if (Adapter) {
568
630
  this._adapter(Adapter).attach(app);
569
631
  } else {
570
- console.warn(`${LOG_PREFIX} Could not detect the framework. Use expressMiddleware(), koaMiddleware(), fastifyHook() or wrapHttpServer().`);
632
+ console.warn(`${LOG_PREFIX} Could not detect the framework. Pass an Express, Koa, Fastify, NestJS or Hono app, or use expressMiddleware(), koaMiddleware(), fastifyHook() or wrapHttpServer().`);
571
633
  }
572
634
  return this;
573
635
  }
@@ -575,7 +637,7 @@ class PeekInside {
575
637
  /**
576
638
  * One-line setup: record every request an app handles
577
639
  *
578
- * @param {Object} app - Express, Koa or Fastify app, or an http.Server
640
+ * @param {Object} app - Express, Koa, Fastify, NestJS or Hono app, or an http.Server
579
641
  * @param {Object} [options] - Configuration options (see constructor)
580
642
  * @returns {PeekInside} PeekInside instance
581
643
  *
@@ -683,6 +745,7 @@ class PeekInside {
683
745
 
684
746
  _track(req, res, target, integration) {
685
747
  let context = null;
748
+ const requests = this._requests;
686
749
  const recorder = new ExchangeRecorder(req, res, target, {
687
750
  maxBodySize: () => this.options.maxBodySize,
688
751
  onExchange: (exchange) => {
@@ -698,6 +761,19 @@ class PeekInside {
698
761
  });
699
762
  context = new RequestContext({ capture: this._exceptions, req, recorder, target, integration });
700
763
  recorder.start();
764
+ if (requests) {
765
+ // Counted off the response itself, so a request ends once whatever happens to its record.
766
+ requests.started();
767
+ let open = true;
768
+ const end = () => {
769
+ if (open) {
770
+ open = false;
771
+ requests.ended();
772
+ }
773
+ };
774
+ res.once('finish', end);
775
+ res.once('close', end);
776
+ }
701
777
  if (this._timeline) {
702
778
  context.timeline = new Timeline(this._timeline, recorder.perfStart);
703
779
  }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The parts of a project's alert rules that only the app's server can check: conditions on a
3
+ * request's path, user and response, and on an exception's or a log line's message, which reach
4
+ * PeekInside Cloud sealed. The matcher fetches them now and then, checks each record before it is
5
+ * sealed, and the transport sends which records met which rule with the batch: never what they
6
+ * hold. Everything else about a rule (a status, a duration, an environment) is checked by
7
+ * PeekInside Cloud itself.
8
+ */
9
+
10
+ import { RequestContext } from '../exceptions/request-context.js';
11
+
12
+ // Fetched again this often, the first time this long after the transport first delivered.
13
+ const REFRESH_MS = 60_000;
14
+ const FIRST_FETCH_MS = 1000;
15
+ // The most of a response body checked for a "contains" condition.
16
+ const MAX_BODY = 64 * 1024;
17
+ const RULE_ID = /^rl[0-9a-z]{12}$/;
18
+
19
+ /**
20
+ * @typedef {Object} SealedRule
21
+ * @property {string} id - The rule's id
22
+ * @property {'requests'|'exceptions'|'logs'} source
23
+ * @property {{ field: string, op: 'is'|'is-not'|'contains'|'matches', value: string }[]} conditions
24
+ */
25
+
26
+ export class RuleMatcher {
27
+ /**
28
+ * @param {Object} options
29
+ * @param {() => Promise<{ rules: SealedRule[] }>} options.fetchRules - Asks PeekInside Cloud for them
30
+ * @param {number} [options.refreshMs]
31
+ */
32
+ constructor({ fetchRules, refreshMs = REFRESH_MS }) {
33
+ this.fetchRules = fetchRules;
34
+ this.refreshMs = refreshMs;
35
+ /** @type {SealedRule[]} */
36
+ this.rules = [];
37
+ this.timer = null;
38
+ this.first = null;
39
+ }
40
+
41
+ /**
42
+ * Fetches the rules shortly, then keeps them current. Its timers never keep the process alive:
43
+ * a script that ends right away never waits for them.
44
+ *
45
+ * @returns {void}
46
+ */
47
+ start() {
48
+ if (this.timer || this.first) return;
49
+ this.first = RequestContext.outside(() =>
50
+ setTimeout(() => {
51
+ void this.refresh();
52
+ this.timer = RequestContext.outside(() => setInterval(() => void this.refresh(), this.refreshMs));
53
+ if (typeof this.timer.unref === 'function') this.timer.unref();
54
+ }, FIRST_FETCH_MS)
55
+ );
56
+ if (typeof this.first.unref === 'function') this.first.unref();
57
+ }
58
+
59
+ stop() {
60
+ clearTimeout(this.first);
61
+ clearInterval(this.timer);
62
+ this.first = null;
63
+ this.timer = null;
64
+ }
65
+
66
+ /**
67
+ * @returns {Promise<void>} Never rejects: without rules, nothing is reported
68
+ */
69
+ async refresh() {
70
+ try {
71
+ const answer = await RequestContext.outside(() => this.fetchRules());
72
+ this.rules = Array.isArray(answer && answer.rules) ? answer.rules.filter(valid) : [];
73
+ } catch (error) {
74
+ // Kept as they were: PeekInside Cloud may be briefly unreachable.
75
+ }
76
+ }
77
+
78
+ /**
79
+ * The rules a record meets on its sealed fields.
80
+ *
81
+ * @param {Object} record - A RequestRecord, ExceptionRecord or LogRecord, before it is sealed
82
+ * @returns {{ rule: string, id: string }[]}
83
+ */
84
+ matches(record) {
85
+ if (!this.rules.length || !record || typeof record.id !== 'string') return [];
86
+ const source = sourceOf(record);
87
+ if (!source) return [];
88
+ const found = [];
89
+ for (const rule of this.rules) {
90
+ if (rule.source !== source) continue;
91
+ if (rule.conditions.every((condition) => meets(condition, valuesOf(record, condition.field)))) found.push({ rule: rule.id, id: record.id });
92
+ }
93
+ return found;
94
+ }
95
+ }
96
+
97
+ function valid(rule) {
98
+ return (
99
+ rule &&
100
+ RULE_ID.test(rule.id) &&
101
+ ['requests', 'exceptions', 'logs'].includes(rule.source) &&
102
+ Array.isArray(rule.conditions) &&
103
+ rule.conditions.length > 0 &&
104
+ rule.conditions.every((condition) => condition && typeof condition.field === 'string' && typeof condition.value === 'string')
105
+ );
106
+ }
107
+
108
+ function sourceOf(record) {
109
+ const name = record.constructor && record.constructor.name;
110
+ if (name === 'RequestRecord') return 'requests';
111
+ if (name === 'ExceptionRecord') return 'exceptions';
112
+ if (name === 'LogRecord') return 'logs';
113
+ return null;
114
+ }
115
+
116
+ // The values a condition looks at (a user has several: its id, email and phone).
117
+ function valuesOf(record, field) {
118
+ const summary = record.summary || {};
119
+ switch (field) {
120
+ case 'path':
121
+ return [summary.path];
122
+ case 'user': {
123
+ const user = summary.user || {};
124
+ return [user.id, user.email, user.phone].filter((value) => value !== undefined && value !== null).map(String);
125
+ }
126
+ case 'response': {
127
+ const body = record.detail && record.detail.response ? record.detail.response.body : null;
128
+ const text = typeof body === 'string' ? body : body && typeof body.text === 'string' ? body.text : '';
129
+ return [text.slice(0, MAX_BODY)];
130
+ }
131
+ case 'name':
132
+ return [summary.name];
133
+ case 'message':
134
+ return [summary.message];
135
+ default:
136
+ return [];
137
+ }
138
+ }
139
+
140
+ // Text compares without case; "matches" takes * as anything. A condition is met by any of the values.
141
+ function meets(condition, values) {
142
+ const wanted = String(condition.value).toLowerCase();
143
+ const pattern = condition.op === 'matches' ? new RegExp(`^${wanted.split('*').map(escape).join('.*')}$`) : null;
144
+ const met = values.some((value) => {
145
+ if (typeof value !== 'string') return false;
146
+ const text = value.toLowerCase();
147
+ if (condition.op === 'is') return text === wanted;
148
+ if (condition.op === 'is-not') return text !== wanted;
149
+ if (condition.op === 'contains') return text.includes(wanted);
150
+ if (condition.op === 'matches') return pattern.test(text);
151
+ return false;
152
+ });
153
+ return condition.op === 'is-not' ? values.length === 0 || met : met;
154
+ }
155
+
156
+ function escape(text) {
157
+ return text.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
158
+ }
@@ -11,7 +11,7 @@ import { randomUUID } from 'crypto';
11
11
  import http from 'http';
12
12
  import { performance } from 'perf_hooks';
13
13
  import { BodyDecoder, EMPTY_BODY } from './body-decoder.js';
14
- import { hasBody, parseQuery } from './http-message.js';
14
+ import { hasBody, parseQuery, stringParams } from './http-message.js';
15
15
  import { RequestBodyTap } from './request-body-tap.js';
16
16
  import { ResponseTap } from './response-tap.js';
17
17
 
@@ -41,6 +41,10 @@ const kBodySource = Symbol('peekInside.bodySource');
41
41
  * @property {Array<{ id: string, name: string, message: string }>} [exceptions] - The first of them
42
42
  * @property {Object} [stats] - Timeline counters (queries, outgoing calls, logs), added by PeekInside
43
43
  * @property {Object} [timeline] - The request's timeline, when something was recorded on it
44
+ * @property {string} [route] - The route pattern it matched ("/users/:id"), when the framework says
45
+ * @property {string} [handler] - The code that handled it ("UsersController.findOne"), when known
46
+ * @property {Array<{ id: string, label: string|null, type: string, preview: string }>} [dumps] - The
47
+ * dumps made during it (local dashboard only)
44
48
  */
45
49
 
46
50
  /**
@@ -250,14 +254,3 @@ export class ExchangeRecorder {
250
254
  return decoder.decodeValue(parsed, headers);
251
255
  }
252
256
  }
253
-
254
- // Route parameters as strings (Express keeps them as strings; other routers may not).
255
- function stringParams(params) {
256
- const result = {};
257
- if (!params || typeof params !== 'object') return result;
258
- for (const key of Object.keys(params)) {
259
- const value = params[key];
260
- if (value !== undefined && value !== null && typeof value !== 'object') result[key] = String(value);
261
- }
262
- return result;
263
- }
@@ -136,3 +136,20 @@ export function hasBody(headers) {
136
136
  export function setOwn(target, key, value) {
137
137
  Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
138
138
  }
139
+
140
+ /**
141
+ * Route parameters as strings, as records keep them: Express keeps them as strings, other routers
142
+ * (Fastify, Hono) may not. Values that are objects are left out.
143
+ *
144
+ * @param {*} params
145
+ * @returns {Object<string, string>}
146
+ */
147
+ export function stringParams(params) {
148
+ const result = {};
149
+ if (!params || typeof params !== 'object') return result;
150
+ for (const key of Object.keys(params)) {
151
+ const value = params[key];
152
+ if (value !== undefined && value !== null && typeof value !== 'object') setOwn(result, key, String(value));
153
+ }
154
+ return result;
155
+ }