@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 +3 -0
- package/dist/index.js +233 -29
- package/dist/index.js.map +1 -1
- package/dist/obsrviq.global.js +24 -24
- package/dist/obsrviq.global.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +44 -0
- package/src/mask.ts +127 -6
- package/src/network.ts +2 -1
- package/src/transport.ts +21 -0
- package/src/websocket.ts +118 -0
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")
|
|
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));
|