@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.d.ts +3 -0
- package/dist/index.js +639 -103
- 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 +49 -1
- package/src/mask.ts +127 -6
- package/src/network.ts +81 -17
- package/src/ratelimit.ts +24 -0
- package/src/recorder.ts +20 -1
- package/src/serialize.ts +63 -7
- package/src/transport.ts +357 -64
- package/src/util.ts +15 -1
- package/src/websocket.ts +143 -0
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
248
|
+
async send(bytes) {
|
|
81
249
|
const url = `${this.opts.endpoint.replace(/\/$/, "")}/v1/batch`;
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
+
mode: "cors"
|
|
344
|
+
}).catch(() => {
|
|
115
345
|
});
|
|
116
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
501
|
-
const
|
|
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 !==
|
|
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")
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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));
|