@obsrviq/tracker 0.4.2 → 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
@@ -1,12 +1,110 @@
1
1
  import { gzipSync } from 'fflate';
2
2
  import { record } from 'rrweb';
3
3
 
4
+ // src/transport.ts
5
+
6
+ // src/mask.ts
7
+ var EMAIL = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi;
8
+ var CC = /\b(?:\d[ -]*?){13,19}\b/g;
9
+ var SSN = /\b\d{3}-\d{2}-\d{4}\b/g;
10
+ var LONG_DIGITS = /\b\d{9,}\b/g;
11
+ var SENSITIVE_STRONG = /(password|passwd|passphrase|secret|token|authoriz|apikey|api[_-]key|creditcard|cardnumber|cardholder|cookie|cvv|cvc)/i;
12
+ var SENSITIVE_WEAK = /* @__PURE__ */ new Set(["pass", "pwd", "auth", "card", "pin", "otp", "ssn", "cvv", "cvc"]);
13
+ function keySegments(key) {
14
+ return key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).map((s) => s.toLowerCase());
15
+ }
16
+ function maskString(input) {
17
+ return input.replace(EMAIL, "\xABemail\xBB").replace(CC, (m) => luhnish(m) ? "\xABcard\xBB" : m).replace(SSN, "\xABssn\xBB").replace(LONG_DIGITS, "\xABnumber\xBB");
18
+ }
19
+ function luhnish(s) {
20
+ const digits = s.replace(/\D/g, "");
21
+ return digits.length >= 13 && digits.length <= 19;
22
+ }
23
+ var customKeys = /* @__PURE__ */ new Set();
24
+ function setCustomMaskKeys(keys) {
25
+ customKeys = new Set(keys.map((k) => String(k).trim().toLowerCase()).filter(Boolean));
26
+ }
27
+ var maskConfigSettled = false;
28
+ function markMaskConfigReady() {
29
+ maskConfigSettled = true;
30
+ }
31
+ function isMaskConfigPending() {
32
+ return !maskConfigSettled;
33
+ }
34
+ function isCustomKey(key) {
35
+ return customKeys.has(key.toLowerCase());
36
+ }
37
+ function isSensitiveKeyName(key) {
38
+ if (isCustomKey(key)) return true;
39
+ if (SENSITIVE_STRONG.test(key)) return true;
40
+ return keySegments(key).some((seg) => SENSITIVE_WEAK.has(seg));
41
+ }
42
+ function redactSensitiveInValue(value, depth = 0) {
43
+ if (depth > 24 || value === null || typeof value !== "object") return value;
44
+ if (Array.isArray(value)) return value.map((v) => redactSensitiveInValue(v, depth + 1));
45
+ const out = {};
46
+ for (const [k, v] of Object.entries(value)) {
47
+ out[k] = isSensitiveKeyName(k) ? "\xABredacted\xBB" : redactSensitiveInValue(v, depth + 1);
48
+ }
49
+ return out;
50
+ }
51
+ function redactSensitiveInRawText(text) {
52
+ let out = text.replace(
53
+ /"([A-Za-z0-9_.\-]+)"\s*:\s*("(?:[^"\\]|\\.)*"|[^,}\]\s]+)/g,
54
+ (m, key) => isSensitiveKeyName(key) ? '"' + key + '": "\xABredacted\xBB"' : m
55
+ );
56
+ out = out.replace(
57
+ /(^|[?&])([A-Za-z0-9_.\-[\]]+)=([^&\s]*)/g,
58
+ (m, pre, key) => isSensitiveKeyName(key) ? pre + key + "=\xABredacted\xBB" : m
59
+ );
60
+ return out;
61
+ }
62
+ function redactSensitiveInText(text) {
63
+ if (!text) return text;
64
+ const trimmed = text.trim();
65
+ if (trimmed[0] === "{" || trimmed[0] === "[") {
66
+ try {
67
+ return JSON.stringify(redactSensitiveInValue(JSON.parse(trimmed)));
68
+ } catch {
69
+ }
70
+ }
71
+ return redactSensitiveInRawText(text);
72
+ }
73
+ function redactBatchSensitiveKeys(events) {
74
+ for (const e of events) {
75
+ if (e.type === "network" || e.type === "ws") {
76
+ const anyE = e;
77
+ if (anyE.requestBody?.preview) anyE.requestBody.preview = redactSensitiveInText(anyE.requestBody.preview);
78
+ if (anyE.responseBody?.preview) anyE.responseBody.preview = redactSensitiveInText(anyE.responseBody.preview);
79
+ if (anyE.body?.preview) anyE.body.preview = redactSensitiveInText(anyE.body.preview);
80
+ } else if (e.type === "custom") {
81
+ const c = e;
82
+ if (c.props) c.props = redactSensitiveInValue(c.props);
83
+ } else if (e.type === "console") {
84
+ const args = e.args;
85
+ if (args) {
86
+ for (const a of args) if (a.kind === "object" && a.json) a.json = redactSensitiveInText(a.json);
87
+ }
88
+ }
89
+ }
90
+ }
91
+
4
92
  // src/transport.ts
