peekinside 1.2.4 → 1.3.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.
- package/README.md +13 -2
- package/index.d.ts +44 -13
- package/package.json +10 -1
- package/src/PeekInside.js +93 -17
- package/src/alerts/rule-matcher.js +158 -0
- package/src/capture/exchange-recorder.js +5 -12
- package/src/capture/http-message.js +17 -0
- package/src/crypto.js +26 -2
- package/src/dumps/dump-capture.js +100 -0
- package/src/dumps/dump-record.js +57 -0
- package/src/dumps/dump-value.js +428 -0
- package/src/exceptions/exception-capture.js +5 -4
- package/src/exceptions/exception-record.js +11 -1
- package/src/exceptions/request-context.js +118 -10
- package/src/hub/ingest-payload.js +89 -8
- package/src/hub/project-log.js +71 -4
- package/src/hub/server.js +19 -8
- package/src/hub/spool-ingester.js +1 -0
- package/src/hub/store.js +44 -1
- package/src/instrumentation/index.js +48 -3
- package/src/instrumentation/jobs.js +114 -6
- package/src/instrumentation/loggers.js +78 -4
- package/src/integrations/fastify.js +18 -1
- package/src/integrations/hono.js +209 -0
- package/src/integrations/nest.js +277 -0
- package/src/integrations/server-binder.js +9 -6
- package/src/logs/log-capture.js +5 -5
- package/src/logs/log-record.js +2 -2
- package/src/record.js +6 -2
- package/src/runtime/request-counter.js +40 -0
- package/src/runtime/sampler.js +120 -6
- package/src/timeline/timeline.js +24 -3
- package/src/transport/batch.js +4 -1
- package/src/transport/cloud.js +34 -3
- package/src/transport/local.js +7 -5
- package/src/user.js +18 -7
package/src/crypto.js
CHANGED
|
@@ -177,13 +177,37 @@ export class ApiKey {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
/**
|
|
180
|
-
* @param {string} apiKey
|
|
180
|
+
* @param {string} apiKey - Surrounding spaces and quotes are ignored (env files that keep them)
|
|
181
181
|
* @returns {ApiKey|null} The key's parts, or null when it isn't a well-formed v1 key
|
|
182
182
|
*/
|
|
183
183
|
static parse(apiKey) {
|
|
184
|
-
const [version, projectId, secret, publicKey, ...rest] =
|
|
184
|
+
const [version, projectId, secret, publicKey, ...rest] = ApiKey.unquote(apiKey).split('.');
|
|
185
185
|
if (version !== 'pik1' || rest.length) return null;
|
|
186
186
|
if (!PROJECT_ID.test(projectId) || !SECRET.test(secret) || !PUBLIC_KEY.test(publicKey)) return null;
|
|
187
187
|
return new ApiKey({ projectId, secret, publicKey });
|
|
188
188
|
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* What's wrong with a value that isn't an API key, in words for the app's log. Never repeats the
|
|
192
|
+
* value: it may hold a secret.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} value
|
|
195
|
+
* @returns {string} A sentence without its final period
|
|
196
|
+
*/
|
|
197
|
+
static problem(value) {
|
|
198
|
+
const text = ApiKey.unquote(value);
|
|
199
|
+
if (PUBLIC_KEY.test(text) && Buffer.from(text, 'base64url')[0] === 4) {
|
|
200
|
+
return 'PEEKINSIDE_API_KEY holds your project\'s public key, not an API key. API keys start with "pik1.": create one in PeekInside Cloud, on the project\'s Settings page under API keys';
|
|
201
|
+
}
|
|
202
|
+
if (!text.startsWith('pik1.')) {
|
|
203
|
+
return 'PEEKINSIDE_API_KEY is not a PeekInside API key (those start with "pik1."). Create one in PeekInside Cloud, on the project\'s Settings page under API keys';
|
|
204
|
+
}
|
|
205
|
+
return 'PEEKINSIDE_API_KEY looks cut off or changed: copy the whole key, from "pik1." to its end';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Without the spaces and one pair of matching quotes around it.
|
|
209
|
+
static unquote(value) {
|
|
210
|
+
const text = String(value).trim();
|
|
211
|
+
return /^(["'`]).*\1$/s.test(text) && text.length > 1 ? text.slice(1, -1).trim() : text;
|
|
212
|
+
}
|
|
189
213
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dumps of one PeekInside instance (PeekInside.dump()): each value is read into a tree (see
|
|
3
|
+
* DumpValue), with the line of the app that dumped it and the request or job run running then,
|
|
4
|
+
* and goes to the local dashboard. A dump during a request is listed on the request's record and
|
|
5
|
+
* placed on its timeline.
|
|
6
|
+
*
|
|
7
|
+
* Dumps are rate limited per process (50 a second, bursts of 200), like log lines. Nothing here
|
|
8
|
+
* throws into the app: dump() always hands the value back.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { randomUUID } from 'crypto';
|
|
12
|
+
import { truncate } from '../exceptions/limits.js';
|
|
13
|
+
import { TokenBucket } from '../exceptions/rate-limiter.js';
|
|
14
|
+
import { RequestContext } from '../exceptions/request-context.js';
|
|
15
|
+
import { DumpRecord } from './dump-record.js';
|
|
16
|
+
import { DumpValue } from './dump-value.js';
|
|
17
|
+
|
|
18
|
+
const MAX_LABEL = 120;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Records dumps for one PeekInside instance.
|
|
22
|
+
*/
|
|
23
|
+
export class DumpCapture {
|
|
24
|
+
/**
|
|
25
|
+
* @param {Object} options
|
|
26
|
+
* @param {import('../exceptions/exception-capture.js').ExceptionCapture} options.capture - The
|
|
27
|
+
* instance's exceptions, whose stack frames (mapped to source files) locate a dump
|
|
28
|
+
* @param {() => Object|null} options.sink - The instance's transport
|
|
29
|
+
* @param {() => boolean} options.isEnabled - Whether the instance records right now
|
|
30
|
+
* @param {TokenBucket} [options.limiter]
|
|
31
|
+
*/
|
|
32
|
+
constructor({ capture, sink, isEnabled, limiter = new TokenBucket({ rate: 50, burst: 200 }) }) {
|
|
33
|
+
this.capture = capture;
|
|
34
|
+
this._sink = sink;
|
|
35
|
+
this._isEnabled = isEnabled;
|
|
36
|
+
this.limiter = limiter;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @type {boolean}
|
|
41
|
+
*/
|
|
42
|
+
get recording() {
|
|
43
|
+
const sink = this._sink();
|
|
44
|
+
return Boolean(sink && !sink.disabled && this._isEnabled());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Records a dump. Never throws.
|
|
49
|
+
*
|
|
50
|
+
* @param {*} value
|
|
51
|
+
* @param {*} label - A string names the dump; anything else is ignored
|
|
52
|
+
* @param {Function} entry - The function the app called (PeekInside.dump): the stack is read
|
|
53
|
+
* from its caller
|
|
54
|
+
* @returns {string|null} The dump's id, or null when nothing was recorded
|
|
55
|
+
*/
|
|
56
|
+
record(value, label, entry) {
|
|
57
|
+
try {
|
|
58
|
+
if (!this.recording) return null;
|
|
59
|
+
const context = RequestContext.current();
|
|
60
|
+
// Requests local mode doesn't record (server traffic) take their dumps with them.
|
|
61
|
+
if (context && context.blocked) return null;
|
|
62
|
+
if (!this.limiter.take()) return null;
|
|
63
|
+
const own = context && context.capture === this.capture ? context : null;
|
|
64
|
+
const { tree, type, preview, truncated } = DumpValue.capture(value);
|
|
65
|
+
const sink = this._sink();
|
|
66
|
+
const record = new DumpRecord({
|
|
67
|
+
id: randomUUID(),
|
|
68
|
+
occurredAt: Date.now(),
|
|
69
|
+
environment: sink.environment,
|
|
70
|
+
release: sink.release || null,
|
|
71
|
+
requestId: own ? own.requestId : null,
|
|
72
|
+
jobRunId: own ? own.jobRunId || null : null,
|
|
73
|
+
label: typeof label === 'string' && label.trim() ? truncate(label.trim(), MAX_LABEL) : null,
|
|
74
|
+
location: this._callSite(entry),
|
|
75
|
+
summary: { type, preview, truncated },
|
|
76
|
+
detail: { value: tree }
|
|
77
|
+
});
|
|
78
|
+
if (own) own.noteDump(record);
|
|
79
|
+
sink.enqueueRecord(record);
|
|
80
|
+
return record.id;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The app's line that called `entry`, mapped through source maps like an exception's frames.
|
|
87
|
+
_callSite(entry) {
|
|
88
|
+
const holder = {};
|
|
89
|
+
const limit = Error.stackTraceLimit;
|
|
90
|
+
try {
|
|
91
|
+
Error.stackTraceLimit = 1;
|
|
92
|
+
Error.captureStackTrace(holder, entry);
|
|
93
|
+
} finally {
|
|
94
|
+
Error.stackTraceLimit = limit;
|
|
95
|
+
}
|
|
96
|
+
const [frame] = this.capture.builder.frames({ stack: String(holder.stack || ''), fullMessage: '', isError: true }, 1);
|
|
97
|
+
if (!frame || !frame.file) return null;
|
|
98
|
+
return { file: frame.file, relativeFile: frame.relativeFile, line: frame.line, column: frame.column, function: frame.function };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One dump, as the local dashboard's Dumps page lists it: when, the label it was given, the line
|
|
3
|
+
* that dumped it, the request or job run it belongs to, and the value. The list shows the value's
|
|
4
|
+
* type and a one-line preview; the tree itself is the dump's detail, loaded when it is opened.
|
|
5
|
+
*
|
|
6
|
+
* Dumps are for development and go to the local dashboard only, so a record is never sealed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A recorded dump.
|
|
11
|
+
*/
|
|
12
|
+
export class DumpRecord {
|
|
13
|
+
/**
|
|
14
|
+
* @param {Object} fields
|
|
15
|
+
* @param {string} fields.id - UUID
|
|
16
|
+
* @param {number} fields.occurredAt - ms since the epoch
|
|
17
|
+
* @param {string} fields.environment
|
|
18
|
+
* @param {string|null} [fields.release=null]
|
|
19
|
+
* @param {string|null} [fields.requestId=null] - The request it was dumped during
|
|
20
|
+
* @param {string|null} [fields.jobRunId=null] - The job run it was dumped during
|
|
21
|
+
* @param {string|null} [fields.label=null] - What the app called it
|
|
22
|
+
* @param {Object|null} [fields.location=null] - The line that dumped it: file (absolute),
|
|
23
|
+
* relativeFile, line, column, function
|
|
24
|
+
* @param {{ type: string, preview: string, truncated: boolean }} fields.summary
|
|
25
|
+
* @param {{ value: Object }} fields.detail - The value's tree (see DumpValue)
|
|
26
|
+
*/
|
|
27
|
+
constructor({ id, occurredAt, environment, release = null, requestId = null, jobRunId = null, label = null, location = null, summary, detail }) {
|
|
28
|
+
this.id = id;
|
|
29
|
+
this.occurredAt = occurredAt;
|
|
30
|
+
this.environment = environment;
|
|
31
|
+
this.release = release;
|
|
32
|
+
this.requestId = requestId;
|
|
33
|
+
this.jobRunId = jobRunId;
|
|
34
|
+
this.label = label;
|
|
35
|
+
this.location = location;
|
|
36
|
+
this.summary = summary;
|
|
37
|
+
this.detail = detail;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What a request's record lists of the dumps made while it was handled.
|
|
42
|
+
*
|
|
43
|
+
* @returns {{ id: string, label: string|null, type: string, preview: string }}
|
|
44
|
+
*/
|
|
45
|
+
link() {
|
|
46
|
+
return { id: this.id, label: this.label, type: this.summary.type, preview: this.summary.preview };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The plain form, as the local hub stores it.
|
|
51
|
+
*
|
|
52
|
+
* @returns {Object}
|
|
53
|
+
*/
|
|
54
|
+
toJSON() {
|
|
55
|
+
return { ...this };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The value a dump shows: a typed tree of what the app passed to dump(), read without running the
|
|
3
|
+
* app's code.
|
|
4
|
+
*
|
|
5
|
+
* console.log turns a value into text; a dump keeps its shape, so the dashboard can show it as a
|
|
6
|
+
* tree that opens level by level, with the type of each node: a Map stays a Map, a Date a date, a
|
|
7
|
+
* class instance says its class. Reading is careful: getters aren't called (they could do
|
|
8
|
+
* anything), proxies aren't looked into, and a cycle becomes a reference to the ancestor it loops
|
|
9
|
+
* back to. The tree stops at bounds on depth, items per object, string length and total size, and
|
|
10
|
+
* says what it left out.
|
|
11
|
+
*
|
|
12
|
+
* Nodes, by `t`:
|
|
13
|
+
*
|
|
14
|
+
* str { v, len? } a string; `len` is its full length when it was cut
|
|
15
|
+
* num { v } big { v } numbers and BigInts, as text (NaN, Infinity and -0 survive)
|
|
16
|
+
* bool { v } null undef hole hole: an empty slot of a sparse array
|
|
17
|
+
* sym { v } a symbol, as text
|
|
18
|
+
* fn { name, kind? } a function; kind is "class", "async" or "generator"
|
|
19
|
+
* date { v } re { v } a date (ISO, or "Invalid Date") and a regular expression
|
|
20
|
+
* err { cls, msg, stack?, props } an error, with its own properties (code, status...)
|
|
21
|
+
* arr { cls?, len, items } an array (cls for subclasses and typed arrays)
|
|
22
|
+
* obj { cls, len, props } an object: cls is its class, null for a plain object
|
|
23
|
+
* map { len, entries } set { len, items }
|
|
24
|
+
* buf { cls, len, hex } a Buffer or ArrayBuffer: its size and first bytes
|
|
25
|
+
* box { cls, v } a boxed primitive (new String("x"))
|
|
26
|
+
* get { set? } an own accessor property, not called
|
|
27
|
+
* ref { path } a cycle: the ancestor it points back to ("$.owner")
|
|
28
|
+
* cut { cls?, len? } not read: past the depth or size bounds
|
|
29
|
+
* proxy promise weak { cls } kinds whose contents aren't read
|
|
30
|
+
*
|
|
31
|
+
* `len` of a container counts all its items, even when only the first ones are in the tree.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { types } from 'util';
|
|
35
|
+
|
|
36
|
+
export const DUMP_LIMITS = Object.freeze({
|
|
37
|
+
depth: 10,
|
|
38
|
+
items: 100,
|
|
39
|
+
string: 4000,
|
|
40
|
+
stack: 4000,
|
|
41
|
+
nodes: 5000,
|
|
42
|
+
// Characters of strings in one tree, all together.
|
|
43
|
+
text: 256 * 1024,
|
|
44
|
+
bytes: 48,
|
|
45
|
+
preview: 160
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Reads values into dump trees.
|
|
50
|
+
*/
|
|
51
|
+
export class DumpValue {
|
|
52
|
+
/**
|
|
53
|
+
* @param {*} value
|
|
54
|
+
* @returns {{ tree: Object, type: string, preview: string, truncated: boolean }} The tree, a short
|
|
55
|
+
* name of its type ("User", "array", "string"), a one-line preview, and whether bounds cut it
|
|
56
|
+
*/
|
|
57
|
+
static capture(value) {
|
|
58
|
+
const reader = new Reader();
|
|
59
|
+
let tree;
|
|
60
|
+
try {
|
|
61
|
+
tree = reader.node(value, 0, '$');
|
|
62
|
+
} catch (error) {
|
|
63
|
+
// Every read is guarded; this is the last line of defense.
|
|
64
|
+
tree = { t: 'cut' };
|
|
65
|
+
reader.truncated = true;
|
|
66
|
+
}
|
|
67
|
+
return { tree, type: DumpValue.typeOf(tree), preview: DumpValue.preview(tree, DUMP_LIMITS.preview), truncated: reader.truncated };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A short name of a node's type: its class for objects, else its kind.
|
|
72
|
+
*
|
|
73
|
+
* @param {Object} node
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
static typeOf(node) {
|
|
77
|
+
switch (node.t) {
|
|
78
|
+
case 'str':
|
|
79
|
+
return 'string';
|
|
80
|
+
case 'num':
|
|
81
|
+
return 'number';
|
|
82
|
+
case 'big':
|
|
83
|
+
return 'bigint';
|
|
84
|
+
case 'bool':
|
|
85
|
+
return 'boolean';
|
|
86
|
+
case 'undef':
|
|
87
|
+
return 'undefined';
|
|
88
|
+
case 'sym':
|
|
89
|
+
return 'symbol';
|
|
90
|
+
case 'fn':
|
|
91
|
+
return node.kind === 'class' ? 'class' : 'function';
|
|
92
|
+
case 're':
|
|
93
|
+
return 'RegExp';
|
|
94
|
+
case 'date':
|
|
95
|
+
return 'Date';
|
|
96
|
+
case 'arr':
|
|
97
|
+
return node.cls || 'array';
|
|
98
|
+
case 'map':
|
|
99
|
+
return 'Map';
|
|
100
|
+
case 'set':
|
|
101
|
+
return 'Set';
|
|
102
|
+
case 'err':
|
|
103
|
+
case 'obj':
|
|
104
|
+
case 'buf':
|
|
105
|
+
case 'box':
|
|
106
|
+
return node.cls || 'object';
|
|
107
|
+
default:
|
|
108
|
+
return node.t;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The node on one line, as a list shows it: `User { id: 1, name: "Ada", … }`.
|
|
114
|
+
*
|
|
115
|
+
* @param {Object} node
|
|
116
|
+
* @param {number} [max=160] - Characters at most
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
static preview(node, max = DUMP_LIMITS.preview) {
|
|
120
|
+
const text = line(node, 2);
|
|
121
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
class Reader {
|
|
126
|
+
constructor() {
|
|
127
|
+
this.nodes = 0;
|
|
128
|
+
this.text = 0;
|
|
129
|
+
this.truncated = false;
|
|
130
|
+
// The objects being read, from the root down, to their paths: a cycle points back to one.
|
|
131
|
+
this.ancestors = new Map();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
node(value, depth, path) {
|
|
135
|
+
this.nodes++;
|
|
136
|
+
switch (typeof value) {
|
|
137
|
+
case 'string':
|
|
138
|
+
return this.string(value, DUMP_LIMITS.string);
|
|
139
|
+
case 'number':
|
|
140
|
+
return { t: 'num', v: Object.is(value, -0) ? '-0' : String(value) };
|
|
141
|
+
case 'bigint':
|
|
142
|
+
return { t: 'big', v: value.toString() };
|
|
143
|
+
case 'boolean':
|
|
144
|
+
return { t: 'bool', v: value };
|
|
145
|
+
case 'undefined':
|
|
146
|
+
return { t: 'undef' };
|
|
147
|
+
case 'symbol':
|
|
148
|
+
return { t: 'sym', v: guard(() => value.toString(), 'Symbol()') };
|
|
149
|
+
case 'function':
|
|
150
|
+
return fn(value);
|
|
151
|
+
default:
|
|
152
|
+
return value === null ? { t: 'null' } : this.object(value, depth, path);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
string(value, max) {
|
|
157
|
+
const room = Math.max(0, Math.min(max, DUMP_LIMITS.text - this.text));
|
|
158
|
+
if (value.length <= room) {
|
|
159
|
+
this.text += value.length;
|
|
160
|
+
return { t: 'str', v: value };
|
|
161
|
+
}
|
|
162
|
+
this.truncated = true;
|
|
163
|
+
const cut = value.slice(0, room);
|
|
164
|
+
this.text += cut.length;
|
|
165
|
+
return { t: 'str', v: cut, len: value.length };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
object(value, depth, path) {
|
|
169
|
+
if (types.isProxy(value)) return { t: 'proxy' };
|
|
170
|
+
const ancestor = this.ancestors.get(value);
|
|
171
|
+
if (ancestor) return { t: 'ref', path: ancestor };
|
|
172
|
+
const cls = className(value);
|
|
173
|
+
if (types.isDate(value)) return { t: 'date', v: guard(() => (Number.isNaN(value.getTime()) ? 'Invalid Date' : value.toISOString()), 'Invalid Date') };
|
|
174
|
+
if (types.isRegExp(value)) return { t: 're', v: guard(() => RegExp.prototype.toString.call(value), '/(?:)/') };
|
|
175
|
+
if (types.isPromise(value)) return { t: 'promise' };
|
|
176
|
+
if (types.isWeakMap(value) || types.isWeakSet(value)) return { t: 'weak', cls: cls || 'Weak' };
|
|
177
|
+
if (Buffer.isBuffer(value)) return { t: 'buf', cls: 'Buffer', len: value.length, hex: value.subarray(0, DUMP_LIMITS.bytes).toString('hex') };
|
|
178
|
+
if (types.isAnyArrayBuffer(value)) {
|
|
179
|
+
return { t: 'buf', cls: cls || 'ArrayBuffer', len: value.byteLength, hex: Buffer.from(value, 0, Math.min(DUMP_LIMITS.bytes, value.byteLength)).toString('hex') };
|
|
180
|
+
}
|
|
181
|
+
if (types.isBoxedPrimitive(value)) return { t: 'box', cls: cls || 'Object', v: this.node(guard(() => value.valueOf(), undefined), depth + 1, path) };
|
|
182
|
+
if (depth >= DUMP_LIMITS.depth || this.nodes > DUMP_LIMITS.nodes || this.text >= DUMP_LIMITS.text) {
|
|
183
|
+
this.truncated = true;
|
|
184
|
+
return { t: 'cut', ...(cls ? { cls } : {}), ...sizeOf(value) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this.ancestors.set(value, path);
|
|
188
|
+
try {
|
|
189
|
+
if (types.isNativeError(value) || value instanceof Error) return this.error(value, cls, depth, path);
|
|
190
|
+
if (Array.isArray(value)) return this.array(value, cls, depth, path);
|
|
191
|
+
if (types.isTypedArray(value)) return this.typedArray(value, cls);
|
|
192
|
+
if (types.isMap(value)) return this.map(value, depth, path);
|
|
193
|
+
if (types.isSet(value)) return this.set(value, depth, path);
|
|
194
|
+
return { t: 'obj', cls, ...this.props(value, depth, path, null) };
|
|
195
|
+
} finally {
|
|
196
|
+
this.ancestors.delete(value);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Own enumerable properties, string keys then symbols; `skip` leaves some out.
|
|
201
|
+
props(value, depth, path, skip) {
|
|
202
|
+
const keys = guard(() => Reflect.ownKeys(value), []);
|
|
203
|
+
const props = [];
|
|
204
|
+
let len = 0;
|
|
205
|
+
for (const key of keys) {
|
|
206
|
+
if (skip && skip.has(key)) continue;
|
|
207
|
+
const descriptor = guard(() => Object.getOwnPropertyDescriptor(value, key), undefined);
|
|
208
|
+
if (!descriptor || !descriptor.enumerable) continue;
|
|
209
|
+
len++;
|
|
210
|
+
if (props.length >= DUMP_LIMITS.items || this.nodes > DUMP_LIMITS.nodes) {
|
|
211
|
+
this.truncated = true;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const name = typeof key === 'symbol' ? `[${guard(() => key.toString(), 'Symbol()')}]` : key;
|
|
215
|
+
props.push([name, this.property(descriptor, depth, childPath(path, name))]);
|
|
216
|
+
}
|
|
217
|
+
return { len, props };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
property(descriptor, depth, path) {
|
|
221
|
+
if ('value' in descriptor) return this.node(descriptor.value, depth + 1, path);
|
|
222
|
+
this.nodes++;
|
|
223
|
+
return descriptor.set ? { t: 'get', set: true } : { t: 'get' };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
array(value, cls, depth, path) {
|
|
227
|
+
const len = value.length;
|
|
228
|
+
const count = Math.min(len, DUMP_LIMITS.items);
|
|
229
|
+
const items = [];
|
|
230
|
+
for (let index = 0; index < count && this.nodes <= DUMP_LIMITS.nodes; index++) {
|
|
231
|
+
const descriptor = guard(() => Object.getOwnPropertyDescriptor(value, index), undefined);
|
|
232
|
+
items.push(descriptor ? this.property(descriptor, depth, `${path}[${index}]`) : (this.nodes++, { t: 'hole' }));
|
|
233
|
+
}
|
|
234
|
+
if (items.length < len) this.truncated = true;
|
|
235
|
+
return { t: 'arr', ...(cls && cls !== 'Array' ? { cls } : {}), len, items };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
typedArray(value, cls) {
|
|
239
|
+
const len = value.length;
|
|
240
|
+
const count = Math.min(len, DUMP_LIMITS.items);
|
|
241
|
+
const items = [];
|
|
242
|
+
for (let index = 0; index < count; index++) {
|
|
243
|
+
this.nodes++;
|
|
244
|
+
const item = value[index];
|
|
245
|
+
items.push(typeof item === 'bigint' ? { t: 'big', v: item.toString() } : { t: 'num', v: Object.is(item, -0) ? '-0' : String(item) });
|
|
246
|
+
}
|
|
247
|
+
if (count < len) this.truncated = true;
|
|
248
|
+
return { t: 'arr', cls: cls || 'TypedArray', len, items };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
map(value, depth, path) {
|
|
252
|
+
const len = guard(() => MAP_SIZE.call(value), 0);
|
|
253
|
+
const entries = [];
|
|
254
|
+
for (const [key, item] of guard(() => Map.prototype.entries.call(value), [])) {
|
|
255
|
+
if (entries.length >= DUMP_LIMITS.items || this.nodes > DUMP_LIMITS.nodes) break;
|
|
256
|
+
const keyNode = this.node(key, depth + 1, `${path}.keys()[${entries.length}]`);
|
|
257
|
+
entries.push([keyNode, this.node(item, depth + 1, `${path}.get(${keyText(keyNode)})`)]);
|
|
258
|
+
}
|
|
259
|
+
if (entries.length < len) this.truncated = true;
|
|
260
|
+
return { t: 'map', len, entries };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
set(value, depth, path) {
|
|
264
|
+
const len = guard(() => SET_SIZE.call(value), 0);
|
|
265
|
+
const items = [];
|
|
266
|
+
for (const item of guard(() => Set.prototype.values.call(value), [])) {
|
|
267
|
+
if (items.length >= DUMP_LIMITS.items || this.nodes > DUMP_LIMITS.nodes) break;
|
|
268
|
+
items.push(this.node(item, depth + 1, `${path}[${items.length}]`));
|
|
269
|
+
}
|
|
270
|
+
if (items.length < len) this.truncated = true;
|
|
271
|
+
return { t: 'set', len, items };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
error(value, cls, depth, path) {
|
|
275
|
+
const name = ownString(value, 'name') || cls || 'Error';
|
|
276
|
+
const message = ownString(value, 'message');
|
|
277
|
+
// V8 keeps a native error's stack behind its own accessor; reading it runs no app code.
|
|
278
|
+
const stack = types.isNativeError(value) ? guard(() => (typeof value.stack === 'string' ? value.stack : ''), '') : ownString(value, 'stack');
|
|
279
|
+
const { props } = this.props(value, depth, path, ERROR_OWN);
|
|
280
|
+
const cause = guard(() => Object.getOwnPropertyDescriptor(value, 'cause'), undefined);
|
|
281
|
+
if (cause && 'value' in cause) props.push(['cause', this.node(cause.value, depth + 1, `${path}.cause`)]);
|
|
282
|
+
return {
|
|
283
|
+
t: 'err',
|
|
284
|
+
cls: name,
|
|
285
|
+
msg: this.string(message, DUMP_LIMITS.string).v,
|
|
286
|
+
...(stack ? { stack: this.string(stack, DUMP_LIMITS.stack).v } : {}),
|
|
287
|
+
props
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const ERROR_OWN = new Set(['name', 'message', 'stack', 'cause']);
|
|
293
|
+
// Map's and Set's own size getters, so an object's own `size` can't stand in.
|
|
294
|
+
const MAP_SIZE = Object.getOwnPropertyDescriptor(Map.prototype, 'size').get;
|
|
295
|
+
const SET_SIZE = Object.getOwnPropertyDescriptor(Set.prototype, 'size').get;
|
|
296
|
+
|
|
297
|
+
function fn(value) {
|
|
298
|
+
if (types.isProxy(value)) return { t: 'proxy' };
|
|
299
|
+
const name = guard(() => {
|
|
300
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, 'name');
|
|
301
|
+
return descriptor && typeof descriptor.value === 'string' ? descriptor.value : '';
|
|
302
|
+
}, '');
|
|
303
|
+
let kind = null;
|
|
304
|
+
if (types.isAsyncFunction(value)) kind = 'async';
|
|
305
|
+
else if (types.isGeneratorFunction(value)) kind = 'generator';
|
|
306
|
+
else if (guard(() => /^class\b/.test(Function.prototype.toString.call(value)), false)) kind = 'class';
|
|
307
|
+
return { t: 'fn', name, ...(kind ? { kind } : {}) };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// The class of an object, read without getters: null for plain objects (and those without a
|
|
311
|
+
// prototype).
|
|
312
|
+
function className(value) {
|
|
313
|
+
return guard(() => {
|
|
314
|
+
const proto = Object.getPrototypeOf(value);
|
|
315
|
+
if (proto === null || proto === Object.prototype) return null;
|
|
316
|
+
const constructor = Object.getOwnPropertyDescriptor(proto, 'constructor');
|
|
317
|
+
const name = constructor && typeof constructor.value === 'function' ? Object.getOwnPropertyDescriptor(constructor.value, 'name') : null;
|
|
318
|
+
return name && typeof name.value === 'string' && name.value ? name.value : 'Object';
|
|
319
|
+
}, null);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// How big a container left unread is, when that is cheap to know.
|
|
323
|
+
function sizeOf(value) {
|
|
324
|
+
if (Array.isArray(value)) return { len: value.length };
|
|
325
|
+
if (types.isMap(value)) return { len: guard(() => MAP_SIZE.call(value), 0) };
|
|
326
|
+
if (types.isSet(value)) return { len: guard(() => SET_SIZE.call(value), 0) };
|
|
327
|
+
return {};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function ownString(value, key) {
|
|
331
|
+
const descriptor = guard(() => Object.getOwnPropertyDescriptor(value, key), undefined);
|
|
332
|
+
if (descriptor && typeof descriptor.value === 'string') return descriptor.value;
|
|
333
|
+
// name and message usually live on the prototype (Error.prototype.name); read as data only.
|
|
334
|
+
for (let proto = guard(() => Object.getPrototypeOf(value), null); proto; proto = guard(() => Object.getPrototypeOf(proto), null)) {
|
|
335
|
+
const inherited = guard(() => Object.getOwnPropertyDescriptor(proto, key), undefined);
|
|
336
|
+
if (inherited) return typeof inherited.value === 'string' ? inherited.value : '';
|
|
337
|
+
}
|
|
338
|
+
return '';
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function childPath(path, key) {
|
|
342
|
+
return /^[A-Za-z_$][\w$]*$/.test(key) ? `${path}.${key}` : `${path}[${JSON.stringify(key)}]`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function keyText(node) {
|
|
346
|
+
return node.t === 'str' ? JSON.stringify(node.v) : node.t === 'num' || node.t === 'big' || node.t === 'bool' ? String(node.v) : '…';
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function guard(read, fallback) {
|
|
350
|
+
try {
|
|
351
|
+
return read();
|
|
352
|
+
} catch (error) {
|
|
353
|
+
return fallback;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// One node as text, `depth` levels down.
|
|
358
|
+
function line(node, depth) {
|
|
359
|
+
switch (node.t) {
|
|
360
|
+
case 'str':
|
|
361
|
+
return JSON.stringify(node.v.length > 60 ? `${node.v.slice(0, 60)}…` : node.v);
|
|
362
|
+
case 'num':
|
|
363
|
+
case 'sym':
|
|
364
|
+
case 'date':
|
|
365
|
+
case 're':
|
|
366
|
+
return node.v;
|
|
367
|
+
case 'big':
|
|
368
|
+
return `${node.v}n`;
|
|
369
|
+
case 'bool':
|
|
370
|
+
return String(node.v);
|
|
371
|
+
case 'null':
|
|
372
|
+
return 'null';
|
|
373
|
+
case 'undef':
|
|
374
|
+
return 'undefined';
|
|
375
|
+
case 'hole':
|
|
376
|
+
return '<empty>';
|
|
377
|
+
case 'fn':
|
|
378
|
+
return node.kind === 'class' ? `[class ${node.name || '(anonymous)'}]` : `[${node.kind === 'async' ? 'AsyncFunction' : 'Function'}${node.name ? ` ${node.name}` : ''}]`;
|
|
379
|
+
case 'err':
|
|
380
|
+
return node.msg ? `${node.cls}: ${node.msg}` : node.cls;
|
|
381
|
+
case 'buf':
|
|
382
|
+
return `<${node.cls} ${(node.hex.match(/../g) || []).slice(0, 8).join(' ')}${node.len > 8 ? ' …' : ''}>`;
|
|
383
|
+
case 'box':
|
|
384
|
+
return `[${node.cls}: ${line(node.v, 0)}]`;
|
|
385
|
+
case 'get':
|
|
386
|
+
return node.set ? '[Getter/Setter]' : '[Getter]';
|
|
387
|
+
case 'ref':
|
|
388
|
+
return `[Circular ${node.path}]`;
|
|
389
|
+
case 'proxy':
|
|
390
|
+
return '[Proxy]';
|
|
391
|
+
case 'promise':
|
|
392
|
+
return 'Promise';
|
|
393
|
+
case 'weak':
|
|
394
|
+
return node.cls;
|
|
395
|
+
case 'cut':
|
|
396
|
+
return `${node.cls ? `${node.cls} ` : ''}{…}`;
|
|
397
|
+
case 'arr': {
|
|
398
|
+
const head = node.cls ? `${node.cls}(${node.len}) ` : '';
|
|
399
|
+
if (!node.len) return `${head}[]`;
|
|
400
|
+
if (depth <= 0) return `${head}[…]`;
|
|
401
|
+
return `${head}[${list(node.items.map((item) => line(item, depth - 1)), node.len)}]`;
|
|
402
|
+
}
|
|
403
|
+
case 'map': {
|
|
404
|
+
if (!node.len) return 'Map(0) {}';
|
|
405
|
+
if (depth <= 0) return `Map(${node.len}) {…}`;
|
|
406
|
+
return `Map(${node.len}) { ${list(node.entries.map(([key, value]) => `${line(key, 0)} => ${line(value, depth - 1)}`), node.len)} }`;
|
|
407
|
+
}
|
|
408
|
+
case 'set': {
|
|
409
|
+
if (!node.len) return 'Set(0) {}';
|
|
410
|
+
if (depth <= 0) return `Set(${node.len}) {…}`;
|
|
411
|
+
return `Set(${node.len}) { ${list(node.items.map((item) => line(item, depth - 1)), node.len)} }`;
|
|
412
|
+
}
|
|
413
|
+
case 'obj': {
|
|
414
|
+
const head = node.cls ? `${node.cls} ` : '';
|
|
415
|
+
if (!node.len) return `${head}{}`;
|
|
416
|
+
if (depth <= 0) return `${head}{…}`;
|
|
417
|
+
return `${head}{ ${list(node.props.map(([key, value]) => `${/^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key)}: ${line(value, depth - 1)}`), node.len)} }`;
|
|
418
|
+
}
|
|
419
|
+
default:
|
|
420
|
+
return '…';
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// The first few parts, and an ellipsis for the rest.
|
|
425
|
+
function list(parts, total) {
|
|
426
|
+
const shown = parts.slice(0, 4);
|
|
427
|
+
return total > shown.length ? `${shown.join(', ')}, …` : shown.join(', ');
|
|
428
|
+
}
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* - Whether an Express error was handled is only known later: an error middleware that receives
|
|
14
14
|
* it may still pass it on to Express's final handler. Such records stay open on the request and
|
|
15
15
|
* are settled when the error reaches the final handler (unhandled) or the request's record is
|
|
16
|
-
* built (handled).
|
|
16
|
+
* built (handled). NestJS exception filters and Hono error handlers work the same way: one that
|
|
17
|
+
* takes an error may still hand it to the framework's default answer.
|
|
17
18
|
*
|
|
18
19
|
* Nothing here ever throws into the app: a failure to record is a lost exception, never a new one.
|
|
19
20
|
*/
|
|
@@ -134,12 +135,12 @@ export class ExceptionCapture {
|
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
/**
|
|
137
|
-
* An error reached a framework during a request: Express, Koa or
|
|
138
|
+
* An error reached a framework during a request: Express, Koa, Fastify, NestJS or Hono.
|
|
138
139
|
*
|
|
139
140
|
* @param {RequestContext|null} context - The request's context
|
|
140
141
|
* @param {*} error
|
|
141
142
|
* @param {Object} options
|
|
142
|
-
* @param {'express'|'koa'|'fastify'} options.mechanism
|
|
143
|
+
* @param {'express'|'koa'|'fastify'|'nest'|'hono'} options.mechanism
|
|
143
144
|
* @param {boolean} options.handled - Whether the app handled it
|
|
144
145
|
* @param {boolean} [options.open=false] - The outcome isn't final yet (see the module comment)
|
|
145
146
|
* @returns {string|null} The exception's id, or null when nothing was recorded
|
|
@@ -290,7 +291,7 @@ export class ExceptionCapture {
|
|
|
290
291
|
exchange.exceptionCount = own.exceptionCount;
|
|
291
292
|
exchange.exceptions = own.links.slice();
|
|
292
293
|
own.completeTimeline(exchange);
|
|
293
|
-
|
|
294
|
+
own.describe(exchange);
|
|
294
295
|
exchanges.push(exchange);
|
|
295
296
|
}
|
|
296
297
|
}
|