@faststats/web 0.13.0 → 0.13.2

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