@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 +3 -0
- package/dist/index.js +411 -91
- package/dist/index.js.map +1 -1
- package/dist/obsrviq.global.js +40 -40
- 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 +242 -64
- 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) {
|
|
@@ -506,43 +583,153 @@ function bodyPreview(text, contentType) {
|
|
|
506
583
|
redacted: masked !== text
|
|
507
584
|
};
|
|
508
585
|
}
|
|
509
|
-
function
|
|
586
|
+
function perfNow() {
|
|
587
|
+
return typeof performance !== "undefined" ? performance.now() : 0;
|
|
588
|
+
}
|
|
589
|
+
function absUrl(raw) {
|
|
510
590
|
try {
|
|
511
|
-
|
|
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
|
|
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")
|
|
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
|
|
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
|
|
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
|
|
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));
|