@obsrviq/tracker 0.5.0 → 0.6.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/dist/index.js +432 -100
- package/dist/index.js.map +1 -1
- package/dist/obsrviq.global.js +40 -40
- package/dist/obsrviq.global.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +5 -1
- package/src/network.ts +79 -16
- package/src/ratelimit.ts +24 -0
- package/src/recorder.ts +20 -1
- package/src/serialize.ts +63 -7
- package/src/transport.ts +340 -68
- package/src/util.ts +15 -1
- package/src/websocket.ts +37 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@obsrviq/tracker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"@rrweb/types": "2.0.1",
|
|
28
28
|
"fflate": "^0.8.2",
|
|
29
29
|
"rrweb": "2.0.1",
|
|
30
|
-
"@obsrviq/types": "0.
|
|
30
|
+
"@obsrviq/types": "0.5.0"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"tsup": "^8.3.5",
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { IngestSessionMeta, ObsrviqEvent, ObsrviqInitConfig } from '@obsrviq/types';
|
|
2
2
|
import type { InstrumentCtx, ResolvedConfig, Teardown } from './context.js';
|
|
3
3
|
import { Transport } from './transport.js';
|
|
4
|
-
import { instrumentDom } from './recorder.js';
|
|
4
|
+
import { instrumentDom, takeFullSnapshot } from './recorder.js';
|
|
5
5
|
import { instrumentConsole } from './console.js';
|
|
6
6
|
import { instrumentNetwork } from './network.js';
|
|
7
7
|
import { instrumentWebSocket } from './websocket.js';
|
|
@@ -215,6 +215,10 @@ class ObsrviqClient {
|
|
|
215
215
|
this.nextSeq = next;
|
|
216
216
|
this.persistSession();
|
|
217
217
|
},
|
|
218
|
+
// Backpressure dropped DOM deltas → re-anchor the rrweb stream immediately
|
|
219
|
+
// so replay stays coherent (a delta stream with a hole is corrupt until
|
|
220
|
+
// the next 30s checkout).
|
|
221
|
+
onDomDrop: () => takeFullSnapshot(),
|
|
218
222
|
onFlush: ({ bytes, events, ok }) => {
|
|
219
223
|
if (ok) {
|
|
220
224
|
this.diag.flushes++;
|
package/src/network.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { NetworkEvent, NetworkResourceType, NetworkTiming } from '@obsrviq/types';
|
|
2
2
|
import type { InstrumentCtx, Teardown } from './context.js';
|
|
3
3
|
import { maskString } from './mask.js';
|
|
4
|
+
import { tokenBucket } from './ratelimit.js';
|
|
4
5
|
import { uuid } from './util.js';
|
|
5
6
|
|
|
6
7
|
const BODY_CAP = 4 * 1024; // 4 KB body preview cap
|
|
8
|
+
const NET_RATE = 50; // sustained network events/sec per page
|
|
9
|
+
const NET_BURST = 100; // burst capacity
|
|
7
10
|
// Response/request header allowlist — everything else is dropped.
|
|
8
11
|
const HEADER_ALLOW = new Set([
|
|
9
12
|
'content-type',
|
|
@@ -56,16 +59,56 @@ function bodyPreview(
|
|
|
56
59
|
contentType: string | undefined,
|
|
57
60
|
): NetworkEvent['requestBody'] {
|
|
58
61
|
if (text == null) return undefined;
|
|
59
|
-
|
|
60
|
-
const
|
|
62
|
+
// Pre-cut giant bodies (2× slack) so masking never regex-scans a multi-MB string.
|
|
63
|
+
const cut = text.length > BODY_CAP * 2 ? text.slice(0, BODY_CAP * 2) : text;
|
|
64
|
+
const masked = maskString(cut);
|
|
65
|
+
const truncated = cut !== text || masked.length > BODY_CAP;
|
|
61
66
|
return {
|
|
62
67
|
contentType,
|
|
63
68
|
preview: truncated ? masked.slice(0, BODY_CAP) : masked,
|
|
64
69
|
truncated,
|
|
65
|
-
redacted: masked !==
|
|
70
|
+
redacted: masked !== cut,
|
|
66
71
|
};
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
/** Read at most `cap`+1 bytes of a Response body via its stream reader (then
|
|
75
|
+
* cancel), so a multi-MB response is never fully buffered just to keep a 4 KB
|
|
76
|
+
* preview. `overflow` = the body had more bytes than `cap`. Falls back to
|
|
77
|
+
* text() where a stream reader isn't available (older fetch polyfills). */
|
|
78
|
+
async function readTextCapped(res: Response, cap: number): Promise<{ text: string; overflow: boolean }> {
|
|
79
|
+
const body = res.body;
|
|
80
|
+
if (!body || typeof body.getReader !== 'function') {
|
|
81
|
+
return { text: await res.text(), overflow: false };
|
|
82
|
+
}
|
|
83
|
+
const reader = body.getReader();
|
|
84
|
+
const decoder = new TextDecoder(); // Response.text() decodes UTF-8 — match it
|
|
85
|
+
let text = '';
|
|
86
|
+
let bytes = 0;
|
|
87
|
+
let overflow = false;
|
|
88
|
+
try {
|
|
89
|
+
for (;;) {
|
|
90
|
+
const { done, value } = await reader.read();
|
|
91
|
+
if (done || !value) {
|
|
92
|
+
text += decoder.decode(); // flush any trailing multi-byte sequence
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
const remaining = cap + 1 - bytes;
|
|
96
|
+
if (value.byteLength >= remaining) {
|
|
97
|
+
// Keep only up to cap+1 bytes total — the +1 detects truncation.
|
|
98
|
+
text += decoder.decode(value.subarray(0, remaining), { stream: true });
|
|
99
|
+
overflow = true;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
text += decoder.decode(value, { stream: true });
|
|
103
|
+
bytes += value.byteLength;
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
// Release the tee'd clone branch so it doesn't buffer the rest of the response.
|
|
107
|
+
void reader.cancel().catch(() => {});
|
|
108
|
+
}
|
|
109
|
+
return { text, overflow };
|
|
110
|
+
}
|
|
111
|
+
|
|
69
112
|
function perfNow(): number {
|
|
70
113
|
return typeof performance !== 'undefined' ? performance.now() : 0;
|
|
71
114
|
}
|
|
@@ -239,8 +282,26 @@ function createTimingResolver(): TimingResolver {
|
|
|
239
282
|
};
|
|
240
283
|
}
|
|
241
284
|
|
|
285
|
+
/** A rate-gated NetworkEvent emitter shared by all capture paths on the page. */
|
|
286
|
+
type EmitNet = (ev: NetworkEvent) => void;
|
|
287
|
+
|
|
242
288
|
export function instrumentNetwork(ctx: InstrumentCtx): Teardown {
|
|
243
289
|
const timing = createTimingResolver();
|
|
290
|
+
// Per-page event throttle — a polling loop / request storm must not flood the
|
|
291
|
+
// buffer. Dropped events are counted and reported on the next captured one.
|
|
292
|
+
const bucket = tokenBucket(NET_RATE, NET_BURST);
|
|
293
|
+
let dropped = 0;
|
|
294
|
+
const emitNet: EmitNet = (ev) => {
|
|
295
|
+
if (!bucket.take()) {
|
|
296
|
+
dropped++;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (dropped > 0) {
|
|
300
|
+
ev.droppedEvents = dropped;
|
|
301
|
+
dropped = 0;
|
|
302
|
+
}
|
|
303
|
+
ctx.emit(ev);
|
|
304
|
+
};
|
|
244
305
|
// On page hide, settle any request whose Resource Timing hasn't arrived yet so
|
|
245
306
|
// its event lands in the transport buffer before the unload flush.
|
|
246
307
|
const onPageHide = () => timing.flushPending();
|
|
@@ -251,10 +312,10 @@ export function instrumentNetwork(ctx: InstrumentCtx): Teardown {
|
|
|
251
312
|
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility);
|
|
252
313
|
|
|
253
314
|
const teardowns: Teardown[] = [];
|
|
254
|
-
teardowns.push(wrapFetch(ctx, timing));
|
|
255
|
-
teardowns.push(wrapXhr(ctx, timing));
|
|
256
|
-
teardowns.push(wrapBeacon(ctx));
|
|
257
|
-
teardowns.push(observeResources(ctx));
|
|
315
|
+
teardowns.push(wrapFetch(ctx, timing, emitNet));
|
|
316
|
+
teardowns.push(wrapXhr(ctx, timing, emitNet));
|
|
317
|
+
teardowns.push(wrapBeacon(ctx, emitNet));
|
|
318
|
+
teardowns.push(observeResources(ctx, emitNet));
|
|
258
319
|
teardowns.push(() => {
|
|
259
320
|
if (typeof window !== 'undefined') window.removeEventListener('pagehide', onPageHide);
|
|
260
321
|
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility);
|
|
@@ -264,7 +325,7 @@ export function instrumentNetwork(ctx: InstrumentCtx): Teardown {
|
|
|
264
325
|
}
|
|
265
326
|
|
|
266
327
|
// ───────────────────────────── fetch ────────────────────────────────
|
|
267
|
-
function wrapFetch(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
|
|
328
|
+
function wrapFetch(ctx: InstrumentCtx, tr: TimingResolver, emitNet: EmitNet): Teardown {
|
|
268
329
|
const orig = window.fetch;
|
|
269
330
|
if (!orig) return () => {};
|
|
270
331
|
window.fetch = async function (input: RequestInfo | URL, init?: RequestInit) {
|
|
@@ -304,7 +365,7 @@ function wrapFetch(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
|
|
|
304
365
|
timing,
|
|
305
366
|
...partial,
|
|
306
367
|
};
|
|
307
|
-
|
|
368
|
+
emitNet(ev);
|
|
308
369
|
});
|
|
309
370
|
};
|
|
310
371
|
|
|
@@ -316,8 +377,10 @@ function wrapFetch(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
|
|
|
316
377
|
let responseBody: NetworkEvent['responseBody'];
|
|
317
378
|
if (ctx.config.captureNetworkBodies && isTexty(res.headers.get('content-type'))) {
|
|
318
379
|
try {
|
|
319
|
-
|
|
380
|
+
// Stream-read the clone up to the preview cap — never buffer a huge body.
|
|
381
|
+
const { text, overflow } = await readTextCapped(res.clone(), BODY_CAP);
|
|
320
382
|
responseBody = bodyPreview(text, res.headers.get('content-type') || undefined);
|
|
383
|
+
if (responseBody && overflow) responseBody.truncated = true;
|
|
321
384
|
} catch {
|
|
322
385
|
/* ignore */
|
|
323
386
|
}
|
|
@@ -364,7 +427,7 @@ interface XhrMeta {
|
|
|
364
427
|
body?: string;
|
|
365
428
|
}
|
|
366
429
|
|
|
367
|
-
function wrapXhr(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
|
|
430
|
+
function wrapXhr(ctx: InstrumentCtx, tr: TimingResolver, emitNet: EmitNet): Teardown {
|
|
368
431
|
const XHR = XMLHttpRequest.prototype;
|
|
369
432
|
const origOpen = XHR.open;
|
|
370
433
|
const origSend = XHR.send;
|
|
@@ -445,7 +508,7 @@ function wrapXhr(ctx: InstrumentCtx, tr: TimingResolver): Teardown {
|
|
|
445
508
|
timing,
|
|
446
509
|
error: error || classifyHttp(status),
|
|
447
510
|
};
|
|
448
|
-
|
|
511
|
+
emitNet(ev);
|
|
449
512
|
});
|
|
450
513
|
};
|
|
451
514
|
this.addEventListener('loadend', () => finish());
|
|
@@ -484,7 +547,7 @@ function parseRawHeaders(ctx: InstrumentCtx, raw: string): Record<string, string
|
|
|
484
547
|
}
|
|
485
548
|
|
|
486
549
|
// ──────────────────────────── sendBeacon ────────────────────────────
|
|
487
|
-
function wrapBeacon(ctx: InstrumentCtx): Teardown {
|
|
550
|
+
function wrapBeacon(ctx: InstrumentCtx, emitNet: EmitNet): Teardown {
|
|
488
551
|
const orig = navigator.sendBeacon;
|
|
489
552
|
if (!orig) return () => {};
|
|
490
553
|
navigator.sendBeacon = function (url: string | URL, data?: BodyInit | null): boolean {
|
|
@@ -511,7 +574,7 @@ function wrapBeacon(ctx: InstrumentCtx): Teardown {
|
|
|
511
574
|
timing: { startT, endT: ctx.clock.now(), duration: 0 },
|
|
512
575
|
error: ok ? undefined : 'network',
|
|
513
576
|
};
|
|
514
|
-
|
|
577
|
+
emitNet(ev);
|
|
515
578
|
}
|
|
516
579
|
}
|
|
517
580
|
return ok;
|
|
@@ -534,7 +597,7 @@ const RESOURCE_TYPE_MAP: Record<string, NetworkResourceType> = {
|
|
|
534
597
|
track: 'media',
|
|
535
598
|
};
|
|
536
599
|
|
|
537
|
-
function observeResources(ctx: InstrumentCtx): Teardown {
|
|
600
|
+
function observeResources(ctx: InstrumentCtx, emitNet: EmitNet): Teardown {
|
|
538
601
|
if (typeof PerformanceObserver === 'undefined') return () => {};
|
|
539
602
|
let obs: PerformanceObserver | undefined;
|
|
540
603
|
try {
|
|
@@ -571,7 +634,7 @@ function observeResources(ctx: InstrumentCtx): Teardown {
|
|
|
571
634
|
responseSize: entry.transferSize || entry.encodedBodySize || undefined,
|
|
572
635
|
timing,
|
|
573
636
|
};
|
|
574
|
-
|
|
637
|
+
emitNet(ev);
|
|
575
638
|
}
|
|
576
639
|
});
|
|
577
640
|
obs.observe({ type: 'resource', buffered: true });
|
package/src/ratelimit.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Shared token-bucket rate limiter for capture paths (network / WebSocket
|
|
2
|
+
// event floods). Kept tiny on purpose — callers count their own drops.
|
|
3
|
+
|
|
4
|
+
export interface TokenBucket {
|
|
5
|
+
/** Spend one token if available; false = the caller should drop the event. */
|
|
6
|
+
take(): boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** A token bucket: refills at `rate` tokens/sec (sustained), holds at most
|
|
10
|
+
* `burst` tokens (burst capacity). */
|
|
11
|
+
export function tokenBucket(rate: number, burst: number): TokenBucket {
|
|
12
|
+
let tokens = burst;
|
|
13
|
+
let last = Date.now();
|
|
14
|
+
return {
|
|
15
|
+
take() {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
tokens = Math.min(burst, tokens + ((now - last) / 1000) * rate);
|
|
18
|
+
last = now;
|
|
19
|
+
if (tokens < 1) return false;
|
|
20
|
+
tokens -= 1;
|
|
21
|
+
return true;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
package/src/recorder.ts
CHANGED
|
@@ -10,11 +10,18 @@ const INCREMENTAL = 3;
|
|
|
10
10
|
export function instrumentDom(ctx: InstrumentCtx): Teardown {
|
|
11
11
|
const stop = record({
|
|
12
12
|
emit(event: eventWithTime) {
|
|
13
|
+
const t = ctx.clock.fromPerf(performanceNow());
|
|
14
|
+
// ONE CLOCK: rrweb stamps events with Date.now(), while everything else in
|
|
15
|
+
// the session runs on the monotonic perf clock (`t`). Rewrite the rrweb
|
|
16
|
+
// timestamp onto that same clock (startedAt + t) so an NTP step / manual
|
|
17
|
+
// clock change mid-session can't desync replay from the console/network
|
|
18
|
+
// lanes — the player only ever uses timestamp DELTAS, so this is safe.
|
|
19
|
+
event.timestamp = ctx.clock.startedAt + t;
|
|
13
20
|
const dom: DomEvent = {
|
|
14
21
|
id: uuid(),
|
|
15
22
|
sessionId: ctx.sessionId,
|
|
16
23
|
type: 'dom',
|
|
17
|
-
t
|
|
24
|
+
t,
|
|
18
25
|
ts: ctx.clock.wall(),
|
|
19
26
|
data: event,
|
|
20
27
|
};
|
|
@@ -48,6 +55,18 @@ export function instrumentDom(ctx: InstrumentCtx): Teardown {
|
|
|
48
55
|
};
|
|
49
56
|
}
|
|
50
57
|
|
|
58
|
+
/** Force a fresh FullSnapshot checkpoint NOW. Used after transport backpressure
|
|
59
|
+
* had to drop buffered DOM deltas — the new snapshot re-anchors the stream so
|
|
60
|
+
* replay stays coherent instead of playing broken deltas until the next 30s
|
|
61
|
+
* checkout. Safe no-op when recording isn't active. */
|
|
62
|
+
export function takeFullSnapshot(): void {
|
|
63
|
+
try {
|
|
64
|
+
record.takeFullSnapshot(true);
|
|
65
|
+
} catch {
|
|
66
|
+
/* not recording */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
function performanceNow(): number {
|
|
52
71
|
return typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
53
72
|
}
|
package/src/serialize.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { SerializedArg } from '@obsrviq/types';
|
|
2
|
-
import { maskString,
|
|
2
|
+
import { maskString, isSensitiveKeyName } from './mask.js';
|
|
3
3
|
|
|
4
4
|
const DEFAULT_MAX_BYTES = 8 * 1024; // 8 KB string cap (§6.1)
|
|
5
|
+
const MAX_WALK_NODES = 2000; // deep-walk node-visit cap (§6.1)
|
|
5
6
|
|
|
6
7
|
function byteLen(s: string): number {
|
|
7
8
|
return new Blob([s]).size;
|
|
@@ -24,9 +25,12 @@ export function serializeArg(arg: unknown, maxBytes = DEFAULT_MAX_BYTES): Serial
|
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
if (type === 'string') {
|
|
27
|
-
|
|
28
|
+
// Pre-cut giant strings (2× slack) so masking never regex-scans a multi-MB arg.
|
|
29
|
+
const s = arg as string;
|
|
30
|
+
const cut = s.length > maxBytes * 2 ? s.slice(0, maxBytes * 2) : s;
|
|
31
|
+
const masked = maskString(cut);
|
|
28
32
|
const { value, truncated } = truncate(masked, maxBytes);
|
|
29
|
-
return { kind: 'string', value, truncated };
|
|
33
|
+
return { kind: 'string', value, truncated: truncated || cut !== s };
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
if (arg instanceof Error) {
|
|
@@ -43,24 +47,76 @@ export function serializeArg(arg: unknown, maxBytes = DEFAULT_MAX_BYTES): Serial
|
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
if (type === 'object' || type === 'function' || type === 'symbol' || type === 'bigint') {
|
|
46
|
-
|
|
50
|
+
// Budgets are enforced DURING the walk (node-visit count + ~2× the byte cap)
|
|
51
|
+
// so a huge object never burns CPU/memory before the final truncate applies.
|
|
52
|
+
const budget: WalkBudget = { nodes: MAX_WALK_NODES, bytes: maxBytes * 2, exhausted: false };
|
|
53
|
+
const masked = maskValueBudgeted(arg, budget);
|
|
47
54
|
let json: string | undefined;
|
|
48
|
-
let truncated =
|
|
55
|
+
let truncated = budget.exhausted;
|
|
49
56
|
try {
|
|
50
57
|
const full = JSON.stringify(masked, safeReplacer());
|
|
51
58
|
const cut = truncate(full ?? 'undefined', maxBytes);
|
|
52
59
|
json = cut.value;
|
|
53
|
-
truncated = cut.truncated;
|
|
60
|
+
truncated = truncated || cut.truncated;
|
|
54
61
|
} catch {
|
|
55
62
|
json = undefined;
|
|
56
63
|
}
|
|
57
|
-
return { kind: 'object', preview: previewOf(
|
|
64
|
+
return { kind: 'object', preview: previewOf(arg), json, truncated };
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
// undefined / fallback
|
|
61
68
|
return { kind: 'primitive', value: String(arg) };
|
|
62
69
|
}
|
|
63
70
|
|
|
71
|
+
interface WalkBudget {
|
|
72
|
+
nodes: number; // node visits left
|
|
73
|
+
bytes: number; // approximate serialized bytes left
|
|
74
|
+
exhausted: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `maskValue` with hard budgets enforced DURING the walk — a node-visit cap and a
|
|
78
|
+
* running approximate byte budget — so a huge arg (e.g. a multi-MB store) is never
|
|
79
|
+
* fully cloned/stringified just to be truncated afterwards. When a budget runs out
|
|
80
|
+
* we stop descending and substitute a '«truncated»' placeholder. Byte-identical to
|
|
81
|
+
* the unbudgeted walk for args that fit the budget. */
|
|
82
|
+
function maskValueBudgeted(value: unknown, budget: WalkBudget, depth = 0): unknown {
|
|
83
|
+
if (budget.exhausted) return '«truncated»';
|
|
84
|
+
if (depth > 6) return '«depth»';
|
|
85
|
+
if (--budget.nodes <= 0) budget.exhausted = true;
|
|
86
|
+
if (typeof value === 'string') {
|
|
87
|
+
// Pre-cut giant strings so masking never regex-scans more than the budget.
|
|
88
|
+
const cut = value.length > budget.bytes ? value.slice(0, Math.max(0, budget.bytes)) : value;
|
|
89
|
+
if (cut !== value) budget.exhausted = true;
|
|
90
|
+
const masked = maskString(cut);
|
|
91
|
+
if ((budget.bytes -= masked.length + 2) <= 0) budget.exhausted = true;
|
|
92
|
+
return masked;
|
|
93
|
+
}
|
|
94
|
+
if (value === null || typeof value !== 'object') return value;
|
|
95
|
+
if (Array.isArray(value)) {
|
|
96
|
+
const out: unknown[] = [];
|
|
97
|
+
for (const v of value) {
|
|
98
|
+
if (budget.exhausted) {
|
|
99
|
+
out.push('«truncated»');
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
out.push(maskValueBudgeted(v, budget, depth + 1));
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
const out: Record<string, unknown> = {};
|
|
107
|
+
for (const k of Object.keys(value)) {
|
|
108
|
+
if (budget.exhausted) {
|
|
109
|
+
out['…'] = '«truncated»';
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
if ((budget.bytes -= k.length + 4) <= 0) budget.exhausted = true;
|
|
113
|
+
out[k] = isSensitiveKeyName(k)
|
|
114
|
+
? '«redacted»'
|
|
115
|
+
: maskValueBudgeted((value as Record<string, unknown>)[k], budget, depth + 1);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
64
120
|
function safeReplacer() {
|
|
65
121
|
const seen = new WeakSet();
|
|
66
122
|
return (_key: string, value: unknown) => {
|