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,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Size limits of exception records, from the data contract shared with PeekInside Cloud, and the
|
|
3
|
+
* helpers that apply them.
|
|
4
|
+
*
|
|
5
|
+
* Exceptions carry text the app doesn't control (messages built from user input, stack traces of
|
|
6
|
+
* deep recursion, whatever was passed to report()), so every field is bounded before it is queued:
|
|
7
|
+
* one exception can never take more than a few hundred kilobytes, however large the error.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const LIMITS = Object.freeze({
|
|
11
|
+
name: 200,
|
|
12
|
+
message: 2000,
|
|
13
|
+
// Preview of a thrown value that isn't an Error
|
|
14
|
+
valuePreview: 500,
|
|
15
|
+
code: 64,
|
|
16
|
+
// Bytes of error.stack
|
|
17
|
+
stack: 32 * 1024,
|
|
18
|
+
frames: 100,
|
|
19
|
+
causeDepth: 5,
|
|
20
|
+
aggregateErrors: 10,
|
|
21
|
+
// Bytes of each cause's stack
|
|
22
|
+
causeStack: 8 * 1024,
|
|
23
|
+
causeFrames: 20,
|
|
24
|
+
// In-app frames that get source lines, and the lines on each side of the frame's line
|
|
25
|
+
contextFrames: 5,
|
|
26
|
+
contextLines: 5,
|
|
27
|
+
contextLineLength: 240,
|
|
28
|
+
tags: 32,
|
|
29
|
+
tagKey: 64,
|
|
30
|
+
tagValue: 256,
|
|
31
|
+
// Bytes of `extra` once serialized, and of the preview kept when it is larger
|
|
32
|
+
extra: 16 * 1024,
|
|
33
|
+
extraPreview: 2048,
|
|
34
|
+
// Bytes of the request body kept with an exception
|
|
35
|
+
requestBody: 8 * 1024,
|
|
36
|
+
// Exceptions listed in a request record, and the length of their messages there
|
|
37
|
+
requestLinks: 10,
|
|
38
|
+
linkMessage: 500
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Cuts a string to at most `max` characters without splitting a surrogate pair.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} text
|
|
45
|
+
* @param {number} max
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
export function truncate(text, max) {
|
|
49
|
+
if (typeof text !== 'string' || text.length <= max) return text;
|
|
50
|
+
let end = max;
|
|
51
|
+
// A high surrogate at the cut would leave half a character behind.
|
|
52
|
+
const code = text.charCodeAt(end - 1);
|
|
53
|
+
if (code >= 0xd800 && code <= 0xdbff) end--;
|
|
54
|
+
return text.slice(0, end);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Cuts a stack trace to at most `maxBytes` bytes of UTF-8, keeping its start and its end: the
|
|
59
|
+
* message comes first and the frames last, and a huge message (an error that quotes a whole
|
|
60
|
+
* response body) must not push the frames out.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} stack
|
|
63
|
+
* @param {number} maxBytes
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
export function truncateStack(stack, maxBytes) {
|
|
67
|
+
if (typeof stack !== 'string' || stack.length * 3 <= maxBytes || Buffer.byteLength(stack) <= maxBytes) return stack;
|
|
68
|
+
const marker = '\n [...]\n';
|
|
69
|
+
const half = Math.floor((maxBytes - marker.length) / 2);
|
|
70
|
+
const head = truncateBytes(stack, half);
|
|
71
|
+
const tail = Buffer.from(stack, 'utf8');
|
|
72
|
+
let start = tail.length - half;
|
|
73
|
+
// Forward over continuation bytes (10xxxxxx) to the start of a character.
|
|
74
|
+
while (start < tail.length && (tail[start] & 0xc0) === 0x80) start++;
|
|
75
|
+
return `${head}${marker}${tail.subarray(start).toString('utf8')}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Cuts a string to at most `maxBytes` bytes of UTF-8, at a character boundary.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} text
|
|
82
|
+
* @param {number} maxBytes
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function truncateBytes(text, maxBytes) {
|
|
86
|
+
if (typeof text !== 'string') return text;
|
|
87
|
+
// Most strings are ASCII-sized: no need to encode them.
|
|
88
|
+
if (text.length * 3 <= maxBytes) return text;
|
|
89
|
+
const buffer = Buffer.from(text, 'utf8');
|
|
90
|
+
if (buffer.length <= maxBytes) return text;
|
|
91
|
+
let end = maxBytes;
|
|
92
|
+
// Back up over continuation bytes (10xxxxxx) to the start of the cut character.
|
|
93
|
+
while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
|
|
94
|
+
return buffer.subarray(0, end).toString('utf8');
|
|
95
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records crashes: uncaught exceptions, and unhandled promise rejections.
|
|
3
|
+
*
|
|
4
|
+
* Observing must not change what Node.js does with them:
|
|
5
|
+
*
|
|
6
|
+
* - 'uncaughtExceptionMonitor' sees an uncaught exception (and a rejection that Node.js raises as
|
|
7
|
+
* one, origin "unhandledRejection") before the process exits, without handling it: the error is
|
|
8
|
+
* still printed the same way and the process still exits with code 1. The process exits as soon
|
|
9
|
+
* as the listener returns, so the record is built and delivered synchronously.
|
|
10
|
+
* - No 'unhandledRejection' listener is added by default: having one is what tells Node.js that
|
|
11
|
+
* the app handles rejections itself, and the process would no longer crash. Only when the app
|
|
12
|
+
* adds its own listener (the decision is then already made) does PeekInside add one too, to
|
|
13
|
+
* record the rejections the app now keeps running after. It is removed with the app's last one.
|
|
14
|
+
*
|
|
15
|
+
* One monitor serves every PeekInside instance in the process: a crash goes to the instance that
|
|
16
|
+
* tracks the request it happened in, otherwise to the most recently created recording instance.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { ExceptionCapture } from './exception-capture.js';
|
|
20
|
+
import { RequestContext } from './request-context.js';
|
|
21
|
+
|
|
22
|
+
// What Node.js raises for a rejection whose reason isn't an error: the reason only survives as
|
|
23
|
+
// text in the message.
|
|
24
|
+
const UNHANDLED_REJECTION_MESSAGE = /The promise rejected with the reason "([\s\S]*)"\.$/;
|
|
25
|
+
|
|
26
|
+
// In --unhandled-rejections=strict mode one rejection reaches both the monitor and the
|
|
27
|
+
// 'unhandledRejection' listeners, right after each other: the second sighting is skipped.
|
|
28
|
+
const SAME_EVENT_MS = 100;
|
|
29
|
+
|
|
30
|
+
let installed = false;
|
|
31
|
+
let observingRejections = false;
|
|
32
|
+
let lastSeen = null;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The process-level hooks, installed once per process while an instance records exceptions
|
|
36
|
+
* automatically.
|
|
37
|
+
*/
|
|
38
|
+
export class ProcessMonitor {
|
|
39
|
+
/**
|
|
40
|
+
* Installs the hooks when some PeekInside instance records exceptions automatically, and removes
|
|
41
|
+
* them when none does anymore.
|
|
42
|
+
*
|
|
43
|
+
* @returns {void}
|
|
44
|
+
*/
|
|
45
|
+
static update() {
|
|
46
|
+
try {
|
|
47
|
+
if (ExceptionCapture.anyAutomatic()) install();
|
|
48
|
+
else uninstall();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// Monitoring must never break the host application.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether an uncaught exception now ends the process: neither an 'uncaughtException' listener
|
|
56
|
+
* nor a capture callback (domains, the REPL) takes it.
|
|
57
|
+
*
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
static isFatal() {
|
|
61
|
+
const captured = typeof process.hasUncaughtExceptionCaptureCallback === 'function' && process.hasUncaughtExceptionCaptureCallback();
|
|
62
|
+
return !captured && process.listenerCount('uncaughtException') === 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether PeekInside observes unhandled rejections with its own listener right now (only while
|
|
67
|
+
* the app has one).
|
|
68
|
+
*
|
|
69
|
+
* @type {boolean}
|
|
70
|
+
*/
|
|
71
|
+
static get observingRejections() {
|
|
72
|
+
return observingRejections;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function install() {
|
|
77
|
+
if (installed) return;
|
|
78
|
+
installed = true;
|
|
79
|
+
process.on('uncaughtExceptionMonitor', onUncaught);
|
|
80
|
+
process.on('newListener', onNewListener);
|
|
81
|
+
process.on('removeListener', onRemoveListener);
|
|
82
|
+
if (process.listenerCount('unhandledRejection') > 0) observeRejections();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function uninstall() {
|
|
86
|
+
if (!installed) return;
|
|
87
|
+
installed = false;
|
|
88
|
+
process.removeListener('uncaughtExceptionMonitor', onUncaught);
|
|
89
|
+
process.removeListener('newListener', onNewListener);
|
|
90
|
+
process.removeListener('removeListener', onRemoveListener);
|
|
91
|
+
stopObservingRejections();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function observeRejections() {
|
|
95
|
+
if (observingRejections) return;
|
|
96
|
+
observingRejections = true;
|
|
97
|
+
process.on('unhandledRejection', onRejection);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function stopObservingRejections() {
|
|
101
|
+
if (!observingRejections) return;
|
|
102
|
+
observingRejections = false;
|
|
103
|
+
process.removeListener('unhandledRejection', onRejection);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The app adds an 'unhandledRejection' listener: it now keeps running after rejections. Ours is
|
|
107
|
+
// added in front of it, so a listener that exits the process can't cut the recording short.
|
|
108
|
+
function onNewListener(event, listener) {
|
|
109
|
+
if (event !== 'unhandledRejection' || listener === onRejection) return;
|
|
110
|
+
try {
|
|
111
|
+
observeRejections();
|
|
112
|
+
} catch (error) {
|
|
113
|
+
// Ignore: recording rejections is optional.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The app's last listener is gone: ours must go too, or rejections would no longer crash.
|
|
118
|
+
function onRemoveListener(event, listener) {
|
|
119
|
+
if (event !== 'unhandledRejection') return;
|
|
120
|
+
if (listener === onRejection) {
|
|
121
|
+
observingRejections = false; // removed by the app (removeAllListeners)
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
if (observingRejections && process.listeners('unhandledRejection').every((item) => item === onRejection)) {
|
|
126
|
+
stopObservingRejections();
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
// Ignore.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function onUncaught(error, origin) {
|
|
134
|
+
try {
|
|
135
|
+
const value = unwrapRejection(error, origin);
|
|
136
|
+
if (alreadyRecorded(value)) return;
|
|
137
|
+
const escaped = RequestContext.escaped();
|
|
138
|
+
const context = escaped || RequestContext.current();
|
|
139
|
+
const capture = captureFor(context);
|
|
140
|
+
if (!capture) return;
|
|
141
|
+
const mechanism = origin === 'unhandledRejection'
|
|
142
|
+
? 'unhandledRejection'
|
|
143
|
+
: escaped && escaped.integration === 'http' ? 'http' : 'uncaught';
|
|
144
|
+
capture.processError(value, {
|
|
145
|
+
mechanism,
|
|
146
|
+
fatal: ProcessMonitor.isFatal(),
|
|
147
|
+
handled: false,
|
|
148
|
+
context
|
|
149
|
+
});
|
|
150
|
+
} catch (failure) {
|
|
151
|
+
// Never interfere with the crash itself.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function onRejection(reason) {
|
|
156
|
+
try {
|
|
157
|
+
if (alreadyRecorded(reason)) return;
|
|
158
|
+
const context = RequestContext.current();
|
|
159
|
+
const capture = captureFor(context);
|
|
160
|
+
if (!capture) return;
|
|
161
|
+
capture.processError(reason, { mechanism: 'unhandledRejection', fatal: false, handled: true, context });
|
|
162
|
+
} catch (failure) {
|
|
163
|
+
// Ignore.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The instance tracking the request, when it records exceptions; otherwise the newest one that does.
|
|
168
|
+
function captureFor(context) {
|
|
169
|
+
const own = context && context.capture;
|
|
170
|
+
if (own && own.automatic && own.recording) return own;
|
|
171
|
+
return ExceptionCapture.latest({ automatic: true });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function alreadyRecorded(value) {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
if (lastSeen && Object.is(lastSeen.value, value) && now - lastSeen.at < SAME_EVENT_MS) return true;
|
|
177
|
+
lastSeen = { value, at: now };
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Node.js wraps a non-error rejection reason (`Promise.reject('timeout')`) in an error of its own
|
|
182
|
+
// whose stack is only Node.js internals. The reason itself says more.
|
|
183
|
+
function unwrapRejection(error, origin) {
|
|
184
|
+
if (origin !== 'unhandledRejection' || error === null || typeof error !== 'object') return error;
|
|
185
|
+
try {
|
|
186
|
+
if (error.code !== 'ERR_UNHANDLED_REJECTION') return error;
|
|
187
|
+
const match = UNHANDLED_REJECTION_MESSAGE.exec(String(error.message));
|
|
188
|
+
return match ? match[1] : error;
|
|
189
|
+
} catch (failure) {
|
|
190
|
+
return error;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounds how many exceptions are recorded per second.
|
|
3
|
+
*
|
|
4
|
+
* An app in trouble can throw thousands of errors a second (a failing database behind every
|
|
5
|
+
* request, a loop that keeps throwing). Each record reads the stack and maybe source files, and
|
|
6
|
+
* waits in memory until it is sent, so a storm must not turn into a memory or CPU problem of its
|
|
7
|
+
* own: past the limit, exceptions are dropped until the bucket refills.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Token bucket: `burst` tokens to start with, refilled at `rate` tokens per second.
|
|
12
|
+
*/
|
|
13
|
+
export class TokenBucket {
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object} [options]
|
|
16
|
+
* @param {number} [options.rate=50] - Tokens added per second
|
|
17
|
+
* @param {number} [options.burst=100] - Most tokens held
|
|
18
|
+
* @param {() => number} [options.now] - Clock in ms (for tests)
|
|
19
|
+
*/
|
|
20
|
+
constructor({ rate = 50, burst = 100, now = Date.now } = {}) {
|
|
21
|
+
this.rate = rate;
|
|
22
|
+
this.burst = burst;
|
|
23
|
+
this._now = now;
|
|
24
|
+
this._tokens = burst;
|
|
25
|
+
this._updatedAt = now();
|
|
26
|
+
/**
|
|
27
|
+
* Exceptions dropped because the bucket was empty
|
|
28
|
+
* @type {number}
|
|
29
|
+
*/
|
|
30
|
+
this.dropped = 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Takes one token.
|
|
35
|
+
*
|
|
36
|
+
* @returns {boolean} False when none is left (the caller drops the exception)
|
|
37
|
+
*/
|
|
38
|
+
take() {
|
|
39
|
+
const now = this._now();
|
|
40
|
+
const elapsed = Math.max(0, now - this._updatedAt) / 1000;
|
|
41
|
+
this._tokens = Math.min(this.burst, this._tokens + elapsed * this.rate);
|
|
42
|
+
this._updatedAt = now;
|
|
43
|
+
if (this._tokens < 1) {
|
|
44
|
+
this.dropped++;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
this._tokens -= 1;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which request a piece of code runs for, so an exception can be linked to it.
|
|
3
|
+
*
|
|
4
|
+
* Each tracked request runs the rest of its handling inside an AsyncLocalStorage context (the
|
|
5
|
+
* integrations wrap next(), done() or the server's emit()), and the context follows timers,
|
|
6
|
+
* promises and the request's own events. One place loses it: an exception thrown synchronously
|
|
7
|
+
* out of the handler unwinds the context before the process-level hooks see it. For that case the
|
|
8
|
+
* request an exception last escaped from is remembered until the current job ends; the uncaught
|
|
9
|
+
* exception hook, which runs right after the throw, picks it up.
|
|
10
|
+
*
|
|
11
|
+
* The escape is noticed in a `finally` block, never by catching and rethrowing: a rethrown error
|
|
12
|
+
* would make Node.js print the rethrowing line instead of the app's line when the process crashes.
|
|
13
|
+
*
|
|
14
|
+
* The context also keeps the request's exception bookkeeping: which errors were already recorded
|
|
15
|
+
* (each error object is recorded once per request), the records whose outcome is still open (see
|
|
16
|
+
* ExceptionCapture), and the count and links that go into the request's record.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
20
|
+
import { BodyDecoder } from '../capture/body-decoder.js';
|
|
21
|
+
import { parseQuery, splitUrl } from '../capture/http-message.js';
|
|
22
|
+
import { LIMITS } from './limits.js';
|
|
23
|
+
|
|
24
|
+
const storage = new AsyncLocalStorage();
|
|
25
|
+
const kContext = Symbol('peekInside.context');
|
|
26
|
+
// The request an exception last escaped from, until the current job ends.
|
|
27
|
+
let escaped = null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One request, as exception capture sees it.
|
|
31
|
+
*/
|
|
32
|
+
export class RequestContext {
|
|
33
|
+
/**
|
|
34
|
+
* The context of the code running now.
|
|
35
|
+
*
|
|
36
|
+
* @returns {RequestContext|null}
|
|
37
|
+
*/
|
|
38
|
+
static current() {
|
|
39
|
+
return storage.getStore() || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Runs `fn` outside any request, along with everything asynchronous it starts: PeekInside's own
|
|
44
|
+
* work (posting batches to the hub or the cloud, starting the hub) runs from inside requests at
|
|
45
|
+
* times, and must never show up on a request's timeline.
|
|
46
|
+
*
|
|
47
|
+
* @param {Function} fn
|
|
48
|
+
* @returns {*} What fn returns
|
|
49
|
+
*/
|
|
50
|
+
static outside(fn) {
|
|
51
|
+
return storage.exit(fn);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The context of a request object.
|
|
56
|
+
*
|
|
57
|
+
* @param {import('http').IncomingMessage} req
|
|
58
|
+
* @returns {RequestContext|null}
|
|
59
|
+
*/
|
|
60
|
+
static of(req) {
|
|
61
|
+
if (!req || (typeof req !== 'object' && typeof req !== 'function')) return null;
|
|
62
|
+
return req[kContext] || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The request an exception escaped from synchronously during the current job, if one did: the
|
|
67
|
+
* request of the exception the uncaught exception hook is looking at.
|
|
68
|
+
*
|
|
69
|
+
* @returns {RequestContext|null}
|
|
70
|
+
*/
|
|
71
|
+
static escaped() {
|
|
72
|
+
return escaped;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {Object} fields
|
|
77
|
+
* @param {import('./exception-capture.js').ExceptionCapture|null} fields.capture - Records the
|
|
78
|
+
* request's exceptions (the PeekInside instance tracking it)
|
|
79
|
+
* @param {import('http').IncomingMessage} fields.req
|
|
80
|
+
* @param {import('../capture/exchange-recorder.js').ExchangeRecorder|null} [fields.recorder] -
|
|
81
|
+
* Records the request itself; null when it isn't recorded
|
|
82
|
+
* @param {{ url: string, pathname: string, search: string }} fields.target - The request target
|
|
83
|
+
* @param {boolean} [fields.blocked=false] - The request reached a server in local mode: neither
|
|
84
|
+
* it nor its exceptions are recorded
|
|
85
|
+
* @param {string} [fields.integration='custom'] - Where tracking started: "express", "koa",
|
|
86
|
+
* "fastify", "http" (a server's emit()) or "custom" (intercept())
|
|
87
|
+
*/
|
|
88
|
+
constructor({ capture, req, recorder = null, target, blocked = false, integration = 'custom' }) {
|
|
89
|
+
this.capture = capture;
|
|
90
|
+
this.recorder = recorder;
|
|
91
|
+
this.blocked = blocked;
|
|
92
|
+
this.integration = integration;
|
|
93
|
+
/**
|
|
94
|
+
* Id of the request's record, or null when it isn't recorded
|
|
95
|
+
* @type {string|null}
|
|
96
|
+
*/
|
|
97
|
+
this.requestId = recorder ? recorder.id : null;
|
|
98
|
+
this.method = String((req && req.method) || '');
|
|
99
|
+
this.url = target.url;
|
|
100
|
+
this.path = target.pathname;
|
|
101
|
+
this.search = target.search;
|
|
102
|
+
|
|
103
|
+
/** Exceptions recorded during the request, for its record. */
|
|
104
|
+
this.exceptionCount = 0;
|
|
105
|
+
this.links = [];
|
|
106
|
+
// Error (or thrown value) -> its record, so each is recorded once per request.
|
|
107
|
+
this.seen = new Map();
|
|
108
|
+
// Records whose outcome is still open: handled unless the error reaches the final handler.
|
|
109
|
+
this.pending = new Map();
|
|
110
|
+
// Set once the request's record is built.
|
|
111
|
+
this.finished = false;
|
|
112
|
+
/**
|
|
113
|
+
* The request's timeline (see src/timeline), when the instance records one
|
|
114
|
+
* @type {import('../timeline/timeline.js').Timeline|null}
|
|
115
|
+
*/
|
|
116
|
+
this.timeline = null;
|
|
117
|
+
/**
|
|
118
|
+
* Who the request is for, once the app said so (PeekInside.setUser())
|
|
119
|
+
* @type {import('../user.js').User|null}
|
|
120
|
+
*/
|
|
121
|
+
this.user = null;
|
|
122
|
+
|
|
123
|
+
this._req = req;
|
|
124
|
+
// What remains for exceptions after the request finished (see release()).
|
|
125
|
+
this._after = null;
|
|
126
|
+
|
|
127
|
+
if (req && typeof req === 'object') {
|
|
128
|
+
Object.defineProperty(req, kContext, { value: this, configurable: true, writable: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Runs `fn` inside this context. When an exception escapes it, this request is remembered as the
|
|
134
|
+
* one it escaped from (see RequestContext.escaped()); the exception itself passes through
|
|
135
|
+
* untouched.
|
|
136
|
+
*
|
|
137
|
+
* @param {Function} fn
|
|
138
|
+
* @returns {*} What `fn` returns
|
|
139
|
+
*/
|
|
140
|
+
run(fn) {
|
|
141
|
+
// Code is running again, so an earlier escape was caught by someone: it is not the one a crash
|
|
142
|
+
// hook may see next.
|
|
143
|
+
escaped = null;
|
|
144
|
+
let returned = false;
|
|
145
|
+
try {
|
|
146
|
+
const result = storage.run(this, fn);
|
|
147
|
+
returned = true;
|
|
148
|
+
return result;
|
|
149
|
+
} finally {
|
|
150
|
+
if (!returned) noteEscape(this);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Makes an emitter's listeners run inside this context. The request's and response's events
|
|
156
|
+
* ('data', 'end', 'finish'...) come from the socket, outside any request, so listeners the app
|
|
157
|
+
* adds to them would otherwise lose the request.
|
|
158
|
+
*
|
|
159
|
+
* @param {import('events').EventEmitter} emitter
|
|
160
|
+
* @returns {void}
|
|
161
|
+
*/
|
|
162
|
+
bind(emitter) {
|
|
163
|
+
if (!emitter || typeof emitter.emit !== 'function') return;
|
|
164
|
+
const context = this;
|
|
165
|
+
const originalEmit = emitter.emit;
|
|
166
|
+
emitter.emit = function () {
|
|
167
|
+
return context.run(() => originalEmit.apply(this, arguments));
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* For the exception list: the request's method and path.
|
|
173
|
+
*
|
|
174
|
+
* @returns {{ method: string, path: string }}
|
|
175
|
+
*/
|
|
176
|
+
requestLine() {
|
|
177
|
+
return { method: this.method, path: this.path };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The request as an exception's detail shows it. After the response, the body is no longer
|
|
182
|
+
* kept (the request's own record has it).
|
|
183
|
+
*
|
|
184
|
+
* @returns {Object} A RequestSnapshot
|
|
185
|
+
*/
|
|
186
|
+
snapshot() {
|
|
187
|
+
if (this.recorder && !this.recorder.recorded) {
|
|
188
|
+
return { id: this.requestId, ...this.recorder.snapshot(LIMITS.requestBody) };
|
|
189
|
+
}
|
|
190
|
+
const after = this._after || RequestSnapshot.partsOf(this._req);
|
|
191
|
+
return {
|
|
192
|
+
id: this.requestId,
|
|
193
|
+
method: this.method,
|
|
194
|
+
url: this.url,
|
|
195
|
+
headers: { ...after.headers },
|
|
196
|
+
query: parseQuery(this.search),
|
|
197
|
+
params: { ...after.params },
|
|
198
|
+
body: '',
|
|
199
|
+
bodyTruncated: false,
|
|
200
|
+
ip: after.ip,
|
|
201
|
+
userAgent: after.userAgent
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Counts a recorded exception for the request's record.
|
|
207
|
+
*
|
|
208
|
+
* @param {import('./exception-record.js').ExceptionRecord} record
|
|
209
|
+
* @returns {void}
|
|
210
|
+
*/
|
|
211
|
+
noteRecorded(record) {
|
|
212
|
+
this.exceptionCount++;
|
|
213
|
+
if (this.links.length < LIMITS.requestLinks) this.links.push(record.link());
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Completes the exchange being turned into the request's record: its exceptions (settling the
|
|
218
|
+
* ones still open) and its timeline. Then lets go of the request (see release()).
|
|
219
|
+
*
|
|
220
|
+
* @param {import('../capture/exchange-recorder.js').Exchange} exchange - Changed in place
|
|
221
|
+
* @returns {void}
|
|
222
|
+
*/
|
|
223
|
+
complete(exchange) {
|
|
224
|
+
if (this.capture) this.capture.finish(this, exchange);
|
|
225
|
+
this.completeTimeline(exchange);
|
|
226
|
+
if (this.user) exchange.user = this.user;
|
|
227
|
+
this.release();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Closes the request's timeline and adds its counters (and its spans, if any) to the exchange.
|
|
232
|
+
*
|
|
233
|
+
* @param {import('../capture/exchange-recorder.js').Exchange} exchange - Changed in place
|
|
234
|
+
* @returns {void}
|
|
235
|
+
*/
|
|
236
|
+
completeTimeline(exchange) {
|
|
237
|
+
if (!this.timeline) return;
|
|
238
|
+
try {
|
|
239
|
+
const { stats, timeline } = this.timeline.close();
|
|
240
|
+
exchange.stats = stats;
|
|
241
|
+
if (timeline) exchange.timeline = timeline;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
// The request is still recorded, without its timeline.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Lets go of the request and response objects once the request's record is built: code that
|
|
249
|
+
* keeps running for the request (a timer, a pool created during it) may hold the context for
|
|
250
|
+
* long, and must not hold the request's bodies (or its timeline) with it.
|
|
251
|
+
*
|
|
252
|
+
* @returns {void}
|
|
253
|
+
*/
|
|
254
|
+
release() {
|
|
255
|
+
this.finished = true;
|
|
256
|
+
this._after = RequestSnapshot.partsOf(this._req);
|
|
257
|
+
this._req = null;
|
|
258
|
+
this.recorder = null;
|
|
259
|
+
this.timeline = null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Snapshots of requests that are not tracked, for report(error, { request }).
|
|
265
|
+
*/
|
|
266
|
+
export class RequestSnapshot {
|
|
267
|
+
/**
|
|
268
|
+
* @param {import('http').IncomingMessage} req
|
|
269
|
+
* @returns {Object} A RequestSnapshot without an id; the body is what a body parser left on
|
|
270
|
+
* req.body, if anything
|
|
271
|
+
*/
|
|
272
|
+
static fromRequest(req) {
|
|
273
|
+
const url = String(req.originalUrl || req.url || '/');
|
|
274
|
+
const { search } = splitUrl(url);
|
|
275
|
+
const parts = RequestSnapshot.partsOf(req);
|
|
276
|
+
let body = '';
|
|
277
|
+
let bodyTruncated = false;
|
|
278
|
+
try {
|
|
279
|
+
if (req.body !== undefined) {
|
|
280
|
+
const content = new BodyDecoder(LIMITS.requestBody).decodeValue(req.body, parts.headers);
|
|
281
|
+
if (content.encoding === 'utf8') {
|
|
282
|
+
body = content.text;
|
|
283
|
+
bodyTruncated = content.truncated;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} catch (error) {
|
|
287
|
+
body = '';
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
id: null,
|
|
291
|
+
method: String(req.method || ''),
|
|
292
|
+
url,
|
|
293
|
+
headers: { ...parts.headers },
|
|
294
|
+
query: parseQuery(search),
|
|
295
|
+
params: { ...parts.params },
|
|
296
|
+
body,
|
|
297
|
+
bodyTruncated,
|
|
298
|
+
ip: parts.ip,
|
|
299
|
+
userAgent: parts.userAgent
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The small parts of a request that a snapshot keeps.
|
|
305
|
+
*
|
|
306
|
+
* @param {import('http').IncomingMessage|null} req
|
|
307
|
+
* @returns {{ headers: Object, params: Object, ip: string, userAgent: string }}
|
|
308
|
+
*/
|
|
309
|
+
static partsOf(req) {
|
|
310
|
+
if (!req) return { headers: {}, params: {}, ip: '', userAgent: '' };
|
|
311
|
+
const headers = req.headers && typeof req.headers === 'object' ? req.headers : {};
|
|
312
|
+
const params = {};
|
|
313
|
+
if (req.params && typeof req.params === 'object') {
|
|
314
|
+
for (const key of Object.keys(req.params)) {
|
|
315
|
+
const value = req.params[key];
|
|
316
|
+
if (value !== undefined && value !== null && typeof value !== 'object') params[key] = String(value);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
headers,
|
|
321
|
+
params,
|
|
322
|
+
ip: String(req.ip || (req.socket && req.socket.remoteAddress) || ''),
|
|
323
|
+
userAgent: String(headers['user-agent'] || '')
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// The innermost context wins: the exception escaped from it first. Once the job ends, the
|
|
329
|
+
// exception was either caught by someone else or has been handled by the uncaught exception
|
|
330
|
+
// hook, so the note is cleared.
|
|
331
|
+
function noteEscape(context) {
|
|
332
|
+
if (escaped) return;
|
|
333
|
+
escaped = context;
|
|
334
|
+
queueMicrotask(() => {
|
|
335
|
+
escaped = null;
|
|
336
|
+
});
|
|
337
|
+
}
|