@obsrviq/tracker 0.4.1 → 0.5.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.d.ts CHANGED
@@ -37,6 +37,9 @@ declare class ObsrviqClient {
37
37
  /** The current session id (null until init). */
38
38
  get sessionId(): string | null;
39
39
  init(config: ObsrviqInitConfig): void;
40
+ /** Fetch the per-site masking config (GET /v1/config) and merge its keys with any
41
+ * static ones. Best-effort: on failure the built-in masking still applies. */
42
+ private loadRemoteMaskKeys;
40
43
  /** (Re)create the session id, clock, meta, and transport. Used by init() and
41
44
  * reset(). Does not start recording — callers decide via maybeStart().
42
45
  * @param seedInitUser seed identity from the init-time `cfg.userId`. True for
package/dist/index.js CHANGED
@@ -1,6 +1,105 @@
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
+ 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
+
4
103
  // src/transport.ts
5
104
  var MAX_BUFFER_EVENTS = 5e3;
6
105
  var Transport = class {
@@ -36,9 +135,15 @@ var Transport = class {
36
135
  /** Build, compress, and send the current buffer. Returns true on success. */
37
136
  async flush(reason) {
38
137
  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);
141
+ return true;
142
+ }
39
143
  this.sending = true;
40
144
  const events = this.buffer;
41
145
  this.buffer = [];