5
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;
6
101
  var Transport = class {
7
102
  constructor(opts) {
8
103
  this.opts = opts;
9
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;
10
108
  this.sending = false;
11
109
  this.retryDelay = 1e3;
12
110
  this.stopped = false;
@@ -22,23 +120,107 @@ var Transport = class {
22
120
  this.timer = setInterval(() => void this.flush("timer"), this.opts.flushIntervalMs);
23
121
  document.addEventListener("visibilitychange", this.onVisibility, { capture: true });
24
122
  window.addEventListener("pagehide", this.onPageHide, { capture: true });
123
+ this.resendSpill();
25
124
  }
26
125
  enqueue(event) {
27
126
  if (this.stopped) return;
28
127
  this.buffer.push(event);
29
128
  if (this.buffer.length >= MAX_BUFFER_EVENTS) {
30
129
  void this.flush("backpressure");
31
- if (this.buffer.length >= MAX_BUFFER_EVENTS) {
32
- 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;
147
+ }
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;
33
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));
34
164
  }
165
+ this.opts.onDomDrop?.();
35
166
  }
36
167
  /** Build, compress, and send the current buffer. Returns true on success. */
37
168
  async flush(reason) {
169
+ if (reason === "pagehide" || reason === "hidden") return this.flushUnload();
38
170
  if (this.sending || this.buffer.length === 0) return true;
171
+ if (isMaskConfigPending() && !this.stopped) {
172
+ setTimeout(() => void this.flush("timer"), 200);
173
+ return true;
174
+ }
39
175
  this.sending = true;
40
176
  const events = this.buffer;
41
177
  this.buffer = [];
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;
42
224
  let batch = {
43
225
  siteKey: this.opts.siteKey,
44
226
  sessionId: this.opts.sessionId,
@@ -53,73 +235,165 @@ var Transport = class {
53
235
  batch = null;
54
236
  }
55
237
  }
56
- if (!batch) {
57
- this.sending = false;
58
- return true;
59
- }
60
- this.opts.onSeqDispatch?.(this.seq + 1);
238
+ if (!batch) return "ok";
61
239
  const json = JSON.stringify(batch);
62
- const gz = gzipSync(new TextEncoder().encode(json));
63
- const ok = await this.send(gz, reason === "pagehide" || reason === "hidden");
64
- this.opts.onFlush?.({ bytes: gz.byteLength, events: events.length, ok });
65
- if (ok) {
66
- this.seq++;
67
- this.retryDelay = 1e3;
68
- this.sending = false;
69
- return true;
70
- }
71
- this.buffer = events.slice(-MAX_BUFFER_EVENTS).concat(this.buffer);
72
- this.sending = false;
73
- if (!this.stopped) {
74
- const jitter = this.retryDelay * (0.5 + Math.random());
75
- this.retryDelay = Math.min(this.retryDelay * 2, 3e4);
76
- setTimeout(() => void this.flush("timer"), jitter);
77
- }
78
- 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;
79
247
  }
