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,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded collection of body bytes.
|
|
3
|
+
*
|
|
4
|
+
* Bodies can be arbitrarily large (uploads, downloads, streams), so only the first `limit` bytes
|
|
5
|
+
* are kept, while the full size is still counted: the dashboard marks a body as truncated when
|
|
6
|
+
* the two differ.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Collects the bytes of one request or response body, up to a limit.
|
|
11
|
+
*/
|
|
12
|
+
export class BodyCapture {
|
|
13
|
+
/**
|
|
14
|
+
* @param {number} limit - Most bytes kept (maxBodySize)
|
|
15
|
+
*/
|
|
16
|
+
constructor(limit) {
|
|
17
|
+
this.limit = limit;
|
|
18
|
+
this._chunks = [];
|
|
19
|
+
this._captured = 0;
|
|
20
|
+
this._size = 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Adds a chunk as the app read or wrote it. Anything that is not body data, such as a missing
|
|
25
|
+
* chunk or a callback passed in its place, is ignored.
|
|
26
|
+
*
|
|
27
|
+
* @param {Buffer|Uint8Array|string|*} chunk
|
|
28
|
+
* @param {string} [encoding] - Encoding of a string chunk (utf8 when missing or unknown)
|
|
29
|
+
* @returns {void}
|
|
30
|
+
*/
|
|
31
|
+
push(chunk, encoding) {
|
|
32
|
+
if (chunk === undefined || chunk === null || typeof chunk === 'function') {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
let buffer;
|
|
36
|
+
try {
|
|
37
|
+
buffer = toBuffer(chunk, encoding);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
this._size += buffer.length;
|
|
42
|
+
if (this._captured >= this.limit) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const take = Math.min(buffer.length, this.limit - this._captured);
|
|
46
|
+
// A copy: the app may reuse its buffer once written, and a slice would keep all of it alive.
|
|
47
|
+
this._chunks.push(Buffer.from(buffer.subarray(0, take)));
|
|
48
|
+
this._captured += take;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Full body size in bytes, including bytes beyond the limit.
|
|
53
|
+
*
|
|
54
|
+
* @type {number}
|
|
55
|
+
*/
|
|
56
|
+
get size() {
|
|
57
|
+
return this._size;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether bytes beyond the limit were left out.
|
|
62
|
+
*
|
|
63
|
+
* @type {boolean}
|
|
64
|
+
*/
|
|
65
|
+
get truncated() {
|
|
66
|
+
return this._size > this._captured;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @returns {Buffer} The bytes kept
|
|
71
|
+
*/
|
|
72
|
+
buffer() {
|
|
73
|
+
return Buffer.concat(this._chunks, this._captured);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function toBuffer(chunk, encoding) {
|
|
78
|
+
if (Buffer.isBuffer(chunk)) {
|
|
79
|
+
return chunk;
|
|
80
|
+
}
|
|
81
|
+
if (chunk instanceof Uint8Array) {
|
|
82
|
+
return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
83
|
+
}
|
|
84
|
+
const charset = typeof encoding === 'string' && Buffer.isEncoding(encoding) ? encoding : 'utf8';
|
|
85
|
+
return Buffer.from(String(chunk), charset);
|
|
86
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns captured body bytes into what the dashboard shows: decompressed, and as text when the
|
|
3
|
+
* body is text, or only its size when it is binary.
|
|
4
|
+
*
|
|
5
|
+
* Also turns a body that a framework parsed before PeekInside could see the bytes into text,
|
|
6
|
+
* from the parsed value.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import zlib from 'zlib';
|
|
10
|
+
import { BodyCapture } from './body-capture.js';
|
|
11
|
+
import { headerValue } from './http-message.js';
|
|
12
|
+
|
|
13
|
+
const TEXTUAL_TYPE = /^(text\/|application\/(json|xml|javascript|ecmascript|x-www-form-urlencoded|graphql|x-ndjson|csv|yaml|x-yaml|toml)\b)|\+(json|xml)\b|^image\/svg\+xml/i;
|
|
14
|
+
const BINARY_TYPE = /^(image|audio|video|font)\/|^application\/(octet-stream|pdf|zip|gzip|x-gzip|x-tar|wasm|x-protobuf|protobuf|grpc|vnd\.ms-|vnd\.openxmlformats)/i;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} BodyContent
|
|
18
|
+
* @property {string} text - The body as text; empty for binary or missing bodies
|
|
19
|
+
* @property {number} size - Full body size in bytes, as sent
|
|
20
|
+
* @property {boolean} truncated - Whether `text` holds only the start of the body
|
|
21
|
+
* @property {'none'|'utf8'|'binary'|'unavailable'} encoding - "unavailable" when the body was
|
|
22
|
+
* never read by the app, so there was nothing to capture
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The content of an absent body.
|
|
27
|
+
*
|
|
28
|
+
* @type {Readonly<BodyContent>}
|
|
29
|
+
*/
|
|
30
|
+
export const EMPTY_BODY = Object.freeze({ text: '', size: 0, truncated: false, encoding: 'none' });
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Decodes captured bodies, keeping at most `limit` bytes of text.
|
|
34
|
+
*/
|
|
35
|
+
export class BodyDecoder {
|
|
36
|
+
/**
|
|
37
|
+
* @param {number} limit - Most bytes of text kept per body, after decompression
|
|
38
|
+
*/
|
|
39
|
+
constructor(limit) {
|
|
40
|
+
this.limit = limit;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Decodes captured bytes: decompressed per Content-Encoding, then kept as text unless they are
|
|
45
|
+
* binary.
|
|
46
|
+
*
|
|
47
|
+
* @param {BodyCapture|null} capture - Captured body
|
|
48
|
+
* @param {Object} headers - Headers of the same message, with lowercase names
|
|
49
|
+
* @returns {BodyContent}
|
|
50
|
+
*/
|
|
51
|
+
decode(capture, headers) {
|
|
52
|
+
if (!capture || capture.size === 0) {
|
|
53
|
+
return EMPTY_BODY;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let buffer = capture.buffer();
|
|
57
|
+
let truncated = capture.truncated;
|
|
58
|
+
let trustContentType = true;
|
|
59
|
+
|
|
60
|
+
const contentEncoding = String(headerValue(headers, 'content-encoding') || '').trim().toLowerCase();
|
|
61
|
+
if (contentEncoding && contentEncoding !== 'identity') {
|
|
62
|
+
const decoded = this._decompress(buffer, contentEncoding);
|
|
63
|
+
if (decoded) {
|
|
64
|
+
buffer = decoded.buffer;
|
|
65
|
+
truncated = truncated || decoded.truncated;
|
|
66
|
+
} else {
|
|
67
|
+
// Not decodable (or already decoded by middleware below us): judge by content
|
|
68
|
+
trustContentType = false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const contentType = String(headerValue(headers, 'content-type') || '');
|
|
73
|
+
if (!BodyDecoder.isTextual(contentType, buffer, trustContentType)) {
|
|
74
|
+
return { text: '', size: capture.size, truncated, encoding: 'binary' };
|
|
75
|
+
}
|
|
76
|
+
return { text: buffer.toString('utf8'), size: capture.size, truncated, encoding: 'utf8' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Content of a body known only from the value a framework parsed it into, because the app read
|
|
81
|
+
* the bytes before PeekInside was tracking the request.
|
|
82
|
+
*
|
|
83
|
+
* @param {*} value - Parsed body: an object, a string, a Buffer, or undefined when never read
|
|
84
|
+
* @param {Object} headers - Request headers
|
|
85
|
+
* @returns {BodyContent} Objects are shown as JSON; the size is Content-Length when known
|
|
86
|
+
*/
|
|
87
|
+
decodeValue(value, headers) {
|
|
88
|
+
const declared = Number(headers['content-length']) || 0;
|
|
89
|
+
|
|
90
|
+
if (value === undefined || value === null) {
|
|
91
|
+
// The application never read the body, so there was nothing to capture.
|
|
92
|
+
return { text: '', size: declared, truncated: false, encoding: 'unavailable' };
|
|
93
|
+
}
|
|
94
|
+
if (Buffer.isBuffer(value)) {
|
|
95
|
+
const capture = new BodyCapture(this.limit);
|
|
96
|
+
capture.push(value);
|
|
97
|
+
return this.decode(capture, headers);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let text;
|
|
101
|
+
if (typeof value === 'string') {
|
|
102
|
+
text = value;
|
|
103
|
+
} else {
|
|
104
|
+
try {
|
|
105
|
+
text = JSON.stringify(value);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
text = String(value);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const bytes = Buffer.byteLength(text);
|
|
112
|
+
const truncated = bytes > this.limit;
|
|
113
|
+
return {
|
|
114
|
+
text: truncated ? Buffer.from(text).subarray(0, this.limit).toString('utf8') : text,
|
|
115
|
+
size: declared || bytes,
|
|
116
|
+
truncated,
|
|
117
|
+
encoding: 'utf8'
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether a body is text: by its Content-Type when that is trusted and conclusive, otherwise by
|
|
123
|
+
* looking at the bytes.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} contentType
|
|
126
|
+
* @param {Buffer} buffer - The body, decompressed
|
|
127
|
+
* @param {boolean} [trustContentType=true] - False when the bytes may not match the headers
|
|
128
|
+
* @returns {boolean}
|
|
129
|
+
*/
|
|
130
|
+
static isTextual(contentType, buffer, trustContentType = true) {
|
|
131
|
+
if (trustContentType && contentType) {
|
|
132
|
+
if (TEXTUAL_TYPE.test(contentType)) return true;
|
|
133
|
+
if (BINARY_TYPE.test(contentType)) return false;
|
|
134
|
+
}
|
|
135
|
+
return BodyDecoder.looksLikeText(buffer);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Heuristic for bodies without a conclusive Content-Type: text has no NUL bytes and few control
|
|
140
|
+
* characters in its first 2 KB.
|
|
141
|
+
*
|
|
142
|
+
* @param {Buffer} buffer
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
static looksLikeText(buffer) {
|
|
146
|
+
const length = Math.min(buffer.length, 2048);
|
|
147
|
+
let suspicious = 0;
|
|
148
|
+
for (let i = 0; i < length; i++) {
|
|
149
|
+
const byte = buffer[i];
|
|
150
|
+
if (byte === 0) return false;
|
|
151
|
+
if (byte < 7 || (byte > 13 && byte < 32)) suspicious++;
|
|
152
|
+
}
|
|
153
|
+
return suspicious <= length * 0.05;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Decompresses gzip, deflate (zlib or raw) and brotli. Output is capped, so a small compressed
|
|
157
|
+
// body can't expand into gigabytes in the app's memory (a "zip bomb"), and a body cut off at
|
|
158
|
+
// the capture limit still yields the part that was captured (sync flush, not finish).
|
|
159
|
+
// Returns null when the body can't be decoded.
|
|
160
|
+
_decompress(buffer, encoding) {
|
|
161
|
+
const limit = this.limit;
|
|
162
|
+
const maxOutputLength = Math.max(limit * 16, 1024 * 1024);
|
|
163
|
+
const options = { finishFlush: zlib.constants.Z_SYNC_FLUSH, maxOutputLength };
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
let output;
|
|
167
|
+
if (encoding === 'gzip' || encoding === 'x-gzip') {
|
|
168
|
+
output = zlib.gunzipSync(buffer, options);
|
|
169
|
+
} else if (encoding === 'deflate') {
|
|
170
|
+
try {
|
|
171
|
+
output = zlib.inflateSync(buffer, options);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
output = zlib.inflateRawSync(buffer, options);
|
|
174
|
+
}
|
|
175
|
+
} else if (encoding === 'br') {
|
|
176
|
+
output = zlib.brotliDecompressSync(buffer, { finishFlush: zlib.constants.BROTLI_OPERATION_FLUSH, maxOutputLength });
|
|
177
|
+
} else {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return output.length > limit
|
|
182
|
+
? { buffer: output.subarray(0, limit), truncated: true }
|
|
183
|
+
: { buffer: output, truncated: false };
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records one request/response exchange: starts the clock, taps both bodies, and once the
|
|
3
|
+
* response has been sent (or the connection closed first) builds the exchange object that a
|
|
4
|
+
* transport turns into a stored record.
|
|
5
|
+
*
|
|
6
|
+
* Exceptions recorded during the request use the recorder too: its id (known from the start) and
|
|
7
|
+
* a snapshot of the request as received so far.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from 'crypto';
|
|
11
|
+
import http from 'http';
|
|
12
|
+
import { performance } from 'perf_hooks';
|
|
13
|
+
import { BodyDecoder, EMPTY_BODY } from './body-decoder.js';
|
|
14
|
+
import { hasBody, parseQuery } from './http-message.js';
|
|
15
|
+
import { RequestBodyTap } from './request-body-tap.js';
|
|
16
|
+
import { ResponseTap } from './response-tap.js';
|
|
17
|
+
|
|
18
|
+
const kBodySource = Symbol('peekInside.bodySource');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} Exchange
|
|
22
|
+
* @property {string} id - Random UUID
|
|
23
|
+
* @property {string} method
|
|
24
|
+
* @property {string} url - Request target as received (originalUrl in Express)
|
|
25
|
+
* @property {string} path - Pathname of `url`
|
|
26
|
+
* @property {Object<string, string|string[]>} query
|
|
27
|
+
* @property {Object<string, string>} params - Route parameters set by the framework, if any
|
|
28
|
+
* @property {Object<string, string|string[]>} headers - Request headers
|
|
29
|
+
* @property {string} body - Request body as text (see bodyEncoding)
|
|
30
|
+
* @property {number} bodySize
|
|
31
|
+
* @property {boolean} bodyTruncated
|
|
32
|
+
* @property {'none'|'utf8'|'binary'|'unavailable'} bodyEncoding
|
|
33
|
+
* @property {string} ip
|
|
34
|
+
* @property {string} userAgent
|
|
35
|
+
* @property {'http'|'https'} protocol
|
|
36
|
+
* @property {string} httpVersion
|
|
37
|
+
* @property {number} timestamp - When the request started (ms since the epoch)
|
|
38
|
+
* @property {Object} response - statusCode (null when nothing was sent), statusMessage, headers,
|
|
39
|
+
* body, size, bodyTruncated, bodyEncoding, responseTime (ms) and aborted
|
|
40
|
+
* @property {number} [exceptionCount] - Exceptions recorded during the request, added by PeekInside
|
|
41
|
+
* @property {Array<{ id: string, name: string, message: string }>} [exceptions] - The first of them
|
|
42
|
+
* @property {Object} [stats] - Timeline counters (queries, outgoing calls, logs), added by PeekInside
|
|
43
|
+
* @property {Object} [timeline] - The request's timeline, when something was recorded on it
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Records a single exchange. Create one per request, then call start() before the app reads the
|
|
48
|
+
* request body.
|
|
49
|
+
*/
|
|
50
|
+
export class ExchangeRecorder {
|
|
51
|
+
/**
|
|
52
|
+
* Tells the recorder where a framework keeps a request's parsed body. It is used when the app
|
|
53
|
+
* read the body before tracking started (a body parser registered before PeekInside), so the
|
|
54
|
+
* bytes themselves were never seen.
|
|
55
|
+
*
|
|
56
|
+
* @param {import('http').IncomingMessage} req
|
|
57
|
+
* @param {() => *} getter - Returns the parsed body, such as Koa's ctx.request.body
|
|
58
|
+
* @returns {void}
|
|
59
|
+
*/
|
|
60
|
+
static setBodySource(req, getter) {
|
|
61
|
+
req[kBodySource] = getter;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {import('http').IncomingMessage} req
|
|
66
|
+
* @param {import('http').ServerResponse} res
|
|
67
|
+
* @param {{ url: string, pathname: string, search: string }} target - The request target, split
|
|
68
|
+
* @param {Object} options
|
|
69
|
+
* @param {() => number} options.maxBodySize - Returns the current maxBodySize; it is read when
|
|
70
|
+
* the bodies are tapped and again when they are decoded, so later option changes apply
|
|
71
|
+
* @param {(exchange: Exchange) => void} options.onExchange - Receives the finished exchange
|
|
72
|
+
*/
|
|
73
|
+
constructor(req, res, { url, pathname, search }, { maxBodySize, onExchange }) {
|
|
74
|
+
this.req = req;
|
|
75
|
+
this.res = res;
|
|
76
|
+
this.url = url;
|
|
77
|
+
this.pathname = pathname;
|
|
78
|
+
this.search = search;
|
|
79
|
+
this._maxBodySize = maxBodySize;
|
|
80
|
+
this._onExchange = onExchange;
|
|
81
|
+
this._recorded = false;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Id of the request record. Created now rather than when the exchange is built, so that
|
|
85
|
+
* exceptions recorded during the request can refer to it.
|
|
86
|
+
* @type {string}
|
|
87
|
+
*/
|
|
88
|
+
this.id = randomUUID();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether the exchange has been built (the response finished, or the connection closed).
|
|
93
|
+
*
|
|
94
|
+
* @type {boolean}
|
|
95
|
+
*/
|
|
96
|
+
get recorded() {
|
|
97
|
+
return this._recorded;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Starts the clock, taps both bodies and waits for the response to finish.
|
|
102
|
+
*
|
|
103
|
+
* @returns {ExchangeRecorder} This recorder
|
|
104
|
+
*/
|
|
105
|
+
start() {
|
|
106
|
+
const res = this.res;
|
|
107
|
+
this.startedAt = Date.now();
|
|
108
|
+
// The timeline's clock: span offsets count from the same instant as occurredAt.
|
|
109
|
+
this.perfStart = performance.now();
|
|
110
|
+
this._start = process.hrtime.bigint();
|
|
111
|
+
this._requestTap = new RequestBodyTap(this.req, this._maxBodySize());
|
|
112
|
+
this._responseTap = new ResponseTap(res, this._maxBodySize());
|
|
113
|
+
|
|
114
|
+
this._onFinish = () => this._finish(false);
|
|
115
|
+
// 'close' without 'finish' means the client went away before the response was sent.
|
|
116
|
+
this._onClose = () => this._finish(!res.writableFinished);
|
|
117
|
+
res.on('finish', this._onFinish);
|
|
118
|
+
res.on('close', this._onClose);
|
|
119
|
+
return this;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Builds the exchange as it stands now.
|
|
124
|
+
*
|
|
125
|
+
* @param {boolean} aborted - Whether the connection closed before the response was sent
|
|
126
|
+
* @returns {Exchange}
|
|
127
|
+
*/
|
|
128
|
+
buildExchange(aborted) {
|
|
129
|
+
const { req, res } = this;
|
|
130
|
+
const duration = Number(process.hrtime.bigint() - this._start) / 1e6;
|
|
131
|
+
const decoder = new BodyDecoder(this._maxBodySize());
|
|
132
|
+
const requestHeaders = { ...req.headers };
|
|
133
|
+
const responseHeaders = this._responseTap.headers();
|
|
134
|
+
const requestContent = this._requestContent(decoder, requestHeaders);
|
|
135
|
+
// HEAD responses never have a body, whatever the app wrote.
|
|
136
|
+
const responseContent = req.method === 'HEAD'
|
|
137
|
+
? EMPTY_BODY
|
|
138
|
+
: decoder.decode(this._responseTap.body, responseHeaders);
|
|
139
|
+
const statusCode = aborted && !res.headersSent ? null : res.statusCode;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
id: this.id,
|
|
143
|
+
method: req.method,
|
|
144
|
+
url: this.url,
|
|
145
|
+
path: this.pathname,
|
|
146
|
+
query: parseQuery(this.search),
|
|
147
|
+
params: req.params && typeof req.params === 'object' ? { ...req.params } : {},
|
|
148
|
+
headers: requestHeaders,
|
|
149
|
+
body: requestContent.text,
|
|
150
|
+
bodySize: requestContent.size,
|
|
151
|
+
bodyTruncated: requestContent.truncated,
|
|
152
|
+
bodyEncoding: requestContent.encoding,
|
|
153
|
+
ip: req.ip || (req.socket && req.socket.remoteAddress) || '',
|
|
154
|
+
userAgent: requestHeaders['user-agent'] || '',
|
|
155
|
+
protocol: req.socket && req.socket.encrypted ? 'https' : 'http',
|
|
156
|
+
httpVersion: req.httpVersion || '1.1',
|
|
157
|
+
timestamp: this.startedAt,
|
|
158
|
+
response: {
|
|
159
|
+
statusCode,
|
|
160
|
+
statusMessage: statusCode === null ? '' : (res.statusMessage || http.STATUS_CODES[statusCode] || ''),
|
|
161
|
+
headers: responseHeaders,
|
|
162
|
+
body: responseContent.text,
|
|
163
|
+
size: responseContent.size,
|
|
164
|
+
bodyTruncated: responseContent.truncated,
|
|
165
|
+
bodyEncoding: responseContent.encoding,
|
|
166
|
+
responseTime: Math.round(duration * 100) / 100,
|
|
167
|
+
aborted: Boolean(aborted)
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The request as an exception record shows it: what has been received so far, with a shorter
|
|
174
|
+
* body than the request record keeps.
|
|
175
|
+
*
|
|
176
|
+
* @param {number} bodyLimit - Most bytes of body text
|
|
177
|
+
* @returns {{ method: string, url: string, headers: Object, query: Object, params: Object,
|
|
178
|
+
* body: string, bodyTruncated: boolean, ip: string, userAgent: string }}
|
|
179
|
+
*/
|
|
180
|
+
snapshot(bodyLimit) {
|
|
181
|
+
const req = this.req;
|
|
182
|
+
const headers = { ...req.headers };
|
|
183
|
+
const content = this._requestContent(new BodyDecoder(bodyLimit), headers);
|
|
184
|
+
return {
|
|
185
|
+
method: String(req.method || ''),
|
|
186
|
+
url: this.url,
|
|
187
|
+
headers,
|
|
188
|
+
query: parseQuery(this.search),
|
|
189
|
+
params: stringParams(req.params),
|
|
190
|
+
body: content.encoding === 'utf8' ? content.text : '',
|
|
191
|
+
bodyTruncated: content.encoding === 'utf8' && content.truncated,
|
|
192
|
+
ip: req.ip || (req.socket && req.socket.remoteAddress) || '',
|
|
193
|
+
userAgent: headers['user-agent'] || ''
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Builds the exchange right away, for a process that is about to exit: a response still in
|
|
199
|
+
* progress is recorded as aborted. Afterwards the recorder records nothing more.
|
|
200
|
+
*
|
|
201
|
+
* @returns {Exchange|null} null when the exchange was already recorded
|
|
202
|
+
*/
|
|
203
|
+
takeExchange() {
|
|
204
|
+
if (this._recorded) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
this._recorded = true;
|
|
208
|
+
this.res.removeListener('finish', this._onFinish);
|
|
209
|
+
this.res.removeListener('close', this._onClose);
|
|
210
|
+
return this.buildExchange(!this.res.writableFinished);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Runs once, on 'finish' or 'close', whichever comes first.
|
|
214
|
+
_finish(aborted) {
|
|
215
|
+
if (this._recorded) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
this._recorded = true;
|
|
219
|
+
this.res.removeListener('finish', this._onFinish);
|
|
220
|
+
this.res.removeListener('close', this._onClose);
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
this._onExchange(this.buildExchange(aborted));
|
|
224
|
+
} catch (error) {
|
|
225
|
+
// Ignore: a malformed exchange must not affect the application.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The request body as the app read it, or, when the app read it before tracking started,
|
|
230
|
+
// what the framework parsed it into.
|
|
231
|
+
_requestContent(decoder, headers) {
|
|
232
|
+
const req = this.req;
|
|
233
|
+
const tapped = this._requestTap.body;
|
|
234
|
+
if (tapped.size > 0) {
|
|
235
|
+
return decoder.decode(tapped, headers);
|
|
236
|
+
}
|
|
237
|
+
if (!hasBody(headers)) {
|
|
238
|
+
return EMPTY_BODY;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let parsed;
|
|
242
|
+
try {
|
|
243
|
+
parsed = typeof req[kBodySource] === 'function' ? req[kBodySource]() : undefined;
|
|
244
|
+
} catch (error) {
|
|
245
|
+
parsed = undefined;
|
|
246
|
+
}
|
|
247
|
+
if (parsed === undefined) {
|
|
248
|
+
parsed = req.body !== undefined ? req.body : req.rawBody;
|
|
249
|
+
}
|
|
250
|
+
return decoder.decodeValue(parsed, headers);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Route parameters as strings (Express keeps them as strings; other routers may not).
|
|
255
|
+
function stringParams(params) {
|
|
256
|
+
const result = {};
|
|
257
|
+
if (!params || typeof params !== 'object') return result;
|
|
258
|
+
for (const key of Object.keys(params)) {
|
|
259
|
+
const value = params[key];
|
|
260
|
+
if (value !== undefined && value !== null && typeof value !== 'object') result[key] = String(value);
|
|
261
|
+
}
|
|
262
|
+
return result;
|
|
263
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the parts of HTTP messages that PeekInside records: request targets, query
|
|
3
|
+
* strings and header objects.
|
|
4
|
+
*
|
|
5
|
+
* Query keys and header names come from the network, so the objects built from them define
|
|
6
|
+
* every key as an own property: a key such as "__proto__" stays a plain entry instead of
|
|
7
|
+
* changing the object's prototype.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Splits a request target into pathname and search, without normalizing it: the dashboard shows
|
|
12
|
+
* the path exactly as the client sent it.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} url - Request target, such as "/users?page=2" or an absolute URL (sent to proxies)
|
|
15
|
+
* @returns {{ pathname: string, search: string }} `search` includes the "?" when present
|
|
16
|
+
*/
|
|
17
|
+
export function splitUrl(url) {
|
|
18
|
+
let target = typeof url === 'string' && url !== '' ? url : '/';
|
|
19
|
+
if (/^[a-z][a-z\d+.-]*:\/\//i.test(target)) {
|
|
20
|
+
// Absolute-form target, as sent to proxies
|
|
21
|
+
try {
|
|
22
|
+
const parsed = new URL(target);
|
|
23
|
+
target = parsed.pathname + parsed.search;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
target = '/';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const index = target.indexOf('?');
|
|
29
|
+
return index === -1
|
|
30
|
+
? { pathname: target, search: '' }
|
|
31
|
+
: { pathname: target.slice(0, index), search: target.slice(index) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parses a query string. Repeated keys become arrays, in order.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} search - Query string, including the leading "?"
|
|
38
|
+
* @returns {Object<string, string|string[]>}
|
|
39
|
+
*/
|
|
40
|
+
export function parseQuery(search) {
|
|
41
|
+
const query = {};
|
|
42
|
+
if (!search || search.length < 2) {
|
|
43
|
+
return query;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let params;
|
|
47
|
+
try {
|
|
48
|
+
params = new URLSearchParams(search.slice(1));
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return query;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const [key, value] of params) {
|
|
54
|
+
const existing = Object.prototype.hasOwnProperty.call(query, key) ? query[key] : undefined;
|
|
55
|
+
setOwn(query, key, existing === undefined ? value : [].concat(existing, value));
|
|
56
|
+
}
|
|
57
|
+
return query;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Turns headers in any form writeHead() accepts into an object with lowercase names.
|
|
62
|
+
*
|
|
63
|
+
* @param {Object|Array} headers - An object, a flat array [name, value, ...] or an array of pairs
|
|
64
|
+
* @returns {Object<string, *>}
|
|
65
|
+
*/
|
|
66
|
+
export function normalizeHeaders(headers) {
|
|
67
|
+
const normalized = {};
|
|
68
|
+
const add = (name, value) => {
|
|
69
|
+
if (name !== undefined && name !== null && value !== undefined) {
|
|
70
|
+
setOwn(normalized, String(name).toLowerCase(), value);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (Array.isArray(headers)) {
|
|
75
|
+
if (headers.length > 0 && Array.isArray(headers[0])) {
|
|
76
|
+
for (const pair of headers) add(pair[0], pair[1]);
|
|
77
|
+
} else {
|
|
78
|
+
for (let i = 0; i + 1 < headers.length; i += 2) add(headers[i], headers[i + 1]);
|
|
79
|
+
}
|
|
80
|
+
} else if (headers && typeof headers === 'object') {
|
|
81
|
+
for (const name of Object.keys(headers)) add(name, headers[name]);
|
|
82
|
+
}
|
|
83
|
+
return normalized;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Merges header objects; later sources win.
|
|
88
|
+
*
|
|
89
|
+
* @param {...(Object|null|undefined)} sources
|
|
90
|
+
* @returns {Object<string, *>}
|
|
91
|
+
*/
|
|
92
|
+
export function mergeHeaders(...sources) {
|
|
93
|
+
const merged = {};
|
|
94
|
+
for (const source of sources) {
|
|
95
|
+
if (!source) continue;
|
|
96
|
+
for (const name of Object.keys(source)) {
|
|
97
|
+
setOwn(merged, name, source[name]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return merged;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {Object} headers - Headers with lowercase names
|
|
105
|
+
* @param {string} name - Lowercase header name
|
|
106
|
+
* @returns {*} The header's value, or its first value when it was sent several times
|
|
107
|
+
*/
|
|
108
|
+
export function headerValue(headers, name) {
|
|
109
|
+
const value = headers[name];
|
|
110
|
+
return Array.isArray(value) ? value[0] : value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Whether request headers announce a body: chunked, or a positive Content-Length.
|
|
115
|
+
*
|
|
116
|
+
* @param {Object} headers - Request headers
|
|
117
|
+
* @returns {boolean}
|
|
118
|
+
*/
|
|
119
|
+
export function hasBody(headers) {
|
|
120
|
+
if (headers['transfer-encoding'] !== undefined) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
const length = Number(headers['content-length']);
|
|
124
|
+
return Number.isFinite(length) && length > 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Sets a property as an own, enumerable entry, even for keys such as "__proto__" that would
|
|
129
|
+
* otherwise change the object's prototype.
|
|
130
|
+
*
|
|
131
|
+
* @param {Object} target
|
|
132
|
+
* @param {string} key
|
|
133
|
+
* @param {*} value
|
|
134
|
+
* @returns {void}
|
|
135
|
+
*/
|
|
136
|
+
export function setOwn(target, key, value) {
|
|
137
|
+
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
|
|
138
|
+
}
|