146
+ redactBatchSensitiveKeys(events);
42
147
  let batch = {
43
148
  siteKey: this.opts.siteKey,
44
149
  sessionId: this.opts.sessionId,
@@ -245,34 +350,6 @@ function performanceNow() {
245
350
  return typeof performance !== "undefined" ? performance.now() : Date.now();
246
351
  }
247
352
 
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
353
  // src/serialize.ts
277
354
  var DEFAULT_MAX_BYTES = 8 * 1024;
278
355
  function byteLen(s) {
@@ -506,43 +583,153 @@ function bodyPreview(text, contentType) {
506
583
  redacted: masked !== text
507
584
  };
508
585
  }
509
- function enrichTiming(ctx, url, base) {
586
+ function perfNow() {
587
+ return typeof performance !== "undefined" ? performance.now() : 0;
588
+ }
589
+ function absUrl(raw) {
510
590
  try {
511
- const entries = performance.getEntriesByType("resource");
512
- const match = entries.filter((e) => e.name === url).pop();
513
- if (!match) return base;
514
- const t = { ...base };
515
- if (match.domainLookupEnd && match.domainLookupStart)
516
- t.dns = Math.max(0, match.domainLookupEnd - match.domainLookupStart);
517
- if (match.connectEnd && match.connectStart)
518
- t.tcp = Math.max(0, match.connectEnd - match.connectStart);
519
- if (match.secureConnectionStart)
520
- t.tls = Math.max(0, match.connectEnd - match.secureConnectionStart);
521
- if (match.responseStart && match.requestStart)
522
- t.ttfb = Math.max(0, match.responseStart - match.requestStart);
523
- if (match.responseEnd && match.responseStart)
524
- t.download = Math.max(0, match.responseEnd - match.responseStart);
525
- return t;
591
+ return new URL(raw, typeof location !== "undefined" ? location.href : void 0).href;
526
592
  } catch {
527
- return base;
593
+ return raw;
594
+ }
595
+ }
596
+ function phasesFrom(m, base) {
597
+ const t = { ...base };
598
+ const r = (n) => Math.round(n);
599
+ if (m.domainLookupStart > 0 && m.domainLookupEnd > m.domainLookupStart)
600
+ t.dns = r(m.domainLookupEnd - m.domainLookupStart);
601
+ if (m.connectStart > 0 && m.connectEnd > m.connectStart) t.tcp = r(m.connectEnd - m.connectStart);
602
+ if (m.secureConnectionStart > 0 && m.connectEnd > m.secureConnectionStart)
603
+ t.tls = r(m.connectEnd - m.secureConnectionStart);
604
+ if (m.requestStart > 0 && m.responseStart > m.requestStart)
605
+ t.ttfb = r(m.responseStart - m.requestStart);
606
+ if (m.responseStart > 0 && m.responseEnd > m.responseStart)
607
+ t.download = r(m.responseEnd - m.responseStart);
608
+ return t;
609
+ }
610
+ function matchEntry(name, perfStart, entries) {
611
+ let best;
612
+ let bestDelta = Infinity;
613
+ for (const e of entries) {
614
+ if (e.name !== name) continue;
615
+ if (e.startTime < perfStart - 4) continue;
616
+ const d = e.startTime - perfStart;
617
+ if (d < bestDelta) {
618
+ bestDelta = d;
619
+ best = e;
620
+ }
528
621
  }
622
+ return best;
623
+ }
624
+ var TIMING_WAIT_MS = 300;
625
+ function createTimingResolver() {
626
+ const pending = [];
627
+ let obs;
628
+ const stopObs = () => {
629
+ if (obs) {
630
+ try {
631
+ obs.disconnect();
632
+ } catch {
633
+ }
634
+ obs = void 0;
635
+ }
636
+ };
637
+ const settle = (p, t) => {
638
+ const i = pending.indexOf(p);
639
+ if (i === -1) return;
640
+ pending.splice(i, 1);
641
+ clearTimeout(p.timer);
642
+ p.resolve(t);
643
+ if (pending.length === 0) stopObs();
644
+ };
645
+ const consume = (entries) => {
646
+ for (const e of entries) {
647
+ let best;
648
+ let bestDelta = Infinity;
649
+ for (const p of pending) {
650
+ if (e.name !== p.name || e.startTime < p.perfStart - 4) continue;
651
+ const d = e.startTime - p.perfStart;
652
+ if (d < bestDelta) {
653
+ bestDelta = d;
654
+ best = p;
655
+ }
656
+ }
657
+ if (best) settle(best, phasesFrom(e, best.base));
658
+ }
659
+ };
660
+ const ensureObserver = () => {
661
+ if (obs || typeof PerformanceObserver === "undefined") return;
662
+ try {
663
+ obs = new PerformanceObserver((list) => consume(list.getEntries()));
664
+ obs.observe({ type: "resource", buffered: false });
665
+ } catch {
666
+ obs = void 0;
667
+ }
668
+ };
669
+ return {
670
+ resolve(rawUrl, perfStart, base) {
671
+ if (typeof performance === "undefined") return Promise.resolve(base);
672
+ const name = absUrl(rawUrl);
673
+ try {
674
+ const now = performance.getEntriesByType("resource");
675
+ const immediate = matchEntry(name, perfStart, now);
676
+ if (immediate) return Promise.resolve(phasesFrom(immediate, base));
677
+ } catch {
678
+ }
679
+ if (typeof PerformanceObserver === "undefined") return Promise.resolve(base);
680
+ return new Promise((resolve) => {
681
+ const p = {
682
+ name,
683
+ perfStart,
684
+ base,
685
+ resolve,
686
+ timer: setTimeout(() => settle(p, base), TIMING_WAIT_MS)
687
+ };
688
+ pending.push(p);
689
+ ensureObserver();
690
+ });
691
+ },
692
+ // Force-settle everything still in flight (with duration-only timing) so their
693
+ // events are emitted into the transport buffer before an unload beacon fires.
694
+ flushPending() {
695
+ for (const p of [...pending]) settle(p, p.base);
696
+ },
697
+ teardown() {
698
+ this.flushPending();
699
+ stopObs();
700
+ }
701
+ };
529
702
  }
530
703
  function instrumentNetwork(ctx) {
704
+ const timing = createTimingResolver();
705
+ const onPageHide = () => timing.flushPending();
706
+ const onVisibility = () => {
707
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") timing.flushPending();
708
+ };
709
+ if (typeof window !== "undefined") window.addEventListener("pagehide", onPageHide);
710
+ if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisibility);
531
711
  const teardowns = [];
532
- teardowns.push(wrapFetch(ctx));
533
- teardowns.push(wrapXhr(ctx));
712
+ teardowns.push(wrapFetch(ctx, timing));
713
+ teardowns.push(wrapXhr(ctx, timing));
534
714
  teardowns.push(wrapBeacon(ctx));
535
715
  teardowns.push(observeResources(ctx));
716
+ teardowns.push(() => {
717
+ if (typeof window !== "undefined") window.removeEventListener("pagehide", onPageHide);
718
+ if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibility);
719
+ timing.teardown();
720
+ });
536
721
  return () => teardowns.forEach((t) => t());
537
722
  }
