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/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] = String(apiKey).trim().split('.');
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 Fastify.
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
- if (own.user) exchange.user = own.user;
294
+ own.describe(exchange);
294
295
  exchanges.push(exchange);
295
296
  }
296
297
  }