flugrekorder 1.1.0-beta.0 → 1.2.0-beta.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 CHANGED
@@ -4,6 +4,8 @@
4
4
  [![CI](https://github.com/rspieker/flugrekorder/actions/workflows/tests.yml/badge.svg)](https://github.com/rspieker/flugrekorder/actions/workflows/tests.yml)
5
5
  [![license](https://img.shields.io/npm/l/flugrekorder)](LICENSE)
6
6
 
7
+ > Following a hunch but not sure if you need a microscope, a periscope, or a telescope? Zoom in, peek around, look beyond — all at once.
8
+
7
9
  > A tireless, impartial, punctilious, incurious spectator. It witnesses every interaction. It takes note. It understands nothing. Remarkably, this is a feature.
8
10
 
9
11
  Wraps any object, function, or array in a transparent `Proxy` and emits a structured `Rekording` for every Reflect trap that fires — get, set, apply, construct, and all others.
@@ -13,6 +15,25 @@ The recorder has no knowledge of what it wraps. If a dependency adds new methods
13
15
 
14
16
  ---
15
17
 
18
+ ## When to use this
19
+
20
+ - A side effect came knocking. Where did it come from? Was it invited? When? By whom?
21
+ - Here, we observe the code in its digital habitat. Undisturbed. Unmodified. Doing — what?
22
+ - The scriptures tell one story. The scribes tell another.
23
+ - You want a record. Not a theory, not a phantom — a record.
24
+ - When the whole district looks suspicious, bring them all to the archives, every soul, every mouse, every elephant. They all give a statement. They don't get a choice.
25
+
26
+ ---
27
+
28
+ ## Why not…
29
+
30
+ - **`console.log`** — you have to know where to put it first.
31
+ - **Mocks and spies** — they replace the real thing; you're no longer observing, you're performing.
32
+ - **Monkey-patching** — one property at a time, brittle, and you'll miss what you didn't think to patch.
33
+ - **A debugger** — interactive, ephemeral, and gone the moment you step past it.
34
+
35
+ ---
36
+
16
37
  ## Installation
17
38
 
18
39
  ```sh
@@ -26,7 +47,7 @@ npm install flugrekorder
26
47
  ```ts
27
48
  import { create, type Rekording } from 'flugrekorder';
28
49
 
29
- const records: Rekording[] = [];
50
+ const records: Array<Rekording> = [];
30
51
  const p = create({ greet: (name: string) => `hello, ${name}` }, {
31
52
  callback: (r) => records.push(r),
32
53
  });
@@ -40,6 +61,224 @@ console.log(records.map((r) => `${r.id} ${r.trap}`));
40
61
 
41
62
  ---
42
63
 
64
+ ## Quick useful patterns
65
+
66
+ ### Watch sort rewrite your array
67
+
68
+ `sort` reads every element to compare, then writes every position back. Most developers think of it as a single operation. It isn't.
69
+
70
+ ```ts
71
+ import { create, format } from 'flugrekorder';
72
+
73
+ const todos = [
74
+ '🔲 document quick examples',
75
+ '🔲 release next version',
76
+ '✅ build flugrekorder',
77
+ ];
78
+ let tracked!: typeof todos;
79
+ tracked = create(todos, {
80
+ only: ['get', 'set', 'apply'],
81
+ callback: (r) => console.log(format(r, tracked)),
82
+ });
83
+
84
+ tracked[0] = tracked[0].replace('🔲', '✅');
85
+ tracked.sort();
86
+ ```
87
+
88
+ Output:
89
+ ```
90
+ 0 → 🔲 document quick examples
91
+ 0 = ✅ document quick examples
92
+ sort → sort
93
+ length → 3
94
+ 0 → ✅ document quick examples
95
+ 1 → 🔲 release next version
96
+ 2 → ✅ build flugrekorder
97
+ 0 = ✅ build flugrekorder
98
+ 1 = ✅ document quick examples
99
+ 2 = 🔲 release next version
100
+ sort()
101
+ ```
102
+
103
+ The read phase (`→`) and write phase (`=`) are visible in sequence. After marking index `0` as done, `sort` reads all three items — including `✅ build flugrekorder` sitting at index `2` — then rewrites every position. The completed item from index `2` lands at `0` because `✅` sorts before `🔲`.
104
+
105
+ ---
106
+
107
+ ### What hint JavaScript passes when it coerces your object
108
+
109
+ Every time JavaScript converts an object to a primitive, it calls `Symbol.toPrimitive` with a hint — `'string'`, `'number'`, or `'default'`. Track a `Date` to see which context passes which:
110
+
111
+ ```ts
112
+ import { create, format } from 'flugrekorder';
113
+
114
+ const date = new Date('2025-01-15T12:00:00Z');
115
+ let tracked!: typeof date;
116
+ tracked = create(date, {
117
+ only: ['get', 'apply'],
118
+ callback: (r) => console.log(format(r, tracked)),
119
+ });
120
+
121
+ `${tracked}`; // template literal
122
+ tracked + ''; // + operator
123
+ +tracked; // unary plus
124
+ ```
125
+
126
+ Output:
127
+ ```
128
+ Symbol(Symbol.toPrimitive) → Symbol(Symbol.toPrimitive)
129
+ Symbol(Symbol.toPrimitive)(string)
130
+ Symbol(Symbol.toPrimitive) → Symbol(Symbol.toPrimitive)
131
+ Symbol(Symbol.toPrimitive)(default)
132
+ Symbol(Symbol.toPrimitive) → Symbol(Symbol.toPrimitive)
133
+ Symbol(Symbol.toPrimitive)(number)
134
+ ```
135
+
136
+ `tracked + ''` passes `'default'`, not `'string'` — most developers expect string-hint here. For `Date` both resolve to the same string representation, but that is a `Date`-specific choice, not a JavaScript guarantee.
137
+
138
+ ---
139
+
140
+ ### What survives `JSON.stringify` — and what doesn't
141
+
142
+ `toJSON` lets an object control its own serialisation. Track one to see exactly what it reads, what it returns, and which values quietly disappear on the way to JSON.
143
+
144
+ ```ts
145
+ import { create, format } from 'flugrekorder';
146
+
147
+ const config: Record<string, Record<string, unknown>> = {
148
+ db: {
149
+ host: 'localhost',
150
+ port: 27017,
151
+ database: 'example',
152
+ timeout: Infinity,
153
+ toJSON() {
154
+ return {
155
+ dsn: `${this['protocol'] ?? 'mongodb'}://${this['host']}:${this['port']}/${this['database']}`,
156
+ timeout: this['timeout'],
157
+ };
158
+ },
159
+ },
160
+ };
161
+
162
+ let tracked!: typeof config;
163
+ tracked = create(config, {
164
+ only: ['get', 'apply'],
165
+ callback: (r) => console.log(format(r, tracked)),
166
+ });
167
+
168
+ console.log(JSON.stringify(tracked));
169
+ ```
170
+
171
+ Output:
172
+ ```
173
+ toJSON → undefined
174
+ db → db
175
+ db.toJSON → db.toJSON
176
+ db.protocol → undefined
177
+ db.host → localhost
178
+ db.port → 27017
179
+ db.database → example
180
+ db.timeout → Infinity
181
+ db.toJSON(db)
182
+ db.toJSON().dsn → mongodb://localhost:27017/example
183
+ db.toJSON().timeout → Infinity
184
+
185
+ {"db":{"dsn":"mongodb://localhost:27017/example","timeout":null}}
186
+ ```
187
+
188
+ `protocol` was read but never set — `toJSON` had a hidden dependency. `timeout` recorded faithfully as `Infinity` throughout, then silently coerced to `null` in the final JSON because `Infinity` is not valid JSON.
189
+
190
+ ---
191
+
192
+ ### How many comparisons does `sort` need?
193
+
194
+ Sorting feels atomic — pass an array, get it back in order. But `sort` calls your comparator repeatedly, and the number of calls — and their order — is what the algorithm actually looks like. Writing every call to disk is an easy way to count them and examine the sequence afterwards.
195
+
196
+ ```ts
197
+ import { createWriteStream } from 'node:fs';
198
+ import { create } from 'flugrekorder';
199
+
200
+ const words = ['banana', 'apple', 'elderberry', 'cherry', 'date', 'fig'];
201
+
202
+ let compare!: (a: string, b: string) => number;
203
+ compare = create((a: string, b: string) => a.localeCompare(b), {
204
+ only: ['apply'],
205
+ stream: createWriteStream('comparisons.ndjson'),
206
+ });
207
+
208
+ words.sort(compare);
209
+ console.log(words);
210
+ ```
211
+
212
+ Output:
213
+ ```
214
+ [ 'apple', 'banana', 'cherry', 'date', 'elderberry', 'fig' ]
215
+ ```
216
+
217
+ `comparisons.ndjson` — 9 lines written, first two shown:
218
+
219
+ ```json
220
+ {"id":"#2","trap":"apply","origin":{"trap":"apply","source":"#1"},"args":[{"$proxy":"#1"},null,["apple","banana"]],"result":-1,"timestamp":1748091234567}
221
+ {"id":"#3","trap":"apply","origin":{"trap":"apply","source":"#1"},"args":[{"$proxy":"#1"},null,["elderberry","apple"]],"result":1,"timestamp":1748091234568}
222
+ ...
223
+ ```
224
+
225
+ For 6 words, V8 made 9 comparisons — not 15 (the bubble sort worst case for 6 elements). The pattern is binary insertion sort: each word is binary-searched into position in the already-sorted prefix. `args[2]` holds the pair being compared; `result` is the comparator's return value. Swap the input for a larger or reverse-sorted list and the count — and the pattern — change.
226
+
227
+ ---
228
+
229
+ ### Request logging
230
+
231
+ An HTTP server handles requests concurrently. `callback` accumulates records per request in memory — `stream` writes directly to disk as each trap fires. Custom IDs make records from concurrent requests separable: every record carries its request's namespace.
232
+
233
+ ```ts
234
+ import { createServer } from 'node:http';
235
+ import { createWriteStream } from 'node:fs';
236
+ import { create } from 'flugrekorder';
237
+
238
+ let n = 0;
239
+ const log = createWriteStream('requests.ndjson');
240
+
241
+ const server = createServer((req, res) => {
242
+ let seq = 0;
243
+ const rid = `req${++n}`;
244
+ let r!: typeof req;
245
+ r = create(req, { id: () => `${rid}:${++seq}`, stream: log });
246
+
247
+ if (r.url === '/hello') {
248
+ res.end(`Hello, ${r.method}!`);
249
+ } else {
250
+ res.statusCode = 404;
251
+ res.end(`not found: ${r.method}: ${r.url}`);
252
+ }
253
+ });
254
+
255
+ await new Promise<void>(resolve => server.listen(3000, resolve));
256
+ console.log((await fetch('http://localhost:3000/hello')).status);
257
+ console.log((await fetch('http://localhost:3000/missing')).status);
258
+ server.close();
259
+ ```
260
+
261
+ Output:
262
+ ```
263
+ 200
264
+ 404
265
+ ```
266
+
267
+ `requests.ndjson` — 5 lines written:
268
+
269
+ ```json
270
+ {"id":"req1:2","trap":"get","origin":{"trap":"get","parent":"req1:1","key":"url"},"args":[{"$unwrap":{"$proxy":"req1:1"}},"url",{"$proxy":"req1:1"}],"result":"/hello","timestamp":1748091234100}
271
+ {"id":"req1:3","trap":"get","origin":{"trap":"get","parent":"req1:1","key":"method"},"args":[{"$unwrap":{"$proxy":"req1:1"}},"method",{"$proxy":"req1:1"}],"result":"GET","timestamp":1748091234101}
272
+ {"id":"req2:2","trap":"get","origin":{"trap":"get","parent":"req2:1","key":"url"},"args":[{"$unwrap":{"$proxy":"req2:1"}},"url",{"$proxy":"req2:1"}],"result":"/missing","timestamp":1748091234150}
273
+ {"id":"req2:3","trap":"get","origin":{"trap":"get","parent":"req2:1","key":"method"},"args":[{"$unwrap":{"$proxy":"req2:1"}},"method",{"$proxy":"req2:1"}],"result":"GET","timestamp":1748091234151}
274
+ {"id":"req2:4","trap":"get","origin":{"trap":"get","parent":"req2:1","key":"url"},"args":[{"$unwrap":{"$proxy":"req2:1"}},"url",{"$proxy":"req2:1"}],"result":"/missing","timestamp":1748091234151}
275
+ ```
276
+
277
+ Certainly not the average request log, but a full picture of what happened when, where and why.
278
+ `req1:*` and `req2:*` are distinct namespaces in the same file. `args[0]` carries `$unwrap` — the raw request object (not the proxy) — because that is what the Reflect `get` trap receives as its target. `args[2]` is the proxy receiver. The 404 handler reads `url` twice and `method` once — exactly as written. Under concurrent load the records interleave by timestamp, but `grep req1` always isolates one request.
279
+
280
+ ---
281
+
43
282
  ## API
44
283
 
45
284
  ### `create(target, options)`
@@ -49,7 +288,7 @@ Wraps `target` in a recording proxy and returns it. The proxy is transparent —
49
288
  ```ts