538
- function wrapFetch(ctx) {
723
+ function wrapFetch(ctx, tr) {
539
724
  const orig = window.fetch;
540
725
  if (!orig) return () => {
541
726
  };
542
727
  window.fetch = async function(input, init) {
543
728
  const rawUrl = input instanceof Request ? input.url : String(input);
544
- if (rawUrl.includes("/v1/batch")) return orig.call(window, input, init);
729
+ if (rawUrl.includes("/v1/batch") || rawUrl.includes("/v1/config"))
730
+ return orig.call(window, input, init);
545
731
  const startT = ctx.clock.now();
732
+ const perfStart = perfNow();
546
733
  const method = (init?.method || (input instanceof Request ? input.method : "GET")).toUpperCase();
547
734
  const url = redactUrl(rawUrl);
548
735
  const reqHeaders = pickHeaders(
@@ -551,25 +738,27 @@ function wrapFetch(ctx) {
551
738
  );
552
739
  const emit = (partial) => {
553
740
  const endT = ctx.clock.now();
554
- const timing = enrichTiming(ctx, rawUrl, { startT, endT, duration: endT - startT });
555
- const ev = {
556
- id: uuid(),
557
- sessionId: ctx.sessionId,
558
- type: "network",
559
- t: startT,
560
- ts: ctx.clock.wall(),
561
- initiator: "fetch",
562
- method,
563
- url,
564
- status: 0,
565
- ok: false,
566
- resourceType: "fetch",
567
- requestHeaders: reqHeaders,
568
- timing,
569
- ...partial
570
- };
571
- ctx.emit(ev);
741
+ const base = { startT, endT, duration: endT - startT };
572
742
  ctx.markActivity();
743
+ void tr.resolve(rawUrl, perfStart, base).then((timing) => {
744
+ const ev = {
745
+ id: uuid(),
746
+ sessionId: ctx.sessionId,
747
+ type: "network",
748
+ t: startT,
749
+ ts: ctx.clock.wall(),
750
+ initiator: "fetch",
751
+ method,
752
+ url,
753
+ status: 0,
754
+ ok: false,
755
+ resourceType: "fetch",
756
+ requestHeaders: reqHeaders,
757
+ timing,
758
+ ...partial
759
+ };
760
+ ctx.emit(ev);
761
+ });
573
762
  };
574
763
  try {
575
764
  const res = await orig.call(window, input, init);
@@ -611,7 +800,7 @@ function classifyFetchError(err) {
611
800
  if (msg.includes("timeout")) return "timeout";
612
801
  return "network";
613
802
  }
614
- function wrapXhr(ctx) {
803
+ function wrapXhr(ctx, tr) {
615
804
  const XHR = XMLHttpRequest.prototype;
616
805
  const origOpen = XHR.open;
617
806
  const origSend = XHR.send;
@@ -624,6 +813,7 @@ function wrapXhr(ctx) {
624
813
  rawUrl,
625
814
  url: redactUrl(rawUrl),
626
815
  startT: 0,
816
+ perfStart: 0,
627
817
  reqHeaders: {}
628
818
  });
629
819
  return origOpen.apply(this, [method, url, ...rest]);
@@ -642,14 +832,15 @@ function wrapXhr(ctx) {
642
832
  const meta = META.get(this);
643
833
  if (meta) {
644
834
  meta.startT = ctx.clock.now();
835
+ meta.perfStart = perfNow();
645
836
  if (ctx.config.captureNetworkBodies) meta.body = stringifyBody(body);
646
837
  const finish = (error) => {
647
838
  const endT = ctx.clock.now();
648
- const timing = enrichTiming(ctx, meta.rawUrl, {
839
+ const base = {
649
840
  startT: meta.startT,
650
841
  endT,
651
842
  duration: endT - meta.startT
652
- });
843
+ };
653
844
  const status = this.status;
654
845
  const respHeaders = parseRawHeaders(ctx, this.getAllResponseHeaders());
655
846
  let responseBody;
@@ -661,28 +852,31 @@ function wrapXhr(ctx) {
661
852
  } catch {
662
853
  }
663
854
  }
664
- const ev = {
665
- id: uuid(),
666
- sessionId: ctx.sessionId,
667
- type: "network",
668
- t: meta.startT,
669
- ts: ctx.clock.wall(),
670
- initiator: "xhr",
671
- method: meta.method,
672
- url: meta.url,
673
- status,
674
- ok: status >= 200 && status < 400,
675
- resourceType: "xhr",
676
- requestHeaders: meta.reqHeaders,
677
- responseHeaders: respHeaders,
678
- responseSize: safeLen(this),
679
- requestBody: ctx.config.captureNetworkBodies ? bodyPreview(meta.body, void 0) : void 0,
680
- responseBody,
681
- timing,
682
- error: error || classifyHttp(status)
683
- };
684
- ctx.emit(ev);
855
+ const responseSize = safeLen(this);
685
856
  ctx.markActivity();
857
+ void tr.resolve(meta.rawUrl, meta.perfStart, base).then((timing) => {
858
+ const ev = {
859
+ id: uuid(),
860
+ sessionId: ctx.sessionId,
861
+ type: "network",
862
+ t: meta.startT,
863
+ ts: ctx.clock.wall(),
864
+ initiator: "xhr",
865
+ method: meta.method,
866
+ url: meta.url,
867
+ status,
868
+ ok: status >= 200 && status < 400,
869
+ resourceType: "xhr",
870
+ requestHeaders: meta.reqHeaders,
871
+ responseHeaders: respHeaders,
872
+ responseSize,
873
+ requestBody: ctx.config.captureNetworkBodies ? bodyPreview(meta.body, void 0) : void 0,
874
+ responseBody,
875
+ timing,
876
+ error: error || classifyHttp(status)
877
+ };
878
+ ctx.emit(ev);
879
+ });
686
880
  };
687
881
  this.addEventListener("loadend", () => finish());
688
882
  this.addEventListener("error", () => finish("network"));
@@ -837,6 +1031,101 @@ function headerEntries(headers) {
837
1031
  return Object.entries(headers);
838
1032
  }
839
1033
 
1034
+ // src/websocket.ts
1035
+ var FRAME_CAP = 4 * 1024;
1036
+ function redactUrl2(raw) {
1037
+ try {
1038
+ const u = new URL(raw, location.href);
1039
+ if (u.search) u.search = "?\u2026";
1040
+ return u.toString();
1041
+ } catch {
1042
+ return raw.split("?")[0] ?? raw;
1043
+ }
1044
+ }
1045
+ function byteLen2(s) {
1046
+ try {
1047
+ return new TextEncoder().encode(s).length;
1048
+ } catch {
1049
+ return s.length;
1050
+ }
1051
+ }
1052
+ function frameInfo(data, capture) {
1053
+ if (typeof data === "string") {
1054
+ const info = { encoding: "text", size: byteLen2(data) };
1055
+ if (capture) {
1056
+ const masked = maskString(data);
1057
+ const truncated = masked.length > FRAME_CAP;
1058
+ info.body = {
1059
+ preview: truncated ? masked.slice(0, FRAME_CAP) : masked,
1060
+ truncated,
1061
+ redacted: masked !== data
1062
+ };
1063
+ }
1064
+ return info;
1065
+ }
1066
+ let size = 0;
1067
+ if (typeof Blob !== "undefined" && data instanceof Blob) size = data.size;
1068
+ else if (data instanceof ArrayBuffer) size = data.byteLength;
1069
+ else if (ArrayBuffer.isView(data)) size = data.byteLength;
1070
+ return { encoding: "binary", size };
1071
+ }
1072
+ function instrumentWebSocket(ctx) {
1073
+ const Orig = typeof window !== "undefined" ? window.WebSocket : void 0;
1074
+ if (!Orig) return () => {
1075
+ };
1076
+ const emit = (partial) => {
1077
+ try {
1078
+ const ev = {
1079
+ id: uuid(),
1080
+ sessionId: ctx.sessionId,
1081
+ type: "ws",
1082
+ t: ctx.clock.now(),
1083
+ ts: ctx.clock.wall(),
1084
+ ...partial
1085
+ };
1086
+ ctx.emit(ev);
1087
+ } catch {
1088
+ }
1089
+ };
1090
+ class WrappedWebSocket extends Orig {
1091
+ constructor(url, protocols) {
1092
+ super(url, protocols);
1093
+ try {
1094
+ const connId = uuid();
1095
+ const redUrl = redactUrl2(String(url));
1096
+ const protos = protocols ? Array.isArray(protocols) ? protocols : [protocols] : void 0;
1097
+ const capture = ctx.config.captureNetworkBodies;
1098
+ this.addEventListener("open", () => emit({ connId, url: redUrl, kind: "open", protocols: protos }));
1099
+ this.addEventListener(
1100
+ "close",
1101
+ (e) => emit({ connId, url: redUrl, kind: "close", code: e.code, reason: e.reason || void 0, wasClean: e.wasClean })
1102
+ );
1103
+ 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
+ });
1109
+ const origSend = super.send.bind(this);
1110
+ this.send = (data) => {
1111
+ origSend(data);
1112
+ try {
1113
+ const info = frameInfo(data, capture);
1114
+ emit({ connId, url: redUrl, kind: "message", dir: "send", ...info });
1115
+ ctx.markActivity();
1116
+ } catch {
1117
+ }
1118
+ };
1119
+ } catch {
1120
+ }
1121
+ }
1122
+ }
1123
+ window.WebSocket = WrappedWebSocket;
1124
+ return () => {
1125
+ window.WebSocket = Orig;
1126
+ };
1127
+ }
1128
+
840
1129
  // src/errors.ts
841
1130
  function instrumentErrors(ctx) {
842
1131
  function emitError(args) {
@@ -1621,6 +1910,9 @@ var ObsrviqClient = class {
1621
1910
  this.config.maskAllInputs = false;
1622
1911
  }
1623
1912
  if (this.config.askPermission) this.config.requireConsent = true;
1913
+ const staticMaskKeys = Array.isArray(config.maskKeys) ? config.maskKeys : [];
1914
+ setCustomMaskKeys(staticMaskKeys);
1915
+ void this.loadRemoteMaskKeys(staticMaskKeys);
1624
1916
  this.startSession();
1625
1917
  if (config.userId != null) this.applyUserId(config.userId);
1626
1918
  if (config.metadata && this.meta) this.meta.metadata = { ...config.metadata };
@@ -1641,6 +1933,33 @@ var ObsrviqClient = class {
1641
1933
  this.maybeStart();
1642
1934
  }
1643
1935
  }
1936
+ /** Fetch the per-site masking config (GET /v1/config) and merge its keys with any
1937
+ * static ones. Best-effort: on failure the built-in masking still applies. */
1938
+ async loadRemoteMaskKeys(staticKeys) {
1939
+ const cfg = this.config;
1940
+ if (!cfg || typeof fetch === "undefined") {
1941
+ markMaskConfigReady();
1942
+ return;
1943
+ }
1944
+ try {
1945
+ const ctrl = new AbortController();
1946
+ const timer = setTimeout(() => ctrl.abort(), 2e3);
1947
+ try {
1948
+ const url = `${cfg.endpoint.replace(/\/$/, "")}/v1/config?key=${encodeURIComponent(cfg.siteKey)}`;
1949
+ const res = await fetch(url, { credentials: "omit", mode: "cors", signal: ctrl.signal });
1950
+ if (res.ok) {
1951
+ const data = await res.json();
1952
+ const remote = Array.isArray(data.maskKeys) ? data.maskKeys.filter((k) => typeof k === "string") : [];
1953
+ setCustomMaskKeys([...staticKeys, ...remote]);
1954
+ }
1955
+ } finally {
1956
+ clearTimeout(timer);
1957
+ }
1958
+ } catch {
1959
+ } finally {
1960
+ markMaskConfigReady();
1961
+ }
1962
+ }
1644
1963
  /** (Re)create the session id, clock, meta, and transport. Used by init() and
1645
1964
  * reset(). Does not start recording — callers decide via maybeStart().
1646
1965
  * @param seedInitUser seed identity from the init-time `cfg.userId`. True for
@@ -1733,6 +2052,7 @@ var ObsrviqClient = class {
1733
2052
  this.transport.start();
1734
2053
  this.teardowns.push(instrumentConsole(ctx));
1735
2054
  this.teardowns.push(instrumentNetwork(ctx));
2055
+ this.teardowns.push(instrumentWebSocket(ctx));
1736
2056
  this.teardowns.push(instrumentErrors(ctx));
1737
2057
  this.teardowns.push(instrumentVitals(ctx));
1738
2058
  this.teardowns.push(instrumentInteractions(ctx));