80
- async send(bytes, unloading) {
248
+ async send(bytes) {
81
249
  const url = `${this.opts.endpoint.replace(/\/$/, "")}/v1/batch`;
82
- const headers = {
83
- "content-type": "application/octet-stream",
84
- "x-obsrviq-key": this.opts.siteKey
85
- };
86
- const body = bytes;
87
- if (unloading) {
88
- const blob = new Blob([bytes], { type: "application/octet-stream" });
89
- 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) {
90
298
  try {
91
- if (navigator.sendBeacon(url, blob)) return true;
299
+ batch = this.opts.beforeSend(batch);
92
300
  } catch {
301
+ batch = null;
93
302
  }
94
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") {
95
328
  try {
96
- void fetch(url, { method: "POST", body, headers, keepalive: true, credentials: "omit", mode: "cors" }).catch(
97
- () => {
98
- }
99
- );
100
- return true;
329
+ if (navigator.sendBeacon(url, blob)) return true;
101
330
  } catch {
102
- return false;
103
331
  }
104
332
  }
105
- const ctrl = new AbortController();
106
- const timeout = setTimeout(() => ctrl.abort(), 15e3);
107
333
  try {
108
- const res = await fetch(url, {
334
+ void fetch(url, {
109
335
  method: "POST",
110
- body,
111
- headers,
336
+ body: bytes,
337
+ headers: {
338
+ "content-type": "application/octet-stream",
339
+ "x-obsrviq-key": this.opts.siteKey
340
+ },
341
+ keepalive: true,
112
342
  credentials: "omit",
113
- mode: "cors",
114
- signal: ctrl.signal
343
+ mode: "cors"
344
+ }).catch(() => {
115
345
  });
116
- return res.ok;
346
+ return true;
117
347
  } catch {
118
348
  return false;
119
- } finally {
120
- clearTimeout(timeout);
121
349
  }
122
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
+ }
123
397
  stop() {
124
398
  this.stopped = true;
125
399
  if (this.timer) clearInterval(this.timer);
@@ -128,6 +402,29 @@ var Transport = class {
128
402
  void this.flush("manual");
129
403
  }
130
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
+ }
131
428
 
132
429
  // src/util.ts
133
430
  function uuid() {
@@ -178,6 +475,13 @@ async function sha256Hex(input) {
178
475
  function detectDevice() {
179
476
  const ua = navigator.userAgent;
180
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
+ }
181
485
  let type = "desktop";
182
486
  if (/iPad|Tablet/i.test(ua)) type = "tablet";
183
487
  else if (/Mobi|Android|iPhone/i.test(ua)) type = "mobile";
@@ -192,7 +496,7 @@ function detectDevice() {
192
496
  else if (/Chrome\//.test(ua)) browser = "Chrome";
193
497
  else if (/Firefox\//.test(ua)) browser = "Firefox";
194
498
  else if (/Safari\//.test(ua)) browser = "Safari";
195
- return { type, os, browser, viewport };
499
+ return { type, os, browser, viewport, tz, tzOffset };
196
500
  }
197
501
  function privacySignalsOptOut() {
198
502
  const nav = navigator;
@@ -206,11 +510,13 @@ var INCREMENTAL = 3;
206
510
  function instrumentDom(ctx) {
207
511
  const stop = record({
208
512
  emit(event) {
513
+ const t = ctx.clock.fromPerf(performanceNow());
514
+ event.timestamp = ctx.clock.startedAt + t;
209
515
  const dom = {
210
516
  id: uuid(),
211
517
  sessionId: ctx.sessionId,
212
518
  type: "dom",
213
- t: ctx.clock.fromPerf(performanceNow()),
519
+ t,
214
520
  ts: ctx.clock.wall(),
215
521
  data: event
216
522
  };
@@ -241,40 +547,19 @@ function instrumentDom(ctx) {
241
547
  }
242
548
  };
243
549
  }
550
+ function takeFullSnapshot() {
551
+ try {
552
+ record.takeFullSnapshot(true);
553
+ } catch {
554
+ }
555
+ }
244
556
  function performanceNow() {
245
557
  return typeof performance !== "undefined" ? performance.now() : Date.now();
246
558
  }
247
559
 
248
- // src/mask.ts
249
- var EMAIL = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi;
250
- var CC = /\b(?:\d[ -]*?){13,19}\b/g;
251
- var SSN = /\b\d{3}-\d{2}-\d{4}\b/g;
252
- var LONG_DIGITS = /\b\d{9,}\b/g;
253
- var SENSITIVE_KEYS = /(pass(word)?|secret|token|api[-_]?key|authorization|cookie|ssn|card|cvv|pin|otp)/i;
254
- function maskString(input) {
255
- return input.replace(EMAIL, "\xABemail\xBB").replace(CC, (m) => luhnish(m) ? "\xABcard\xBB" : m).replace(SSN, "\xABssn\xBB").replace(LONG_DIGITS, "\xABnumber\xBB");
256
- }
257
- function luhnish(s) {
258
- const digits = s.replace(/\D/g, "");
259
- return digits.length >= 13 && digits.length <= 19;
260
- }
261
- function isSensitiveKey(key) {
262
- return SENSITIVE_KEYS.test(key);
263
- }
264
- function maskValue(value, depth = 0) {
265
- if (depth > 6) return "\xABdepth\xBB";
266
- if (typeof value === "string") return maskString(value);
267
- if (value === null || typeof value !== "object") return value;
268
- if (Array.isArray(value)) return value.map((v) => maskValue(v, depth + 1));
269
- const out = {};
270
- for (const [k, v] of Object.entries(value)) {
271
- out[k] = isSensitiveKey(k) ? "\xABredacted\xBB" : maskValue(v, depth + 1);
272
- }
273
- return out;
274
- }
275
-
276
560
  // src/serialize.ts
277
561
  var DEFAULT_MAX_BYTES = 8 * 1024;
562
+ var MAX_WALK_NODES = 2e3;
278
563
  function byteLen(s) {
279
564
  return new Blob([s]).size;
280
565
  }
@@ -290,9 +575,11 @@ function serializeArg(arg, maxBytes = DEFAULT_MAX_BYTES) {
290
575
  return { kind: "primitive", value: arg };
291
576
  }
292
577
  if (type === "string") {
293
- 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);
294
581
  const { value, truncated } = truncate(masked, maxBytes);
295
- return { kind: "string", value, truncated };
582
+ return { kind: "string", value, truncated: truncated || cut !== s };
296
583
  }
297
584
  if (arg instanceof Error) {
298
585
  return {
@@ -306,21 +593,56 @@ function serializeArg(arg, maxBytes = DEFAULT_MAX_BYTES) {
306
593
  return { kind: "dom", selector: cssPath(arg) };
307
594
  }
308
595
  if (type === "object" || type === "function" || type === "symbol" || type === "bigint") {
309
- const masked = maskValue(arg);
596
+ const budget = { nodes: MAX_WALK_NODES, bytes: maxBytes * 2, exhausted: false };
597
+ const masked = maskValueBudgeted(arg, budget);
310
598
  let json;
311
- let truncated = false;
599
+ let truncated = budget.exhausted;
312
600
  try {
313
601
  const full = JSON.stringify(masked, safeReplacer());
314
602
  const cut = truncate(full ?? "undefined", maxBytes);
315
603
  json = cut.value;
316
- truncated = cut.truncated;
604
+ truncated = truncated || cut.truncated;
317
605
  } catch {
318
606
  json = void 0;
319
607
  }
320
- return { kind: "object", preview: previewOf(masked), json, truncated };
608
+ return { kind: "object", preview: previewOf(arg), json, truncated };
321
609
  }
322
610
  return { kind: "primitive", value: String(arg) };
323
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
+ }
324
646
  function safeReplacer() {
325
647
  const seen = /* @__PURE__ */ new WeakSet();
326
648
  return (_key, value) => {
@@ -454,8 +776,26 @@ function errorToConsoleEvent(ctx, err) {
454
776
  };
455
777
  }
456
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
+
457
795
  // src/network.ts
458
796
  var BODY_CAP = 4 * 1024;
797
+ var NET_RATE = 50;
798
+ var NET_BURST = 100;
459
799
  var HEADER_ALLOW = /* @__PURE__ */ new Set([
460
800
  "content-type",
461
801
  "content-length",
@@ -497,15 +837,48 @@ function pickHeaders(redact, entries) {
497
837
  }
498
838
  function bodyPreview(text, contentType) {
499
839
  if (text == null) return void 0;
500
- const masked = maskString(text);
501
- 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;
502
843
  return {
503
844
  contentType,
504
845
  preview: truncated ? masked.slice(0, BODY_CAP) : masked,
505
846
  truncated,
506
- redacted: masked !== text
847
+ redacted: masked !== cut
507
848
  };
508
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
+ }
509
882
  function perfNow() {
510
883
  return typeof performance !== "undefined" ? performance.now() : 0;
511
884
  }
@@ -625,6 +998,19 @@ function createTimingResolver() {
625
998
  }
626
999
  function instrumentNetwork(ctx) {
627
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
+ };
628
1014
  const onPageHide = () => timing.flushPending();
629
1015
  const onVisibility = () => {
630
1016
  if (typeof document !== "undefined" && document.visibilityState === "hidden") timing.flushPending();
@@ -632,10 +1018,10 @@ function instrumentNetwork(ctx) {
632
1018
  if (typeof window !== "undefined") window.addEventListener("pagehide", onPageHide);
633
1019
  if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisibility);
634
1020
  const teardowns = [];
635
- teardowns.push(wrapFetch(ctx, timing));
636
- teardowns.push(wrapXhr(ctx, timing));
637
- teardowns.push(wrapBeacon(ctx));
638
- 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));
639
1025
  teardowns.push(() => {
640
1026
  if (typeof window !== "undefined") window.removeEventListener("pagehide", onPageHide);
641
1027
  if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibility);
@@ -643,13 +1029,14 @@ function instrumentNetwork(ctx) {
643
1029
  });
644
1030
  return () => teardowns.forEach((t) => t());
645
1031
  }
646
- function wrapFetch(ctx, tr) {
1032
+ function wrapFetch(ctx, tr, emitNet) {
647
1033
  const orig = window.fetch;
648
1034
  if (!orig) return () => {
649
1035
  };
650
1036
  window.fetch = async function(input, init) {
651
1037
  const rawUrl = input instanceof Request ? input.url : String(input);
652
- if (rawUrl.includes("/v1/batch")) return orig.call(window, input, init);
1038
+ if (rawUrl.includes("/v1/batch") || rawUrl.includes("/v1/config"))
1039
+ return orig.call(window, input, init);
653
1040
  const startT = ctx.clock.now();
654
1041
  const perfStart = perfNow();
655
1042
  const method = (init?.method || (input instanceof Request ? input.method : "GET")).toUpperCase();
@@ -679,7 +1066,7 @@ function wrapFetch(ctx, tr) {
679
1066
  timing,
680
1067
  ...partial
681
1068
  };
682
- ctx.emit(ev);
1069
+ emitNet(ev);
683
1070
  });
684
1071
  };
685
1072
  try {
@@ -690,8 +1077,9 @@ function wrapFetch(ctx, tr) {
690
1077
  let responseBody;
691
1078
  if (ctx.config.captureNetworkBodies && isTexty(res.headers.get("content-type"))) {
692
1079
  try {
693
- const text = await res.clone().text();
1080
+ const { text, overflow } = await readTextCapped(res.clone(), BODY_CAP);
694
1081
  responseBody = bodyPreview(text, res.headers.get("content-type") || void 0);
1082
+ if (responseBody && overflow) responseBody.truncated = true;
695
1083
  } catch {
696
1084
  }
697
1085
  }
@@ -722,7 +1110,7 @@ function classifyFetchError(err) {
722
1110
  if (msg.includes("timeout")) return "timeout";
723
1111
  return "network";
724
1112
  }
725
- function wrapXhr(ctx, tr) {
1113
+ function wrapXhr(ctx, tr, emitNet) {
726
1114
  const XHR = XMLHttpRequest.prototype;
727
1115
  const origOpen = XHR.open;
728
1116
  const origSend = XHR.send;
@@ -797,7 +1185,7 @@ function wrapXhr(ctx, tr) {
797
1185
  timing,
798
1186
  error: error || classifyHttp(status)
799
1187
  };
800
- ctx.emit(ev);
1188
+ emitNet(ev);
801
1189
  });
802
1190
  };
803
1191
  this.addEventListener("loadend", () => finish());
@@ -830,7 +1218,7 @@ function parseRawHeaders(ctx, raw) {
830
1218
  }
831
1219
  return pickHeaders(ctx.config.redactHeaders, entries);
832
1220
  }
833
- function wrapBeacon(ctx) {
1221
+ function wrapBeacon(ctx, emitNet) {
834
1222
  const orig = navigator.sendBeacon;
835
1223
  if (!orig) return () => {
836
1224
  };
@@ -857,7 +1245,7 @@ function wrapBeacon(ctx) {
857
1245
  timing: { startT, endT: ctx.clock.now(), duration: 0 },
858
1246
  error: ok ? void 0 : "network"
859
1247
  };
860
- ctx.emit(ev);
1248
+ emitNet(ev);
861
1249
  }
862
1250
  }
863
1251
  return ok;
@@ -877,7 +1265,7 @@ var RESOURCE_TYPE_MAP = {
877
1265
  video: "media",
878
1266
  track: "media"
879
1267
  };
880
- function observeResources(ctx) {
1268
+ function observeResources(ctx, emitNet) {
881
1269
  if (typeof PerformanceObserver === "undefined") return () => {
882
1270
  };
883
1271
  let obs;
@@ -914,7 +1302,7 @@ function observeResources(ctx) {
914
1302
  responseSize: entry.transferSize || entry.encodedBodySize || void 0,
915
1303
  timing
916
1304
  };
917
- ctx.emit(ev);
1305
+ emitNet(ev);
918
1306
  }
919
1307
  });
920
1308
  obs.observe({ type: "resource", buffered: true });
@@ -953,6 +1341,119 @@ function headerEntries(headers) {
953
1341
  return Object.entries(headers);
954
1342
  }
955
1343
 
1344
+ // src/websocket.ts
1345
+ var FRAME_CAP = 4 * 1024;
1346
+ var MSG_RATE = 20;
1347
+ var MSG_BURST = 40;
1348
+ function redactUrl2(raw) {
1349
+ try {
1350
+ const u = new URL(raw, location.href);
1351
+ if (u.search) u.search = "?\u2026";
1352
+ return u.toString();
1353
+ } catch {
1354
+ return raw.split("?")[0] ?? raw;
1355
+ }
1356
+ }
1357
+ function byteLen2(s) {
1358
+ try {
1359
+ return new TextEncoder().encode(s).length;
1360
+ } catch {
1361
+ return s.length;
1362
+ }
1363
+ }
1364
+ function frameInfo(data, capture) {
1365
+ if (typeof data === "string") {
1366
+ const info = { encoding: "text", size: byteLen2(data) };
1367
+ if (capture) {
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;
1371
+ info.body = {
1372
+ preview: truncated ? masked.slice(0, FRAME_CAP) : masked,
1373
+ truncated,
1374
+ redacted: masked !== cut
1375
+ };
1376
+ }
1377
+ return info;
1378
+ }
1379
+ let size = 0;
1380
+ if (typeof Blob !== "undefined" && data instanceof Blob) size = data.size;
1381
+ else if (data instanceof ArrayBuffer) size = data.byteLength;
1382
+ else if (ArrayBuffer.isView(data)) size = data.byteLength;
1383
+ return { encoding: "binary", size };
1384
+ }
1385
+ function instrumentWebSocket(ctx) {
1386
+ const Orig = typeof window !== "undefined" ? window.WebSocket : void 0;
1387
+ if (!Orig) return () => {
1388
+ };
1389
+ const emit = (partial) => {
1390
+ try {
1391
+ const ev = {
1392
+ id: uuid(),
1393
+ sessionId: ctx.sessionId,
1394
+ type: "ws",
1395
+ t: ctx.clock.now(),
1396
+ ts: ctx.clock.wall(),
1397
+ ...partial
1398
+ };
1399
+ ctx.emit(ev);
1400
+ } catch {
1401
+ }
1402
+ };
1403
+ class WrappedWebSocket extends Orig {
1404
+ constructor(url, protocols) {
1405
+ super(url, protocols);
1406
+ try {
1407
+ const connId = uuid();
1408
+ const redUrl = redactUrl2(String(url));
1409
+ const protos = protocols ? Array.isArray(protocols) ? protocols : [protocols] : void 0;
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
+ };
1424
+ this.addEventListener("open", () => emit({ connId, url: redUrl, kind: "open", protocols: protos }));
1425
+ this.addEventListener(
1426
+ "close",
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
+ })
1436
+ );
1437
+ this.addEventListener("error", () => emit({ connId, url: redUrl, kind: "error" }));
1438
+ this.addEventListener("message", (e) => emitFrame("recv", e.data));
1439
+ const origSend = super.send.bind(this);
1440
+ this.send = (data) => {
1441
+ origSend(data);
1442
+ try {
1443
+ emitFrame("send", data);
1444
+ } catch {
1445
+ }
1446
+ };
1447
+ } catch {
1448
+ }
1449
+ }
1450
+ }
1451
+ window.WebSocket = WrappedWebSocket;
1452
+ return () => {
1453
+ window.WebSocket = Orig;
1454
+ };
1455
+ }
1456
+
956
1457
  // src/errors.ts
957
1458
  function instrumentErrors(ctx) {
958
1459
  function emitError(args) {
@@ -1737,6 +2238,9 @@ var ObsrviqClient = class {
1737
2238
  this.config.maskAllInputs = false;
1738
2239
  }
1739
2240
  if (this.config.askPermission) this.config.requireConsent = true;
2241
+ const staticMaskKeys = Array.isArray(config.maskKeys) ? config.maskKeys : [];
2242
+ setCustomMaskKeys(staticMaskKeys);
2243
+ void this.loadRemoteMaskKeys(staticMaskKeys);
1740
2244
  this.startSession();
1741
2245
  if (config.userId != null) this.applyUserId(config.userId);
1742
2246
  if (config.metadata && this.meta) this.meta.metadata = { ...config.metadata };
@@ -1757,6 +2261,33 @@ var ObsrviqClient = class {
1757
2261
  this.maybeStart();
1758
2262
  }
1759
2263
  }
2264
+ /** Fetch the per-site masking config (GET /v1/config) and merge its keys with any
2265
+ * static ones. Best-effort: on failure the built-in masking still applies. */
2266
+ async loadRemoteMaskKeys(staticKeys) {
2267
+ const cfg = this.config;
2268
+ if (!cfg || typeof fetch === "undefined") {
2269
+ markMaskConfigReady();
2270
+ return;
2271
+ }
2272
+ try {
2273
+ const ctrl = new AbortController();
2274
+ const timer = setTimeout(() => ctrl.abort(), 2e3);
2275
+ try {
2276
+ const url = `${cfg.endpoint.replace(/\/$/, "")}/v1/config?key=${encodeURIComponent(cfg.siteKey)}`;
2277
+ const res = await fetch(url, { credentials: "omit", mode: "cors", signal: ctrl.signal });
2278
+ if (res.ok) {
2279
+ const data = await res.json();
2280
+ const remote = Array.isArray(data.maskKeys) ? data.maskKeys.filter((k) => typeof k === "string") : [];
2281
+ setCustomMaskKeys([...staticKeys, ...remote]);
2282
+ }
2283
+ } finally {
2284
+ clearTimeout(timer);
2285
+ }
2286
+ } catch {
2287
+ } finally {
2288
+ markMaskConfigReady();
2289
+ }
2290
+ }
1760
2291
  /** (Re)create the session id, clock, meta, and transport. Used by init() and
1761
2292
  * reset(). Does not start recording — callers decide via maybeStart().
1762
2293
  * @param seedInitUser seed identity from the init-time `cfg.userId`. True for
@@ -1795,6 +2326,10 @@ var ObsrviqClient = class {
1795
2326
  this.nextSeq = next;
1796
2327
  this.persistSession();
1797
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(),
1798
2333
  onFlush: ({ bytes, events, ok }) => {
1799
2334
  if (ok) {
1800
2335
  this.diag.flushes++;
@@ -1849,6 +2384,7 @@ var ObsrviqClient = class {
1849
2384
  this.transport.start();
1850
2385
  this.teardowns.push(instrumentConsole(ctx));
1851
2386
  this.teardowns.push(instrumentNetwork(ctx));
2387
+ this.teardowns.push(instrumentWebSocket(ctx));
1852
2388
  this.teardowns.push(instrumentErrors(ctx));
1853
2389
  this.teardowns.push(instrumentVitals(ctx));
1854
2390
  this.teardowns.push(instrumentInteractions(ctx));