peekinside 0.0.0-stage → 1.2.1
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/LICENSE +21 -0
- package/README.md +45 -2
- package/bin/peekinside.js +8 -0
- package/index.d.ts +389 -0
- package/index.js +3 -0
- package/package.json +77 -4
- package/src/PeekInside.js +731 -0
- package/src/capture/body-capture.js +86 -0
- package/src/capture/body-decoder.js +188 -0
- package/src/capture/exchange-recorder.js +263 -0
- package/src/capture/http-message.js +138 -0
- package/src/capture/ignored-paths.js +49 -0
- package/src/capture/request-body-tap.js +40 -0
- package/src/capture/response-tap.js +83 -0
- package/src/cli.js +254 -0
- package/src/config.js +183 -0
- package/src/crypto.js +189 -0
- package/src/deployment.js +183 -0
- package/src/exceptions/exception-builder.js +339 -0
- package/src/exceptions/exception-capture.js +407 -0
- package/src/exceptions/exception-record.js +118 -0
- package/src/exceptions/fingerprint.js +60 -0
- package/src/exceptions/frame-classifier.js +114 -0
- package/src/exceptions/limits.js +95 -0
- package/src/exceptions/process-monitor.js +192 -0
- package/src/exceptions/rate-limiter.js +50 -0
- package/src/exceptions/request-context.js +337 -0
- package/src/exceptions/safe-json.js +186 -0
- package/src/exceptions/source-context.js +144 -0
- package/src/exceptions/source-maps.js +313 -0
- package/src/exceptions/stack-parser.js +179 -0
- package/src/hub/brand/peekinside-span-core-mark-dark.svg +7 -0
- package/src/hub/brand/peekinside-span-core-mark-light.svg +7 -0
- package/src/hub/client.js +184 -0
- package/src/hub/entry.js +4 -0
- package/src/hub/exception-state.js +97 -0
- package/src/hub/ingest-payload.js +362 -0
- package/src/hub/process.js +175 -0
- package/src/hub/project-log.js +559 -0
- package/src/hub/segment-log.js +282 -0
- package/src/hub/server.js +561 -0
- package/src/hub/spool-ingester.js +110 -0
- package/src/hub/store.js +404 -0
- package/src/instrumentation/console.js +83 -0
- package/src/instrumentation/http.js +146 -0
- package/src/instrumentation/index.js +234 -0
- package/src/instrumentation/jobs.js +186 -0
- package/src/instrumentation/loggers.js +135 -0
- package/src/instrumentation/mongodb.js +221 -0
- package/src/instrumentation/mysql2.js +161 -0
- package/src/instrumentation/operation.js +70 -0
- package/src/instrumentation/patch.js +72 -0
- package/src/instrumentation/pg.js +218 -0
- package/src/instrumentation/prisma.js +181 -0
- package/src/integrations/express-errors.js +135 -0
- package/src/integrations/express.js +57 -0
- package/src/integrations/fastify.js +72 -0
- package/src/integrations/framework-adapter.js +53 -0
- package/src/integrations/http-server.js +32 -0
- package/src/integrations/koa.js +72 -0
- package/src/integrations/server-binder.js +120 -0
- package/src/jobs/job-context.js +40 -0
- package/src/jobs/job-run-record.js +104 -0
- package/src/jobs/job-runner.js +173 -0
- package/src/logs/log-capture.js +143 -0
- package/src/logs/log-record.js +74 -0
- package/src/project.js +76 -0
- package/src/record.js +181 -0
- package/src/release.js +157 -0
- package/src/runtime/loop-monitor.js +162 -0
- package/src/runtime/sampler.js +93 -0
- package/src/spool.js +195 -0
- package/src/timeline/caller.js +129 -0
- package/src/timeline/execution-hash.js +110 -0
- package/src/timeline/lru.js +50 -0
- package/src/timeline/masking.js +266 -0
- package/src/timeline/shape.js +77 -0
- package/src/timeline/sql.js +396 -0
- package/src/timeline/timeline.js +642 -0
- package/src/transport/batch.js +324 -0
- package/src/transport/cloud.js +363 -0
- package/src/transport/local.js +354 -0
- 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
|
+
}
|