@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 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,26 @@ 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
+
534
795
  // src/network.ts
535
796
  var BODY_CAP = 4 * 1024;
797
+ var NET_RATE = 50;
798
+ var NET_BURST = 100;
536
799
  var HEADER_ALLOW = /* @__PURE__ */ new Set([
537
800
  "content-type",
538
801
  "content-length",
@@ -574,15 +837,48 @@ function pickHeaders(redact, entries) {
574
837
  }
575
838
  function bodyPreview(text, contentType) {
576
839
  if (text == null) return void 0;
577
- const masked = maskString(text);
578
- const truncated = masked.length > BODY_CAP;
840
+ const cut = text.length > BODY_CAP * 2 ? text.slice(0, BODY_CAP * 2) : text;
841
+ const masked = maskString(cut);
842
+ const truncated = cut !== text || masked.length > BODY_CAP;
579
843
  return {
580
844
  contentType,
581
845
  preview: truncated ? masked.slice(0, BODY_CAP) : masked,
582
846
  truncated,
583
- redacted: masked !== text
847
+ redacted: masked !== cut
584
848
  };
585
849
  }
850
+ async function readTextCapped(res, cap) {
851
+ const body = res.body;
852
+ if (!body || typeof body.getReader !== "function") {
853
+ return { text: await res.text(), overflow: false };
854
+ }
855
+ const reader = body.getReader();
856
+ const decoder = new TextDecoder();
857
+ let text = "";
858
+ let bytes = 0;
859
+ let overflow = false;
860
+ try {
861
+ for (; ; ) {
862
+ const { done, value } = await reader.read();
863
+ if (done || !value) {
864
+ text += decoder.decode();
865
+ break;
866
+ }
867
+ const remaining = cap + 1 - bytes;
868
+ if (value.byteLength >= remaining) {
869
+ text += decoder.decode(value.subarray(0, remaining), { stream: true });
870
+ overflow = true;
871
+ break;
872
+ }
873
+ text += decoder.decode(value, { stream: true });
874
+ bytes += value.byteLength;
875
+ }
876
+ } finally {
877
+ void reader.cancel().catch(() => {
878
+ });
879
+ }
880
+ return { text, overflow };
881
+ }
586
882
  function perfNow() {
587
883
  return typeof performance !== "undefined" ? performance.now() : 0;
588
884
  }
@@ -702,6 +998,19 @@ function createTimingResolver() {
702
998
  }
703
999
  function instrumentNetwork(ctx) {
704
1000
  const timing = createTimingResolver();
1001
+ const bucket = tokenBucket(NET_RATE, NET_BURST);
1002
+ let dropped = 0;
1003
+ const emitNet = (ev) => {
1004
+ if (!bucket.take()) {
1005
+ dropped++;
1006
+ return;
1007
+ }
1008
+ if (dropped > 0) {
1009
+ ev.droppedEvents = dropped;
1010
+ dropped = 0;
1011
+ }
1012
+ ctx.emit(ev);
1013
+ };
705
1014
  const onPageHide = () => timing.flushPending();
706
1015
  const onVisibility = () => {
707
1016
  if (typeof document !== "undefined" && document.visibilityState === "hidden") timing.flushPending();
@@ -709,10 +1018,10 @@ function instrumentNetwork(ctx) {
709
1018
  if (typeof window !== "undefined") window.addEventListener("pagehide", onPageHide);
710
1019
  if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisibility);
711
1020
  const teardowns = [];
712
- teardowns.push(wrapFetch(ctx, timing));
713
- teardowns.push(wrapXhr(ctx, timing));
714
- teardowns.push(wrapBeacon(ctx));
715
- teardowns.push(observeResources(ctx));
1021
+ teardowns.push(wrapFetch(ctx, timing, emitNet));
1022
+ teardowns.push(wrapXhr(ctx, timing, emitNet));
1023
+ teardowns.push(wrapBeacon(ctx, emitNet));
1024
+ teardowns.push(observeResources(ctx, emitNet));
716
1025
  teardowns.push(() => {
717
1026
  if (typeof window !== "undefined") window.removeEventListener("pagehide", onPageHide);
718
1027
  if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibility);
@@ -720,7 +1029,7 @@ function instrumentNetwork(ctx) {
720
1029
  });
721
1030
  return () => teardowns.forEach((t) => t());
722
1031
  }
723
- function wrapFetch(ctx, tr) {
1032
+ function wrapFetch(ctx, tr, emitNet) {
724
1033
  const orig = window.fetch;
725
1034
  if (!orig) return () => {
726
1035
  };
@@ -757,7 +1066,7 @@ function wrapFetch(ctx, tr) {
757
1066
  timing,
758
1067
  ...partial
759
1068
  };
760
- ctx.emit(ev);
1069
+ emitNet(ev);
761
1070
  });
762
1071
  };
763
1072
  try {
@@ -768,8 +1077,9 @@ function wrapFetch(ctx, tr) {
768
1077
  let responseBody;
769
1078
  if (ctx.config.captureNetworkBodies && isTexty(res.headers.get("content-type"))) {
770
1079
  try {
771
- const text = await res.clone().text();
1080
+ const { text, overflow } = await readTextCapped(res.clone(), BODY_CAP);
772
1081
  responseBody = bodyPreview(text, res.headers.get("content-type") || void 0);
1082
+ if (responseBody && overflow) responseBody.truncated = true;
773
1083
  } catch {
774
1084
  }
775
1085
  }
@@ -800,7 +1110,7 @@ function classifyFetchError(err) {
800
1110
  if (msg.includes("timeout")) return "timeout";
801
1111
  return "network";
802
1112
  }
803
- function wrapXhr(ctx, tr) {
1113
+ function wrapXhr(ctx, tr, emitNet) {
804
1114
  const XHR = XMLHttpRequest.prototype;
805
1115
  const origOpen = XHR.open;
806
1116
  const origSend = XHR.send;
@@ -875,7 +1185,7 @@ function wrapXhr(ctx, tr) {
875
1185
  timing,
876
1186
  error: error || classifyHttp(status)
877
1187
  };
878
- ctx.emit(ev);
1188
+ emitNet(ev);
879
1189
  });
880
1190
  };
881
1191
  this.addEventListener("loadend", () => finish());
@@ -908,7 +1218,7 @@ function parseRawHeaders(ctx, raw) {
908
1218
  }
909
1219
  return pickHeaders(ctx.config.redactHeaders, entries);
910
1220
  }
911
- function wrapBeacon(ctx) {
1221
+ function wrapBeacon(ctx, emitNet) {
912
1222
  const orig = navigator.sendBeacon;
913
1223
  if (!orig) return () => {
914
1224
  };
@@ -935,7 +1245,7 @@ function wrapBeacon(ctx) {
935
1245
  timing: { startT, endT: ctx.clock.now(), duration: 0 },
936
1246
  error: ok ? void 0 : "network"
937
1247
  };
938
- ctx.emit(ev);
1248
+ emitNet(ev);
939
1249
  }
940
1250
  }
941
1251
  return ok;
@@ -955,7 +1265,7 @@ var RESOURCE_TYPE_MAP = {
955
1265
  video: "media",
956
1266
  track: "media"
957
1267
  };
958
- function observeResources(ctx) {
1268
+ function observeResources(ctx, emitNet) {
959
1269
  if (typeof PerformanceObserver === "undefined") return () => {
960
1270
  };
961
1271
  let obs;
@@ -992,7 +1302,7 @@ function observeResources(ctx) {
992
1302
  responseSize: entry.transferSize || entry.encodedBodySize || void 0,
993
1303
  timing
994
1304
  };
995
- ctx.emit(ev);
1305
+ emitNet(ev);
996
1306
  }
997
1307
  });
998
1308
  obs.observe({ type: "resource", buffered: true });
@@ -1033,6 +1343,8 @@ function headerEntries(headers) {
1033
1343
 
1034
1344
  // src/websocket.ts
1035
1345
  var FRAME_CAP = 4 * 1024;
1346
+ var MSG_RATE = 20;
1347
+ var MSG_BURST = 40;
1036
1348
  function redactUrl2(raw) {
1037
1349
  try {
1038
1350
  const u = new URL(raw, location.href);
@@ -1053,12 +1365,13 @@ function frameInfo(data, capture) {
1053
1365
  if (typeof data === "string") {
1054
1366
  const info = { encoding: "text", size: byteLen2(data) };
1055
1367
  if (capture) {
1056
- const masked = maskString(data);
1057
- const truncated = masked.length > FRAME_CAP;
1368
+ const cut = data.length > FRAME_CAP * 2 ? data.slice(0, FRAME_CAP * 2) : data;
1369
+ const masked = maskString(cut);
1370
+ const truncated = cut !== data || masked.length > FRAME_CAP;
1058
1371
  info.body = {
1059
1372
  preview: truncated ? masked.slice(0, FRAME_CAP) : masked,
1060
1373
  truncated,
1061
- redacted: masked !== data
1374
+ redacted: masked !== cut
1062
1375
  };
1063
1376
  }
1064
1377
  return info;
@@ -1095,24 +1408,39 @@ function instrumentWebSocket(ctx) {
1095
1408
  const redUrl = redactUrl2(String(url));
1096
1409
  const protos = protocols ? Array.isArray(protocols) ? protocols : [protocols] : void 0;
1097
1410
  const capture = ctx.config.captureNetworkBodies;
1411
+ const bucket = tokenBucket(MSG_RATE, MSG_BURST);
1412
+ let droppedFrames = 0;
1413
+ const emitFrame = (dir, data) => {
1414
+ ctx.markActivity();
1415
+ if (!bucket.take()) {
1416
+ droppedFrames++;
1417
+ return;
1418
+ }
1419
+ const info = frameInfo(data, capture);
1420
+ const dropped = droppedFrames;
1421
+ droppedFrames = 0;
1422
+ emit({ connId, url: redUrl, kind: "message", dir, ...info, droppedFrames: dropped || void 0 });
1423
+ };
1098
1424
  this.addEventListener("open", () => emit({ connId, url: redUrl, kind: "open", protocols: protos }));
1099
1425
  this.addEventListener(
1100
1426
  "close",
1101
- (e) => emit({ connId, url: redUrl, kind: "close", code: e.code, reason: e.reason || void 0, wasClean: e.wasClean })
1427
+ (e) => emit({
1428
+ connId,
1429
+ url: redUrl,
1430
+ kind: "close",
1431
+ code: e.code,
1432
+ reason: e.reason || void 0,
1433
+ wasClean: e.wasClean,
1434
+ droppedFrames: droppedFrames || void 0
1435
+ })
1102
1436
  );
1103
1437
  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
- });
1438
+ this.addEventListener("message", (e) => emitFrame("recv", e.data));
1109
1439
  const origSend = super.send.bind(this);
1110
1440
  this.send = (data) => {
1111
1441
  origSend(data);
1112
1442
  try {
1113
- const info = frameInfo(data, capture);
1114
- emit({ connId, url: redUrl, kind: "message", dir: "send", ...info });
1115
- ctx.markActivity();
1443
+ emitFrame("send", data);
1116
1444
  } catch {
1117
1445
  }
1118
1446
  };
@@ -1998,6 +2326,10 @@ var ObsrviqClient = class {
1998
2326
  this.nextSeq = next;
1999
2327
  this.persistSession();
2000
2328
  },
2329
+ // Backpressure dropped DOM deltas → re-anchor the rrweb stream immediately
2330
+ // so replay stays coherent (a delta stream with a hole is corrupt until
2331
+ // the next 30s checkout).
2332
+ onDomDrop: () => takeFullSnapshot(),
2001
2333
  onFlush: ({ bytes, events, ok }) => {
2002
2334
  if (ok) {
2003
2335
  this.diag.flushes++;