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,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
+ }