@obsrviq/tracker 0.5.0 → 0.7.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 CHANGED
@@ -88,24 +88,23 @@ function redactBatchSensitiveKeys(events) {
88
88
  }
89
89
  }
90
90
  }
91
- function maskValue(value, depth = 0) {
92
- if (depth > 6) return "\xABdepth\xBB";
93
- if (typeof value === "string") return maskString(value);
94
- if (value === null || typeof value !== "object") return value;
95
- if (Array.isArray(value)) return value.map((v) => maskValue(v, depth + 1));
96
- const out = {};
97
- for (const [k, v] of Object.entries(value)) {
98
- out[k] = isSensitiveKeyName(k) ? "\xABredacted\xBB" : maskValue(v, depth + 1);
99
- }
100
- return out;
101
- }
102
91
 
103
92
  // src/transport.ts
104
93
  var MAX_BUFFER_EVENTS = 5e3;
94
+ var MAX_BATCH_JSON_BYTES = 4 * 1024 * 1024;
95
+ var BEACON_PIECE_BYTES = 56 * 1024;
96
+ var SPILL_KEY = "obsrviq.spill";
97
+ var SPILL_MAX_BYTES = 15e5;
98
+ var SPILL_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
99
+ var RR_FULL_SNAPSHOT = 2;
100
+ var RR_META = 4;
105
101
  var Transport = class {
106
102
  constructor(opts) {
107
103
  this.opts = opts;
108
104
  this.buffer = [];
105
+ /** Floor set by an unload flush that consumed seqs out-of-band (beacon pieces),
106
+ * so the normal path can never re-use one of them. */
107
+ this.seqFloor = 0;
109
108
  this.sending = false;
110
109
  this.retryDelay = 1e3;
111
110
  this.stopped = false;
@@ -121,29 +120,107 @@ var Transport = class {
121
120
  this.timer = setInterval(() => void this.flush("timer"), this.opts.flushIntervalMs);
122
121
  document.addEventListener("visibilitychange", this.onVisibility, { capture: true });
123
122
  window.addEventListener("pagehide", this.onPageHide, { capture: true });
123
+ this.resendSpill();
124
124
  }
125
125
  enqueue(event) {
126
126
  if (this.stopped) return;
127
127
  this.buffer.push(event);
128
128
  if (this.buffer.length >= MAX_BUFFER_EVENTS) {
129
129
  void this.flush("backpressure");
130
- if (this.buffer.length >= MAX_BUFFER_EVENTS) {
131
- this.buffer = this.buffer.filter((e, i) => e.type !== "dom" || i % 2 === 0);
130
+ if (this.buffer.length >= MAX_BUFFER_EVENTS) this.shedDomBacklog();
131
+ }
132
+ }
133
+ /** Drop buffered DOM events WITHOUT breaking rrweb's delta chain. Deltas only
134
+ * apply on top of everything before them, so arbitrary dropping corrupts the
135
+ * replay until the next 30s checkpoint. Instead: if the buffer holds a
136
+ * FullSnapshot, drop only the DOM events BEFORE it (the snapshot re-anchors
137
+ * everything after). Otherwise drop ALL buffered DOM and ask the recorder for
138
+ * a fresh FullSnapshot so the stream re-anchors immediately. Non-DOM events
139
+ * (console/network/errors/…) are independent and kept. */
140
+ shedDomBacklog() {
141
+ let snapIdx = -1;
142
+ for (let i = this.buffer.length - 1; i >= 0; i--) {
143
+ const e = this.buffer[i];
144
+ if (e?.type === "dom" && e.data?.type === RR_FULL_SNAPSHOT) {
145
+ snapIdx = i;
146
+ break;
132
147
  }
133
148
  }
149
+ if (snapIdx > 0) {
150
+ let keepFrom = snapIdx;
151
+ const prev = this.buffer[snapIdx - 1];
152
+ if (prev?.type === "dom" && prev.data?.type === RR_META) {
153
+ keepFrom = snapIdx - 1;
154
+ }
155
+ const kept = this.buffer.filter((e, i) => e.type !== "dom" || i >= keepFrom);
156
+ if (kept.length < this.buffer.length) {
157
+ this.buffer = kept;
158
+ if (this.buffer.length < MAX_BUFFER_EVENTS) return;
159
+ }
160
+ }
161
+ this.buffer = this.buffer.filter((e) => e.type !== "dom");
162
+ if (this.buffer.length >= MAX_BUFFER_EVENTS) {
163
+ this.buffer = this.buffer.slice(-Math.floor(MAX_BUFFER_EVENTS / 2));
164
+ }
165
+ this.opts.onDomDrop?.();
134
166
  }
135
167
  /** Build, compress, and send the current buffer. Returns true on success. */
136
168
  async flush(reason) {
169
+ if (reason === "pagehide" || reason === "hidden") return this.flushUnload();
137
170
  if (this.sending || this.buffer.length === 0) return true;
138
- const unloading = reason === "pagehide" || reason === "hidden";
139
- if (!unloading && isMaskConfigPending()) {
140
- if (!this.stopped) setTimeout(() => void this.flush("timer"), 200);
171
+ if (isMaskConfigPending() && !this.stopped) {
172
+ setTimeout(() => void this.flush("timer"), 200);
141
173
  return true;
142
174
  }
143
175
  this.sending = true;
144
176
  const events = this.buffer;
145
177
  this.buffer = [];
146
178
  redactBatchSensitiveKeys(events);
179
+ let ok;
180
+ try {
181
+ ok = await this.sendEvents(events);
182
+ } finally {
183
+ this.sending = false;
184
+ }
185
+ if (ok && this.stopped && this.buffer.length > 0) void this.flush("manual");
186
+ return ok;
187
+ }
188
+ /** Send events as one or more seq-stamped batches: oversized batches split in
189
+ * half (byte cap here, or a server 413), poison batches (other 4xx) are dropped
190
+ * instead of retried forever, and transient failures re-buffer + back off. */
191
+ async sendEvents(events) {
192
+ const queue = [events];
193
+ while (queue.length > 0) {
194
+ const slice = queue[0];
195
+ const result = await this.dispatch(slice);
196
+ if (result === "ok") {
197
+ queue.shift();
198
+ continue;
199
+ }
200
+ if (result === "too_large" && slice.length > 1) {
201
+ const mid = Math.ceil(slice.length / 2);
202
+ queue.splice(0, 1, slice.slice(0, mid), slice.slice(mid));
203
+ continue;
204
+ }
205
+ if (result === "fatal" || result === "too_large") {
206
+ queue.shift();
207
+ continue;
208
+ }
209
+ this.buffer = [].concat(...queue, this.buffer);
210
+ if (this.buffer.length >= MAX_BUFFER_EVENTS) this.shedDomBacklog();
211
+ if (!this.stopped) {
212
+ const jitter = this.retryDelay * (0.5 + Math.random());
213
+ this.retryDelay = Math.min(this.retryDelay * 2, 3e4);
214
+ setTimeout(() => void this.flush("timer"), jitter);
215
+ }
216
+ return false;
217
+ }
218
+ this.retryDelay = 1e3;
219
+ return true;
220
+ }
221
+ /** One batch → one network attempt. Owns seq assignment/advance. */
222
+ async dispatch(events) {
223
+ if (this.seqFloor > this.seq) this.seq = this.seqFloor;
147
224
  let batch = {
148
225
  siteKey: this.opts.siteKey,
149
226
  sessionId: this.opts.sessionId,
@@ -158,73 +235,165 @@ var Transport = class {
158
235
  batch = null;
159
236
  }
160
237
  }
161
- if (!batch) {
162
- this.sending = false;
163
- return true;
164
- }
165
- this.opts.onSeqDispatch?.(this.seq + 1);
238
+ if (!batch) return "ok";
166
239
  const json = JSON.stringify(batch);
167
- const gz = gzipSync(new TextEncoder().encode(json));
168
- const ok = await this.send(gz, reason === "pagehide" || reason === "hidden");
169
- this.opts.onFlush?.({ bytes: gz.byteLength, events: events.length, ok });
170
- if (ok) {
171
- this.seq++;
172
- this.retryDelay = 1e3;
173
- this.sending = false;
174
- return true;
175
- }
176
- this.buffer = events.slice(-MAX_BUFFER_EVENTS).concat(this.buffer);
177
- this.sending = false;
178
- if (!this.stopped) {
179
- const jitter = this.retryDelay * (0.5 + Math.random());
180
- this.retryDelay = Math.min(this.retryDelay * 2, 3e4);
181
- setTimeout(() => void this.flush("timer"), jitter);
182
- }
183
- return false;
240
+ if (json.length > MAX_BATCH_JSON_BYTES && events.length > 1) return "too_large";
241
+ this.opts.onSeqDispatch?.(this.seq + 1);
242
+ const gz = await gzipBytes(new TextEncoder().encode(json));
243
+ const result = await this.send(gz);
244
+ this.opts.onFlush?.({ bytes: gz.byteLength, events: events.length, ok: result === "ok" });
245
+ if (result === "ok") this.seq = Math.max(this.seq + 1, this.seqFloor);
246
+ return result;
184
247
  }
185
- async send(bytes, unloading) {
248
+ async send(bytes) {
186
249
  const url = `${this.opts.endpoint.replace(/\/$/, "")}/v1/batch`;
187
- const headers = {
188
- "content-type": "application/octet-stream",
189
- "x-obsrviq-key": this.opts.siteKey
190
- };
191
- const body = bytes;
192
- if (unloading) {
193
- const blob = new Blob([bytes], { type: "application/octet-stream" });
194
- if (typeof navigator.sendBeacon === "function") {
250
+ const ctrl = new AbortController();
251
+ const timeout = setTimeout(() => ctrl.abort(), 15e3);
252
+ try {
253
+ const res = await fetch(url, {
254
+ method: "POST",
255
+ body: bytes,
256
+ headers: {
257
+ "content-type": "application/octet-stream",
258
+ "x-obsrviq-key": this.opts.siteKey
259
+ },
260
+ credentials: "omit",
261
+ mode: "cors",
262
+ signal: ctrl.signal
263
+ });
264
+ if (res.ok) return "ok";
265
+ if (res.status === 413) return "too_large";
266
+ if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) return "fatal";
267
+ return "retry";
268
+ } catch {
269
+ return "retry";
270
+ } finally {
271
+ clearTimeout(timeout);
272
+ }
273
+ }
274
+ /** Unload flush (pagehide / tab hidden). Fully synchronous — the page may be
275
+ * gone before any await resolves. Ignores the `sending` guard (that in-flight
276
+ * fetch is about to be cancelled; its seq is skipped via seqFloor), chunks the
277
+ * tail into beacon-sized pieces (sendBeacon caps bodies at ~64KB — one big
278
+ * batch would be rejected whole), and spills anything the beacon queue refuses
279
+ * to localStorage for the next page load to resend. */
280
+ flushUnload() {
281
+ if (this.buffer.length === 0) return true;
282
+ const events = this.buffer;
283
+ this.buffer = [];
284
+ redactBatchSensitiveKeys(events);
285
+ let seq = Math.max(this.sending ? this.seq + 1 : this.seq, this.seqFloor);
286
+ const pieces = [];
287
+ const stack = [events];
288
+ while (stack.length > 0) {
289
+ const slice = stack.shift();
290
+ let batch = {
291
+ siteKey: this.opts.siteKey,
292
+ sessionId: this.opts.sessionId,
293
+ seq,
294
+ meta: this.opts.getMeta(),
295
+ events: slice
296
+ };
297
+ if (this.opts.beforeSend) {
195
298
  try {
196
- if (navigator.sendBeacon(url, blob)) return true;
299
+ batch = this.opts.beforeSend(batch);
197
300
  } catch {
301
+ batch = null;
198
302
  }
199
303
  }
304
+ if (!batch) continue;
305
+ const gz = gzipSync(new TextEncoder().encode(JSON.stringify(batch)));
306
+ if (gz.byteLength > BEACON_PIECE_BYTES && slice.length > 1) {
307
+ const mid = Math.ceil(slice.length / 2);
308
+ stack.unshift(slice.slice(0, mid), slice.slice(mid));
309
+ continue;
310
+ }
311
+ pieces.push({ gz, count: slice.length });
312
+ seq++;
313
+ }
314
+ this.seqFloor = seq;
315
+ this.opts.onSeqDispatch?.(seq);
316
+ for (const { gz, count } of pieces) {
317
+ const sent = gz.byteLength <= BEACON_PIECE_BYTES && this.sendUnload(gz);
318
+ if (!sent) this.spill(gz);
319
+ this.opts.onFlush?.({ bytes: gz.byteLength, events: count, ok: sent });
320
+ }
321
+ return true;
322
+ }
323
+ /** Fire-and-forget delivery that survives navigation. True = queued. */
324
+ sendUnload(bytes) {
325
+ const url = `${this.opts.endpoint.replace(/\/$/, "")}/v1/batch`;
326
+ const blob = new Blob([bytes], { type: "application/octet-stream" });
327
+ if (typeof navigator.sendBeacon === "function") {
200
328
  try {
201
- void fetch(url, { method: "POST", body, headers, keepalive: true, credentials: "omit", mode: "cors" }).catch(
202
- () => {
203
- }
204
- );
205
- return true;
329
+ if (navigator.sendBeacon(url, blob)) return true;
206
330
  } catch {
207
- return false;
208
331
  }
209
332
  }
210
- const ctrl = new AbortController();
211
- const timeout = setTimeout(() => ctrl.abort(), 15e3);
212
333
  try {
213
- const res = await fetch(url, {
334
+ void fetch(url, {
214
335
  method: "POST",
215
- body,
216
- headers,
336
+ body: bytes,
337
+ headers: {
338
+ "content-type": "application/octet-stream",
339
+ "x-obsrviq-key": this.opts.siteKey
340
+ },
341
+ keepalive: true,
217
342
  credentials: "omit",
218
- mode: "cors",
219
- signal: ctrl.signal
343
+ mode: "cors"
344
+ }).catch(() => {
220
345
  });
221
- return res.ok;
346
+ return true;
222
347
  } catch {
223
348
  return false;
224
- } finally {
225
- clearTimeout(timeout);
226
349
  }
227
350
  }
351
+ /** Append one gzipped batch to the localStorage spill (bounded, oldest-first
352
+ * eviction). Quota/private-mode failures mean the tail is lost — same as
353
+ * before spilling existed. */
354
+ spill(gz) {
355
+ try {
356
+ const raw = window.localStorage.getItem(SPILL_KEY);
357
+ const entries = raw ? JSON.parse(raw) : [];
358
+ entries.push({ k: this.opts.siteKey, b: b64encode(gz), ts: Date.now() });
359
+ let total = entries.reduce((n, e) => n + e.b.length, 0);
360
+ while (entries.length > 0 && total > SPILL_MAX_BYTES) {
361
+ total -= entries[0].b.length;
362
+ entries.shift();
363
+ }
364
+ window.localStorage.setItem(SPILL_KEY, JSON.stringify(entries));
365
+ } catch {
366
+ }
367
+ }
368
+ /** Resend batches a previous page's unload spilled. Entries are claimed
369
+ * (removed) up front so two tabs can't double-send; still-transient failures
370
+ * are re-spilled for the next load. Fire-and-forget. */
371
+ resendSpill() {
372
+ let mine = [];
373
+ try {
374
+ const raw = window.localStorage.getItem(SPILL_KEY);
375
+ if (!raw) return;
376
+ const entries = JSON.parse(raw);
377
+ const now = Date.now();
378
+ const fresh = entries.filter((e) => e && typeof e.b === "string" && now - e.ts < SPILL_MAX_AGE_MS);
379
+ mine = fresh.filter((e) => e.k === this.opts.siteKey);
380
+ const others = fresh.filter((e) => e.k !== this.opts.siteKey);
381
+ if (others.length > 0) window.localStorage.setItem(SPILL_KEY, JSON.stringify(others));
382
+ else window.localStorage.removeItem(SPILL_KEY);
383
+ } catch {
384
+ return;
385
+ }
386
+ if (mine.length === 0) return;
387
+ void (async () => {
388
+ for (const entry of mine) {
389
+ try {
390
+ const result = await this.send(b64decode(entry.b));
391
+ if (result === "retry") this.spill(b64decode(entry.b));
392
+ } catch {
393
+ }
394
+ }
395
+ })();
396
+ }
228
397
  stop() {
229
398
  this.stopped = true;
230
399
  if (this.timer) clearInterval(this.timer);
@@ -233,6 +402,29 @@ var Transport = class {
233
402
  void this.flush("manual");
234
403
  }
235
404
  };
405
+ async function gzipBytes(bytes) {
406
+ if (typeof CompressionStream !== "undefined") {
407
+ try {
408
+ const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip"));
409
+ return new Uint8Array(await new Response(stream).arrayBuffer());
410
+ } catch {
411
+ }
412
+ }
413
+ return gzipSync(bytes);
414
+ }
415
+ function b64encode(bytes) {
416
+ let bin = "";
417
+ for (let i = 0; i < bytes.length; i += 32768) {
418
+ bin += String.fromCharCode(...bytes.subarray(i, i + 32768));
419
+ }
420
+ return btoa(bin);
421
+ }
422
+ function b64decode(s) {
423
+ const bin = atob(s);
424
+ const out = new Uint8Array(bin.length);
425
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
426
+ return out;
427
+ }
236
428
 
237
429
  // src/util.ts
238
430
  function uuid() {
@@ -283,6 +475,13 @@ async function sha256Hex(input) {
283
475
  function detectDevice() {
284
476
  const ua = navigator.userAgent;
285
477
  const viewport = { w: window.innerWidth, h: window.innerHeight };
478
+ let tz;
479
+ let tzOffset;
480
+ try {
481
+ tz = Intl.DateTimeFormat().resolvedOptions().timeZone || void 0;
482
+ tzOffset = -(/* @__PURE__ */ new Date()).getTimezoneOffset();
483
+ } catch {
484
+ }
286
485
  let type = "desktop";
287
486
  if (/iPad|Tablet/i.test(ua)) type = "tablet";
288
487
  else if (/Mobi|Android|iPhone/i.test(ua)) type = "mobile";
@@ -297,7 +496,7 @@ function detectDevice() {
297
496
  else if (/Chrome\//.test(ua)) browser = "Chrome";
298
497
  else if (/Firefox\//.test(ua)) browser = "Firefox";
299
498
  else if (/Safari\//.test(ua)) browser = "Safari";
300
- return { type, os, browser, viewport };
499
+ return { type, os, browser, viewport, tz, tzOffset };
301
500
  }
302
501
  function privacySignalsOptOut() {
303
502
  const nav = navigator;
@@ -311,11 +510,13 @@ var INCREMENTAL = 3;
311
510
  function instrumentDom(ctx) {
312
511
  const stop = record({
313
512
  emit(event) {
513
+ const t = ctx.clock.fromPerf(performanceNow());
514
+ event.timestamp = ctx.clock.startedAt + t;
314
515
  const dom = {
315
516
  id: uuid(),
316
517
  sessionId: ctx.sessionId,
317
518
  type: "dom",
318
- t: ctx.clock.fromPerf(performanceNow()),
519
+ t,
319
520
  ts: ctx.clock.wall(),
320
521
  data: event
321
522
  };
@@ -346,12 +547,19 @@ function instrumentDom(ctx) {
346
547
  }
347
548
  };
348
549
  }
550
+ function takeFullSnapshot() {
551
+ try {
552
+ record.takeFullSnapshot(true);
553
+ } catch {
554
+ }
555
+ }
349
556
  function performanceNow() {
350
557
  return typeof performance !== "undefined" ? performance.now() : Date.now();
351
558
  }
352
559
 
353
560
  // src/serialize.ts
354
561
  var DEFAULT_MAX_BYTES = 8 * 1024;
562
+ var MAX_WALK_NODES = 2e3;
355
563
  function byteLen(s) {
356
564
  return new Blob([s]).size;
357
565
  }
@@ -367,9 +575,11 @@ function serializeArg(arg, maxBytes = DEFAULT_MAX_BYTES) {
367
575
  return { kind: "primitive", value: arg };
368
576
  }
369
577
  if (type === "string") {
370
- const masked = maskString(arg);
578
+ const s = arg;
579
+ const cut = s.length > maxBytes * 2 ? s.slice(0, maxBytes * 2) : s;
580
+ const masked = maskString(cut);
371
581
  const { value, truncated } = truncate(masked, maxBytes);
372
- return { kind: "string", value, truncated };
582
+ return { kind: "string", value, truncated: truncated || cut !== s };
373
583
  }
374
584
  if (arg instanceof Error) {
375
585
  return {
@@ -383,21 +593,56 @@ function serializeArg(arg, maxBytes = DEFAULT_MAX_BYTES) {
383
593
  return { kind: "dom", selector: cssPath(arg) };
384
594
  }
385
595
  if (type === "object" || type === "function" || type === "symbol" || type === "bigint") {
386
- const masked = maskValue(arg);
596
+ const budget = { nodes: MAX_WALK_NODES, bytes: maxBytes * 2, exhausted: false };
597
+ const masked = maskValueBudgeted(arg, budget);
387
598
  let json;
388
- let truncated = false;
599
+ let truncated = budget.exhausted;
389
600
  try {
390
601
  const full = JSON.stringify(masked, safeReplacer());
391
602
  const cut = truncate(full ?? "undefined", maxBytes);
392
603
  json = cut.value;
393
- truncated = cut.truncated;
604
+ truncated = truncated || cut.truncated;
394
605
  } catch {
395
606
  json = void 0;
396
607
  }
397
- return { kind: "object", preview: previewOf(masked), json, truncated };
608
+ return { kind: "object", preview: previewOf(arg), json, truncated };
398
609
  }
399
610
  return { kind: "primitive", value: String(arg) };
400
611
  }
612
+ function maskValueBudgeted(value, budget, depth = 0) {
613
+ if (budget.exhausted) return "\xABtruncated\xBB";
614
+ if (depth > 6) return "\xABdepth\xBB";
615
+ if (--budget.nodes <= 0) budget.exhausted = true;
616
+ if (typeof value === "string") {
617
+ const cut = value.length > budget.bytes ? value.slice(0, Math.max(0, budget.bytes)) : value;
618
+ if (cut !== value) budget.exhausted = true;
619
+ const masked = maskString(cut);
620
+ if ((budget.bytes -= masked.length + 2) <= 0) budget.exhausted = true;
621
+ return masked;
622
+ }
623
+ if (value === null || typeof value !== "object") return value;
624
+ if (Array.isArray(value)) {
625
+ const out2 = [];
626
+ for (const v of value) {
627
+ if (budget.exhausted) {
628
+ out2.push("\xABtruncated\xBB");
629
+ break;
630
+ }
631
+ out2.push(maskValueBudgeted(v, budget, depth + 1));
632
+ }
633
+ return out2;
634
+ }
635
+ const out = {};
636
+ for (const k of Object.keys(value)) {
637
+ if (budget.exhausted) {
638
+ out["\u2026"] = "\xABtruncated\xBB";
639
+ break;
640
+ }
641
+ if ((budget.bytes -= k.length + 4) <= 0) budget.exhausted = true;
642
+ out[k] = isSensitiveKeyName(k) ? "\xABredacted\xBB" : maskValueBudgeted(value[k], budget, depth + 1);
643
+ }
644
+ return out;
645
+ }
401
646
  function safeReplacer() {
402
647
  const seen = /* @__PURE__ */ new WeakSet();
403
648
  return (_key, value) => {
@@ -531,8 +776,83 @@ function errorToConsoleEvent(ctx, err) {
531
776
  };
532
777
  }
533
778
 
779
+ // src/ratelimit.ts
780
+ function tokenBucket(rate2, burst) {
781
+ let tokens = burst;
782
+ let last = Date.now();
783
+ return {
784
+ take() {
785
+ const now = Date.now();
786
+ tokens = Math.min(burst, tokens + (now - last) / 1e3 * rate2);
787
+ last = now;
788
+ if (tokens < 1) return false;
789
+ tokens -= 1;
790
+ return true;
791
+ }
792
+ };
793
+ }
794
+
795
+ // src/trace.ts
796
+ function randomHex(bytes) {
797
+ const arr = new Uint8Array(bytes);
798
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
799
+ crypto.getRandomValues(arr);
800
+ } else {
801
+ for (let i = 0; i < bytes; i++) arr[i] = Math.random() * 256 | 0;
802
+ }
803
+ let s = "";
804
+ for (let i = 0; i < bytes; i++) s += arr[i].toString(16).padStart(2, "0");
805
+ return s;
806
+ }
807
+ function newTrace() {
808
+ const traceId = randomHex(16);
809
+ const spanId = randomHex(8);
810
+ return { traceId, spanId, header: `00-${traceId}-${spanId}-01` };
811
+ }
812
+ function traceTarget(rawUrl, cfg) {
813
+ if (!cfg.tracePropagation) return false;
814
+ let u;
815
+ try {
816
+ u = new URL(rawUrl, typeof location !== "undefined" ? location.href : void 0);
817
+ } catch {
818
+ return false;
819
+ }
820
+ if (u.protocol !== "http:" && u.protocol !== "https:") return false;
821
+ if (typeof location !== "undefined" && u.origin === location.origin) return true;
822
+ return hostAllowed(u.hostname, cfg.traceHosts);
823
+ }
824
+ function hostAllowed(hostname, hosts) {
825
+ const h = hostname.toLowerCase();
826
+ for (const raw of hosts) {
827
+ let entry = String(raw).trim().toLowerCase();
828
+ if (!entry) continue;
829
+ if (entry.includes("://")) {
830
+ try {
831
+ entry = new URL(entry).hostname;
832
+ } catch {
833
+ }
834
+ }
835
+ entry = entry.replace(/^\*?\./, "");
836
+ if (h === entry || h.endsWith("." + entry)) return true;
837
+ }
838
+ return false;
839
+ }
840
+ function injectFetchTrace(input, init, header) {
841
+ const hasInitHeaders = !!init && "headers" in init && init.headers != null;
842
+ if (input instanceof Request && !hasInitHeaders) {
843
+ const h2 = new Headers(input.headers);
844
+ if (!h2.has("traceparent")) h2.set("traceparent", header);
845
+ return { input: new Request(input, { headers: h2 }), init };
846
+ }
847
+ const h = new Headers(init?.headers ?? void 0);
848
+ if (!h.has("traceparent")) h.set("traceparent", header);
849
+ return { input, init: { ...init || {}, headers: h } };
850
+ }
851
+
534
852
  // src/network.ts
535
853
  var BODY_CAP = 4 * 1024;
854
+ var NET_RATE = 50;
855
+ var NET_BURST = 100;
536
856
  var HEADER_ALLOW = /* @__PURE__ */ new Set([
537
857
  "content-type",
538
858
  "content-length",
@@ -544,6 +864,7 @@ var HEADER_ALLOW = /* @__PURE__ */ new Set([
544
864
  "content-encoding",
545
865
  "x-request-id",
546
866
  "x-correlation-id",
867
+ "traceparent",
547
868
  "server",
548
869
  "vary"
549
870
  ]);
@@ -574,15 +895,48 @@ function pickHeaders(redact, entries) {
574
895
  }
575
896
  function bodyPreview(text, contentType) {
576
897
  if (text == null) return void 0;
577
- const masked = maskString(text);
578
- const truncated = masked.length > BODY_CAP;
898
+ const cut = text.length > BODY_CAP * 2 ? text.slice(0, BODY_CAP * 2) : text;
899
+ const masked = maskString(cut);
900
+ const truncated = cut !== text || masked.length > BODY_CAP;
579
901
  return {
580
902
  contentType,
581
903
  preview: truncated ? masked.slice(0, BODY_CAP) : masked,
582
904
  truncated,
583
- redacted: masked !== text
905
+ redacted: masked !== cut
584
906
  };
585
907
  }
908
+ async function readTextCapped(res, cap) {
909
+ const body = res.body;
910
+ if (!body || typeof body.getReader !== "function") {
911
+ return { text: await res.text(), overflow: false };
912
+ }
913
+ const reader = body.getReader();
914
+ const decoder = new TextDecoder();
915
+ let text = "";
916
+ let bytes = 0;
917
+ let overflow = false;
918
+ try {
919
+ for (; ; ) {
920
+ const { done, value } = await reader.read();
921
+ if (done || !value) {
922
+ text += decoder.decode();
923
+ break;
924
+ }
925
+ const remaining = cap + 1 - bytes;
926
+ if (value.byteLength >= remaining) {
927
+ text += decoder.decode(value.subarray(0, remaining), { stream: true });
928
+ overflow = true;
929
+ break;
930
+ }
931
+ text += decoder.decode(value, { stream: true });
932
+ bytes += value.byteLength;
933
+ }
934
+ } finally {
935
+ void reader.cancel().catch(() => {
936
+ });
937
+ }
938
+ return { text, overflow };
939
+ }
586
940
  function perfNow() {
587
941
  return typeof performance !== "undefined" ? performance.now() : 0;
588
942
  }
@@ -702,6 +1056,19 @@ function createTimingResolver() {
702
1056
  }
703
1057
  function instrumentNetwork(ctx) {
704
1058
  const timing = createTimingResolver();
1059
+ const bucket = tokenBucket(NET_RATE, NET_BURST);
1060
+ let dropped = 0;
1061
+ const emitNet = (ev) => {
1062
+ if (!bucket.take()) {
1063
+ dropped++;
1064
+ return;
1065
+ }
1066
+ if (dropped > 0) {
1067
+ ev.droppedEvents = dropped;
1068
+ dropped = 0;
1069
+ }
1070
+ ctx.emit(ev);
1071
+ };
705
1072
  const onPageHide = () => timing.flushPending();
706
1073
  const onVisibility = () => {
707
1074
  if (typeof document !== "undefined" && document.visibilityState === "hidden") timing.flushPending();
@@ -709,10 +1076,10 @@ function instrumentNetwork(ctx) {
709
1076
  if (typeof window !== "undefined") window.addEventListener("pagehide", onPageHide);
710
1077
  if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisibility);
711
1078
  const teardowns = [];
712
- teardowns.push(wrapFetch(ctx, timing));
713
- teardowns.push(wrapXhr(ctx, timing));
714
- teardowns.push(wrapBeacon(ctx));
715
- teardowns.push(observeResources(ctx));
1079
+ teardowns.push(wrapFetch(ctx, timing, emitNet));
1080
+ teardowns.push(wrapXhr(ctx, timing, emitNet));
1081
+ teardowns.push(wrapBeacon(ctx, emitNet));
1082
+ teardowns.push(observeResources(ctx, emitNet));
716
1083
  teardowns.push(() => {
717
1084
  if (typeof window !== "undefined") window.removeEventListener("pagehide", onPageHide);
718
1085
  if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibility);
@@ -720,7 +1087,7 @@ function instrumentNetwork(ctx) {
720
1087
  });
721
1088
  return () => teardowns.forEach((t) => t());
722
1089
  }
723
- function wrapFetch(ctx, tr) {
1090
+ function wrapFetch(ctx, tr, emitNet) {
724
1091
  const orig = window.fetch;
725
1092
  if (!orig) return () => {
726
1093
  };
@@ -732,6 +1099,17 @@ function wrapFetch(ctx, tr) {
732
1099
  const perfStart = perfNow();
733
1100
  const method = (init?.method || (input instanceof Request ? input.method : "GET")).toUpperCase();
734
1101
  const url = redactUrl(rawUrl);
1102
+ let traceId;
1103
+ if (traceTarget(rawUrl, ctx.config)) {
1104
+ try {
1105
+ const tr2 = newTrace();
1106
+ const inj = injectFetchTrace(input, init, tr2.header);
1107
+ input = inj.input;
1108
+ init = inj.init;
1109
+ traceId = tr2.traceId;
1110
+ } catch {
1111
+ }
1112
+ }
735
1113
  const reqHeaders = pickHeaders(
736
1114
  ctx.config.redactHeaders,
737
1115
  headerEntries(init?.headers ?? (input instanceof Request ? input.headers : void 0))
@@ -754,10 +1132,11 @@ function wrapFetch(ctx, tr) {
754
1132
  ok: false,
755
1133
  resourceType: "fetch",
756
1134
  requestHeaders: reqHeaders,
1135
+ traceId,
757
1136
  timing,
758
1137
  ...partial
759
1138
  };
760
- ctx.emit(ev);
1139
+ emitNet(ev);
761
1140
  });
762
1141
  };
763
1142
  try {
@@ -768,8 +1147,9 @@ function wrapFetch(ctx, tr) {
768
1147
  let responseBody;
769
1148
  if (ctx.config.captureNetworkBodies && isTexty(res.headers.get("content-type"))) {
770
1149
  try {
771
- const text = await res.clone().text();
1150
+ const { text, overflow } = await readTextCapped(res.clone(), BODY_CAP);
772
1151
  responseBody = bodyPreview(text, res.headers.get("content-type") || void 0);
1152
+ if (responseBody && overflow) responseBody.truncated = true;
773
1153
  } catch {
774
1154
  }
775
1155
  }
@@ -800,7 +1180,7 @@ function classifyFetchError(err) {
800
1180
  if (msg.includes("timeout")) return "timeout";
801
1181
  return "network";
802
1182
  }
803
- function wrapXhr(ctx, tr) {
1183
+ function wrapXhr(ctx, tr, emitNet) {
804
1184
  const XHR = XMLHttpRequest.prototype;
805
1185
  const origOpen = XHR.open;
806
1186
  const origSend = XHR.send;
@@ -834,6 +1214,14 @@ function wrapXhr(ctx, tr) {
834
1214
  meta.startT = ctx.clock.now();
835
1215
  meta.perfStart = perfNow();
836
1216
  if (ctx.config.captureNetworkBodies) meta.body = stringifyBody(body);
1217
+ if (traceTarget(meta.rawUrl, ctx.config)) {
1218
+ try {
1219
+ const tr2 = newTrace();
1220
+ this.setRequestHeader("traceparent", tr2.header);
1221
+ meta.traceId = tr2.traceId;
1222
+ } catch {
1223
+ }
1224
+ }
837
1225
  const finish = (error) => {
838
1226
  const endT = ctx.clock.now();
839
1227
  const base = {
@@ -868,6 +1256,7 @@ function wrapXhr(ctx, tr) {
868
1256
  ok: status >= 200 && status < 400,
869
1257
  resourceType: "xhr",
870
1258
  requestHeaders: meta.reqHeaders,
1259
+ traceId: meta.traceId,
871
1260
  responseHeaders: respHeaders,
872
1261
  responseSize,
873
1262
  requestBody: ctx.config.captureNetworkBodies ? bodyPreview(meta.body, void 0) : void 0,
@@ -875,7 +1264,7 @@ function wrapXhr(ctx, tr) {
875
1264
  timing,
876
1265
  error: error || classifyHttp(status)
877
1266
  };
878
- ctx.emit(ev);
1267
+ emitNet(ev);
879
1268
  });
880
1269
  };
881
1270
  this.addEventListener("loadend", () => finish());
@@ -908,7 +1297,7 @@ function parseRawHeaders(ctx, raw) {
908
1297
  }
909
1298
  return pickHeaders(ctx.config.redactHeaders, entries);
910
1299
  }
911
- function wrapBeacon(ctx) {
1300
+ function wrapBeacon(ctx, emitNet) {
912
1301
  const orig = navigator.sendBeacon;
913
1302
  if (!orig) return () => {
914
1303
  };
@@ -935,7 +1324,7 @@ function wrapBeacon(ctx) {
935
1324
  timing: { startT, endT: ctx.clock.now(), duration: 0 },
936
1325
  error: ok ? void 0 : "network"
937
1326
  };
938
- ctx.emit(ev);
1327
+ emitNet(ev);
939
1328
  }
940
1329
  }
941
1330
  return ok;
@@ -955,7 +1344,7 @@ var RESOURCE_TYPE_MAP = {
955
1344
  video: "media",
956
1345
  track: "media"
957
1346
  };
958
- function observeResources(ctx) {
1347
+ function observeResources(ctx, emitNet) {
959
1348
  if (typeof PerformanceObserver === "undefined") return () => {
960
1349
  };
961
1350
  let obs;
@@ -992,7 +1381,7 @@ function observeResources(ctx) {
992
1381
  responseSize: entry.transferSize || entry.encodedBodySize || void 0,
993
1382
  timing
994
1383
  };
995
- ctx.emit(ev);
1384
+ emitNet(ev);
996
1385
  }
997
1386
  });
998
1387
  obs.observe({ type: "resource", buffered: true });
@@ -1033,6 +1422,8 @@ function headerEntries(headers) {
1033
1422
 
1034
1423
  // src/websocket.ts
1035
1424
  var FRAME_CAP = 4 * 1024;
1425
+ var MSG_RATE = 20;
1426
+ var MSG_BURST = 40;
1036
1427
  function redactUrl2(raw) {
1037
1428
  try {
1038
1429
  const u = new URL(raw, location.href);
@@ -1053,12 +1444,13 @@ function frameInfo(data, capture) {
1053
1444
  if (typeof data === "string") {
1054
1445
  const info = { encoding: "text", size: byteLen2(data) };
1055
1446
  if (capture) {
1056
- const masked = maskString(data);
1057
- const truncated = masked.length > FRAME_CAP;
1447
+ const cut = data.length > FRAME_CAP * 2 ? data.slice(0, FRAME_CAP * 2) : data;
1448
+ const masked = maskString(cut);
1449
+ const truncated = cut !== data || masked.length > FRAME_CAP;
1058
1450
  info.body = {
1059
1451
  preview: truncated ? masked.slice(0, FRAME_CAP) : masked,
1060
1452
  truncated,
1061
- redacted: masked !== data
1453
+ redacted: masked !== cut
1062
1454
  };
1063
1455
  }
1064
1456
  return info;
@@ -1095,24 +1487,39 @@ function instrumentWebSocket(ctx) {
1095
1487
  const redUrl = redactUrl2(String(url));
1096
1488
  const protos = protocols ? Array.isArray(protocols) ? protocols : [protocols] : void 0;
1097
1489
  const capture = ctx.config.captureNetworkBodies;
1490
+ const bucket = tokenBucket(MSG_RATE, MSG_BURST);
1491
+ let droppedFrames = 0;
1492
+ const emitFrame = (dir, data) => {
1493
+ ctx.markActivity();
1494
+ if (!bucket.take()) {
1495
+ droppedFrames++;
1496
+ return;
1497
+ }
1498
+ const info = frameInfo(data, capture);
1499
+ const dropped = droppedFrames;
1500
+ droppedFrames = 0;
1501
+ emit({ connId, url: redUrl, kind: "message", dir, ...info, droppedFrames: dropped || void 0 });
1502
+ };
1098
1503
  this.addEventListener("open", () => emit({ connId, url: redUrl, kind: "open", protocols: protos }));
1099
1504
  this.addEventListener(
1100
1505
  "close",
1101
- (e) => emit({ connId, url: redUrl, kind: "close", code: e.code, reason: e.reason || void 0, wasClean: e.wasClean })
1506
+ (e) => emit({
1507
+ connId,
1508
+ url: redUrl,
1509
+ kind: "close",
1510
+ code: e.code,
1511
+ reason: e.reason || void 0,
1512
+ wasClean: e.wasClean,
1513
+ droppedFrames: droppedFrames || void 0
1514
+ })
1102
1515
  );
1103
1516
  this.addEventListener("error", () => emit({ connId, url: redUrl, kind: "error" }));
1104
- this.addEventListener("message", (e) => {
1105
- const info = frameInfo(e.data, capture);
1106
- emit({ connId, url: redUrl, kind: "message", dir: "recv", ...info });
1107
- ctx.markActivity();
1108
- });
1517
+ this.addEventListener("message", (e) => emitFrame("recv", e.data));
1109
1518
  const origSend = super.send.bind(this);
1110
1519
  this.send = (data) => {
1111
1520
  origSend(data);
1112
1521
  try {
1113
- const info = frameInfo(data, capture);
1114
- emit({ connId, url: redUrl, kind: "message", dir: "send", ...info });
1115
- ctx.markActivity();
1522
+ emitFrame("send", data);
1116
1523
  } catch {
1117
1524
  }
1118
1525
  };
@@ -1851,6 +2258,8 @@ var DEFAULTS = {
1851
2258
  noPrivacy: false,
1852
2259
  captureNetworkBodies: false,
1853
2260
  redactHeaders: ["authorization", "cookie", "set-cookie"],
2261
+ tracePropagation: false,
2262
+ traceHosts: [],
1854
2263
  sampleRate: 1,
1855
2264
  recordCanvas: false,
1856
2265
  endpoint: "https://in.lumera.app",
@@ -1998,6 +2407,10 @@ var ObsrviqClient = class {
1998
2407
  this.nextSeq = next;
1999
2408
  this.persistSession();
2000
2409
  },
2410
+ // Backpressure dropped DOM deltas → re-anchor the rrweb stream immediately
2411
+ // so replay stays coherent (a delta stream with a hole is corrupt until
2412
+ // the next 30s checkout).
2413
+ onDomDrop: () => takeFullSnapshot(),
2001
2414
  onFlush: ({ bytes, events, ok }) => {
2002
2415
  if (ok) {
2003
2416
  this.diag.flushes++;