@obsrviq/tracker 0.4.2 → 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) {
@@ -649,7 +726,8 @@ function wrapFetch(ctx, tr) {
649
726
  };
650
727
  window.fetch = async function(input, init) {
651
728
  const rawUrl = input instanceof Request ? input.url : String(input);
652
- 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);
653
731
  const startT = ctx.clock.now();
654
732
  const perfStart = perfNow();
655
733
  const method = (init?.method || (input instanceof Request ? input.method : "GET")).toUpperCase();
@@ -953,6 +1031,101 @@ function headerEntries(headers) {
953
1031
  return Object.entries(headers);
954
1032
  }
955
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
+
956
1129
  // src/errors.ts
957
1130
  function instrumentErrors(ctx) {
958
1131
  function emitError(args) {
@@ -1737,6 +1910,9 @@ var ObsrviqClient = class {
1737
1910
  this.config.maskAllInputs = false;
1738
1911
  }
1739
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);
1740
1916
  this.startSession();
1741
1917
  if (config.userId != null) this.applyUserId(config.userId);
1742
1918
  if (config.metadata && this.meta) this.meta.metadata = { ...config.metadata };
@@ -1757,6 +1933,33 @@ var ObsrviqClient = class {
1757
1933
  this.maybeStart();
1758
1934
  }
1759
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
+ }
1760
1963
  /** (Re)create the session id, clock, meta, and transport. Used by init() and
1761
1964
  * reset(). Does not start recording — callers decide via maybeStart().
1762
1965
  * @param seedInitUser seed identity from the init-time `cfg.userId`. True for
@@ -1849,6 +2052,7 @@ var ObsrviqClient = class {
1849
2052
  this.transport.start();
1850
2053
  this.teardowns.push(instrumentConsole(ctx));
1851
2054
  this.teardowns.push(instrumentNetwork(ctx));
2055
+ this.teardowns.push(instrumentWebSocket(ctx));
1852
2056
  this.teardowns.push(instrumentErrors(ctx));
1853
2057
  this.teardowns.push(instrumentVitals(ctx));
1854
2058
  this.teardowns.push(instrumentInteractions(ctx));