50
289
  import { create, type Rekording } from 'flugrekorder';
51
290
 
52
- const records: Rekording[] = [];
291
+ const records: Array<Rekording> = [];
53
292
  const p = create(target, { callback: (r) => records.push(r) });
54
293
  ```
55
294
 
@@ -61,7 +300,11 @@ const p = create(target, { callback: (r) => records.push(r) });
61
300
  | `stream` | `Writable` | — | Node.js Writable; records are written as newline-delimited JSON. Mutually exclusive with `callback`. |
62
301
  | `id` | `number \| (() => string)` | `0` | Starting integer for the auto-incrementing ID sequence, or a custom generator. IDs take the form `#1`, `#2`, … unless overridden. |
63
302
  | `recursive` | `boolean` | `true` | When `false`, only the root target is proxied. Values returned from traps are passed through as-is. |
64
- | `only` | `string[]` | all traps | Allowlist of Reflect trap names to record. Traps not listed pass straight through to `Reflect` without emitting a record. |
303
+ | `only` | `Array<string>` | all traps | Allowlist of Reflect trap names to record. Traps not listed pass straight through to `Reflect` without emitting a record. |
304
+ | `depth` | `number` | `Infinity` | Maximum depth for nested object serialization. Deeper values are replaced with `[…]`. |
305
+ | `redact` | `Redactor \| Array<Redactor>` | — | Function(s) to redact sensitive values. Return `true` to replace with `"[redacted]"`, `false` to keep as-is, or a string for custom replacement. |
306
+ | `truncate` | `number` | `Infinity` | Maximum string length before truncation. Longer strings are truncated with `…`. |
307
+ | `bind` | `boolean` | `undefined` | Controls how flugrekorder handles classes with `#` private fields. See [Private fields](#private-fields-bind). |
65
308
 
66
309
  One of `callback` or `stream` is required.
67
310
 
@@ -127,8 +370,8 @@ import { create, getPath } from 'flugrekorder';
127
370
 
128
371
  const p = create({ a: { b: { fn: () => ({ v: 1 }) } } }, { callback: () => {} });
129
372
 
130
- getPath(p); // ''
131
- getPath(p.a); // 'a'
373
+ getPath(p); // ''
374
+ getPath(p.a); // 'a'
132
375
  getPath(p.a.b.fn); // 'a.b.fn'
133
376
  getPath(p.a.b.fn()); // 'a.b.fn()'
134
377
  ```
@@ -158,7 +401,7 @@ Looks up a proxy by its recorded ID within the same graph as `proxy`. Useful for
158
401
  ```ts
159
402
  import { create, getProxyById, type Rekording } from 'flugrekorder';
160
403
 
161
- const records: Rekording[] = [];
404
+ const records: Array<Rekording> = [];
162
405
  const p = create({ nested: { x: 1 } }, { callback: (r) => records.push(r) });
163
406
 
164
407
  p.nested; // triggers a get, result is { $proxy: '#2' }
@@ -176,17 +419,18 @@ Every emitted record has this structure:
176
419
  ```ts
177
420
  type Rekording = {
178
421
  id: string; // e.g. '#3'
179
- trap: string; // Reflect trap name: 'get', 'set', 'apply', …
422
+ trap: string; // Reflect trap name: 'get', 'set', 'apply', … or a synthetic variant ('apply:native', 'apply:structure', 'apply:private')
180
423
  origin: {
181
424
  trap: 'get' | 'set' | 'defineProperty' | 'getOwnPropertyDescriptor';
182
425
  parent: string; // ID of the proxy this trap fired on
183
426
  key: string; // property name (symbols are serialised to their string form)
184
427
  } | {
185
428
  trap: 'apply' | 'construct';
186
- source: string; // ID of the function/constructor proxy that was called
187
- } | null; // null for the root proxy
188
- args: Serialized[]; // trap arguments
189
- result: Serialized; // return value
429
+ source: string; // ID of the function/constructor proxy that was called
430
+ } | null; // null for the root proxy
431
+ args: Array<Serialized>; // trap arguments
432
+ result: Serialized; // return value
433
+ timestamp: number; // Date.now() at the moment the trap fired
190
434
  };
191
435
  ```
192
436
 
@@ -257,98 +501,136 @@ Promises cannot be proxied directly — native `.then()` checks for the `[[Promi
257
501
 
258
502
  Trap specs use two wrapping modes. `wrap` creates a new proxy for any proxiable value not already in the graph — used for results, where a newly returned object should be recorded. `wrapKnown` only wraps values that are already in the graph — used for call arguments, where passing a plain object to a proxied function should not silently create a new proxy out of it.
259
503
 
260
- ---
261
-
262
- ## Examples
504
+ ### Native boundary crossings (`apply:native`, `apply:structure`, `apply:private`)
263
505
 
264
- ### Stream interactions to a file (NDJSON)
506
+ Some native code rejects a `Proxy` as `this` or as an argument — V8 checks the real target at the C++ level before any JS runs. flugrekorder catches these failures, retries with the unwrapped real target, and records the crossing with a synthetic trap name so it remains visible in the rekording.
265
507
 
266
- ```ts
267
- import { createWriteStream } from 'node:fs';
268
- import { create } from 'flugrekorder';
508
+ **`apply:native`** — a C++ method threw `TypeError: Illegal invocation` because `this` was a Proxy. The real target is substituted for `this` and the call is retried. The unwrapped `this` appears as `{ $unwrap: { $proxy: "<id>" } }` in `args[1]`.
269
509
 
270
- const log = createWriteStream('interactions.ndjson');
271
- const tracked = create(myService, { stream: log });
510
+ **`apply:structure`** — a function internally passed a proxied argument to the [Structured Clone Algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) (`structuredClone`, `postMessage`, `history.pushState`, …), which threw `DataCloneError`. flugrekorder unwraps any proxy arguments in the call and retries. Unwrapped args appear as `{ $unwrap: { $proxy: "<id>" } }` in `args[2]`.
272
511
 
273
- // Every trap now writes one JSON line to interactions.ndjson
274
- await tracked.processOrder(orderId);
275
- ```
512
+ **Remaining gap** — a *direct* `structuredClone(proxy)` call that never passes through a flugrekorder trap cannot be intercepted; V8 resolves the proxy before any JS runs. Use `structuredClone(getTarget(proxy))` at the call site as a workaround.
276
513
 
277
- ### Record only method calls
514
+ ### Private fields (`bind`)
278
515
 
279
- ```ts
280
- import { create, type Rekording } from 'flugrekorder';
516
+ JavaScript `#` private fields are stored on the real instance. Accessing them with `this` as a Proxy throws `TypeError: Cannot read private member … from an object whose class did not declare it` — the engine enforces the class boundary at the field level.
281
517
 
282
- const records: Rekording[] = [];
283
- const p = create(myApi, {
284
- callback: (r) => records.push(r),
285
- only: ['apply'],
286
- });
518
+ The `bind` option controls how flugrekorder responds:
287
519
 
288
- p.users.find({ active: true });
520
+ **`undefined` (default)** — flugrekorder tries normally. If a method or getter throws the private-field TypeError, it retries with the real target as `this`. Method calls record as `apply:private` instead of `apply` to signal the crossing; getter reads record transparently as `get`. This is the zero-friction path — no configuration needed, no crashes.
289
521
 
290
- // records contains only 'apply' entries — one per function call
291
- console.log(records.length); // 1
292
- ```
522
+ **`bind: true`** — flugrekorder pre-binds all methods to the real target at `get` time, before any call is attempted. Calls record as plain `apply` and never throw. The trade-off: any property access that happens *inside* a method (e.g. `this.name`) runs on the real target, bypassing the proxy. Those internal reads and writes are not recorded.
293
523
 
294
- ### Inspect the call graph after the fact
524
+ **`bind: false`** — flugrekorder never retries. If a method throws due to a `#` private field, the error propagates unchanged. Use this when you know the class has no `#` fields and want to ensure unexpected private-field errors surface rather than being silently retried.
295
525
 
296
526
  ```ts
297
- import { create, getPath, getProxyById, type Rekording } from 'flugrekorder';
527
+ class Counter {
528
+ #count = 0;
529
+ increment() { this.#count++; }
530
+ get value() { return this.#count; }
531
+ }
532
+
533
+ // default: automatic retry, apply:private in recording
534
+ const p = create(new Counter(), { callback });
535
+ p.increment(); // records trap: 'apply:private'
536
+ p.value; // records trap: 'get'
537
+
538
+ // bind: true: pre-bound, plain apply in recording, internals invisible
539
+ const q = create(new Counter(), { bind: true, callback });
540
+ q.increment(); // records trap: 'apply'
541
+ q.value; // records trap: 'get'
542
+ ```
298
543
 
299
- const records: Rekording[] = [];
300
- const p = create(myService, { callback: (r) => records.push(r) });
544
+ **The inverse** — a class that uses `#` private fields exclusively for its internal state is naturally opaque to flugrekorder: method calls are recorded but what happens inside them is not. This is an effective opt-out for library authors who want their implementation details to remain unobservable regardless of whether a caller wraps their object.
301
545
 
302
- myService.run(p);
546
+ ---
303
547
 
304
- // Find every method call and display its path
305
- records
306
- .filter((r) => r.trap === 'apply')
307
- .forEach((r) => {
308
- if (r.origin && 'source' in r.origin) {
309
- const fn = getProxyById(r.origin.source, p);
310
- if (fn) console.log('called:', getPath(fn));
311
- }
312
- });
313
- ```
548
+ ## Types
314
549
 
315
- ### Custom ID sequence
550
+ ### `Proxiable`
551
+ Any value that can be proxied: objects or functions.
316
552
 
317
553
  ```ts
318
- import { create } from 'flugrekorder';
319
-
320
- // Prefix IDs with a session token for correlation across multiple recordings
321
- const session = crypto.randomUUID();
322
- let n = 0;
323
- const p = create(target, {
324
- callback: (r) => console.log(r.id),
325
- id: () => `${session}:${++n}`,
326
- });
554
+ type Proxiable = object | Function;
327
555
  ```
328
556
 
329
- ---
557
+ ### `Redactor`
558
+ Function that decides how to handle sensitive values during serialization.
330
559
 
331
- ## Types
560
+ ```ts
561
+ type Redactor = (
562
+ key: string | symbol,
563
+ value: unknown,
564
+ target: object,
565
+ ) => string | boolean | null;
566
+ ```
332
567
 
333
568
  ```ts
334
- export type Proxiable = object | Function;
569
+ /**
570
+ * @param key - Property key being serialized
571
+ * @param value - Value at that key
572
+ * @param target - Parent object containing the key
573
+ * @returns `true` to replace with `"[redacted]"`, `false` to keep as-is,
574
+ * a string for custom replacement, or `null` to drop the key
575
+ */
576
+ const hideSecrets: Redactor = (key) => key === 'password';
577
+ const redactSecrets: Redactor = (key, value) => key === 'password' && '*'.repeat(String(value).length);
578
+ const dropSecrets: Redactor = (key) => key === 'password' ? null : false;
579
+ ```
335
580
 
336
- export type Serialized =
337
- | string | number | boolean | bigint | null | undefined
338
- | { readonly $proxy: string }
339
- | Serialized[]
340
- | { [key: string]: Serialized };
581
+ ### `Origin`
582
+ Describes how a proxy was created. Every proxied value carries its `Origin` so you can trace where it came from in the proxy tree. `null` for the root proxy.
341
583
 
342
- export type Origin =
584
+ ```ts
585
+ type Origin =
343
586
  | { trap: 'get' | 'set' | 'defineProperty' | 'getOwnPropertyDescriptor'; parent: string; key: string | symbol }
344
587
  | { trap: 'apply' | 'construct'; source: string }
345
588
  | null;
589
+ ```
590
+
591
+ | Field | Type | Description |
592
+ |-------|------|-------------|
593
+ | `trap` | `string` | The trap that created this proxy |
594
+ | `parent` | `string` | Proxy ID of the parent that returned this value |
595
+ | `key` | `string \| symbol` | Property key accessed |
596
+ | `source` | `string` | Proxy ID of the function that returned this value |
346
597
 
347
- export type Rekording = {
598
+ ### `Rekording`
599
+ A single recorded interaction from a proxy trap. Every trap firing produces one `Rekording` emitted to your callback or stream.
600
+
601
+ ```ts
602
+ type Rekording = {
348
603
  id: string;
349
604
  trap: string;
350
- origin: { trap: string; parent?: string; key?: string; source?: string } | null;
351
- args: Serialized[];
605
+ origin: Origin;
606
+ args: Array<Serialized>;
352
607
  result: Serialized;
608
+ timestamp: number;
353
609
  };
354
610
  ```
611
+
612
+ | Field | Type | Description |
613
+ |-------|------|-------------|
614
+ | `id` | `string` | Unique identifier for this record |
615
+ | `trap` | `string` | Reflect trap name (`get`, `set`, `apply`, etc.) |
616
+ | `origin` | `Origin` | How this proxy was created |
617
+ | `args` | `Array<Serialized>` | Arguments passed to the trap |
618
+ | `result` | `Serialized` | Return value from the trap |
619
+ | `timestamp` | `number` | When the trap fired |
620
+
621
+ ### `Serialized`
622
+ Serialized recorded values can be:
623
+ - Primitives: `string`, `number`, `boolean`, `bigint`, `null`, `undefined`
624
+ - Proxy reference: `{ $proxy: string }` — a proxiable value known to the graph, referenced by ID
625
+ - Raw target reference: `{ $unwrap: { $proxy: string } }` — the real target behind a proxy, recorded when a native boundary crossing required unwrapping (see [`apply:native`](#native-boundary-crossings-and-applynative))
626
+ - Array of `Serialized`
627
+ - Plain object with `Serialized` values
628
+
629
+ ```ts
630
+ type Serialized =
631
+ | string | number | boolean | bigint | null | undefined
632
+ | { readonly $proxy: string }
633
+ | { readonly $unwrap: { readonly $proxy: string } }
634
+ | Array<Serialized>
635
+ | { [key: string]: Serialized };
636
+ ```
@@ -1,11 +1,10 @@
1
1
  import { Writable } from 'node:stream';
2
2
 
3
- /** Any value that can be wrapped in a Proxy — objects and functions. */
4
- type Proxiable = object | Function;
3
+ type Proxiable = object;
5
4
  /** Reflect traps that operate on a property — carry a parent ID and key in their origin. */
6
5
  type PropertyTrap = 'get' | 'set' | 'defineProperty' | 'getOwnPropertyDescriptor';
7
- /** Reflect traps that invoke a callable — carry a source ID in their origin. */
8
- type CallTrap = 'apply' | 'construct';
6
+ /** Reflect traps that invoke a callable — carry a source ID in their origin. Includes synthetic variants for native boundary crossings. */
7
+ type CallTrap = 'apply' | 'construct' | 'apply:native' | 'construct:native' | 'apply:structure' | 'apply:private';
9
8
  /** Describes how a proxy was created — which trap fired, on which parent, and under which key or source. Null for root proxies. */
10
9
  type Origin = {
11
10
  trap: PropertyTrap;
@@ -24,12 +23,24 @@ type SerializedOrigin = {
24
23
  trap: CallTrap;
25
24
  source: string;
26
25
  } | null;
27
- /** A JSON-safe representation of any value. Proxiable values are replaced with `{ $proxy: id }` tags. */
26
+ /** A JSON-safe representation of any value. Proxiable values are replaced with `{ $proxy: id }` tags. Raw targets (proxiable values known to the graph but passed unwrapped) are tagged `{ $unwrap: { $proxy: id } }`. */
28
27
  type Serialized = string | number | boolean | bigint | null | undefined | {
29
28
  readonly $proxy: string;
29
+ } | {
30
+ readonly $unwrap: {
31
+ readonly $proxy: string;
32
+ };
30
33
  } | Array<Serialized> | {
31
34
  [key: string]: Serialized;
32
35
  };
36
+ /** Returns true for any value that can meaningfully be wrapped in a Proxy. */
37
+ declare function isProxiable(value: unknown): value is Proxiable;
38
+ /**
39
+ * Determines whether and how a key/value pair should be redacted during serialization.
40
+ * Return `false` to keep as-is, `true` to replace with `"[redacted]"`,
41
+ * a string to use as a custom replacement, or `null` to drop the key entirely.
42
+ */
43
+ type Redactor = (key: string | symbol, value: unknown, target: object) => string | boolean | null;
33
44
  /** A single recorded interaction — one Reflect trap firing on one proxy. */
34
45
  type Rekording = {
35
46
  id: string;
@@ -37,25 +48,14 @@ type Rekording = {
37
48
  origin: SerializedOrigin;
38
49
  args: Array<Serialized>;
39
50
  result: Serialized;
51
+ timestamp: number;
40
52
  };
41
- type CreateOptions = {
42
- id?: number | (() => string);
43
- recursive?: boolean;
44
- only?: string[];
45
- } & ({
46
- stream: Writable;
47
- callback?: never;
48
- } | {
49
- callback: (record: Rekording) => void;
50
- stream?: never;
51
- });
52
- /**
53
- * Wraps `target` in a transparent recording proxy.
54
- * Every Reflect trap that fires emits a `Rekording` to `callback` or `stream`.
55
- */
56
- declare function create<T extends Proxiable>(target: T, options: CreateOptions): T;
57
- /** Returns `true` if `value` is a proxy created by this module. */
58
- declare function isFlugrekorder(value: unknown): value is Proxiable;
53
+
54
+ /** Converts a `Rekording` to a human-readable string.
55
+ * When `proxy` is supplied, `{ $proxy: id }` tags in args and results resolve to dotted paths.
56
+ * When omitted, raw IDs are shown instead. */
57
+ declare function format(rekording: Rekording, proxy?: Proxiable): string;
58
+
59
59
  /** Returns the original unwrapped target of a proxy, or `null` for non-proxies. */
60
60
  declare function getTarget(pxy: Proxiable): Proxiable | null;
61
61
  /**
@@ -81,4 +81,29 @@ declare function getAncestors(pxy: Proxiable): Array<{
81
81
  */
82
82
  declare function getPath(pxy: Proxiable): string;
83
83
 
84
- export { type CallTrap, type Origin, type PropertyTrap, type Proxiable, type Rekording, type Serialized, create, getAncestors, getOrigin, getPath, getProxyById, getTarget, isFlugrekorder };
84
+ type CommonCreateOptions = {
85
+ id?: number | (() => string);
86
+ recursive?: boolean;
87
+ only?: Array<string>;
88
+ filter?: (rekording: Rekording) => boolean;
89
+ depth?: number;
90
+ redact?: Redactor | Array<Redactor>;
91
+ truncate?: number;
92
+ bind?: boolean;
93
+ };
94
+ type StreamCreateOptions = CommonCreateOptions & {
95
+ stream: Writable;
96
+ };
97
+ type CallbackCreateOptions = CommonCreateOptions & {
98
+ callback: (record: Rekording) => void;
99
+ };
100
+ type CreateOptions = StreamCreateOptions | CallbackCreateOptions;
101
+ /**
102
+ * Wraps `target` in a transparent recording proxy.
103
+ * Every Reflect trap that fires emits a `Rekording` to `callback` or `stream`.
104
+ */
105
+ declare function create<T extends Proxiable>(target: T, options: CreateOptions): T;
106
+ /** Returns `true` if `value` is a proxy created by this module. */
107
+ declare function isFlugrekorder(value: unknown): value is Proxiable;
108
+
109
+ export { type CallTrap, type Origin, type PropertyTrap, type Proxiable, type Redactor, type Rekording, type Serialized, create, format, getAncestors, getOrigin, getPath, getProxyById, getTarget, isFlugrekorder, isProxiable };