@faststats/web 0.13.0 → 0.13.1

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/replay.js CHANGED
@@ -1,6 +1,6 @@
1
- import { a as resolveBaseUrl, n as URLS, o as sanitizeUrl, t as sendData } from "./chunks/send-data-C76HbZ8d.js";
2
- import { a as touchActivity, c as setStorageItem, n as getSessionContext, o as getStorageItem, r as onSessionRotated, t as createId } from "./chunks/session-manager-DUpF3mi3.js";
3
- import { t as getAnonymousId } from "./chunks/identifiers-DOtve3E5.js";
1
+ import { a as resolveBaseUrl, c as trackerExtension, n as URLS, o as sanitizeUrl, t as sendData } from "./chunks/send-data-B7P3f5PT.js";
2
+ import { a as touchActivity, n as getSessionContext, o as getStorageItem, r as onSessionRotated, s as setStorageItem, t as createId } from "./chunks/session-manager-VM5RZj9H.js";
3
+ import { t as getAnonymousId } from "./chunks/identifiers-3X8XI1pD.js";
4
4
  import { record } from "@rrweb/record";
5
5
  import { getRecordConsolePlugin } from "@rrweb/rrweb-plugin-console-record";
6
6
  import { getRecordNetworkPlugin } from "@rrweb/rrweb-plugin-network-record";
@@ -92,226 +92,337 @@ function nextReplaySequence(siteKey, context, cookieless) {
92
92
  return sequence;
93
93
  }
94
94
  //#endregion
95
- //#region src/replay.ts
96
- const MAX_TIMEOUT_MS = 2147483647;
97
- const DEFAULT_MAX_QUEUE_SIZE_BYTES = 2097152;
98
- const DEFAULT_MAX_BATCH_SIZE_BYTES = 921600;
99
- const KEEPALIVE_LIMIT_BYTES = 61440;
100
- const DEFAULT_MAX_LOW_LATENCY_BATCH_SIZE_BYTES = 57344;
95
+ //#region src/replay-uploader.ts
101
96
  const RETRY_BASE_DELAY_MS = 2e3;
102
97
  const RETRY_MAX_ATTEMPTS = 5;
103
98
  const RETRY_COOLDOWN_MS = 3e5;
104
- const VIEW_META_EVENT_TYPE = 5;
105
- const RRWEB_EVENT_FULL_SNAPSHOT = 2;
106
- const RRWEB_EVENT_META = 4;
107
- const REPLAY_SEQUENTIAL_ID_KEY = "_faststatsSeqId";
99
+ const KEEPALIVE_LIMIT_BYTES = 61440;
108
100
  const textEncoder = new TextEncoder();
109
- const eventSizeCache = /* @__PURE__ */ new WeakMap();
110
- const batchSizeCache = /* @__PURE__ */ new WeakMap();
101
+ const sizeCache = /* @__PURE__ */ new WeakMap();
102
+ /** UTF-8 size of `value` as JSON, cached since queued values never change. */
103
+ function jsonBytes(value) {
104
+ let size = sizeCache.get(value);
105
+ if (size === void 0) {
106
+ size = textEncoder.encode(JSON.stringify(value)).byteLength;
107
+ sizeCache.set(value, size);
108
+ }
109
+ return size;
110
+ }
111
+ function retryDelay(attempt) {
112
+ if (attempt >= RETRY_MAX_ATTEMPTS) return RETRY_COOLDOWN_MS;
113
+ const ceiling = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
114
+ return Math.round(ceiling * (.5 + Math.random() * .5));
115
+ }
111
116
  async function gzip(json) {
112
117
  const stream = new Blob([json]).stream().pipeThrough(new CompressionStream("gzip"));
113
118
  return new Uint8Array(await new Response(stream).arrayBuffer());
114
119
  }
