@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/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, drop the oldest DOM events (keep structured signal).
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
- this.buffer = this.buffer.filter((e, i) => e.type !== 'dom' || i % 2 === 0);
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
- // Unload flushes (pagehide/hidden) can't wait, so they proceed best-effort.
65
- const unloading = reason === 'pagehide' || reason === 'hidden';
66
- if (!unloading && isMaskConfigPending()) {
67
- if (!this.stopped) setTimeout(() => void this.flush('timer'), 200);
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
- this.sending = false;
99
- return true; // dropped intentionally
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 json = JSON.stringify(batch);
111
- const gz = gzipSync(new TextEncoder().encode(json));
112
- const ok = await this.send(gz, reason === 'pagehide' || reason === 'hidden');
113
- this.opts.onFlush?.({ bytes: gz.byteLength, events: events.length, ok });
114
-
115
- if (ok) {
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
- // Failed: re-buffer (capped) and retry with jitter.
123
- this.buffer = events.slice(-MAX_BUFFER_EVENTS).concat(this.buffer);
124
- this.sending = false;
125
- if (!this.stopped) {
126
- const jitter = this.retryDelay * (0.5 + Math.random());
127
- this.retryDelay = Math.min(this.retryDelay * 2, 30_000);
128
- setTimeout(() => void this.flush('timer'), jitter);
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
- private async send(bytes: Uint8Array, unloading: boolean): Promise<boolean> {
134
- const url = `${this.opts.endpoint.replace(/\/$/, '')}/v1/batch`;
135
- const headers = {
136
- 'content-type': 'application/octet-stream',
137
- 'x-obsrviq-key': this.opts.siteKey,
138
- };
139
- const body = bytes as unknown as BodyInit;
140
-
141
- // Unload path: a normal fetch would be cancelled as the page goes away, so
142
- // use sendBeacon (survives navigation) or a keepalive fetch. BOTH cap the
143
- // body at ~64KB, so large (snapshot) batches are best-effort here — but the
144
- // periodic flushes below send them reliably during normal operation.
145
- if (unloading) {
146
- const blob = new Blob([bytes as unknown as BlobPart], { type: 'application/octet-stream' });
147
- if (typeof navigator.sendBeacon === 'function') {
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
- if (navigator.sendBeacon(url, blob)) return true;
318
+ batch = this.opts.beforeSend(batch);
150
319
  } catch {
151
- /* fall through */
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
- void fetch(url, { method: 'POST', body, headers, keepalive: true, credentials: 'omit', mode: 'cors' }).catch(
156
- () => {},
157
- );
158
- return true;
354
+ if (navigator.sendBeacon(url, blob)) return true;
159
355
  } catch {
160
- return false;
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
- const res = await fetch(url, {
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
- signal: ctrl.signal,
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
- const masked = maskString(data);
35
- const truncated = masked.length > FRAME_CAP;
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 !== data,
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({ connId, url: redUrl, kind: 'close', code: e.code, reason: e.reason || undefined, wasClean: e.wasClean }),
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
- const info = frameInfo(data, capture);
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
  }