flugrekorder
v1.2.0
Published
A tireless, impartial, punctilious, incurious spectator. It witnesses every interaction. It takes note. It understands nothing. Remarkably, this is a feature.
Maintainers
Readme
flugrekorder
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.
A tireless, impartial, punctilious, incurious spectator. It witnesses every interaction. It takes note. It understands nothing. Remarkably, this is a feature.
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.
Design principle: record structure, relay behaviour, understand nothing. The recorder has no knowledge of what it wraps. If a dependency adds new methods, they are recorded automatically.
When to use this
- A side effect came knocking. Where did it come from? Was it invited? When? By whom?
- Here, we observe the code in its digital habitat. Undisturbed. Unmodified. Doing — what?
- The scriptures tell one story. The scribes tell another.
- You want a record. Not a theory, not a phantom — a record.
- 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.
Why not…
console.log— you have to know where to put it first.- Mocks and spies — they replace the real thing; you're no longer observing, you're performing.
- Monkey-patching — one property at a time, brittle, and you'll miss what you didn't think to patch.
- A debugger — interactive, ephemeral, and gone the moment you step past it.
Installation
npm install flugrekorderQuick start
import { create, type Rekording } from 'flugrekorder';
const records: Array<Rekording> = [];
const p = create({ greet: (name: string) => `hello, ${name}` }, {
callback: (r) => records.push(r),
});
p.greet('world');
console.log(records.map((r) => `${r.id} ${r.trap}`));
// #1 get
// #2 applyQuick useful patterns
Watch sort rewrite your array
sort reads every element to compare, then writes every position back. Most developers think of it as a single operation. It isn't.
import { create, format } from 'flugrekorder';
const todos = [
'🔲 document quick examples',
'🔲 release next version',
'✅ build flugrekorder',
];
let tracked!: typeof todos;
tracked = create(todos, {
only: ['get', 'set', 'apply'],
callback: (r) => console.log(format(r, tracked)),
});
tracked[0] = tracked[0].replace('🔲', '✅');
tracked.sort();Output:
0 → 🔲 document quick examples
0 = ✅ document quick examples
sort → sort
length → 3
0 → ✅ document quick examples
1 → 🔲 release next version
2 → ✅ build flugrekorder
0 = ✅ build flugrekorder
1 = ✅ document quick examples
2 = 🔲 release next version
sort()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 🔲.
What hint JavaScript passes when it coerces your object
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:
import { create, format } from 'flugrekorder';
const date = new Date('2025-01-15T12:00:00Z');
let tracked!: typeof date;
tracked = create(date, {
only: ['get', 'apply'],
callback: (r) => console.log(format(r, tracked)),
});
`${tracked}`; // template literal
tracked + ''; // + operator
+tracked; // unary plusOutput:
Symbol(Symbol.toPrimitive) → Symbol(Symbol.toPrimitive)
Symbol(Symbol.toPrimitive)(string)
Symbol(Symbol.toPrimitive) → Symbol(Symbol.toPrimitive)
Symbol(Symbol.toPrimitive)(default)
Symbol(Symbol.toPrimitive) → Symbol(Symbol.toPrimitive)
Symbol(Symbol.toPrimitive)(number)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.
What survives JSON.stringify — and what doesn't
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.
import { create, format } from 'flugrekorder';
const config: Record<string, Record<string, unknown>> = {
db: {
host: 'localhost',
port: 27017,
database: 'example',
timeout: Infinity,
toJSON() {
return {
dsn: `${this['protocol'] ?? 'mongodb'}://${this['host']}:${this['port']}/${this['database']}`,
timeout: this['timeout'],
};
},
},
};
let tracked!: typeof config;
tracked = create(config, {
only: ['get', 'apply'],
callback: (r) => console.log(format(r, tracked)),
});
console.log(JSON.stringify(tracked));Output:
toJSON → undefined
db → db
db.toJSON → db.toJSON
db.protocol → undefined
db.host → localhost
db.port → 27017
db.database → example
db.timeout → Infinity
db.toJSON(db)
db.toJSON().dsn → mongodb://localhost:27017/example
db.toJSON().timeout → Infinity
{"db":{"dsn":"mongodb://localhost:27017/example","timeout":null}}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.
How many comparisons does sort need?
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.
import { createWriteStream } from 'node:fs';
import { create } from 'flugrekorder';
const words = ['banana', 'apple', 'elderberry', 'cherry', 'date', 'fig'];
let compare!: (a: string, b: string) => number;
compare = create((a: string, b: string) => a.localeCompare(b), {
only: ['apply'],
stream: createWriteStream('comparisons.ndjson'),
});
words.sort(compare);
console.log(words);Output:
[ 'apple', 'banana', 'cherry', 'date', 'elderberry', 'fig' ]comparisons.ndjson — 9 lines written, first two shown:
{"id":"#2","trap":"apply","origin":{"trap":"apply","source":"#1"},"args":[{"$proxy":"#1"},null,["apple","banana"]],"result":-1,"timestamp":1748091234567}
{"id":"#3","trap":"apply","origin":{"trap":"apply","source":"#1"},"args":[{"$proxy":"#1"},null,["elderberry","apple"]],"result":1,"timestamp":1748091234568}
...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.
Request logging
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.
import { createServer } from 'node:http';
import { createWriteStream } from 'node:fs';
import { create } from 'flugrekorder';
let n = 0;
const log = createWriteStream('requests.ndjson');
const server = createServer((req, res) => {
let seq = 0;
const rid = `req${++n}`;
let r!: typeof req;
r = create(req, { id: () => `${rid}:${++seq}`, stream: log });
if (r.url === '/hello') {
res.end(`Hello, ${r.method}!`);
} else {
res.statusCode = 404;
res.end(`not found: ${r.method}: ${r.url}`);
}
});
await new Promise<void>(resolve => server.listen(3000, resolve));
console.log((await fetch('http://localhost:3000/hello')).status);
console.log((await fetch('http://localhost:3000/missing')).status);
server.close();Output:
200
404requests.ndjson — 5 lines written:
{"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}
{"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}
{"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}
{"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}
{"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}Certainly not the average request log, but a full picture of what happened when, where and why.
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.
API
create(target, options)
Wraps target in a recording proxy and returns it. The proxy is transparent — all operations on it behave identically to the original.
import { create, type Rekording } from 'flugrekorder';
const records: Array<Rekording> = [];
const p = create(target, { callback: (r) => records.push(r) });options
| Field | Type | Default | Description |
|---|---|---|---|
| callback | (r: Rekording) => void | — | Called synchronously with each record. Mutually exclusive with stream. |
| stream | Writable | — | Node.js Writable; records are written as newline-delimited JSON. Mutually exclusive with callback. |
| id | number \| (() => string) | 0 | Starting integer for the auto-incrementing ID sequence, or a custom generator. IDs take the form #1, #2, … unless overridden. |
| recursive | boolean | true | When false, only the root target is proxied. Values returned from traps are passed through as-is. |
| only | Array<string> | all traps | Allowlist of Reflect trap names to record. Traps not listed pass straight through to Reflect without emitting a record. |
| depth | number | Infinity | Maximum depth for nested object serialization. Deeper values are replaced with […]. |
| 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. |
| truncate | number | Infinity | Maximum string length before truncation. Longer strings are truncated with …. |
| bind | boolean | undefined | Controls how flugrekorder handles classes with # private fields. See Private fields. |
One of callback or stream is required.
isFlugrekorder(value)
Returns true if value is a proxy created by this module.
import { create, isFlugrekorder } from 'flugrekorder';
const p = create({ nested: { x: 1 } }, { callback: () => {} });
isFlugrekorder(p); // true
isFlugrekorder(p.nested); // true — proxied recursively
isFlugrekorder({}); // false
isFlugrekorder(42); // falsegetOrigin(proxy)
Returns the structured Origin of a proxy — how and from where it was created. Returns null for the root proxy and for non-proxies.
import { create, getOrigin } from 'flugrekorder';
const p = create({ a: { v: 1 } }, { callback: () => {} });
getOrigin(p); // null (root)
getOrigin(p.a); // { trap: 'get', parent: '#1', key: 'a' }getAncestors(proxy)
Walks the origin chain from the root proxy down to proxy and returns every step as an ordered array of { proxy, origin } pairs, root first. Returns an empty array for non-proxies.
import { create, getAncestors } from 'flugrekorder';
const p = create({ a: { b: { c: 1 } } }, { callback: () => {} });
getAncestors(p.a.b);
// [
// { proxy: <root>, origin: null },
// { proxy: <a>, origin: { trap: 'get', parent: '#1', key: 'a' } },
// { proxy: <b>, origin: { trap: 'get', parent: '#2', key: 'b' } },
// ]getPath(proxy)
Produces a human-readable dotted path string. Function and constructor calls are annotated with (). Returns an empty string for the root proxy and for non-proxies.
import { create, getPath } from 'flugrekorder';
const p = create({ a: { b: { fn: () => ({ v: 1 }) } } }, { callback: () => {} });
getPath(p); // ''
getPath(p.a); // 'a'
getPath(p.a.b.fn); // 'a.b.fn'
getPath(p.a.b.fn()); // 'a.b.fn()'getTarget(proxy)
Returns the original unwrapped target of a proxy. Returns null for non-proxies.
import { create, getTarget } from 'flugrekorder';
const target = { x: 1 };
const p = create(target, { callback: () => {} });
getTarget(p) === target; // true
getTarget({}); // nullgetProxyById(id, proxy)
Looks up a proxy by its recorded ID within the same graph as proxy. Useful for resolving { $proxy: id } references in recorded args and results back to live proxy objects. Returns undefined if the ID is not found.
import { create, getProxyById, type Rekording } from 'flugrekorder';
const records: Array<Rekording> = [];
const p = create({ nested: { x: 1 } }, { callback: (r) => records.push(r) });
p.nested; // triggers a get, result is { $proxy: '#2' }
const id = (records[0].result as { $proxy: string }).$proxy; // '#2'
const nested = getProxyById(id, p); // returns the same proxy as p.nestedThe Rekording shape
Every emitted record has this structure:
type Rekording = {
id: string; // e.g. '#3'
trap: string; // Reflect trap name: 'get', 'set', 'apply', … or a synthetic variant ('apply:native', 'apply:structure', 'apply:private')
origin: {
trap: 'get' | 'set' | 'defineProperty' | 'getOwnPropertyDescriptor';
parent: string; // ID of the proxy this trap fired on
key: string; // property name (symbols are serialised to their string form)
} | {
trap: 'apply' | 'construct';
source: string; // ID of the function/constructor proxy that was called
} | null; // null for the root proxy
args: Array<Serialized>; // trap arguments
result: Serialized; // return value
timestamp: number; // Date.now() at the moment the trap fired
};Serialized values are JSON-safe: primitives pass through unchanged, proxiable values become { $proxy: '<id>' }, arrays are serialised element-by-element, plain objects are serialised by value (with circular-reference protection).
Resolving paths inside a callback
getProxyById and getPath are most useful when called inside the callback while the trap is still in context — not as a post-processing step on the raw recordings.
When an apply trap fires, origin.source is the ID of the function proxy that was called. Resolving it immediately gives you a human-readable path:
let p!: typeof myService;
p = create(myService, {
only: ['get', 'apply'],
callback(r) {
if (r.trap !== 'apply' || !r.origin || !('source' in r.origin)) return;
const fn = getProxyById(r.origin.source, p);
if (fn) console.log('called:', getPath(fn)); // e.g. "users.find"
},
});The same pattern works for set traps — origin.parent is the ID of the proxy being written to:
let p!: typeof config;
p = create(config, {
callback(r) {
if (r.trap !== 'set' || !r.origin || !('parent' in r.origin)) return;
const parent = getProxyById(r.origin.parent, p);
const prefix = parent ? getPath(parent) : '';
console.log(`${prefix ? `${prefix}.` : ''}${r.origin.key} =`, r.args[2]);
// e.g. "db.port = 5433"
},
});Note the let p!: typeof … pattern: the callback closes over p before it is assigned, but p is fully set by the time any trap fires, so the reference is always valid.
How it works
One graph per session
Every create() call produces an isolated Graph — a session-scoped registry that maps proxies, targets, and IDs to each other. Once all references to a proxied tree are dropped, the graph is eligible for garbage collection. There are no module-level leaks between independent recordings.
Structured origin
Each proxy carries an Origin that describes exactly how it was created: which trap fired, on which parent proxy, and under which key (for property traps) or from which function proxy (for call traps). This makes the recording self-describing — you can reconstruct a full call graph from the records alone, without keeping any external state.
Earlier designs tracked paths as arrays of keys. That approach broke down when the same object was accessed via different routes, or when proxies were collected before the path could be read. Storing a parent ID and a key instead of a full path makes the origin both stable and compact.
{ $proxy: id } serialization
Proxiable values (objects and functions) in args and results are replaced with { $proxy: '<id>' } tags rather than inlined. This keeps records JSON-safe, avoids circular reference problems, and lets you resolve references back to live proxies via getProxyById when needed.
Promises
Promises cannot be proxied directly — native .then() checks for the [[PromiseState]] internal slot and throws if this is a Proxy. Instead, flugrekorder returns a new Promise that resolves to a proxy of the settled value, maintaining the stability guarantee across async boundaries.
wrap vs wrapKnown
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.
Native boundary crossings (apply:native, apply:structure, apply:private)
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.
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].
apply:structure — a function internally passed a proxied argument to the 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].
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.
Private fields (bind)
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.
The bind option controls how flugrekorder responds:
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.
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.
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.
class Counter {
#count = 0;
increment() { this.#count++; }
get value() { return this.#count; }
}
// default: automatic retry, apply:private in recording
const p = create(new Counter(), { callback });
p.increment(); // records trap: 'apply:private'
p.value; // records trap: 'get'
// bind: true: pre-bound, plain apply in recording, internals invisible
const q = create(new Counter(), { bind: true, callback });
q.increment(); // records trap: 'apply'
q.value; // records trap: 'get'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.
Types
Proxiable
Any value that can be proxied: objects or functions.
type Proxiable = object | Function;Redactor
Function that decides how to handle sensitive values during serialization.
type Redactor = (
key: string | symbol,
value: unknown,
target: object,
) => string | boolean | null;/**
* @param key - Property key being serialized
* @param value - Value at that key
* @param target - Parent object containing the key
* @returns `true` to replace with `"[redacted]"`, `false` to keep as-is,
* a string for custom replacement, or `null` to drop the key
*/
const hideSecrets: Redactor = (key) => key === 'password';
const redactSecrets: Redactor = (key, value) => key === 'password' && '*'.repeat(String(value).length);
const dropSecrets: Redactor = (key) => key === 'password' ? null : false;Origin
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.
type Origin =
| { trap: 'get' | 'set' | 'defineProperty' | 'getOwnPropertyDescriptor'; parent: string; key: string | symbol }
| { trap: 'apply' | 'construct'; source: string }
| null;| Field | Type | Description |
|-------|------|-------------|
| trap | string | The trap that created this proxy |
| parent | string | Proxy ID of the parent that returned this value |
| key | string \| symbol | Property key accessed |
| source | string | Proxy ID of the function that returned this value |
Rekording
A single recorded interaction from a proxy trap. Every trap firing produces one Rekording emitted to your callback or stream.
type Rekording = {
id: string;
trap: string;
origin: Origin;
args: Array<Serialized>;
result: Serialized;
timestamp: number;
};| Field | Type | Description |
|-------|------|-------------|
| id | string | Unique identifier for this record |
| trap | string | Reflect trap name (get, set, apply, etc.) |
| origin | Origin | How this proxy was created |
| args | Array<Serialized> | Arguments passed to the trap |
| result | Serialized | Return value from the trap |
| timestamp | number | When the trap fired |
Serialized
Serialized recorded values can be:
- Primitives:
string,number,boolean,bigint,null,undefined - Proxy reference:
{ $proxy: string }— a proxiable value known to the graph, referenced by ID - Raw target reference:
{ $unwrap: { $proxy: string } }— the real target behind a proxy, recorded when a native boundary crossing required unwrapping (seeapply:native) - Array of
Serialized - Plain object with
Serializedvalues
type Serialized =
| string | number | boolean | bigint | null | undefined
| { readonly $proxy: string }
| { readonly $unwrap: { readonly $proxy: string } }
| Array<Serialized>
| { [key: string]: Serialized };