peekinside 0.0.0-stage → 1.2.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/bin/peekinside.js +8 -0
  4. package/index.d.ts +389 -0
  5. package/index.js +3 -0
  6. package/package.json +77 -4
  7. package/src/PeekInside.js +731 -0
  8. package/src/capture/body-capture.js +86 -0
  9. package/src/capture/body-decoder.js +188 -0
  10. package/src/capture/exchange-recorder.js +263 -0
  11. package/src/capture/http-message.js +138 -0
  12. package/src/capture/ignored-paths.js +49 -0
  13. package/src/capture/request-body-tap.js +40 -0
  14. package/src/capture/response-tap.js +83 -0
  15. package/src/cli.js +254 -0
  16. package/src/config.js +183 -0
  17. package/src/crypto.js +189 -0
  18. package/src/deployment.js +183 -0
  19. package/src/exceptions/exception-builder.js +339 -0
  20. package/src/exceptions/exception-capture.js +407 -0
  21. package/src/exceptions/exception-record.js +118 -0
  22. package/src/exceptions/fingerprint.js +60 -0
  23. package/src/exceptions/frame-classifier.js +114 -0
  24. package/src/exceptions/limits.js +95 -0
  25. package/src/exceptions/process-monitor.js +192 -0
  26. package/src/exceptions/rate-limiter.js +50 -0
  27. package/src/exceptions/request-context.js +337 -0
  28. package/src/exceptions/safe-json.js +186 -0
  29. package/src/exceptions/source-context.js +144 -0
  30. package/src/exceptions/source-maps.js +313 -0
  31. package/src/exceptions/stack-parser.js +179 -0
  32. package/src/hub/brand/peekinside-span-core-mark-dark.svg +7 -0
  33. package/src/hub/brand/peekinside-span-core-mark-light.svg +7 -0
  34. package/src/hub/client.js +184 -0
  35. package/src/hub/entry.js +4 -0
  36. package/src/hub/exception-state.js +97 -0
  37. package/src/hub/ingest-payload.js +362 -0
  38. package/src/hub/process.js +175 -0
  39. package/src/hub/project-log.js +559 -0
  40. package/src/hub/segment-log.js +282 -0
  41. package/src/hub/server.js +538 -0
  42. package/src/hub/spool-ingester.js +110 -0
  43. package/src/hub/store.js +404 -0
  44. package/src/instrumentation/console.js +83 -0
  45. package/src/instrumentation/http.js +146 -0
  46. package/src/instrumentation/index.js +234 -0
  47. package/src/instrumentation/jobs.js +186 -0
  48. package/src/instrumentation/loggers.js +135 -0
  49. package/src/instrumentation/mongodb.js +221 -0
  50. package/src/instrumentation/mysql2.js +161 -0
  51. package/src/instrumentation/operation.js +70 -0
  52. package/src/instrumentation/patch.js +72 -0
  53. package/src/instrumentation/pg.js +218 -0
  54. package/src/instrumentation/prisma.js +181 -0
  55. package/src/integrations/express-errors.js +135 -0
  56. package/src/integrations/express.js +57 -0
  57. package/src/integrations/fastify.js +72 -0
  58. package/src/integrations/framework-adapter.js +53 -0
  59. package/src/integrations/http-server.js +32 -0
  60. package/src/integrations/koa.js +72 -0
  61. package/src/integrations/server-binder.js +120 -0
  62. package/src/jobs/job-context.js +40 -0
  63. package/src/jobs/job-run-record.js +104 -0
  64. package/src/jobs/job-runner.js +173 -0
  65. package/src/logs/log-capture.js +143 -0
  66. package/src/logs/log-record.js +74 -0
  67. package/src/project.js +76 -0
  68. package/src/record.js +181 -0
  69. package/src/release.js +157 -0
  70. package/src/runtime/loop-monitor.js +162 -0
  71. package/src/runtime/sampler.js +93 -0
  72. package/src/spool.js +195 -0
  73. package/src/timeline/caller.js +129 -0
  74. package/src/timeline/execution-hash.js +110 -0
  75. package/src/timeline/lru.js +50 -0
  76. package/src/timeline/masking.js +266 -0
  77. package/src/timeline/shape.js +77 -0
  78. package/src/timeline/sql.js +396 -0
  79. package/src/timeline/timeline.js +642 -0
  80. package/src/transport/batch.js +324 -0
  81. package/src/transport/cloud.js +363 -0
  82. package/src/transport/local.js +354 -0
  83. package/src/user.js +83 -0
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Records the exceptions of one PeekInside instance: from the framework integrations, from
3
+ * report(), and from the process-level hooks (see ProcessMonitor).
4
+ *
5
+ * Every path ends here, so the rules live in one place:
6
+ *
7
+ * - Nothing is recorded while the instance doesn't record (paused, stopped, no destination), and
8
+ * nothing for requests that local mode refused to record because they reached a server.
9
+ * - An error object is recorded once per request, however many hooks see it.
10
+ * - At most 50 exceptions a second (bursts of 100) are recorded; crashes always are.
11
+ * - Framework errors with a 4xx status (`createError(404)`, `ctx.throw(400)`) are how apps answer
12
+ * bad requests, not bugs, and aren't recorded automatically.
13
+ * - Whether an Express error was handled is only known later: an error middleware that receives
14
+ * it may still pass it on to Express's final handler. Such records stay open on the request and
15
+ * are settled when the error reaches the final handler (unhandled) or the request's record is
16
+ * built (handled).
17
+ *
18
+ * Nothing here ever throws into the app: a failure to record is a lost exception, never a new one.
19
+ */
20
+
21
+ import { ExceptionBuilder } from './exception-builder.js';
22
+ import { TokenBucket } from './rate-limiter.js';
23
+ import { RequestContext, RequestSnapshot } from './request-context.js';
24
+ import { splitUrl } from '../capture/http-message.js';
25
+ import { UserInfo } from '../user.js';
26
+
27
+ // Open records of a request that already finished wait this long for Express's final handler.
28
+ const LATE_SETTLE_MS = 50;
29
+
30
+ // Every capture, oldest first. Weak, so an app that drops its PeekInside instance doesn't leak it.
31
+ const registry = [];
32
+
33
+ /**
34
+ * @typedef {Object} DeliverySink
35
+ * @property {(record: import('./exception-record.js').ExceptionRecord) => void} enqueueRecord -
36
+ * Queues a record for the next batch
37
+ * @property {(batch: { exceptions: Object[], exchanges: Object[], fatal: boolean }) => void} deliverNow -
38
+ * Delivers synchronously, for a process that may exit right away
39
+ * @property {string} environment
40
+ * @property {string|null} release
41
+ * @property {boolean} disabled
42
+ */
43
+
44
+ /**
45
+ * Exception recording for one PeekInside instance.
46
+ */
47
+ export class ExceptionCapture {
48
+ /**
49
+ * Adds a capture to the instances PeekInside.report() and the process hooks choose from.
50
+ *
51
+ * @param {ExceptionCapture} capture
52
+ * @returns {void}
53
+ */
54
+ static register(capture) {
55
+ registry.push(new WeakRef(capture));
56
+ }
57
+
58
+ /**
59
+ * @param {ExceptionCapture} capture
60
+ * @returns {void}
61
+ */
62
+ static unregister(capture) {
63
+ for (let i = registry.length - 1; i >= 0; i--) {
64
+ const current = registry[i].deref();
65
+ if (!current || current === capture) registry.splice(i, 1);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * The most recently created capture that is recording.
71
+ *
72
+ * @param {Object} [options]
73
+ * @param {boolean} [options.automatic=false] - Only captures that record exceptions automatically
74
+ * (captureExceptions)
75
+ * @returns {ExceptionCapture|null}
76
+ */
77
+ static latest({ automatic = false } = {}) {
78
+ for (let i = registry.length - 1; i >= 0; i--) {
79
+ const capture = registry[i].deref();
80
+ if (!capture) {
81
+ registry.splice(i, 1);
82
+ continue;
83
+ }
84
+ if (capture.recording && (!automatic || capture.automatic)) return capture;
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * Whether any registered capture records exceptions automatically, recording or not right now.
91
+ *
92
+ * @returns {boolean}
93
+ */
94
+ static anyAutomatic() {
95
+ return registry.some((ref) => {
96
+ const capture = ref.deref();
97
+ return Boolean(capture && capture.automatic);
98
+ });
99
+ }
100
+
101
+ /**
102
+ * @param {Object} options
103
+ * @param {string} options.root - The project folder: frames inside it are in-app
104
+ * @param {boolean} [options.automatic=true] - Record exceptions automatically (captureExceptions);
105
+ * report() works either way
106
+ * @param {boolean} [options.sourceContext=true] - Read source lines around in-app frames
107
+ * @param {boolean} [options.sourceMaps=true] - Map frames of compiled code to their sources
108
+ * @param {() => boolean} options.isEnabled - Whether the instance records right now
109
+ * @param {() => DeliverySink|null} options.sink - Where records go (the instance's transport)
110
+ * @param {TokenBucket} [options.limiter] - Rate limit (default: 50 a second, bursts of 100)
111
+ * @param {Object} [options.owner] - The PeekInside instance it records for
112
+ */
113
+ constructor({ root, automatic = true, sourceContext = true, sourceMaps = true, isEnabled, sink, limiter = new TokenBucket(), owner = null }) {
114
+ /**
115
+ * The PeekInside instance it records for
116
+ * @type {Object|null}
117
+ */
118
+ this.owner = owner;
119
+ this.automatic = automatic;
120
+ this.builder = new ExceptionBuilder({ root, sourceContext, sourceMaps });
121
+ this.limiter = limiter;
122
+ this._isEnabled = isEnabled;
123
+ this._sink = sink;
124
+ }
125
+
126
+ /**
127
+ * Whether exceptions are recorded right now.
128
+ *
129
+ * @type {boolean}
130
+ */
131
+ get recording() {
132
+ const sink = this._sink();
133
+ return Boolean(sink && !sink.disabled && this._isEnabled());
134
+ }
135
+
136
+ /**
137
+ * An error reached a framework during a request: Express, Koa or Fastify.
138
+ *
139
+ * @param {RequestContext|null} context - The request's context
140
+ * @param {*} error
141
+ * @param {Object} options
142
+ * @param {'express'|'koa'|'fastify'} options.mechanism
143
+ * @param {boolean} options.handled - Whether the app handled it
144
+ * @param {boolean} [options.open=false] - The outcome isn't final yet (see the module comment)
145
+ * @returns {string|null} The exception's id, or null when nothing was recorded
146
+ */
147
+ frameworkError(context, error, { mechanism, handled, open = false }) {
148
+ try {
149
+ if (!this.automatic || !context || context.capture !== this || context.blocked) return null;
150
+ if (!this.recording || isClientError(error)) return null;
151
+
152
+ const existing = context.seen.get(error);
153
+ if (existing) {
154
+ // Handled on the way, but it still reached the final handler: unhandled after all.
155
+ if (!handled && context.pending.has(error)) {
156
+ context.pending.delete(error);
157
+ existing.handled = false;
158
+ this._emit(existing, context);
159
+ }
160
+ return existing.id;
161
+ }
162
+ if (!this.limiter.take()) return null;
163
+
164
+ const record = this._build(error, { mechanism, handled, fatal: false, context });
165
+ context.seen.set(error, record);
166
+ noteSpan(context, record);
167
+ if (open) {
168
+ context.pending.set(error, record);
169
+ if (context.finished) this._settleLater(context);
170
+ } else {
171
+ this._emit(record, context);
172
+ }
173
+ return record.id;
174
+ } catch (failure) {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Records an error the app reports (PeekInside#report()).
181
+ *
182
+ * @param {*} error
183
+ * @param {Object} [options]
184
+ * @param {import('http').IncomingMessage} [options.request] - The request it relates to (a
185
+ * Fastify request or a Koa context work too); otherwise the request running now, if any
186
+ * @param {Object<string, string>} [options.tags]
187
+ * @param {*} [options.extra]
188
+ * @param {boolean} [options.handled=true]
189
+ * @param {*} [options.user] - Who it happened to (see UserInfo); by default, the request's user
190
+ * @returns {string|null} The exception's id, or null when nothing was recorded
191
+ */
192
+ report(error, { request, tags, extra, handled = true, user } = {}) {
193
+ try {
194
+ if (!this.recording) return null;
195
+ const explicit = nodeRequest(request);
196
+ let context = explicit ? RequestContext.of(explicit) : RequestContext.current();
197
+ if (context && context.capture !== this) context = null;
198
+ if (context && context.blocked) return null;
199
+
200
+ const dedupe = context && error !== null && typeof error === 'object';
201
+ if (dedupe && context.seen.has(error)) return context.seen.get(error).id;
202
+ if (!this.limiter.take()) return null;
203
+
204
+ const record = this._build(error, {
205
+ mechanism: 'report',
206
+ handled: handled !== false,
207
+ fatal: false,
208
+ context,
209
+ request: context ? null : explicit,
210
+ tags,
211
+ extra,
212
+ user: user === undefined ? undefined : UserInfo.normalize(user)
213
+ });
214
+ if (dedupe) context.seen.set(error, record);
215
+ noteSpan(context, record);
216
+ this._emit(record, context);
217
+ return record.id;
218
+ } catch (failure) {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Records the error a job run failed with (PeekInside.job(), a scheduled task, a queue worker's
225
+ * job): an exception like the others, with mechanism "job", linked to the run.
226
+ *
227
+ * @param {import('../jobs/job-context.js').JobContext} context
228
+ * @param {*} error
229
+ * @returns {string|null} The exception's id, or null when nothing was recorded
230
+ */
231
+ jobError(context, error) {
232
+ try {
233
+ if (!this.recording || !context) return null;
234
+ const dedupe = error !== null && typeof error === 'object';
235
+ if (dedupe && context.seen.has(error)) return context.seen.get(error).id;
236
+ if (!this.limiter.take()) return null;
237
+ const record = this._build(error, { mechanism: 'job', handled: false, fatal: false, context });
238
+ if (dedupe) context.seen.set(error, record);
239
+ noteSpan(context, record);
240
+ this._emit(record, context);
241
+ return record.id;
242
+ } catch (failure) {
243
+ return null;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Records an exception seen by the process-level hooks, and delivers it synchronously: the
249
+ * process may exit as soon as the hook returns. For a crash, the request it happened in is
250
+ * delivered too, as aborted, with the exceptions still open on it.
251
+ *
252
+ * @param {*} error
253
+ * @param {Object} options
254
+ * @param {'uncaught'|'unhandledRejection'|'http'} options.mechanism
255
+ * @param {boolean} options.fatal - The process is about to exit
256
+ * @param {boolean} options.handled
257
+ * @param {RequestContext|null} options.context - The request it happened in, if known
258
+ * @returns {string|null} The exception's id, or null when nothing was recorded
259
+ */
260
+ processError(error, { mechanism, fatal, handled, context }) {
261
+ try {
262
+ if (!this.recording || (context && context.blocked)) return null;
263
+ // A crash is recorded whatever the rate: it happens once.
264
+ if (!fatal && !this.limiter.take()) return null;
265
+
266
+ const own = context && context.capture === this ? context : null;
267
+ if (own) own.pending.delete(error);
268
+ const record = this._build(error, { mechanism, handled, fatal, context: own });
269
+
270
+ const exceptions = [];
271
+ const exchanges = [];
272
+ if (own && fatal) {
273
+ // The request's open records are settled as they stand: the process won't get further.
274
+ for (const open of own.pending.values()) {
275
+ own.noteRecorded(open);
276
+ exceptions.push(open);
277
+ }
278
+ own.pending.clear();
279
+ }
280
+ if (own) {
281
+ own.seen.set(error, record);
282
+ own.noteRecorded(record);
283
+ noteSpan(own, record);
284
+ }
285
+ exceptions.push(record);
286
+
287
+ if (own && fatal && own.recorder) {
288
+ const exchange = own.recorder.takeExchange();
289
+ if (exchange) {
290
+ exchange.exceptionCount = own.exceptionCount;
291
+ exchange.exceptions = own.links.slice();
292
+ own.completeTimeline(exchange);
293
+ if (own.user) exchange.user = own.user;
294
+ exchanges.push(exchange);
295
+ }
296
+ }
297
+
298
+ const sink = this._sink();
299
+ if (sink) sink.deliverNow({ exceptions, exchanges, fatal });
300
+ return record.id;
301
+ } catch (failure) {
302
+ return null;
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Called when a request's record is built: settles its open records, and adds the exception
308
+ * count and links to the exchange.
309
+ *
310
+ * @param {RequestContext} context
311
+ * @param {import('../capture/exchange-recorder.js').Exchange} exchange - Changed in place
312
+ * @returns {void}
313
+ */
314
+ finish(context, exchange) {
315
+ try {
316
+ this.settle(context);
317
+ exchange.exceptionCount = context.exceptionCount;
318
+ exchange.exceptions = context.links.slice();
319
+ } catch (failure) {
320
+ // The request is still recorded, without its exceptions.
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Queues a request's open records as handled: the error never reached the final handler.
326
+ *
327
+ * @param {RequestContext} context
328
+ * @returns {void}
329
+ */
330
+ settle(context) {
331
+ if (!context.pending.size) return;
332
+ const records = [...context.pending.values()];
333
+ context.pending.clear();
334
+ for (const record of records) this._emit(record, context);
335
+ }
336
+
337
+ // An error middleware received an error after the request's record was built (it answered,
338
+ // then passed an error on): settle shortly, unless the final handler gets the error first.
339
+ _settleLater(context) {
340
+ const timer = setTimeout(() => this.settle(context), LATE_SETTLE_MS);
341
+ if (typeof timer.unref === 'function') timer.unref();
342
+ }
343
+
344
+ _build(error, { mechanism, handled, fatal, context, request = null, tags, extra, user }) {
345
+ const sink = this._sink();
346
+ let snapshot = null;
347
+ let requestLine = null;
348
+ if (context) {
349
+ snapshot = context.snapshot();
350
+ requestLine = context.requestLine();
351
+ } else if (request) {
352
+ snapshot = RequestSnapshot.fromRequest(request);
353
+ requestLine = { method: snapshot.method, path: splitUrl(snapshot.url).pathname };
354
+ }
355
+ return this.builder.build(error, {
356
+ mechanism,
357
+ handled,
358
+ fatal,
359
+ environment: sink ? sink.environment : 'development',
360
+ release: sink ? sink.release || null : null,
361
+ requestId: context ? context.requestId : null,
362
+ request: snapshot,
363
+ requestLine,
364
+ user: user !== undefined ? user : context ? context.user : null,
365
+ jobRunId: context ? context.jobRunId || null : null,
366
+ job: context && context.jobName ? { name: context.jobName } : null,
367
+ tags,
368
+ extra
369
+ });
370
+ }
371
+
372
+ _emit(record, context) {
373
+ if (context) context.noteRecorded(record);
374
+ const sink = this._sink();
375
+ if (sink) sink.enqueueRecord(record);
376
+ }
377
+ }
378
+
379
+ // An exception recorded during a request is a span on its timeline too.
380
+ function noteSpan(context, record) {
381
+ if (context && context.timeline) context.timeline.exception(record);
382
+ }
383
+
384
+ // The Node.js request behind what report() was given: the request itself (Express, http), a
385
+ // Fastify request (`raw`) or a Koa context or request (`req`).
386
+ function nodeRequest(request) {
387
+ if (!request || typeof request !== 'object') return null;
388
+ for (const candidate of [request, request.raw, request.req]) {
389
+ if (candidate && typeof candidate === 'object' && RequestContext.of(candidate)) return candidate;
390
+ }
391
+ for (const candidate of [request.raw, request.req]) {
392
+ if (candidate && typeof candidate === 'object' && candidate.headers && typeof candidate.method === 'string') return candidate;
393
+ }
394
+ return request;
395
+ }
396
+
397
+ // Status 4xx on the error: the app's answer to a bad request.
398
+ function isClientError(error) {
399
+ if (error === null || typeof error !== 'object') return false;
400
+ let status;
401
+ try {
402
+ status = error.status !== undefined ? error.status : error.statusCode;
403
+ } catch (failure) {
404
+ return false;
405
+ }
406
+ return Number.isInteger(status) && status >= 400 && status < 500;
407
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The stored form of an exception, shared by the local hub and PeekInside Cloud (see the data
3
+ * contract): a few plain fields for lists and filters, a summary for the exception list, and the
4
+ * detail (stack, frames, causes, request, process), loaded when someone opens the exception.
5
+ */
6
+
7
+ import { LIMITS, truncate } from './limits.js';
8
+
9
+ /**
10
+ * One recorded exception.
11
+ *
12
+ * The local hub receives the plain form (toJSON()). PeekInside Cloud receives the sealed form:
13
+ * the summary and the detail are encrypted to the project's public key, bound to the exception
14
+ * id, like the summary and detail of requests.
15
+ */
16
+ export class ExceptionRecord {
17
+ /**
18
+ * @param {Object} fields
19
+ * @param {string} fields.id - UUID
20
+ * @param {number} fields.occurredAt - When it was recorded (ms since the epoch)
21
+ * @param {string} fields.environment - Normalized environment label
22
+ * @param {string|null} [fields.release=null] - The running release (see release.js)
23
+ * @param {boolean} fields.handled - Whether the app caught it (an error middleware, report())
24
+ * @param {boolean} fields.fatal - Whether it crashed the process
25
+ * @param {string|null} fields.requestId - The request it happened in
26
+ * @param {string|null} [fields.jobRunId=null] - The job run it happened in
27
+ * @param {Object} fields.summary - name, message, code, fingerprint, mechanism, location, request
28
+ * @param {Object} fields.detail - stack, frames, causes, request, process, tags, extra
29
+ */
30
+ constructor({ id, occurredAt, environment, release = null, handled, fatal, requestId, jobRunId = null, summary, detail }) {
31
+ this.id = id;
32
+ this.occurredAt = occurredAt;
33
+ this.environment = environment;
34
+ this.release = release;
35
+ this.handled = handled;
36
+ this.fatal = fatal;
37
+ this.requestId = requestId;
38
+ this.jobRunId = jobRunId;
39
+ this.summary = summary;
40
+ this.detail = detail;
41
+ }
42
+
43
+ /**
44
+ * The plain form, as the local hub stores it. Also used by JSON.stringify().
45
+ *
46
+ * @returns {Object}
47
+ */
48
+ toJSON() {
49
+ return {
50
+ id: this.id,
51
+ occurredAt: this.occurredAt,
52
+ environment: this.environment,
53
+ release: this.release,
54
+ handled: this.handled,
55
+ fatal: this.fatal,
56
+ requestId: this.requestId,
57
+ jobRunId: this.jobRunId,
58
+ summary: this.summary,
59
+ detail: this.detail
60
+ };
61
+ }
62
+
63
+ /**
64
+ * The sealed form, for PeekInside Cloud: the summary and the detail are encrypted, bound to this
65
+ * exception's id so they can't be swapped with another record's.
66
+ *
67
+ * @param {import('../crypto.js').Sealer} sealer - Sealer of the batch
68
+ * @returns {Promise<Object>}
69
+ */
70
+ async seal(sealer) {
71
+ const { summary, detail, ...meta } = this.toJSON();
72
+ return {
73
+ ...meta,
74
+ summary: await sealer.seal(JSON.stringify(summary), ExceptionRecord.summaryAad(this.id)),
75
+ detail: await sealer.seal(JSON.stringify(detail), ExceptionRecord.detailAad(this.id))
76
+ };
77
+ }
78
+
79
+ /**
80
+ * seal() for a process that is crashing, where nothing asynchronous runs anymore.
81
+ *
82
+ * @param {import('../crypto.js').SyncSealer} sealer
83
+ * @returns {Object}
84
+ */
85
+ sealSync(sealer) {
86
+ const { summary, detail, ...meta } = this.toJSON();
87
+ return {
88
+ ...meta,
89
+ summary: sealer.seal(JSON.stringify(summary), ExceptionRecord.summaryAad(this.id)),
90
+ detail: sealer.seal(JSON.stringify(detail), ExceptionRecord.detailAad(this.id))
91
+ };
92
+ }
93
+
94
+ /**
95
+ * How the record of the request it happened in lists it, so the request inspector can link to it.
96
+ *
97
+ * @returns {{ id: string, name: string, message: string }}
98
+ */
99
+ link() {
100
+ return { id: this.id, name: this.summary.name, message: truncate(this.summary.message, LIMITS.linkMessage) };
101
+ }
102
+
103
+ /**
104
+ * @param {string} id
105
+ * @returns {string} Additional authenticated data of a sealed summary
106
+ */
107
+ static summaryAad(id) {
108
+ return `exception:summary:${id}`;
109
+ }
110
+
111
+ /**
112
+ * @param {string} id
113
+ * @returns {string} Additional authenticated data of a sealed detail
114
+ */
115
+ static detailAad(id) {
116
+ return `exception:detail:${id}`;
117
+ }
118
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Groups exceptions into issues: exceptions with the same fingerprint are the same problem.
3
+ *
4
+ * The rule is part of the data contract with PeekInside Cloud, so both dashboards group the same
5
+ * way:
6
+ *
7
+ * with in-app frames: name + "\n" + the top three in-app frames as "relativeFile:function"
8
+ * without: name + "\n" + code + "\n" + the normalized message
9
+ *
10
+ * hashed with SHA-256 and cut to 16 hex characters. Line numbers are left out on purpose, so an
11
+ * edit above the throwing line keeps the issue together, and the message is normalized so that
12
+ * "User 42 not found" and "User 43 not found" are one issue.
13
+ */
14
+
15
+ import { createHash } from 'crypto';
16
+
17
+ const UUID = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
18
+ const HEX_RUN = /\b(?:0x)?[0-9a-f]{8,}\b/gi;
19
+ const NUMBER = /\d+(?:\.\d+)?/g;
20
+ // Double-quoted and backtick strings anywhere; single-quoted strings only where a quote opens a
21
+ // string (start or after a non-word character), so the apostrophe in "can't" opens nothing.
22
+ const QUOTED = /"[^"\n]*"|`[^`\n]*`|(^|[^\w])'[^'\n]*'/g;
23
+
24
+ /**
25
+ * Computes fingerprints.
26
+ */
27
+ export class Fingerprint {
28
+ /**
29
+ * @param {Object} exception
30
+ * @param {string} exception.name - Summary name
31
+ * @param {string|null} exception.code
32
+ * @param {string} exception.message - Summary message
33
+ * @param {import('./frame-classifier.js').Frame[]} exception.frames - Top frame first
34
+ * @returns {string} 16 lowercase hex characters
35
+ */
36
+ static of({ name, code, message, frames }) {
37
+ const inApp = frames.filter((frame) => frame.inApp).slice(0, 3);
38
+ const basis = inApp.length > 0
39
+ ? `${name}\n${inApp.map((frame) => `${frame.relativeFile}:${frame.function ?? '?'}`).join('\n')}`
40
+ : `${name}\n${code ?? ''}\n${Fingerprint.normalizeMessage(message)}`;
41
+ return createHash('sha256').update(basis).digest('hex').slice(0, 16);
42
+ }
43
+
44
+ /**
45
+ * Replaces the parts of a message that change from one occurrence to the next: quoted strings,
46
+ * UUIDs, runs of 8+ hex digits and numbers.
47
+ *
48
+ * @param {string} message
49
+ * @returns {string}
50
+ * @example
51
+ * Fingerprint.normalizeMessage('User 42 not found in "eu-west"') // 'User <num> not found in <str>'
52
+ */
53
+ static normalizeMessage(message) {
54
+ return String(message ?? '')
55
+ .replace(QUOTED, (match, before) => `${before ?? ''}<str>`)
56
+ .replace(UUID, '<uuid>')
57
+ .replace(HEX_RUN, '<hex>')
58
+ .replace(NUMBER, '<num>');
59
+ }
60
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Tells the app's own code apart from its dependencies and Node.js itself, frame by frame.
3
+ *
4
+ * The dashboard highlights in-app frames, groups exceptions by them (see fingerprint.js) and shows
5
+ * their source lines, so "in app" has to mean the code people can change: files inside the
6
+ * project folder, except anything under node_modules.
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+
12
+ /**
13
+ * @typedef {Object} Frame
14
+ * @property {string|null} function
15
+ * @property {string|null} file - Absolute path, "node:..." for internals, null for built-ins
16
+ * @property {string|null} relativeFile - Relative to the project folder with forward slashes, when
17
+ * the file is inside it
18
+ * @property {number|null} line
19
+ * @property {number|null} column
20
+ * @property {boolean} inApp - Inside the project folder and not under node_modules
21
+ * @property {string|null} module - npm package ("express", "@scope/pkg") of node_modules frames,
22
+ * "node" for Node.js internals
23
+ * @property {boolean} native
24
+ * @property {boolean} async
25
+ * @property {{ start: number, lines: string[] }|null} context - Source lines, set by SourceContext
26
+ */
27
+
28
+ /**
29
+ * Classifies parsed frames relative to one project folder.
30
+ */
31
+ export class FrameClassifier {
32
+ /**
33
+ * @param {string} root - The project folder (ProjectIdentity#root)
34
+ */
35
+ constructor(root) {
36
+ this.root = path.resolve(root || process.cwd());
37
+ // Stack traces show where modules really are: a project reached through a symlink (macOS's
38
+ // /tmp and /var, linked workspaces, mounted volumes) appears under its real path there.
39
+ let real = this.root;
40
+ try {
41
+ real = fs.realpathSync(this.root);
42
+ } catch (error) {
43
+ real = this.root;
44
+ }
45
+ this.roots = real === this.root ? [this.root] : [this.root, real];
46
+ }
47
+
48
+ /**
49
+ * Completes a parsed frame with where it comes from.
50
+ *
51
+ * @param {import('./stack-parser.js').ParsedFrame} parsed
52
+ * @returns {Frame}
53
+ */
54
+ classify(parsed) {
55
+ const file = parsed.file;
56
+ let relativeFile = null;
57
+ let inApp = false;
58
+ let module = null;
59
+
60
+ if (file && file.startsWith('node:')) {
61
+ module = 'node';
62
+ } else if (file) {
63
+ module = FrameClassifier.moduleOf(file);
64
+ relativeFile = this.relativeFile(file);
65
+ inApp = relativeFile !== null && !relativeFile.split('/').includes('node_modules');
66
+ }
67
+
68
+ return {
69
+ function: parsed.function,
70
+ file,
71
+ relativeFile,
72
+ line: parsed.line,
73
+ column: parsed.column,
74
+ inApp,
75
+ module,
76
+ native: parsed.native,
77
+ async: parsed.async,
78
+ context: null
79
+ };
80
+ }
81
+
82
+ /**
83
+ * @param {string} file
84
+ * @returns {string|null} The path relative to the project folder, with forward slashes, or
85
+ * null when the file is outside it (or isn't a path)
86
+ */
87
+ relativeFile(file) {
88
+ if (!path.isAbsolute(file)) return null;
89
+ for (const root of this.roots) {
90
+ const relative = path.relative(root, file);
91
+ if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) continue;
92
+ return relative.split(path.sep).join('/');
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /**
98
+ * The npm package a file belongs to: the folder after the last node_modules segment, so
99
+ * nested installs and pnpm's .pnpm store name the package that actually runs.
100
+ *
101
+ * @param {string} file
102
+ * @returns {string|null}
103
+ */
104
+ static moduleOf(file) {
105
+ const segments = file.split(/[\\/]/);
106
+ const index = segments.lastIndexOf('node_modules');
107
+ if (index === -1 || index + 1 >= segments.length) return null;
108
+ const name = segments[index + 1];
109
+ if (name.startsWith('@')) {
110
+ return index + 2 < segments.length ? `${name}/${segments[index + 2]}` : null;
111
+ }
112
+ return name || null;
113
+ }
114
+ }