115
- function boundedInteger(value, fallback, min, max) {
116
- if (value === void 0 || !Number.isFinite(value) || value < min) return fallback;
117
- return Math.min(Math.trunc(value), max);
120
+ var ReplayUploader = class {
121
+ options;
122
+ queue = [];
123
+ /** Bytes of every queued batch, which count against the replay budget. */
124
+ bytes = 0;
125
+ sending;
126
+ lowLatency = false;
127
+ retryTimer;
128
+ attempt = 0;
129
+ constructor(options) {
130
+ this.options = options;
131
+ }
132
+ get length() {
133
+ return this.queue.length;
134
+ }
135
+ add(batch) {
136
+ this.queue.push(batch);
137
+ this.bytes += jsonBytes(batch);
138
+ }
139
+ clear() {
140
+ this.queue.length = 0;
141
+ this.bytes = 0;
142
+ this.cancelRetry();
143
+ }
144
+ cancelRetry() {
145
+ clearTimeout(this.retryTimer);
146
+ this.retryTimer = void 0;
147
+ this.attempt = 0;
148
+ }
149
+ /** Runs a scheduled retry now, keeping the backoff of earlier failures. */
150
+ retryNow() {
151
+ clearTimeout(this.retryTimer);
152
+ this.retryTimer = void 0;
153
+ if (this.attempt === RETRY_MAX_ATTEMPTS) this.attempt = 0;
154
+ return this.send(false);
155
+ }
156
+ /** Sends the queue in order. Waits out a scheduled retry instead. */
157
+ send(lowLatency) {
158
+ if (!this.sending && (!this.queue.length || this.retryTimer)) return Promise.resolve();
159
+ this.lowLatency ||= lowLatency;
160
+ this.sending ??= this.drain();
161
+ return this.sending;
162
+ }
163
+ /**
164
+ * Hands every batch to the browser at once, newest first, since the page may
165
+ * be gone before an awaited request settles. Resending a batch that is
166
+ * already in flight is idempotent on the server.
167
+ */
168
+ sendAll() {
169
+ for (const batch of [...this.queue].reverse()) this.post(batch, true).then((sent) => sent && this.remove(batch));
170
+ }
171
+ async drain() {
172
+ try {
173
+ for (let batch = this.queue[0]; batch; batch = this.queue[0]) {
174
+ if (!await this.post(batch, this.lowLatency)) {
175
+ this.options.log(`Failed to send replay batch ${batch.sequence}`);
176
+ if (this.options.canRetry()) this.scheduleRetry();
177
+ return;
178
+ }
179
+ this.attempt = 0;
180
+ this.remove(batch);
181
+ }
182
+ } finally {
183
+ this.sending = void 0;
184
+ this.lowLatency = false;
185
+ }
186
+ }
187
+ scheduleRetry() {
188
+ this.attempt = Math.min(this.attempt + 1, RETRY_MAX_ATTEMPTS);
189
+ const delay = retryDelay(this.attempt);
190
+ this.options.log(`Retrying replay delivery in ${delay}ms`);
191
+ this.retryTimer = setTimeout(() => void this.retryNow(), delay);
192
+ }
193
+ remove(batch) {
194
+ const index = this.queue.indexOf(batch);
195
+ if (index < 0) return;
196
+ this.queue.splice(index, 1);
197
+ this.bytes -= jsonBytes(batch);
198
+ this.options.log(`Sent replay batch ${batch.sequence}`);
199
+ }
200
+ async post(batch, lowLatency) {
201
+ const json = JSON.stringify(batch);
202
+ const keepalive = lowLatency && jsonBytes(batch) <= KEEPALIVE_LIMIT_BYTES;
203
+ let data = json;
204
+ let contentType = "application/json";
205
+ let query = "";
206
+ if (!lowLatency && this.options.compress) try {
207
+ data = await gzip(json);
208
+ contentType = "application/octet-stream";
209
+ query = "?encoding=gzip";
210
+ } catch {
211
+ this.options.log("Compression failed, using uncompressed");
212
+ }
213
+ return sendData({
214
+ url: `${this.options.endpoint}${query}`,
215
+ data,
216
+ contentType,
217
+ debug: this.options.debug,
218
+ debugPrefix: "[Replay]",
219
+ useBeacon: keepalive,
220
+ keepalive,
221
+ settleRejected: true
222
+ });
223
+ }
224
+ };
225
+ //#endregion
226
+ //#region src/replay.ts
227
+ const MAX_TIMEOUT_MS = 2147483647;
228
+ const LOW_LATENCY_BATCH_BYTES = 57344;
229
+ const LIMITS = {
230
+ flushInterval: [
231
+ 5e3,
232
+ 1,
233
+ MAX_TIMEOUT_MS
234
+ ],
235
+ maxEvents: [
236
+ 1e3,
237
+ 1,
238
+ 1e5
239
+ ],
240
+ maxBatchSizeBytes: [
241
+ 921600,
242
+ 1,
243
+ 33554432
244
+ ],
245
+ maxLowLatencyBatchSizeBytes: [
246
+ LOW_LATENCY_BATCH_BYTES,
247
+ 1,
248
+ LOW_LATENCY_BATCH_BYTES
249
+ ],
250
+ maxPendingBatches: [
251
+ 30,
252
+ 1,
253
+ 1e3
254
+ ],
255
+ maxQueueSizeBytes: [
256
+ 2097152,
257
+ 1,
258
+ 33554432
259
+ ],
260
+ minReplayLengthMs: [
261
+ 3e3,
262
+ 0,
263
+ MAX_TIMEOUT_MS
264
+ ]
265
+ };
266
+ const RRWEB_FULL_SNAPSHOT = 2;
267
+ const RRWEB_META = 4;
268
+ const RRWEB_CUSTOM = 5;
269
+ const SEQUENCE_KEY = "_faststatsSeqId";
270
+ function limits(options) {
271
+ const entries = Object.entries(LIMITS).map(([name, [fallback, min, max]]) => {
272
+ const value = options[name];
273
+ return [name, value === void 0 || !Number.isFinite(value) || value < min ? fallback : Math.min(Math.trunc(value), max)];
274
+ });
275
+ return Object.fromEntries(entries);
118
276
  }
119
- function sessionReplay(options = {}) {
120
- return {
121
- name: "session-replay",
122
- setup(context) {
123
- const tracker = new ReplayTracker({
124
- ...options,
125
- siteKey: context.siteKey,
126
- baseUrl: context.baseUrl,
127
- debug: context.debug,
128
- trackHash: context.trackHash,
129
- cookieless: context.isCookieless()
130
- });
131
- context.on("pageChange", ({ url }) => tracker.trackPageChange(url));
132
- context.on("pageHide", ({ persisted, terminal }) => tracker.onPageHidden(persisted, terminal));
133
- context.on("pageShow", ({ persisted }) => tracker.onPageShow(persisted));
134
- context.on("consentChange", ({ cookieless }) => tracker.setCookielessMode(cookieless));
135
- tracker.start();
136
- return ({ discard }) => tracker.stop(discard);
277
+ /** Splits events into chunks of at most `maxBytes`, keeping oversized ones whole. */
278
+ function chunk(events, maxBytes) {
279
+ const chunks = [];
280
+ let current = [];
281
+ let size = 0;
282
+ for (const event of events) {
283
+ const bytes = jsonBytes(event) + 1;
284
+ if (!current.length || size + bytes > maxBytes) {
285
+ current = [];
286
+ chunks.push(current);
287
+ size = 0;
137
288
  }
138
- };
289
+ current.push(event);
290
+ size += bytes;
291
+ }
292
+ return chunks;
293
+ }
294
+ function sessionReplay(options = {}) {
295
+ return trackerExtension("session-replay", (context) => new ReplayTracker({
296
+ ...options,
297
+ ...context
298
+ }), (tracker, context) => {
299
+ context.on("pageChange", ({ url }) => tracker.trackPageChange(url));
300
+ context.on("pageShow", ({ persisted }) => tracker.onPageShow(persisted));
301
+ });
139
302
  }
140
303
  var ReplayTracker = class {
141
304
  options;
142
- endpoint;
143
- compress;
144
- flushInterval;
145
- maxEvents;
146
- maxBatchSizeBytes;
147
- maxLowLatencyBatchSizeBytes;
148
- maxPendingBatches;
149
- maxQueueSizeBytes;
150
- minReplayLengthMs;
151
- events = [];
152
- pending = [];
153
- queuedEventsSizeBytes = 0;
154
- pendingSizeBytes = 0;
155
- viewId = createId();
156
- viewUrl = "";
157
- chunkStartedAt = 0;
305
+ limits;
306
+ uploader;
307
+ throttler;
308
+ cookieless;
158
309
  started = false;
159
- disposed = false;
160
- startTime = 0;
161
- intervalId = null;
162
- flushTask = null;
163
- retryTask = null;
164
- retryAttempt = 0;
165
- minLengthFlushTask = null;
310
+ overflowed = false;
166
311
  stopRecording;
167
- activeSessionId = "";
168
- sending = false;
169
- queuedFlush = null;
170
- unsubscribeRotation;
171
- lastCookielessMode;
172
- needsFinalization = false;
173
- initialFlushPending = true;
312
+ stopFollowing;
313
+ intervalTimer;
314
+ flushTimer;
315
+ minLengthTimer;
316
+ viewId = createId();
317
+ viewUrl = "";
318
+ session;
319
+ streamStart = 0;
320
+ chunkStart = 0;
321
+ sequence = 0;
322
+ events = [];
323
+ eventBytes = 0;
174
324
  resumed = false;
175
- overflowed = false;
176
- replayEventSequence = 0;
177
- mutationThrottler;
325
+ unfinalized = false;
178
326
  constructor(options) {
179
327
  this.options = options;
180
- this.lastCookielessMode = options.cookieless ?? false;
181
- this.endpoint = `${resolveBaseUrl(options.baseUrl)}${URLS.replay}`;
182
- this.compress = (options.compress ?? true) && typeof window !== "undefined" && "CompressionStream" in window;
183
- this.flushInterval = boundedInteger(options.flushInterval, 5e3, 1, MAX_TIMEOUT_MS);
184
- this.maxEvents = boundedInteger(options.maxEvents, 1e3, 1, 1e5);
185
- this.maxBatchSizeBytes = boundedInteger(options.maxBatchSizeBytes, DEFAULT_MAX_BATCH_SIZE_BYTES, 1, 33554432);
186
- this.maxLowLatencyBatchSizeBytes = boundedInteger(options.maxLowLatencyBatchSizeBytes, DEFAULT_MAX_LOW_LATENCY_BATCH_SIZE_BYTES, 1, DEFAULT_MAX_LOW_LATENCY_BATCH_SIZE_BYTES);
187
- this.maxPendingBatches = boundedInteger(options.maxPendingBatches, 30, 1, 1e3);
188
- this.maxQueueSizeBytes = boundedInteger(options.maxQueueSizeBytes, DEFAULT_MAX_QUEUE_SIZE_BYTES, 1, 33554432);
189
- this.minReplayLengthMs = boundedInteger(options.minReplayLengthMs, 3e3, 0, MAX_TIMEOUT_MS);
190
- this.mutationThrottler = options.mutationThrottle?.enabled === false ? null : new ReplayMutationThrottler(options.mutationThrottle);
191
- }
192
- log(...args) {
193
- if (this.options.debug) console.log("[Replay]", ...args);
194
- }
195
- sessionContext() {
196
- return getSessionContext(this.options.siteKey, this.lastCookielessMode);
197
- }
198
- sanitize(url) {
199
- return sanitizeUrl(url, this.options.trackHash ?? false);
200
- }
201
- clearFlushTimers() {
202
- if (this.flushTask) clearTimeout(this.flushTask);
203
- if (this.retryTask) clearTimeout(this.retryTask);
204
- if (this.minLengthFlushTask) clearTimeout(this.minLengthFlushTask);
205
- this.flushTask = null;
206
- this.retryTask = null;
207
- this.retryAttempt = 0;
208
- this.minLengthFlushTask = null;
209
- }
210
- dropEvents() {
211
- this.events.length = 0;
212
- this.queuedEventsSizeBytes = 0;
213
- }
214
- clearQueues() {
215
- this.needsFinalization = false;
216
- this.dropEvents();
217
- this.pending.length = 0;
218
- this.pendingSizeBytes = 0;
219
- }
220
- finalizeSession(context, flushReason) {
221
- if (!this.needsFinalization) return;
222
- const batch = this.createBatch([], context, true, flushReason);
223
- if (batch) this.enqueueBatch(batch);
224
- }
225
- setCookielessMode(cookieless) {
226
- if (this.lastCookielessMode === cookieless) return;
227
- this.lastCookielessMode = cookieless;
228
- if (this.started) this.subscribeToSessionRotation();
229
- this.clearQueues();
230
- this.clearFlushTimers();
231
- const context = this.sessionContext();
232
- this.activeSessionId = context.sessionId;
233
- this.resetSessionTiming(context);
234
- this.beginReplayStream();
328
+ this.cookieless = options.cookieless ?? false;
329
+ this.limits = limits(options);
330
+ this.uploader = new ReplayUploader({
331
+ endpoint: `${resolveBaseUrl(options.baseUrl)}${URLS.replay}`,
332
+ compress: (options.compress ?? true) && typeof window !== "undefined" && "CompressionStream" in window,
333
+ debug: options.debug,
334
+ canRetry: () => this.started,
335
+ log: (...details) => this.log(...details)
336
+ });
337
+ this.throttler = options.mutationThrottle?.enabled === false ? null : new ReplayMutationThrottler(options.mutationThrottle);
235
338
  }
236
339
  start() {
237
340
  if (this.started || typeof window === "undefined") return;
238
341
  this.started = true;
239
- this.disposed = false;
240
- this.initialFlushPending = true;
241
342
  this.overflowed = false;
242
- this.replayEventSequence = 0;
243
343
  this.viewId = createId();
244
344
  this.viewUrl = this.sanitize(window.location.href);
245
- const context = this.sessionContext();
246
- this.activeSessionId = context.sessionId;
247
- this.resetSessionTiming(context);
248
- this.subscribeToSessionRotation();
345
+ this.beginStream(this.currentSession());
346
+ this.followSession();
249
347
  this.beginRecording();
250
- this.intervalId = setInterval(() => this.requestFlush("interval"), this.flushInterval);
348
+ this.intervalTimer = setInterval(() => this.requestFlush("interval"), this.limits.flushInterval);
251
349
  this.log("Recording started");
252
350
  }
351
+ stop(discard = false) {
352
+ if (!this.started) return;
353
+ this.started = false;
354
+ this.stopFollowing?.();
355
+ this.stopRecording?.();
356
+ this.stopRecording = void 0;
357
+ clearInterval(this.intervalTimer);
358
+ clearTimeout(this.flushTimer);
359
+ clearTimeout(this.minLengthTimer);
360
+ this.flushTimer = void 0;
361
+ this.uploader.cancelRetry();
362
+ if (discard) {
363
+ this.dropEvents();
364
+ this.uploader.clear();
365
+ this.log("Recording discarded");
366
+ return;
367
+ }
368
+ this.finish("stop");
369
+ this.log("Recording stopped");
370
+ }
371
+ /** Nothing captured under the old identity may be sent under the new one. */
372
+ setCookielessMode(cookieless) {
373
+ if (this.cookieless === cookieless) return;
374
+ this.cookieless = cookieless;
375
+ clearTimeout(this.flushTimer);
376
+ this.flushTimer = void 0;
377
+ this.uploader.clear();
378
+ this.dropEvents();
379
+ this.unfinalized = false;
380
+ if (!this.started) return;
381
+ this.beginStream(this.currentSession());
382
+ this.followSession();
383
+ this.takeFullSnapshot();
384
+ }
253
385
  trackPageChange(url) {
254
386
  if (!this.started) return;
255
387
  this.viewId = createId();
256
- const href = this.sanitize(url ?? window.location.href);
257
- this.viewUrl = href;
388
+ this.viewUrl = this.sanitize(url ?? window.location.href);
258
389
  this.onEvent({
259
- type: VIEW_META_EVENT_TYPE,
390
+ type: RRWEB_CUSTOM,
260
391
  timestamp: Date.now(),
261
392
  data: {
262
393
  tag: "faststats:view",
263
394
  payload: {
264
- href,
395
+ href: this.viewUrl,
265
396
  viewId: this.viewId
266
397
  }
267
398
  }
268
399
  });
269
400
  }
270
401
  onPageHidden(persisted = false, terminal = false) {
271
- if (persisted) return;
272
- if (terminal) {
273
- this.requestTerminalFlush("pageHidden");
274
- return;
275
- }
276
- this.flush(true, false, "pageHidden");
402
+ if (persisted || !this.started) return;
403
+ if (terminal) this.finish("pageHidden");
404
+ else this.flush("pageHidden", true);
277
405
  }
278
406
  onPageShow(persisted = false) {
279
407
  if (!persisted || !this.started) return;
280
- if (this.pending.length > 0) this.flush(false, false, "pageShow");
408
+ this.uploader.retryNow();
281
409
  }
282
- recordingPlugins() {
283
- const plugins = [];
284
- if (this.options.recordConsole === true) plugins.push(getRecordConsolePlugin({
285
- level: this.options.consoleLevel ?? [
286
- "error",
287
- "warn",
288
- "info",
289
- "log"
290
- ],
291
- lengthThreshold: this.options.consoleLengthThreshold ?? 200,
292
- stringifyOptions: {
293
- numOfKeysLimit: 25,
294
- depthOfLimit: 3,
295
- stringLengthLimit: this.options.consoleStringLengthLimit ?? 500
296
- }
297
- }));
298
- if (this.options.recordNetwork === true) {
299
- const options = this.options.networkOptions;
300
- const base = resolveBaseUrl(this.options.baseUrl);
301
- const endpoints = new Set(Object.values(URLS).map((path) => {
302
- const url = new URL(`${base}${path}`, window.location.href);
303
- return `${url.origin}${url.pathname}`;
304
- }));
305
- plugins.push(getRecordNetworkPlugin({
306
- ...options,
307
- transformRequestFn: (request) => {
308
- const url = new URL(request.name, window.location.href);
309
- if (endpoints.has(`${url.origin}${url.pathname}`)) return;
310
- return options?.transformRequestFn ? options.transformRequestFn(request) : request;
311
- }
312
- }));
313
- }
314
- return plugins;
410
+ log(...details) {
411
+ if (this.options.debug) console.log("[Replay]", ...details);
412
+ }
413
+ sanitize(url) {
414
+ return sanitizeUrl(url, this.options.trackHash ?? false);
415
+ }
416
+ currentSession() {
417
+ return getSessionContext(this.options.siteKey, this.cookieless);
418
+ }
419
+ followSession() {
420
+ this.stopFollowing?.();
421
+ this.stopFollowing = onSessionRotated(this.options.siteKey, this.cookieless, (_prev, next) => {
422
+ if (!this.started) return;
423
+ this.rotate(next);
424
+ if (this.uploader.length) queueMicrotask(() => void this.flush("sessionRotate"));
425
+ });
315
426
  }
316
427
  beginRecording() {
317
428
  this.stopRecording = record({
@@ -349,46 +460,42 @@ var ReplayTracker = class {
349
460
  plugins: this.recordingPlugins()
350
461
  });
351
462
  }
352
- stop(discard = false) {
353
- if (!this.started) return;
354
- this.started = false;
355
- this.disposed = true;
356
- this.unsubscribeRotation?.();
357
- this.unsubscribeRotation = void 0;
358
- this.stopRecording?.();
359
- this.stopRecording = void 0;
360
- if (this.intervalId) clearInterval(this.intervalId);
361
- this.clearFlushTimers();
362
- this.intervalId = null;
363
- if (discard) {
364
- this.clearQueues();
365
- this.log("Recording discarded");
366
- return;
463
+ recordingPlugins() {
464
+ const plugins = [];
465
+ if (this.options.recordConsole === true) plugins.push(getRecordConsolePlugin({
466
+ level: this.options.consoleLevel ?? [
467
+ "error",
468
+ "warn",
469
+ "info",
470
+ "log"
471
+ ],
472
+ lengthThreshold: this.options.consoleLengthThreshold ?? 200,
473
+ stringifyOptions: {
474
+ numOfKeysLimit: 25,
475
+ depthOfLimit: 3,
476
+ stringLengthLimit: this.options.consoleStringLengthLimit ?? 500
477
+ }
478
+ }));
479
+ if (this.options.recordNetwork === true) {
480
+ const options = this.options.networkOptions;
481
+ const base = resolveBaseUrl(this.options.baseUrl);
482
+ const route = (url) => {
483
+ const { origin, pathname } = new URL(url, window.location.href);
484
+ return origin + pathname;
485
+ };
486
+ const endpoints = new Set(Object.values(URLS).map((path) => route(base + path)));
487
+ plugins.push(getRecordNetworkPlugin({
488
+ ...options,
489
+ transformRequestFn: (request) => {
490
+ if (endpoints.has(route(request.name))) return;
491
+ const transform = options?.transformRequestFn;
492
+ return transform ? transform(request) : request;
493
+ }
494
+ }));
367
495
  }
368
- this.requestTerminalFlush("stop");
369
- this.log("Recording stopped");
496
+ return plugins;
370
497
  }
371
- onSessionRotated(prev, next) {
372
- if (!this.started) return;
373
- this.applySessionRotation(prev, next);
374
- if (this.pending.length > 0) queueMicrotask(() => void this.flush(false, false, "sessionRotate"));
375
- }
376
- subscribeToSessionRotation() {
377
- this.unsubscribeRotation?.();
378
- this.unsubscribeRotation = onSessionRotated(this.options.siteKey, this.lastCookielessMode, (prev, next) => this.onSessionRotated(prev, next));
379
- }
380
- applySessionRotation(prev, next) {
381
- if (this.activeSessionId === next.sessionId) return;
382
- if (this.hasReachedMinLength()) this.enqueueEventsIfReady(prev, "sessionRotate");
383
- else this.dropEvents();
384
- this.finalizeSession(prev, "sessionRotate");
385
- this.needsFinalization = false;
386
- this.activeSessionId = next.sessionId;
387
- this.resetSessionTiming(next);
388
- this.beginReplayStream();
389
- }
390
- beginReplayStream() {
391
- this.replayEventSequence = 0;
498
+ takeFullSnapshot() {
392
499
  const stopRecording = this.stopRecording;
393
500
  if (!stopRecording) return;
394
501
  try {
@@ -397,7 +504,6 @@ var ReplayTracker = class {
397
504
  this.stopRecording = void 0;
398
505
  try {
399
506
  stopRecording();
400
- this.replayEventSequence = 0;
401
507
  this.beginRecording();
402
508
  } catch (restartError) {
403
509
  this.log("Failed to restart replay stream", {
@@ -407,261 +513,139 @@ var ReplayTracker = class {
407
513
  }
408
514
  }
409
515
  }
410
- resetSessionTiming(next) {
411
- if (this.minLengthFlushTask) clearTimeout(this.minLengthFlushTask);
412
- this.minLengthFlushTask = null;
413
- this.resumed = hasReplaySequence(this.options.siteKey, next, this.lastCookielessMode);
414
- this.startTime = Date.now();
415
- this.chunkStartedAt = Date.now();
416
- this.initialFlushPending = true;
516
+ stopCapture(reason) {
517
+ this.log(reason);
518
+ this.overflowed = true;
519
+ this.stopRecording?.();
520
+ this.stopRecording = void 0;
417
521
  }
418
522
  onEvent = (event) => {
419
523
  if (this.overflowed) return;
420
- let capturedEvent = event;
421
- if (event.type === RRWEB_EVENT_META) {
422
- const data = event.data;
423
- if (typeof data.href === "string") capturedEvent = {
424
- ...event,
425
- data: {
426
- ...data,
427
- href: this.sanitize(data.href)
428
- }
429
- };
430
- }
431
- if (capturedEvent.type === RRWEB_EVENT_FULL_SNAPSHOT) this.mutationThrottler?.reset();
432
- else {
433
- const throttled = this.mutationThrottler?.throttle(capturedEvent);
434
- if (this.mutationThrottler && !throttled) return;
435
- if (throttled) capturedEvent = throttled;
524
+ const { data } = event;
525
+ if (event.type === RRWEB_META && typeof data?.href === "string") event = {
526
+ ...event,
527
+ data: {
528
+ ...data,
529
+ href: this.sanitize(data.href)
530
+ }
531
+ };
532
+ if (event.type === RRWEB_FULL_SNAPSHOT) this.throttler?.reset();
533
+ else if (this.throttler) {
534
+ const throttled = this.throttler.throttle(event);
535
+ if (!throttled) return;
536
+ event = throttled;
436
537
  }
437
- const sequencedEvent = {
438
- ...capturedEvent,
439
- [REPLAY_SEQUENTIAL_ID_KEY]: ++this.replayEventSequence
538
+ const sequenced = {
539
+ ...event,
540
+ [SEQUENCE_KEY]: ++this.sequence
440
541
  };
441
- const eventBytes = this.eventSizeBytes(sequencedEvent);
442
- if (this.pendingSizeBytes + this.queuedEventsSizeBytes + eventBytes > this.maxQueueSizeBytes) {
542
+ const bytes = jsonBytes(sequenced) + 1;
543
+ if (this.uploader.bytes + this.eventBytes + bytes > this.limits.maxQueueSizeBytes) {
443
544
  this.stopCapture("Replay buffer limit reached");
444
545
  return;
445
546
  }
446
- this.events.push(sequencedEvent);
447
- this.queuedEventsSizeBytes += eventBytes;
448
- let reason;
449
- if (this.events.length >= this.maxEvents) reason = "maxEvents";
450
- else if (this.queuedEventsSizeBytes >= this.maxBatchSizeBytes) reason = "maxBytes";
451
- if (reason) this.requestFlush(reason);
452
- else this.scheduleMinLengthFlush();
547
+ this.events.push(sequenced);
548
+ this.eventBytes += bytes;
549
+ if (this.events.length >= this.limits.maxEvents) this.requestFlush("maxEvents");
550
+ else if (this.eventBytes >= this.limits.maxBatchSizeBytes) this.requestFlush("maxBytes");
453
551
  };
454
- requestTerminalFlush(reason) {
455
- if (!this.hasReachedMinLength()) {
456
- this.dropEvents();
457
- this.needsFinalization = false;
458
- this.log(`Recording too short (${Date.now() - this.startTime}ms), discarding events`);
459
- if (this.pending.length === 0) return;
460
- }
461
- this.flush(true, true, reason);
552
+ beginStream(session) {
553
+ this.session = session;
554
+ this.dropEvents();
555
+ this.sequence = 0;
556
+ this.unfinalized = false;
557
+ this.resumed = hasReplaySequence(this.options.siteKey, session, this.cookieless);
558
+ this.streamStart = this.chunkStart = Date.now();
559
+ clearTimeout(this.minLengthTimer);
560
+ if (this.started && !this.reachedMinLength()) this.minLengthTimer = setTimeout(() => this.requestFlush("minLength"), this.limits.minReplayLengthMs);
561
+ }
562
+ rotate(next) {
563
+ if (next.sessionId === this.session.sessionId) return;
564
+ this.batchEvents("sessionRotate");
565
+ this.endStream("sessionRotate");
566
+ this.beginStream(next);
567
+ this.takeFullSnapshot();
568
+ }
569
+ reachedMinLength() {
570
+ return this.resumed || Date.now() - this.streamStart >= this.limits.minReplayLengthMs;
462
571
  }
463
- hasReachedMinLength() {
464
- return this.resumed || this.minReplayLengthMs <= 0 || Date.now() - this.startTime >= this.minReplayLengthMs;
572
+ dropEvents() {
573
+ this.events = [];
574
+ this.eventBytes = 0;
465
575
  }
466
576
  requestFlush(reason) {
467
- if (this.flushTask || this.events.length === 0) return;
468
- if (this.minLengthFlushTask) {
469
- clearTimeout(this.minLengthFlushTask);
470
- this.minLengthFlushTask = null;
471
- }
472
- this.flushTask = setTimeout(() => {
473
- this.flushTask = null;
474
- this.flush(false, false, reason);
475
- }, 0);
476
- }
477
- scheduleMinLengthFlush() {
478
- if (!this.initialFlushPending || this.events.length === 0 || this.minReplayLengthMs <= 0) return;
479
- if (this.hasReachedMinLength()) {
480
- this.initialFlushPending = false;
481
- this.requestFlush("minLength");
482
- return;
577
+ if (this.flushTimer || !this.events.length) return;
578
+ this.flushTimer = setTimeout(() => {
579
+ this.flushTimer = void 0;
580
+ this.flush(reason);
581
+ });
582
+ }
583
+ async flush(reason, lowLatency = false) {
584
+ if (this.events.length) {
585
+ this.rotate(touchActivity(this.options.siteKey, this.cookieless));
586
+ this.batchEvents(reason, lowLatency);
483
587
  }
484
- if (this.minLengthFlushTask) return;
485
- this.minLengthFlushTask = setTimeout(() => {
486
- this.minLengthFlushTask = null;
487
- this.initialFlushPending = false;
488
- this.requestFlush("minLength");
489
- }, this.minReplayLengthMs - (Date.now() - this.startTime));
490
- }
491
- enqueueEventsIfReady(context, flushReason, maxBatchSizeBytes = this.maxBatchSizeBytes) {
492
- if (this.events.length === 0) return;
493
- if (!this.hasReachedMinLength()) {
494
- this.scheduleMinLengthFlush();
495
- return;
588
+ await this.uploader.send(lowLatency);
589
+ }
590
+ finish(reason) {
591
+ if (this.reachedMinLength()) {
592
+ if (this.events.length) this.rotate(touchActivity(this.options.siteKey, this.cookieless));
593
+ this.batchEvents(reason, true);
594
+ this.endStream(reason);
595
+ } else {
596
+ this.log(`Recording too short (${Date.now() - this.streamStart}ms), discarding events`);
597
+ this.dropEvents();
496
598
  }
497
- for (const events of this.takeEventBatches(maxBatchSizeBytes)) {
498
- if (this.pending.length >= this.maxPendingBatches) {
599
+ this.uploader.sendAll();
600
+ }
601
+ batchEvents(reason, lowLatency = false) {
602
+ if (!this.events.length || !this.reachedMinLength()) return;
603
+ const maxBytes = lowLatency ? this.limits.maxLowLatencyBatchSizeBytes : this.limits.maxBatchSizeBytes;
604
+ const events = this.events;
605
+ this.dropEvents();
606
+ for (const batch of chunk(events, maxBytes)) {
607
+ if (this.uploader.length >= this.limits.maxPendingBatches) {
499
608
  this.stopCapture("Replay queue limit reached");
500
- break;
501
- }
502
- const batch = this.createBatch(events, context, false, flushReason);
503
- if (!batch) break;
504
- this.enqueueBatch(batch);
505
- }
506
- this.queuedEventsSizeBytes = 0;
507
- }
508
- takeEventBatches(maxBatchSizeBytes) {
509
- const batches = [];
510
- let current = [];
511
- let currentSize = 0;
512
- for (const event of this.events.splice(0)) {
513
- const eventSize = this.eventSizeBytes(event);
514
- if (current.length > 0 && currentSize + eventSize > maxBatchSizeBytes) {
515
- batches.push(current);
516
- current = [];
517
- currentSize = 0;
609
+ return;
518
610
  }
519
- current.push(event);
520
- currentSize += eventSize;
611
+ if (!this.enqueue(batch, reason)) return;
521
612
  }
522
- if (current.length > 0) batches.push(current);
523
- return batches;
524
613
  }
525
- createBatch(events, context, isFinal, flushReason) {
526
- const identifier = getAnonymousId(this.options.siteKey, this.lastCookielessMode);
527
- const sequence = nextReplaySequence(this.options.siteKey, context, this.lastCookielessMode);
614
+ endStream(reason) {
615
+ if (this.unfinalized) this.enqueue([], reason, true);
616
+ }
617
+ enqueue(events, reason, isFinal = false) {
618
+ const { siteKey } = this.options;
619
+ const { sessionId, windowId, sessionStart } = this.session;
620
+ const sequence = nextReplaySequence(siteKey, this.session, this.cookieless);
528
621
  if (sequence === void 0) {
529
622
  this.stopCapture("Replay sequence state is invalid or exhausted");
530
- return;
623
+ return false;
531
624
  }
532
- const chunkStartedAt = this.chunkStartedAt;
625
+ const identifier = getAnonymousId(siteKey, this.cookieless);
626
+ const chunkStartedAt = this.chunkStart;
533
627
  const now = Date.now();
534
- this.chunkStartedAt = now;
535
- return {
536
- token: this.options.siteKey,
537
- sessionId: context.sessionId,
538
- windowId: context.windowId,
628
+ this.chunkStart = now;
629
+ this.unfinalized = !isFinal;
630
+ this.uploader.add({
631
+ token: siteKey,
632
+ sessionId,
633
+ windowId,
539
634
  viewId: this.viewId,
540
- sessionStart: context.sessionStart,
635
+ sessionStart,
541
636
  chunkStartedAt,
542
637
  chunkEndedAt: now,
543
638
  ...identifier ? { identifier } : {},
544
- batchId: `${context.sessionId}-${sequence}-${chunkStartedAt}`,
639
+ batchId: `${sessionId}-${sequence}-${chunkStartedAt}`,
545
640
  sequence,
546
641
  sequenceContractVersion: 1,
547
642
  timestamp: now,
548
- url: this.viewUrl || this.sanitize(window.location.href),
643
+ url: this.viewUrl,
549
644
  ...isFinal ? { isFinal: true } : {},
550
- flushReason,
645
+ flushReason: reason,
551
646
  events
552
- };
553
- }
554
- eventSizeBytes(event) {
555
- let size = eventSizeCache.get(event);
556
- if (size === void 0) {
557
- size = textEncoder.encode(JSON.stringify(event)).byteLength + 1;
558
- eventSizeCache.set(event, size);
559
- }
560
- return size;
561
- }
562
- batchSizeBytes(batch) {
563
- const cached = batchSizeCache.get(batch);
564
- if (cached !== void 0) return cached;
565
- const size = textEncoder.encode(JSON.stringify(batch)).byteLength;
566
- batchSizeCache.set(batch, size);
567
- return size;
568
- }
569
- removePending(batch) {
570
- const index = this.pending.indexOf(batch);
571
- if (index < 0) return;
572
- this.pending.splice(index, 1);
573
- this.pendingSizeBytes = Math.max(0, this.pendingSizeBytes - this.batchSizeBytes(batch));
574
- this.log(`Sent replay batch ${batch.sequence}`);
575
- }
576
- stopCapture(reason) {
577
- this.log(reason);
578
- this.overflowed = true;
579
- this.stopRecording?.();
580
- this.stopRecording = void 0;
581
- }
582
- enqueueBatch(batch) {
583
- this.needsFinalization = !batch.isFinal;
584
- this.pending.push(batch);
585
- this.pendingSizeBytes += this.batchSizeBytes(batch);
586
- }
587
- async flush(lowLatency, isFinal = false, flushReason = "manual") {
588
- if (this.events.length === 0 && !isFinal) {
589
- await this.sendPending(lowLatency);
590
- return;
591
- }
592
- const rotation = touchActivity(this.options.siteKey, this.lastCookielessMode);
593
- if (rotation) this.applySessionRotation(rotation.prev, rotation.next);
594
- const context = this.sessionContext();
595
- this.enqueueEventsIfReady(context, flushReason, lowLatency ? this.maxLowLatencyBatchSizeBytes : this.maxBatchSizeBytes);
596
- if (!isFinal) return this.sendPending(lowLatency);
597
- this.finalizeSession(context, flushReason);
598
- for (const batch of [...this.pending].reverse()) this.sendBatch(batch, true).then((sent) => sent && this.removePending(batch));
599
- }
600
- async sendPending(lowLatency) {
601
- if (this.retryTask) return;
602
- if (this.sending) {
603
- this.queuedFlush = !!this.queuedFlush || lowLatency;
604
- return;
605
- }
606
- this.sending = true;
607
- try {
608
- while (this.pending.length > 0) {
609
- const batch = this.pending[0];
610
- if (!await this.sendBatch(batch, lowLatency)) {
611
- this.log(`Failed to send replay batch ${batch.sequence}`);
612
- if (!this.disposed) this.scheduleRetry();
613
- break;
614
- }
615
- this.retryAttempt = 0;
616
- this.removePending(batch);
617
- if (this.queuedFlush) break;
618
- }
619
- } finally {
620
- this.sending = false;
621
- if (this.queuedFlush !== null) {
622
- const lowLatency = this.queuedFlush;
623
- this.queuedFlush = null;
624
- this.sendPending(lowLatency);
625
- }
626
- }
627
- }
628
- scheduleRetry() {
629
- if (this.retryTask) return;
630
- this.retryAttempt = Math.min(this.retryAttempt + 1, RETRY_MAX_ATTEMPTS);
631
- const delay = this.retryDelay(this.retryAttempt);
632
- this.log(this.retryAttempt === RETRY_MAX_ATTEMPTS ? `Replay delivery paused for ${delay}ms` : `Retrying replay delivery in ${delay}ms`);
633
- this.retryTask = setTimeout(() => {
634
- this.retryTask = null;
635
- if (this.retryAttempt === RETRY_MAX_ATTEMPTS) this.retryAttempt = 0;
636
- this.sendPending(false);
637
- }, delay);
638
- }
639
- retryDelay(attempt) {
640
- if (attempt >= RETRY_MAX_ATTEMPTS) return RETRY_COOLDOWN_MS;
641
- const ceiling = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
642
- return Math.round(ceiling * (.5 + Math.random() * .5));
643
- }
644
- async sendBatch(batch, lowLatency) {
645
- const json = JSON.stringify(batch);
646
- const keepalive = lowLatency && this.batchSizeBytes(batch) <= KEEPALIVE_LIMIT_BYTES;
647
- const send = (data, contentType, query = "") => sendData({
648
- url: `${this.endpoint}${query}`,
649
- data,
650
- contentType,
651
- debug: this.options.debug,
652
- debugPrefix: "[Replay]",
653
- useBeacon: keepalive,
654
- keepalive,
655
- settleRejected: true
656
647
  });
657
- if (!lowLatency && this.compress) try {
658
- const compressed = await gzip(json);
659
- this.log(`Compressed ${json.length}B -> ${compressed.byteLength}B (${Math.round(compressed.byteLength / json.length * 100)}%)`);
660
- return send(compressed, "application/octet-stream", "?encoding=gzip");
661
- } catch {
662
- this.log("Compression failed, using uncompressed");
663
- }
664
- return send(json, "application/json");
648
+ return true;
665
649
  }
666
650
  };
667
651
  //#endregion