@obsrviq/tracker 0.5.0 → 0.7.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 +513 -100
- package/dist/index.js.map +1 -1
- package/dist/obsrviq.global.js +42 -42
- package/dist/obsrviq.global.js.map +1 -1
- package/package.json +2 -2
- package/src/context.ts +4 -0
- package/src/index.ts +7 -1
- package/src/network.ts +111 -16
- package/src/ratelimit.ts +24 -0
- package/src/recorder.ts +20 -1
- package/src/serialize.ts +63 -7
- package/src/trace.ts +100 -0
- package/src/transport.ts +340 -68
- package/src/util.ts +15 -1
- package/src/websocket.ts +37 -12
package/src/transport.ts
CHANGED
|
@@ -16,15 +16,50 @@ export interface TransportOptions {
|
|
|
16
16
|
* the host persist progress synchronously so a later page load resumes without a
|
|
17
17
|
* seq collision even if an unload interrupts the in-flight request. */
|
|
18
18
|
onSeqDispatch?: (nextSeq: number) => void;
|
|
19
|
+
/** Called after backpressure had to drop buffered DOM deltas — the host should
|
|
20
|
+
* force a fresh rrweb FullSnapshot so the replay stream re-anchors instead of
|
|
21
|
+
* playing corrupt deltas until the next scheduled checkpoint. */
|
|
22
|
+
onDomDrop?: () => void;
|
|
19
23
|
/** test/observability hook */
|
|
20
24
|
onFlush?: (info: { bytes: number; events: number; ok: boolean }) => void;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
const MAX_BUFFER_EVENTS = 5000; // backpressure cap (R-5)
|
|
28
|
+
/** Split batches whose JSON exceeds this before sending — well under the ingest
|
|
29
|
+
* body cap (16MB gzip) even at gzip-hostile content, and keeps main-thread
|
|
30
|
+
* stringify/compress work per flush bounded. */
|
|
31
|
+
const MAX_BATCH_JSON_BYTES = 4 * 1024 * 1024;
|
|
32
|
+
/** Target gzip size per unload piece — sendBeacon/keepalive cap in-flight bodies
|
|
33
|
+
* at ~64KB, so pieces are kept comfortably under it. */
|
|
34
|
+
const BEACON_PIECE_BYTES = 56 * 1024;
|
|
35
|
+
/** localStorage spill of batches an unload couldn't beacon out (queue full /
|
|
36
|
+
* oversized). Resent by the next page load. Bounded so we respect the ~5MB
|
|
37
|
+
* origin quota shared with the app. */
|
|
38
|
+
const SPILL_KEY = 'obsrviq.spill';
|
|
39
|
+
const SPILL_MAX_BYTES = 1_500_000;
|
|
40
|
+
const SPILL_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
41
|
+
|
|
42
|
+
// rrweb event types we need to recognise for snapshot-aware dropping.
|
|
43
|
+
const RR_FULL_SNAPSHOT = 2;
|
|
44
|
+
const RR_META = 4;
|
|
45
|
+
|
|
46
|
+
/** Outcome of one network attempt — drives retry/split/drop decisions. */
|
|
47
|
+
type SendResult = 'ok' | 'too_large' | 'fatal' | 'retry';
|
|
48
|
+
|
|
49
|
+
interface SpillEntry {
|
|
50
|
+
/** siteKey — so multi-site pages only resend their own batches. */
|
|
51
|
+
k: string;
|
|
52
|
+
/** base64 of the gzipped, fully-stamped IngestBatch (self-contained). */
|
|
53
|
+
b: string;
|
|
54
|
+
ts: number;
|
|
55
|
+
}
|
|
24
56
|
|
|
25
57
|
export class Transport {
|
|
26
58
|
private buffer: ObsrviqEvent[] = [];
|
|
27
59
|
private seq: number;
|
|
60
|
+
/** Floor set by an unload flush that consumed seqs out-of-band (beacon pieces),
|
|
61
|
+
* so the normal path can never re-use one of them. */
|
|
62
|
+
private seqFloor = 0;
|
|
28
63
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
29
64
|
private sending = false;
|
|
30
65
|
private retryDelay = 1000;
|
|
@@ -38,33 +73,76 @@ export class Transport {
|
|
|
38
73
|
this.timer = setInterval(() => void this.flush('timer'), this.opts.flushIntervalMs);
|
|
39
74
|
document.addEventListener('visibilitychange', this.onVisibility, { capture: true });
|
|
40
75
|
window.addEventListener('pagehide', this.onPageHide, { capture: true });
|
|
76
|
+
// Recover anything a previous page's unload had to spill to localStorage.
|
|
77
|
+
this.resendSpill();
|
|
41
78
|
}
|
|
42
79
|
|
|
43
80
|
enqueue(event: ObsrviqEvent) {
|
|
44
81
|
if (this.stopped) return;
|
|
45
82
|
this.buffer.push(event);
|
|
46
83
|
// Backpressure: if we've buffered a lot, flush early; if still over after a
|
|
47
|
-
// failed flush,
|
|
84
|
+
// failed flush, shed DOM backlog without corrupting the delta stream.
|
|
48
85
|
if (this.buffer.length >= MAX_BUFFER_EVENTS) {
|
|
49
86
|
void this.flush('backpressure');
|
|
50
|
-
if (this.buffer.length >= MAX_BUFFER_EVENTS)
|
|
51
|
-
|
|
87
|
+
if (this.buffer.length >= MAX_BUFFER_EVENTS) this.shedDomBacklog();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Drop buffered DOM events WITHOUT breaking rrweb's delta chain. Deltas only
|
|
92
|
+
* apply on top of everything before them, so arbitrary dropping corrupts the
|
|
93
|
+
* replay until the next 30s checkpoint. Instead: if the buffer holds a
|
|
94
|
+
* FullSnapshot, drop only the DOM events BEFORE it (the snapshot re-anchors
|
|
95
|
+
* everything after). Otherwise drop ALL buffered DOM and ask the recorder for
|
|
96
|
+
* a fresh FullSnapshot so the stream re-anchors immediately. Non-DOM events
|
|
97
|
+
* (console/network/errors/…) are independent and kept. */
|
|
98
|
+
private shedDomBacklog() {
|
|
99
|
+
let snapIdx = -1;
|
|
100
|
+
for (let i = this.buffer.length - 1; i >= 0; i--) {
|
|
101
|
+
const e = this.buffer[i];
|
|
102
|
+
if (e?.type === 'dom' && (e.data as { type?: number } | undefined)?.type === RR_FULL_SNAPSHOT) {
|
|
103
|
+
snapIdx = i;
|
|
104
|
+
break;
|
|
52
105
|
}
|
|
53
106
|
}
|
|
107
|
+
if (snapIdx > 0) {
|
|
108
|
+
// Keep from the snapshot's preceding Meta (rrweb emits Meta → FullSnapshot).
|
|
109
|
+
let keepFrom = snapIdx;
|
|
110
|
+
const prev = this.buffer[snapIdx - 1];
|
|
111
|
+
if (prev?.type === 'dom' && (prev.data as { type?: number } | undefined)?.type === RR_META) {
|
|
112
|
+
keepFrom = snapIdx - 1;
|
|
113
|
+
}
|
|
114
|
+
const kept = this.buffer.filter((e, i) => e.type !== 'dom' || i >= keepFrom);
|
|
115
|
+
if (kept.length < this.buffer.length) {
|
|
116
|
+
this.buffer = kept;
|
|
117
|
+
if (this.buffer.length < MAX_BUFFER_EVENTS) return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// No snapshot to anchor on (or one window alone overflows): drop all DOM and
|
|
121
|
+
// re-anchor with a fresh snapshot.
|
|
122
|
+
this.buffer = this.buffer.filter((e) => e.type !== 'dom');
|
|
123
|
+
// Pathological non-DOM flood: shed oldest half so the buffer stays bounded.
|
|
124
|
+
if (this.buffer.length >= MAX_BUFFER_EVENTS) {
|
|
125
|
+
this.buffer = this.buffer.slice(-Math.floor(MAX_BUFFER_EVENTS / 2));
|
|
126
|
+
}
|
|
127
|
+
this.opts.onDomDrop?.();
|
|
54
128
|
}
|
|
55
129
|
|
|
56
130
|
/** Build, compress, and send the current buffer. Returns true on success. */
|
|
57
131
|
async flush(reason: 'timer' | 'hidden' | 'pagehide' | 'manual' | 'backpressure'): Promise<boolean> {
|
|
132
|
+
// Unload path is separate: it must run even mid-send (the in-flight fetch is
|
|
133
|
+
// about to be killed by navigation) and can't await anything.
|
|
134
|
+
if (reason === 'pagehide' || reason === 'hidden') return this.flushUnload();
|
|
135
|
+
|
|
58
136
|
if (this.sending || this.buffer.length === 0) return true;
|
|
59
137
|
|
|
60
138
|
// Hold non-unload sends until the per-site mask config has loaded, so a
|
|
61
139
|
// remote-only custom key can't leak in the brief init fetch window. The fetch
|
|
62
140
|
// is bounded (index.ts marks config ready within ~2s even on failure/timeout),
|
|
63
141
|
// so this delays at most the first batch — it never blocks data indefinitely.
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
142
|
+
// A stopped transport can't wait (there'll be no retry) — its final flush
|
|
143
|
+
// proceeds best-effort with whatever keys have loaded, like the unload path.
|
|
144
|
+
if (isMaskConfigPending() && !this.stopped) {
|
|
145
|
+
setTimeout(() => void this.flush('timer'), 200);
|
|
68
146
|
return true;
|
|
69
147
|
}
|
|
70
148
|
|
|
@@ -80,6 +158,62 @@ export class Transport {
|
|
|
80
158
|
// before the per-site config arrived.
|
|
81
159
|
redactBatchSensitiveKeys(events);
|
|
82
160
|
|
|
161
|
+
let ok: boolean;
|
|
162
|
+
try {
|
|
163
|
+
ok = await this.sendEvents(events);
|
|
164
|
+
} finally {
|
|
165
|
+
this.sending = false;
|
|
166
|
+
}
|
|
167
|
+
// stop() during this send no-oped (sending guard) — drain what it left
|
|
168
|
+
// behind, since no timer remains to pick it up. Only on success: a failure
|
|
169
|
+
// just re-buffered, and retrying here would spin against a dead network.
|
|
170
|
+
if (ok && this.stopped && this.buffer.length > 0) void this.flush('manual');
|
|
171
|
+
return ok;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Send events as one or more seq-stamped batches: oversized batches split in
|
|
175
|
+
* half (byte cap here, or a server 413), poison batches (other 4xx) are dropped
|
|
176
|
+
* instead of retried forever, and transient failures re-buffer + back off. */
|
|
177
|
+
private async sendEvents(events: ObsrviqEvent[]): Promise<boolean> {
|
|
178
|
+
const queue: ObsrviqEvent[][] = [events];
|
|
179
|
+
while (queue.length > 0) {
|
|
180
|
+
const slice = queue[0]!;
|
|
181
|
+
const result = await this.dispatch(slice);
|
|
182
|
+
if (result === 'ok') {
|
|
183
|
+
queue.shift();
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (result === 'too_large' && slice.length > 1) {
|
|
187
|
+
const mid = Math.ceil(slice.length / 2);
|
|
188
|
+
queue.splice(0, 1, slice.slice(0, mid), slice.slice(mid));
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (result === 'fatal' || result === 'too_large') {
|
|
192
|
+
// Rejected outright (bad key / revoked site / single event over the cap):
|
|
193
|
+
// retrying can never succeed — drop this slice, keep sending the rest.
|
|
194
|
+
queue.shift();
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
// Transient (network / 5xx / 429): re-buffer everything left, in original
|
|
198
|
+
// order ahead of anything captured meanwhile, and retry with jitter.
|
|
199
|
+
this.buffer = ([] as ObsrviqEvent[]).concat(...queue, this.buffer);
|
|
200
|
+
if (this.buffer.length >= MAX_BUFFER_EVENTS) this.shedDomBacklog();
|
|
201
|
+
if (!this.stopped) {
|
|
202
|
+
const jitter = this.retryDelay * (0.5 + Math.random());
|
|
203
|
+
this.retryDelay = Math.min(this.retryDelay * 2, 30_000);
|
|
204
|
+
setTimeout(() => void this.flush('timer'), jitter);
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
this.retryDelay = 1000;
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** One batch → one network attempt. Owns seq assignment/advance. */
|
|
213
|
+
private async dispatch(events: ObsrviqEvent[]): Promise<SendResult> {
|
|
214
|
+
// Never re-use a seq an unload flush consumed out-of-band.
|
|
215
|
+
if (this.seqFloor > this.seq) this.seq = this.seqFloor;
|
|
216
|
+
|
|
83
217
|
let batch: IngestBatch | null = {
|
|
84
218
|
siteKey: this.opts.siteKey,
|
|
85
219
|
sessionId: this.opts.sessionId,
|
|
@@ -94,10 +228,12 @@ export class Transport {
|
|
|
94
228
|
batch = null;
|
|
95
229
|
}
|
|
96
230
|
}
|
|
97
|
-
if (!batch)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
231
|
+
if (!batch) return 'ok'; // dropped intentionally
|
|
232
|
+
|
|
233
|
+
const json = JSON.stringify(batch);
|
|
234
|
+
// Byte cap: split BEFORE compressing/sending rather than bounce off the
|
|
235
|
+
// server's body limit (a lost batch) — sendEvents halves it and retries.
|
|
236
|
+
if (json.length > MAX_BATCH_JSON_BYTES && events.length > 1) return 'too_large';
|
|
101
237
|
|
|
102
238
|
// Reserve the next seq + stamp activity SYNCHRONOUSLY at dispatch — before any
|
|
103
239
|
// network await. This is what session continuation persists, so an unload that
|
|
@@ -107,84 +243,188 @@ export class Transport {
|
|
|
107
243
|
// active-but-offline session isn't wrongly treated as idle and split.
|
|
108
244
|
this.opts.onSeqDispatch?.(this.seq + 1);
|
|
109
245
|
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
this.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
this.seq++;
|
|
117
|
-
this.retryDelay = 1000;
|
|
118
|
-
this.sending = false;
|
|
119
|
-
return true;
|
|
120
|
-
}
|
|
246
|
+
const gz = await gzipBytes(new TextEncoder().encode(json));
|
|
247
|
+
const result = await this.send(gz);
|
|
248
|
+
this.opts.onFlush?.({ bytes: gz.byteLength, events: events.length, ok: result === 'ok' });
|
|
249
|
+
if (result === 'ok') this.seq = Math.max(this.seq + 1, this.seqFloor);
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
121
252
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
253
|
+
private async send(bytes: Uint8Array): Promise<SendResult> {
|
|
254
|
+
const url = `${this.opts.endpoint.replace(/\/$/, '')}/v1/batch`;
|
|
255
|
+
// Normal flush: a plain fetch — NO `keepalive`, so there's no 64KB body cap
|
|
256
|
+
// and large FullSnapshot batches actually send. Await the real result so a
|
|
257
|
+
// failure re-buffers and retries instead of being silently dropped. A timeout
|
|
258
|
+
// aborts a hung request so it can't stall all future flushes (`sending` stays
|
|
259
|
+
// true until this resolves).
|
|
260
|
+
const ctrl = new AbortController();
|
|
261
|
+
const timeout = setTimeout(() => ctrl.abort(), 15_000);
|
|
262
|
+
try {
|
|
263
|
+
const res = await fetch(url, {
|
|
264
|
+
method: 'POST',
|
|
265
|
+
body: bytes as unknown as BodyInit,
|
|
266
|
+
headers: {
|
|
267
|
+
'content-type': 'application/octet-stream',
|
|
268
|
+
'x-obsrviq-key': this.opts.siteKey,
|
|
269
|
+
},
|
|
270
|
+
credentials: 'omit',
|
|
271
|
+
mode: 'cors',
|
|
272
|
+
signal: ctrl.signal,
|
|
273
|
+
});
|
|
274
|
+
if (res.ok) return 'ok';
|
|
275
|
+
if (res.status === 413) return 'too_large';
|
|
276
|
+
// 408 (timeout) and 429 (rate limit) are transient; other 4xx means the
|
|
277
|
+
// server will never accept this batch — retrying just re-loses it later.
|
|
278
|
+
if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) return 'fatal';
|
|
279
|
+
return 'retry';
|
|
280
|
+
} catch {
|
|
281
|
+
return 'retry';
|
|
282
|
+
} finally {
|
|
283
|
+
clearTimeout(timeout);
|
|
129
284
|
}
|
|
130
|
-
return false;
|
|
131
285
|
}
|
|
132
286
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
287
|
+
/** Unload flush (pagehide / tab hidden). Fully synchronous — the page may be
|
|
288
|
+
* gone before any await resolves. Ignores the `sending` guard (that in-flight
|
|
289
|
+
* fetch is about to be cancelled; its seq is skipped via seqFloor), chunks the
|
|
290
|
+
* tail into beacon-sized pieces (sendBeacon caps bodies at ~64KB — one big
|
|
291
|
+
* batch would be rejected whole), and spills anything the beacon queue refuses
|
|
292
|
+
* to localStorage for the next page load to resend. */
|
|
293
|
+
private flushUnload(): boolean {
|
|
294
|
+
if (this.buffer.length === 0) return true;
|
|
295
|
+
const events = this.buffer;
|
|
296
|
+
this.buffer = [];
|
|
297
|
+
// Best-effort masking: built-in + whatever custom keys have loaded by now
|
|
298
|
+
// (an unload can't wait for the config fetch).
|
|
299
|
+
redactBatchSensitiveKeys(events);
|
|
300
|
+
|
|
301
|
+
// The in-flight batch (if any) holds this.seq — start past it; a prior
|
|
302
|
+
// unload flush (hidden → pagehide) may have raised the floor further.
|
|
303
|
+
let seq = Math.max(this.sending ? this.seq + 1 : this.seq, this.seqFloor);
|
|
304
|
+
|
|
305
|
+
const pieces: Array<{ gz: Uint8Array; count: number }> = [];
|
|
306
|
+
const stack: ObsrviqEvent[][] = [events];
|
|
307
|
+
while (stack.length > 0) {
|
|
308
|
+
const slice = stack.shift()!;
|
|
309
|
+
let batch: IngestBatch | null = {
|
|
310
|
+
siteKey: this.opts.siteKey,
|
|
311
|
+
sessionId: this.opts.sessionId,
|
|
312
|
+
seq,
|
|
313
|
+
meta: this.opts.getMeta(),
|
|
314
|
+
events: slice,
|
|
315
|
+
};
|
|
316
|
+
if (this.opts.beforeSend) {
|
|
148
317
|
try {
|
|
149
|
-
|
|
318
|
+
batch = this.opts.beforeSend(batch);
|
|
150
319
|
} catch {
|
|
151
|
-
|
|
320
|
+
batch = null;
|
|
152
321
|
}
|
|
153
322
|
}
|
|
323
|
+
if (!batch) continue; // dropped intentionally
|
|
324
|
+
const gz = gzipSync(new TextEncoder().encode(JSON.stringify(batch)));
|
|
325
|
+
if (gz.byteLength > BEACON_PIECE_BYTES && slice.length > 1) {
|
|
326
|
+
// Too big for a beacon — halve and re-stamp (the seq wasn't consumed).
|
|
327
|
+
const mid = Math.ceil(slice.length / 2);
|
|
328
|
+
stack.unshift(slice.slice(0, mid), slice.slice(mid));
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
pieces.push({ gz, count: slice.length });
|
|
332
|
+
seq++;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Persist seq progress BEFORE handing bytes to the beacon queue, so a resumed
|
|
336
|
+
// session can't collide with a piece that's still in flight after unload.
|
|
337
|
+
this.seqFloor = seq;
|
|
338
|
+
this.opts.onSeqDispatch?.(seq);
|
|
339
|
+
|
|
340
|
+
for (const { gz, count } of pieces) {
|
|
341
|
+
const sent = gz.byteLength <= BEACON_PIECE_BYTES && this.sendUnload(gz);
|
|
342
|
+
if (!sent) this.spill(gz); // beacon queue full / single oversized event
|
|
343
|
+
this.opts.onFlush?.({ bytes: gz.byteLength, events: count, ok: sent });
|
|
344
|
+
}
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Fire-and-forget delivery that survives navigation. True = queued. */
|
|
349
|
+
private sendUnload(bytes: Uint8Array): boolean {
|
|
350
|
+
const url = `${this.opts.endpoint.replace(/\/$/, '')}/v1/batch`;
|
|
351
|
+
const blob = new Blob([bytes as unknown as BlobPart], { type: 'application/octet-stream' });
|
|
352
|
+
if (typeof navigator.sendBeacon === 'function') {
|
|
154
353
|
try {
|
|
155
|
-
|
|
156
|
-
() => {},
|
|
157
|
-
);
|
|
158
|
-
return true;
|
|
354
|
+
if (navigator.sendBeacon(url, blob)) return true;
|
|
159
355
|
} catch {
|
|
160
|
-
|
|
356
|
+
/* fall through */
|
|
161
357
|
}
|
|
162
358
|
}
|
|
163
|
-
|
|
164
|
-
// Normal flush: a plain fetch — NO `keepalive`, so there's no 64KB body cap
|
|
165
|
-
// and large FullSnapshot batches actually send. Await the real result so a
|
|
166
|
-
// failure (rejected / 4xx / 5xx / network) re-buffers and retries instead of
|
|
167
|
-
// being silently dropped. A timeout aborts a hung request so it can't stall
|
|
168
|
-
// all future flushes (`sending` stays true until this resolves).
|
|
169
|
-
const ctrl = new AbortController();
|
|
170
|
-
const timeout = setTimeout(() => ctrl.abort(), 15_000);
|
|
171
359
|
try {
|
|
172
|
-
|
|
360
|
+
void fetch(url, {
|
|
173
361
|
method: 'POST',
|
|
174
|
-
body,
|
|
175
|
-
headers
|
|
362
|
+
body: bytes as unknown as BodyInit,
|
|
363
|
+
headers: {
|
|
364
|
+
'content-type': 'application/octet-stream',
|
|
365
|
+
'x-obsrviq-key': this.opts.siteKey,
|
|
366
|
+
},
|
|
367
|
+
keepalive: true,
|
|
176
368
|
credentials: 'omit',
|
|
177
369
|
mode: 'cors',
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return res.ok;
|
|
370
|
+
}).catch(() => {});
|
|
371
|
+
return true;
|
|
181
372
|
} catch {
|
|
182
373
|
return false;
|
|
183
|
-
} finally {
|
|
184
|
-
clearTimeout(timeout);
|
|
185
374
|
}
|
|
186
375
|
}
|
|
187
376
|
|
|
377
|
+
/** Append one gzipped batch to the localStorage spill (bounded, oldest-first
|
|
378
|
+
* eviction). Quota/private-mode failures mean the tail is lost — same as
|
|
379
|
+
* before spilling existed. */
|
|
380
|
+
private spill(gz: Uint8Array) {
|
|
381
|
+
try {
|
|
382
|
+
const raw = window.localStorage.getItem(SPILL_KEY);
|
|
383
|
+
const entries: SpillEntry[] = raw ? (JSON.parse(raw) as SpillEntry[]) : [];
|
|
384
|
+
entries.push({ k: this.opts.siteKey, b: b64encode(gz), ts: Date.now() });
|
|
385
|
+
let total = entries.reduce((n, e) => n + e.b.length, 0);
|
|
386
|
+
while (entries.length > 0 && total > SPILL_MAX_BYTES) {
|
|
387
|
+
total -= entries[0]!.b.length;
|
|
388
|
+
entries.shift();
|
|
389
|
+
}
|
|
390
|
+
window.localStorage.setItem(SPILL_KEY, JSON.stringify(entries));
|
|
391
|
+
} catch {
|
|
392
|
+
/* unavailable / quota — drop */
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Resend batches a previous page's unload spilled. Entries are claimed
|
|
397
|
+
* (removed) up front so two tabs can't double-send; still-transient failures
|
|
398
|
+
* are re-spilled for the next load. Fire-and-forget. */
|
|
399
|
+
private resendSpill() {
|
|
400
|
+
let mine: SpillEntry[] = [];
|
|
401
|
+
try {
|
|
402
|
+
const raw = window.localStorage.getItem(SPILL_KEY);
|
|
403
|
+
if (!raw) return;
|
|
404
|
+
const entries = JSON.parse(raw) as SpillEntry[];
|
|
405
|
+
const now = Date.now();
|
|
406
|
+
const fresh = entries.filter((e) => e && typeof e.b === 'string' && now - e.ts < SPILL_MAX_AGE_MS);
|
|
407
|
+
mine = fresh.filter((e) => e.k === this.opts.siteKey);
|
|
408
|
+
const others = fresh.filter((e) => e.k !== this.opts.siteKey);
|
|
409
|
+
if (others.length > 0) window.localStorage.setItem(SPILL_KEY, JSON.stringify(others));
|
|
410
|
+
else window.localStorage.removeItem(SPILL_KEY);
|
|
411
|
+
} catch {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (mine.length === 0) return;
|
|
415
|
+
void (async () => {
|
|
416
|
+
for (const entry of mine) {
|
|
417
|
+
try {
|
|
418
|
+
const result = await this.send(b64decode(entry.b));
|
|
419
|
+
if (result === 'retry') this.spill(b64decode(entry.b)); // network still down
|
|
420
|
+
// ok / fatal / too_large → done with it either way
|
|
421
|
+
} catch {
|
|
422
|
+
/* drop */
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
})();
|
|
426
|
+
}
|
|
427
|
+
|
|
188
428
|
private onVisibility = () => {
|
|
189
429
|
if (document.visibilityState === 'hidden') void this.flush('hidden');
|
|
190
430
|
};
|
|
@@ -200,3 +440,35 @@ export class Transport {
|
|
|
200
440
|
void this.flush('manual');
|
|
201
441
|
}
|
|
202
442
|
}
|
|
443
|
+
|
|
444
|
+
/** gzip — browser-native async CompressionStream when available (keeps multi-MB
|
|
445
|
+
* snapshot compression off the main thread), fflate's gzipSync otherwise. The
|
|
446
|
+
* unload path always uses gzipSync directly (it can't await). */
|
|
447
|
+
async function gzipBytes(bytes: Uint8Array): Promise<Uint8Array> {
|
|
448
|
+
if (typeof CompressionStream !== 'undefined') {
|
|
449
|
+
try {
|
|
450
|
+
const stream = new Blob([bytes as unknown as BlobPart])
|
|
451
|
+
.stream()
|
|
452
|
+
.pipeThrough(new CompressionStream('gzip'));
|
|
453
|
+
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
454
|
+
} catch {
|
|
455
|
+
/* fall back to sync */
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return gzipSync(bytes);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function b64encode(bytes: Uint8Array): string {
|
|
462
|
+
let bin = '';
|
|
463
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
464
|
+
bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
465
|
+
}
|
|
466
|
+
return btoa(bin);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function b64decode(s: string): Uint8Array {
|
|
470
|
+
const bin = atob(s);
|
|
471
|
+
const out = new Uint8Array(bin.length);
|
|
472
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
473
|
+
return out;
|
|
474
|
+
}
|
package/src/util.ts
CHANGED
|
@@ -64,9 +64,23 @@ export function detectDevice(): {
|
|
|
64
64
|
os?: string;
|
|
65
65
|
browser?: string;
|
|
66
66
|
viewport: { w: number; h: number };
|
|
67
|
+
tz?: string;
|
|
68
|
+
tzOffset?: number;
|
|
67
69
|
} {
|
|
68
70
|
const ua = navigator.userAgent;
|
|
69
71
|
const viewport = { w: window.innerWidth, h: window.innerHeight };
|
|
72
|
+
|
|
73
|
+
// The visitor's own timezone — so the player can show wall-clock times in their
|
|
74
|
+
// local time, not just the viewer's. IANA name (DST-correct) plus a numeric
|
|
75
|
+
// offset EAST of UTC as a fallback label. Both best-effort.
|
|
76
|
+
let tz: string | undefined;
|
|
77
|
+
let tzOffset: number | undefined;
|
|
78
|
+
try {
|
|
79
|
+
tz = Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
|
|
80
|
+
tzOffset = -new Date().getTimezoneOffset();
|
|
81
|
+
} catch {
|
|
82
|
+
/* Intl unavailable — omit timezone */
|
|
83
|
+
}
|
|
70
84
|
let type: 'desktop' | 'mobile' | 'tablet' = 'desktop';
|
|
71
85
|
if (/iPad|Tablet/i.test(ua)) type = 'tablet';
|
|
72
86
|
else if (/Mobi|Android|iPhone/i.test(ua)) type = 'mobile';
|
|
@@ -84,7 +98,7 @@ export function detectDevice(): {
|
|
|
84
98
|
else if (/Firefox\//.test(ua)) browser = 'Firefox';
|
|
85
99
|
else if (/Safari\//.test(ua)) browser = 'Safari';
|
|
86
100
|
|
|
87
|
-
return { type, os, browser, viewport };
|
|
101
|
+
return { type, os, browser, viewport, tz, tzOffset };
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
/** Global Privacy Control / Do Not Track. */
|
package/src/websocket.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { WsEvent } from '@obsrviq/types';
|
|
2
2
|
import type { InstrumentCtx, Teardown } from './context.js';
|
|
3
3
|
import { maskString } from './mask.js';
|
|
4
|
+
import { tokenBucket } from './ratelimit.js';
|
|
4
5
|
import { uuid } from './util.js';
|
|
5
6
|
|
|
6
7
|
const FRAME_CAP = 4 * 1024; // 4 KB text-frame preview cap (mirrors network bodies)
|
|
8
|
+
const MSG_RATE = 20; // sustained message-frame events/sec per socket
|
|
9
|
+
const MSG_BURST = 40; // burst capacity
|
|
7
10
|
|
|
8
11
|
function redactUrl(raw: string): string {
|
|
9
12
|
try {
|
|
@@ -31,12 +34,14 @@ function frameInfo(data: unknown, capture: boolean): FrameInfo {
|
|
|
31
34
|
if (typeof data === 'string') {
|
|
32
35
|
const info: FrameInfo = { encoding: 'text', size: byteLen(data) };
|
|
33
36
|
if (capture) {
|
|
34
|
-
|
|
35
|
-
const
|
|
37
|
+
// Pre-cut giant frames (2× slack) so masking never regex-scans a multi-MB payload.
|
|
38
|
+
const cut = data.length > FRAME_CAP * 2 ? data.slice(0, FRAME_CAP * 2) : data;
|
|
39
|
+
const masked = maskString(cut);
|
|
40
|
+
const truncated = cut !== data || masked.length > FRAME_CAP;
|
|
36
41
|
info.body = {
|
|
37
42
|
preview: truncated ? masked.slice(0, FRAME_CAP) : masked,
|
|
38
43
|
truncated,
|
|
39
|
-
redacted: masked !==
|
|
44
|
+
redacted: masked !== cut,
|
|
40
45
|
};
|
|
41
46
|
}
|
|
42
47
|
return info;
|
|
@@ -80,16 +85,38 @@ export function instrumentWebSocket(ctx: InstrumentCtx): Teardown {
|
|
|
80
85
|
const protos = protocols ? (Array.isArray(protocols) ? protocols : [protocols]) : undefined;
|
|
81
86
|
const capture = ctx.config.captureNetworkBodies;
|
|
82
87
|
|
|
88
|
+
// Per-socket frame throttle — a chatty socket (100s of msg/s) must not
|
|
89
|
+
// flood the buffer. Dropped frames are counted and reported on the next
|
|
90
|
+
// captured frame (or the close event). Lifecycle events (open/close/error)
|
|
91
|
+
// are never throttled.
|
|
92
|
+
const bucket = tokenBucket(MSG_RATE, MSG_BURST);
|
|
93
|
+
let droppedFrames = 0;
|
|
94
|
+
const emitFrame = (dir: 'send' | 'recv', data: unknown) => {
|
|
95
|
+
ctx.markActivity(); // dropped frames still count as app activity
|
|
96
|
+
if (!bucket.take()) {
|
|
97
|
+
droppedFrames++;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const info = frameInfo(data, capture);
|
|
101
|
+
const dropped = droppedFrames;
|
|
102
|
+
droppedFrames = 0;
|
|
103
|
+
emit({ connId, url: redUrl, kind: 'message', dir, ...info, droppedFrames: dropped || undefined });
|
|
104
|
+
};
|
|
105
|
+
|
|
83
106
|
this.addEventListener('open', () => emit({ connId, url: redUrl, kind: 'open', protocols: protos }));
|
|
84
107
|
this.addEventListener('close', (e) =>
|
|
85
|
-
emit({
|
|
108
|
+
emit({
|
|
109
|
+
connId,
|
|
110
|
+
url: redUrl,
|
|
111
|
+
kind: 'close',
|
|
112
|
+
code: e.code,
|
|
113
|
+
reason: e.reason || undefined,
|
|
114
|
+
wasClean: e.wasClean,
|
|
115
|
+
droppedFrames: droppedFrames || undefined,
|
|
116
|
+
}),
|
|
86
117
|
);
|
|
87
118
|
this.addEventListener('error', () => emit({ connId, url: redUrl, kind: 'error' }));
|
|
88
|
-
this.addEventListener('message', (e: MessageEvent) =>
|
|
89
|
-
const info = frameInfo(e.data, capture);
|
|
90
|
-
emit({ connId, url: redUrl, kind: 'message', dir: 'recv', ...info });
|
|
91
|
-
ctx.markActivity();
|
|
92
|
-
});
|
|
119
|
+
this.addEventListener('message', (e: MessageEvent) => emitFrame('recv', e.data));
|
|
93
120
|
|
|
94
121
|
// Capture outgoing frames by shadowing send() on this instance. Call the
|
|
95
122
|
// real send FIRST — if it throws (e.g. send() while CONNECTING) the error
|
|
@@ -98,9 +125,7 @@ export function instrumentWebSocket(ctx: InstrumentCtx): Teardown {
|
|
|
98
125
|
this.send = (data: string | ArrayBufferLike | Blob | ArrayBufferView) => {
|
|
99
126
|
origSend(data);
|
|
100
127
|
try {
|
|
101
|
-
|
|
102
|
-
emit({ connId, url: redUrl, kind: 'message', dir: 'send', ...info });
|
|
103
|
-
ctx.markActivity();
|
|
128
|
+
emitFrame('send', data);
|
|
104
129
|
} catch {
|
|
105
130
|
/* ignore capture errors */
|
|
106
131
|
}
|