@obsrviq/tracker 0.5.0 → 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.js +432 -100
- 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 +5 -1
- package/src/network.ts +79 -16
- package/src/ratelimit.ts +24 -0
- package/src/recorder.ts +20 -1
- package/src/serialize.ts +63 -7
- package/src/transport.ts +340 -68
- package/src/util.ts +15 -1
- package/src/websocket.ts +37 -12
package/dist/index.js
CHANGED
|
@@ -88,24 +88,23 @@ function redactBatchSensitiveKeys(events) {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
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
91
|
|
|
103
92
|
// src/transport.ts
|
|
104
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;
|
|
105
101
|
var Transport = class {
|
|
106
102
|
constructor(opts) {
|
|
107
103
|
this.opts = opts;
|
|
108
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;
|
|
109
108
|
this.sending = false;
|
|
110
109
|
this.retryDelay = 1e3;
|
|
111
110
|
this.stopped = false;
|
|
@@ -121,29 +120,107 @@ var Transport = class {
|
|
|
121
120
|
this.timer = setInterval(() => void this.flush("timer"), this.opts.flushIntervalMs);
|
|
122
121
|
document.addEventListener("visibilitychange", this.onVisibility, { capture: true });
|
|
123
122
|
window.addEventListener("pagehide", this.onPageHide, { capture: true });
|
|
123
|
+
this.resendSpill();
|
|
124
124
|
}
|
|
125
125
|
enqueue(event) {
|
|
126
126
|
if (this.stopped) return;
|
|
127
127
|
this.buffer.push(event);
|
|
128
128
|
if (this.buffer.length >= MAX_BUFFER_EVENTS) {
|
|
129
129
|
void this.flush("backpressure");
|
|
130
|
-
if (this.buffer.length >= MAX_BUFFER_EVENTS)
|
|
131
|
-
|
|
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;
|
|
132
147
|
}
|
|
133
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;
|
|
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));
|
|
164
|
+
}
|
|
165
|
+
this.opts.onDomDrop?.();
|
|
134
166
|
}
|
|
135
167
|
/** Build, compress, and send the current buffer. Returns true on success. */
|
|
136
168
|
async flush(reason) {
|
|
169
|
+
if (reason === "pagehide" || reason === "hidden") return this.flushUnload();
|
|
137
170
|
if (this.sending || this.buffer.length === 0) return true;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (!this.stopped) setTimeout(() => void this.flush("timer"), 200);
|
|
171
|
+
if (isMaskConfigPending() && !this.stopped) {
|
|
172
|
+
setTimeout(() => void this.flush("timer"), 200);
|
|
141
173
|
return true;
|
|
142
174
|
}
|
|
143
175
|
this.sending = true;
|
|
144
176
|
const events = this.buffer;
|
|
145
177
|
this.buffer = [];
|
|
146
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;
|
|
147
224
|
let batch = {
|
|
148
225
|
siteKey: this.opts.siteKey,
|
|
149
226
|
sessionId: this.opts.sessionId,
|
|
@@ -158,73 +235,165 @@ var Transport = class {
|
|
|
158
235
|
batch = null;
|
|
159
236
|
}
|
|
160
237
|
}
|
|
161
|
-
if (!batch)
|
|
162
|
-
this.sending = false;
|
|
163
|
-
return true;
|
|
164
|
-
}
|
|
165
|
-
this.opts.onSeqDispatch?.(this.seq + 1);
|
|
238
|
+
if (!batch) return "ok";
|
|
166
239
|
const json = JSON.stringify(batch);
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return true;
|
|
175
|
-
}
|
|
176
|
-
this.buffer = events.slice(-MAX_BUFFER_EVENTS).concat(this.buffer);
|
|
177
|
-
this.sending = false;
|
|
178
|
-
if (!this.stopped) {
|
|
179
|
-
const jitter = this.retryDelay * (0.5 + Math.random());
|
|
180
|
-
this.retryDelay = Math.min(this.retryDelay * 2, 3e4);
|
|
181
|
-
setTimeout(() => void this.flush("timer"), jitter);
|
|
182
|
-
}
|
|
183
|
-
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;
|
|
184
247
|
}
|
|
185
|
-
async send(bytes
|
|
248
|
+
async send(bytes) {
|
|
186
249
|
const url = `${this.opts.endpoint.replace(/\/$/, "")}/v1/batch`;
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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) {
|
|
195
298
|
try {
|
|
196
|
-
|
|
299
|
+
batch = this.opts.beforeSend(batch);
|
|
197
300
|
} catch {
|
|
301
|
+
batch = null;
|
|
198
302
|
}
|
|
199
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") {
|
|
200
328
|
try {
|
|
201
|
-
|
|
202
|
-
() => {
|
|
203
|
-
}
|
|
204
|
-
);
|
|
205
|
-
return true;
|
|
329
|
+
if (navigator.sendBeacon(url, blob)) return true;
|
|
206
330
|
} catch {
|
|
207
|
-
return false;
|
|
208
331
|
}
|
|
209
332
|
}
|
|
210
|
-
const ctrl = new AbortController();
|
|
211
|
-
const timeout = setTimeout(() => ctrl.abort(), 15e3);
|
|
212
333
|
try {
|
|
213
|
-
|
|
334
|
+
void fetch(url, {
|
|
214
335
|
method: "POST",
|
|
215
|
-
body,
|
|
216
|
-
headers
|
|
336
|
+
body: bytes,
|
|
337
|
+
headers: {
|
|
338
|
+
"content-type": "application/octet-stream",
|
|
339
|
+
"x-obsrviq-key": this.opts.siteKey
|
|
340
|
+
},
|
|
341
|
+
keepalive: true,
|
|
217
342
|
credentials: "omit",
|
|
218
|
-
mode: "cors"
|
|
219
|
-
|
|
343
|
+
mode: "cors"
|
|
344
|
+
}).catch(() => {
|
|
220
345
|
});
|
|
221
|
-
return
|
|
346
|
+
return true;
|
|
222
347
|
} catch {
|
|
223
348
|
return false;
|
|
224
|
-
} finally {
|
|
225
|
-
clearTimeout(timeout);
|
|
226
349
|
}
|
|
227
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
|
+
}
|
|
228
397
|
stop() {
|
|
229
398
|
this.stopped = true;
|
|
230
399
|
if (this.timer) clearInterval(this.timer);
|
|
@@ -233,6 +402,29 @@ var Transport = class {
|
|
|
233
402
|
void this.flush("manual");
|
|
234
403
|
}
|
|
235
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
|
+
}
|
|
236
428
|
|
|
237
429
|
// src/util.ts
|
|
238
430
|
function uuid() {
|
|
@@ -283,6 +475,13 @@ async function sha256Hex(input) {
|
|
|
283
475
|
function detectDevice() {
|
|
284
476
|
const ua = navigator.userAgent;
|
|
285
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
|
+
}
|
|
286
485
|
let type = "desktop";
|
|
287
486
|
if (/iPad|Tablet/i.test(ua)) type = "tablet";
|
|
288
487
|
else if (/Mobi|Android|iPhone/i.test(ua)) type = "mobile";
|
|
@@ -297,7 +496,7 @@ function detectDevice() {
|
|
|
297
496
|
else if (/Chrome\//.test(ua)) browser = "Chrome";
|
|
298
497
|
else if (/Firefox\//.test(ua)) browser = "Firefox";
|
|
299
498
|
else if (/Safari\//.test(ua)) browser = "Safari";
|
|
300
|
-
return { type, os, browser, viewport };
|
|
499
|
+
return { type, os, browser, viewport, tz, tzOffset };
|
|
301
500
|
}
|
|
302
501
|
function privacySignalsOptOut() {
|
|
303
502
|
const nav = navigator;
|
|
@@ -311,11 +510,13 @@ var INCREMENTAL = 3;
|
|
|
311
510
|
function instrumentDom(ctx) {
|
|
312
511
|
const stop = record({
|
|
313
512
|
emit(event) {
|
|
513
|
+
const t = ctx.clock.fromPerf(performanceNow());
|
|
514
|
+
event.timestamp = ctx.clock.startedAt + t;
|
|
314
515
|
const dom = {
|
|
315
516
|
id: uuid(),
|
|
316
517
|
sessionId: ctx.sessionId,
|
|
317
518
|
type: "dom",
|
|
318
|
-
t
|
|
519
|
+
t,
|
|
319
520
|
ts: ctx.clock.wall(),
|
|
320
521
|
data: event
|
|
321
522
|
};
|
|
@@ -346,12 +547,19 @@ function instrumentDom(ctx) {
|
|
|
346
547
|
}
|
|
347
548
|
};
|
|
348
549
|
}
|
|
550
|
+
function takeFullSnapshot() {
|
|
551
|
+
try {
|
|
552
|
+
record.takeFullSnapshot(true);
|
|
553
|
+
} catch {
|
|
554
|
+
}
|
|
555
|
+
}
|
|
349
556
|
function performanceNow() {
|
|
350
557
|
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
351
558
|
}
|
|
352
559
|
|
|
353
560
|
// src/serialize.ts
|
|
354
561
|
var DEFAULT_MAX_BYTES = 8 * 1024;
|
|
562
|
+
var MAX_WALK_NODES = 2e3;
|
|
355
563
|
function byteLen(s) {
|
|
356
564
|
return new Blob([s]).size;
|
|
357
565
|
}
|
|
@@ -367,9 +575,11 @@ function serializeArg(arg, maxBytes = DEFAULT_MAX_BYTES) {
|
|
|
367
575
|
return { kind: "primitive", value: arg };
|
|
368
576
|
}
|
|
369
577
|
if (type === "string") {
|
|
370
|
-
const
|
|
578
|
+
const s = arg;
|
|
579
|
+
const cut = s.length > maxBytes * 2 ? s.slice(0, maxBytes * 2) : s;
|
|
580
|
+
const masked = maskString(cut);
|
|
371
581
|
const { value, truncated } = truncate(masked, maxBytes);
|
|
372
|
-
return { kind: "string", value, truncated };
|
|
582
|
+
return { kind: "string", value, truncated: truncated || cut !== s };
|
|
373
583
|
}
|
|
374
584
|
if (arg instanceof Error) {
|
|
375
585
|
return {
|
|
@@ -383,21 +593,56 @@ function serializeArg(arg, maxBytes = DEFAULT_MAX_BYTES) {
|
|
|
383
593
|
return { kind: "dom", selector: cssPath(arg) };
|
|
384
594
|
}
|
|
385
595
|
if (type === "object" || type === "function" || type === "symbol" || type === "bigint") {
|
|
386
|
-
const
|
|
596
|
+
const budget = { nodes: MAX_WALK_NODES, bytes: maxBytes * 2, exhausted: false };
|
|
597
|
+
const masked = maskValueBudgeted(arg, budget);
|
|
387
598
|
let json;
|
|
388
|
-
let truncated =
|
|
599
|
+
let truncated = budget.exhausted;
|
|
389
600
|
try {
|
|
390
601
|
const full = JSON.stringify(masked, safeReplacer());
|
|
391
602
|
const cut = truncate(full ?? "undefined", maxBytes);
|
|
392
603
|
json = cut.value;
|
|
393
|
-
truncated = cut.truncated;
|
|
604
|
+
truncated = truncated || cut.truncated;
|
|
394
605
|
} catch {
|
|
395
606
|
json = void 0;
|
|
396
607
|
}
|
|
397
|
-
return { kind: "object", preview: previewOf(
|
|
608
|
+
return { kind: "object", preview: previewOf(arg), json, truncated };
|
|
398
609
|
}
|
|
399
610
|
return { kind: "primitive", value: String(arg) };
|
|
400
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
|
+
}
|
|
401
646
|
function safeReplacer() {
|
|
402
647
|
const seen = /* @__PURE__ */ new WeakSet();
|
|
403
648
|
return (_key, value) => {
|
|
@@ -531,8 +776,26 @@ function errorToConsoleEvent(ctx, err) {
|
|
|
531
776
|
};
|
|
532
777
|
}
|
|
533
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
|
+
|
|
534
795
|
// src/network.ts
|
|
535
796
|
var BODY_CAP = 4 * 1024;
|
|
797
|
+
var NET_RATE = 50;
|
|
798
|
+
var NET_BURST = 100;
|
|
536
799
|
var HEADER_ALLOW = /* @__PURE__ */ new Set([
|
|
537
800
|
"content-type",
|
|
538
801
|
"content-length",
|
|
@@ -574,15 +837,48 @@ function pickHeaders(redact, entries) {
|
|
|
574
837
|
}
|
|
575
838
|
function bodyPreview(text, contentType) {
|
|
576
839
|
if (text == null) return void 0;
|
|
577
|
-
const
|
|
578
|
-
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;
|
|
579
843
|
return {
|
|
580
844
|
contentType,
|
|
581
845
|
preview: truncated ? masked.slice(0, BODY_CAP) : masked,
|
|
582
846
|
truncated,
|
|
583
|
-
redacted: masked !==
|
|
847
|
+
redacted: masked !== cut
|
|
584
848
|
};
|
|
585
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
|
+
}
|
|
586
882
|
function perfNow() {
|
|
587
883
|
return typeof performance !== "undefined" ? performance.now() : 0;
|
|
588
884
|
}
|
|
@@ -702,6 +998,19 @@ function createTimingResolver() {
|
|
|
702
998
|
}
|
|
703
999
|
function instrumentNetwork(ctx) {
|
|
704
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
|
+
};
|
|
705
1014
|
const onPageHide = () => timing.flushPending();
|
|
706
1015
|
const onVisibility = () => {
|
|
707
1016
|
if (typeof document !== "undefined" && document.visibilityState === "hidden") timing.flushPending();
|
|
@@ -709,10 +1018,10 @@ function instrumentNetwork(ctx) {
|
|
|
709
1018
|
if (typeof window !== "undefined") window.addEventListener("pagehide", onPageHide);
|
|
710
1019
|
if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisibility);
|
|
711
1020
|
const teardowns = [];
|
|
712
|
-
teardowns.push(wrapFetch(ctx, timing));
|
|
713
|
-
teardowns.push(wrapXhr(ctx, timing));
|
|
714
|
-
teardowns.push(wrapBeacon(ctx));
|
|
715
|
-
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));
|
|
716
1025
|
teardowns.push(() => {
|
|
717
1026
|
if (typeof window !== "undefined") window.removeEventListener("pagehide", onPageHide);
|
|
718
1027
|
if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibility);
|
|
@@ -720,7 +1029,7 @@ function instrumentNetwork(ctx) {
|
|
|
720
1029
|
});
|
|
721
1030
|
return () => teardowns.forEach((t) => t());
|
|
722
1031
|
}
|
|
723
|
-
function wrapFetch(ctx, tr) {
|
|
1032
|
+
function wrapFetch(ctx, tr, emitNet) {
|
|
724
1033
|
const orig = window.fetch;
|
|
725
1034
|
if (!orig) return () => {
|
|
726
1035
|
};
|
|
@@ -757,7 +1066,7 @@ function wrapFetch(ctx, tr) {
|
|
|
757
1066
|
timing,
|
|
758
1067
|
...partial
|
|
759
1068
|
};
|
|
760
|
-
|
|
1069
|
+
emitNet(ev);
|
|
761
1070
|
});
|
|
762
1071
|
};
|
|
763
1072
|
try {
|
|
@@ -768,8 +1077,9 @@ function wrapFetch(ctx, tr) {
|
|
|
768
1077
|
let responseBody;
|
|
769
1078
|
if (ctx.config.captureNetworkBodies && isTexty(res.headers.get("content-type"))) {
|
|
770
1079
|
try {
|
|
771
|
-
const text = await res.clone()
|
|
1080
|
+
const { text, overflow } = await readTextCapped(res.clone(), BODY_CAP);
|
|
772
1081
|
responseBody = bodyPreview(text, res.headers.get("content-type") || void 0);
|
|
1082
|
+
if (responseBody && overflow) responseBody.truncated = true;
|
|
773
1083
|
} catch {
|
|
774
1084
|
}
|
|
775
1085
|
}
|
|
@@ -800,7 +1110,7 @@ function classifyFetchError(err) {
|
|
|
800
1110
|
if (msg.includes("timeout")) return "timeout";
|
|
801
1111
|
return "network";
|
|
802
1112
|
}
|
|
803
|
-
function wrapXhr(ctx, tr) {
|
|
1113
|
+
function wrapXhr(ctx, tr, emitNet) {
|
|
804
1114
|
const XHR = XMLHttpRequest.prototype;
|
|
805
1115
|
const origOpen = XHR.open;
|
|
806
1116
|
const origSend = XHR.send;
|
|
@@ -875,7 +1185,7 @@ function wrapXhr(ctx, tr) {
|
|
|
875
1185
|
timing,
|
|
876
1186
|
error: error || classifyHttp(status)
|
|
877
1187
|
};
|
|
878
|
-
|
|
1188
|
+
emitNet(ev);
|
|
879
1189
|
});
|
|
880
1190
|
};
|
|
881
1191
|
this.addEventListener("loadend", () => finish());
|
|
@@ -908,7 +1218,7 @@ function parseRawHeaders(ctx, raw) {
|
|
|
908
1218
|
}
|
|
909
1219
|
return pickHeaders(ctx.config.redactHeaders, entries);
|
|
910
1220
|
}
|
|
911
|
-
function wrapBeacon(ctx) {
|
|
1221
|
+
function wrapBeacon(ctx, emitNet) {
|
|
912
1222
|
const orig = navigator.sendBeacon;
|
|
913
1223
|
if (!orig) return () => {
|
|
914
1224
|
};
|
|
@@ -935,7 +1245,7 @@ function wrapBeacon(ctx) {
|
|
|
935
1245
|
timing: { startT, endT: ctx.clock.now(), duration: 0 },
|
|
936
1246
|
error: ok ? void 0 : "network"
|
|
937
1247
|
};
|
|
938
|
-
|
|
1248
|
+
emitNet(ev);
|
|
939
1249
|
}
|
|
940
1250
|
}
|
|
941
1251
|
return ok;
|
|
@@ -955,7 +1265,7 @@ var RESOURCE_TYPE_MAP = {
|
|
|
955
1265
|
video: "media",
|
|
956
1266
|
track: "media"
|
|
957
1267
|
};
|
|
958
|
-
function observeResources(ctx) {
|
|
1268
|
+
function observeResources(ctx, emitNet) {
|
|
959
1269
|
if (typeof PerformanceObserver === "undefined") return () => {
|
|
960
1270
|
};
|
|
961
1271
|
let obs;
|
|
@@ -992,7 +1302,7 @@ function observeResources(ctx) {
|
|
|
992
1302
|
responseSize: entry.transferSize || entry.encodedBodySize || void 0,
|
|
993
1303
|
timing
|
|
994
1304
|
};
|
|
995
|
-
|
|
1305
|
+
emitNet(ev);
|
|
996
1306
|
}
|
|
997
1307
|
});
|
|
998
1308
|
obs.observe({ type: "resource", buffered: true });
|
|
@@ -1033,6 +1343,8 @@ function headerEntries(headers) {
|
|
|
1033
1343
|
|
|
1034
1344
|
// src/websocket.ts
|
|
1035
1345
|
var FRAME_CAP = 4 * 1024;
|
|
1346
|
+
var MSG_RATE = 20;
|
|
1347
|
+
var MSG_BURST = 40;
|
|
1036
1348
|
function redactUrl2(raw) {
|
|
1037
1349
|
try {
|
|
1038
1350
|
const u = new URL(raw, location.href);
|
|
@@ -1053,12 +1365,13 @@ function frameInfo(data, capture) {
|
|
|
1053
1365
|
if (typeof data === "string") {
|
|
1054
1366
|
const info = { encoding: "text", size: byteLen2(data) };
|
|
1055
1367
|
if (capture) {
|
|
1056
|
-
const
|
|
1057
|
-
const
|
|
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;
|
|
1058
1371
|
info.body = {
|
|
1059
1372
|
preview: truncated ? masked.slice(0, FRAME_CAP) : masked,
|
|
1060
1373
|
truncated,
|
|
1061
|
-
redacted: masked !==
|
|
1374
|
+
redacted: masked !== cut
|
|
1062
1375
|
};
|
|
1063
1376
|
}
|
|
1064
1377
|
return info;
|
|
@@ -1095,24 +1408,39 @@ function instrumentWebSocket(ctx) {
|
|
|
1095
1408
|
const redUrl = redactUrl2(String(url));
|
|
1096
1409
|
const protos = protocols ? Array.isArray(protocols) ? protocols : [protocols] : void 0;
|
|
1097
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
|
+
};
|
|
1098
1424
|
this.addEventListener("open", () => emit({ connId, url: redUrl, kind: "open", protocols: protos }));
|
|
1099
1425
|
this.addEventListener(
|
|
1100
1426
|
"close",
|
|
1101
|
-
(e) => emit({
|
|
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
|
+
})
|
|
1102
1436
|
);
|
|
1103
1437
|
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
|
-
});
|
|
1438
|
+
this.addEventListener("message", (e) => emitFrame("recv", e.data));
|
|
1109
1439
|
const origSend = super.send.bind(this);
|
|
1110
1440
|
this.send = (data) => {
|
|
1111
1441
|
origSend(data);
|
|
1112
1442
|
try {
|
|
1113
|
-
|
|
1114
|
-
emit({ connId, url: redUrl, kind: "message", dir: "send", ...info });
|
|
1115
|
-
ctx.markActivity();
|
|
1443
|
+
emitFrame("send", data);
|
|
1116
1444
|
} catch {
|
|
1117
1445
|
}
|
|
1118
1446
|
};
|
|
@@ -1998,6 +2326,10 @@ var ObsrviqClient = class {
|
|
|
1998
2326
|
this.nextSeq = next;
|
|
1999
2327
|
this.persistSession();
|
|
2000
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(),
|
|
2001
2333
|
onFlush: ({ bytes, events, ok }) => {
|
|
2002
2334
|
if (ok) {
|
|
2003
2335
|
this.diag.flushes++;
|