@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@obsrviq/tracker",
3
- "version": "0.5.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.4.1"
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
- const masked = maskString(text);
60
- const truncated = masked.length > BODY_CAP;
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 !== text,
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
- ctx.emit(ev);
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
- const text = await res.clone().text();
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
- ctx.emit(ev);
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
- ctx.emit(ev);
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
- ctx.emit(ev);
637
+ emitNet(ev);
575
638
  }
576
639
  });
577
640
  obs.observe({ type: 'resource', buffered: true });
@@ -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: ctx.clock.fromPerf(performanceNow()),
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, maskValue } from './mask.js';
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
- const masked = maskString(arg as string);
28
+ // Pre-cut giant strings ( 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
- const masked = maskValue(arg);
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 = false;
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(masked), json, truncated };
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) => {