@faststats/web 0.9.0 → 0.10.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/README.md CHANGED
@@ -78,6 +78,37 @@ since the last delivered checkpoint. The collector must accept successive events
78
78
  for the same visitor and URL; deploy the removal of its five-second debounce
79
79
  before releasing this SDK.
80
80
 
81
+ ### Network recording in session replay
82
+
83
+ Network recording is disabled by default. Enable it with
84
+ `sessionReplay({ recordNetwork: true })`. This records resource URLs and timing
85
+ data using rrweb's network plugin. Headers and bodies are not recorded by default.
86
+ FastStats collection endpoints (including a custom `baseUrl`) are always excluded
87
+ to prevent recording replay uploads recursively.
88
+
89
+ Use `networkOptions` to configure rrweb's plugin, including filtering or redaction:
90
+
91
+ ```ts
92
+ sessionReplay({
93
+ recordNetwork: true,
94
+ networkOptions: {
95
+ initiatorTypes: ["fetch", "xmlhttprequest"],
96
+ transformRequestFn: (request) => {
97
+ const url = new URL(request.name);
98
+ if (url.pathname.startsWith("/private/")) return;
99
+ url.search = "";
100
+ request.name = url.toString();
101
+ return request;
102
+ },
103
+ },
104
+ });
105
+ ```
106
+
107
+ `recordHeaders`, `recordBody`, and `recordInitialRequests` can also be set in
108
+ `networkOptions`. Redact sensitive data before enabling headers or bodies.
109
+ Network options alone do not enable recording. The same replay options are
110
+ available through the React and Nuxt integrations.
111
+
81
112
  ### Custom extensions
82
113
 
83
114
  An extension registers only the lifecycle hooks it needs. Hook failures are
package/dist/error.js CHANGED
@@ -3,7 +3,7 @@ import { r as getSessionContext, t as sendData } from "./chunks/send-data-Dplz7m
3
3
  import { t as getAnonymousId } from "./chunks/identifiers-xX7o7oZz.js";
4
4
  //#region src/error.ts
5
5
  const SDK_NAME = "@faststats/web";
6
- const SDK_VERSION = "0.9.0";
6
+ const SDK_VERSION = "0.10.0";
7
7
  function errorTracking(options = {}) {
8
8
  return {
9
9
  name: "error-tracking",
package/dist/replay.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { t as AnalyticsExtension } from "./chunks/extensions-DW7tJY0T.js";
2
2
  import { record } from "@rrweb/record";
3
3
  import { LogLevel } from "@rrweb/rrweb-plugin-console-record";
4
- //#region ../../node_modules/.bun/@rrweb+types@2.1.4/node_modules/@rrweb/types/dist/index.d.ts
4
+ import { NetworkRecordOptions } from "@rrweb/rrweb-plugin-network-record";
5
+ //#region ../../node_modules/.bun/@rrweb+types@2.1.6/node_modules/@rrweb/types/dist/index.d.ts
5
6
  declare global {
6
7
  interface Window {
7
8
  FontFace: typeof FontFace;
@@ -45,6 +46,10 @@ export interface ReplayTrackerOptions {
45
46
  consoleLevel?: LogLevel[];
46
47
  consoleLengthThreshold?: number;
47
48
  consoleStringLengthLimit?: number;
49
+ /** Opt in to network recording. Headers and bodies are off by default. */
50
+ recordNetwork?: boolean;
51
+ /** rrweb network options; only used when recordNetwork is true. */
52
+ networkOptions?: NetworkRecordOptions;
48
53
  mutationThrottle?: ReplayMutationThrottleOptions;
49
54
  }
50
55
  export type SessionReplayExtensionOptions = Omit<ReplayTrackerOptions, "siteKey" | "baseUrl" | "debug" | "trackHash" | "cookieless">;
@@ -80,11 +85,10 @@ export default class ReplayTracker {
80
85
  private stopRecording?;
81
86
  private activeSessionId;
82
87
  private sending;
83
- private inFlightBatch;
84
88
  private queuedFlush;
85
89
  private unsubscribeRotation?;
86
90
  private lastCookielessMode;
87
- private terminalFlushRequested;
91
+ private needsFinalization;
88
92
  private initialFlushPending;
89
93
  private overflowed;
90
94
  private replayEventSequence;
@@ -95,13 +99,14 @@ export default class ReplayTracker {
95
99
  private clearQueues;
96
100
  private dropMinLengthBlockedBatches;
97
101
  private unblockSessionBatches;
98
- private finalizeSessionPending;
102
+ private finalizeSession;
99
103
  private nextBatchSequence;
100
104
  setCookielessMode(cookieless: boolean): void;
101
105
  start(): void;
102
106
  trackPageChange(url?: string): void;
103
107
  onPageHidden(persisted?: boolean, terminal?: boolean): void;
104
108
  onPageShow(persisted?: boolean): void;
109
+ private recordingPlugins;
105
110
  private beginRecording;
106
111
  stop(discard?: boolean): void;
107
112
  private onSessionRotated;
@@ -123,6 +128,7 @@ export default class ReplayTracker {
123
128
  private removePending;
124
129
  private enqueueBatch;
125
130
  private flush;
131
+ private sendPending;
126
132
  private scheduleRetry;
127
133
  private retryDelay;
128
134
  private sendBatch;
package/dist/replay.js CHANGED
@@ -3,6 +3,7 @@ import { i as onSessionRotated, l as setStorageItem, n as createId, o as touchAc
3
3
  import { t as getAnonymousId } from "./chunks/identifiers-xX7o7oZz.js";
4
4
  import { record } from "@rrweb/record";
5
5
  import { getRecordConsolePlugin } from "@rrweb/rrweb-plugin-console-record";
6
+ import { getRecordNetworkPlugin } from "@rrweb/rrweb-plugin-network-record";
6
7
  //#region src/replay-mutation-throttler.ts
7
8
  const INCREMENTAL_SNAPSHOT_EVENT_TYPE = 3;
8
9
  const MUTATION_SOURCE_TYPE = 0;
@@ -117,11 +118,10 @@ var ReplayTracker = class {
117
118
  stopRecording;
118
119
  activeSessionId = "";
119
120
  sending = false;
120
- inFlightBatch = null;
121
121
  queuedFlush = null;
122
122
  unsubscribeRotation;
123
123
  lastCookielessMode;
124
- terminalFlushRequested = false;
124
+ needsFinalization = false;
125
125
  initialFlushPending = true;
126
126
  overflowed = false;
127
127
  replayEventSequence = 0;
@@ -152,12 +152,14 @@ var ReplayTracker = class {
152
152
  this.minLengthFlushTask = null;
153
153
  }
154
154
  clearQueues() {
155
+ this.needsFinalization = false;
155
156
  this.events.length = 0;
156
157
  this.queuedEventsSizeBytes = 0;
157
158
  this.pending.length = 0;
158
159
  this.pendingSizeBytes = 0;
159
160
  }
160
161
  dropMinLengthBlockedBatches() {
162
+ this.needsFinalization = false;
161
163
  for (let index = this.pending.length - 1; index >= 0; index--) {
162
164
  const batch = this.pending[index];
163
165
  if (batch && this.minLengthBlockedBatches.has(batch)) this.removePending(batch);
@@ -166,18 +168,9 @@ var ReplayTracker = class {
166
168
  unblockSessionBatches(sessionId) {
167
169
  for (const batch of this.pending) if (batch.sessionId === sessionId) this.minLengthBlockedBatches.delete(batch);
168
170
  }
169
- finalizeSessionPending(context, flushReason) {
170
- const batch = this.pending.findLast((candidate) => candidate.sessionId === context.sessionId);
171
- if (!batch || batch.isFinal) return;
172
- if (batch === this.inFlightBatch) {
173
- this.enqueueBatch(this.createBatch([], context, true, flushReason));
174
- return;
175
- }
176
- const previousSize = this.batchSizeBytes(batch);
177
- batch.isFinal = true;
178
- batch.flushReason = flushReason;
179
- batchSizeCache.delete(batch);
180
- this.pendingSizeBytes += this.batchSizeBytes(batch) - previousSize;
171
+ finalizeSession(context, flushReason) {
172
+ if (!this.needsFinalization) return;
173
+ this.enqueueBatch(this.createBatch([], context, true, flushReason));
181
174
  }
182
175
  nextBatchSequence(context) {
183
176
  const key = `faststats_replay_batch_sequence_${this.options.siteKey}_${context.windowId}`;
@@ -204,7 +197,6 @@ var ReplayTracker = class {
204
197
  if (this.started || typeof window === "undefined") return;
205
198
  this.started = true;
206
199
  this.disposed = false;
207
- this.terminalFlushRequested = false;
208
200
  this.initialFlushPending = true;
209
201
  this.overflowed = false;
210
202
  this.replayEventSequence = 0;
@@ -243,13 +235,13 @@ var ReplayTracker = class {
243
235
  this.requestTerminalFlush("pageHidden");
244
236
  return;
245
237
  }
246
- this.flush(false, false, "pageHidden");
238
+ this.flush(true, false, "pageHidden");
247
239
  }
248
240
  onPageShow(persisted = false) {
249
241
  if (!persisted || !this.started) return;
250
242
  if (this.pending.length > 0) this.flush(false, false, "pageShow");
251
243
  }
252
- beginRecording() {
244
+ recordingPlugins() {
253
245
  const plugins = [];
254
246
  if (this.options.recordConsole === true) plugins.push(getRecordConsolePlugin({
255
247
  level: this.options.consoleLevel ?? [
@@ -265,6 +257,25 @@ var ReplayTracker = class {
265
257
  stringLengthLimit: this.options.consoleStringLengthLimit ?? 500
266
258
  }
267
259
  }));
260
+ if (this.options.recordNetwork === true) {
261
+ const options = this.options.networkOptions;
262
+ const base = resolveBaseUrl(this.options.baseUrl, ANALYTICS_BASE);
263
+ const endpoints = new Set(Object.values(URLS).map((path) => {
264
+ const url = new URL(`${base}${path}`, window.location.href);
265
+ return `${url.origin}${url.pathname}`;
266
+ }));
267
+ plugins.push(getRecordNetworkPlugin({
268
+ ...options,
269
+ transformRequestFn: (request) => {
270
+ const url = new URL(request.name, window.location.href);
271
+ if (endpoints.has(`${url.origin}${url.pathname}`)) return;
272
+ return options?.transformRequestFn ? options.transformRequestFn(request) : request;
273
+ }
274
+ }));
275
+ }
276
+ return plugins;
277
+ }
278
+ beginRecording() {
268
279
  const stop = record({
269
280
  emit: this.onEvent,
270
281
  sampling: this.options.sampling ?? {
@@ -297,7 +308,7 @@ var ReplayTracker = class {
297
308
  maskTextSelector: this.options.maskTextSelector,
298
309
  checkoutEveryNms: this.options.checkoutEveryNms ?? 3e5,
299
310
  checkoutEveryNth: this.options.checkoutEveryNth,
300
- plugins
311
+ plugins: this.recordingPlugins()
301
312
  });
302
313
  this.stopRecording = stop;
303
314
  }
@@ -342,8 +353,9 @@ var ReplayTracker = class {
342
353
  isFinal: true,
343
354
  flushReason: "sessionRotate"
344
355
  });
345
- this.finalizeSessionPending(prev, "sessionRotate");
356
+ this.finalizeSession(prev, "sessionRotate");
346
357
  this.unblockSessionBatches(prev.sessionId);
358
+ this.needsFinalization = false;
347
359
  this.activeSessionId = next.sessionId;
348
360
  this.resetSessionTiming(next);
349
361
  this.beginReplayStream();
@@ -407,8 +419,6 @@ var ReplayTracker = class {
407
419
  this.requestTerminalFlush("unload");
408
420
  };
409
421
  requestTerminalFlush(reason) {
410
- if (this.terminalFlushRequested) return;
411
- this.terminalFlushRequested = true;
412
422
  if (!this.hasReachedMinLength()) {
413
423
  this.events.length = 0;
414
424
  this.queuedEventsSizeBytes = 0;
@@ -530,6 +540,7 @@ var ReplayTracker = class {
530
540
  this.stopRecording = void 0;
531
541
  return;
532
542
  }
543
+ this.needsFinalization = !batch.isFinal;
533
544
  this.pending.push(batch);
534
545
  this.pendingSizeBytes += size;
535
546
  }
@@ -542,12 +553,15 @@ var ReplayTracker = class {
542
553
  flushReason,
543
554
  maxBatchSizeBytes: lowLatency ? this.maxLowLatencyBatchSizeBytes : this.maxBatchSizeBytes
544
555
  });
545
- if (isFinal) this.finalizeSessionPending(context, flushReason);
556
+ if (isFinal) this.finalizeSession(context, flushReason);
546
557
  if (this.pending.length === 0) return;
547
558
  if (isFinal) for (const batch of this.pending) this.minLengthBlockedBatches.delete(batch);
548
559
  else if (!rotation && flushReason !== "sessionRotate" && this.hasReachedMinLength()) this.unblockSessionBatches(context.sessionId);
560
+ await this.sendPending(lowLatency, lowLatency && isFinal);
561
+ }
562
+ async sendPending(lowLatency, bypassRetryDelay) {
549
563
  if (this.retryTask) {
550
- if (!lowLatency || !isFinal) return;
564
+ if (!bypassRetryDelay) return;
551
565
  clearTimeout(this.retryTask);
552
566
  this.retryTask = null;
553
567
  }
@@ -555,8 +569,7 @@ var ReplayTracker = class {
555
569
  const queued = this.queuedFlush;
556
570
  this.queuedFlush = {
557
571
  lowLatency: (queued?.lowLatency ?? false) || lowLatency,
558
- isFinal: queued?.isFinal || isFinal,
559
- flushReason: isFinal ? flushReason : queued?.flushReason ?? flushReason
572
+ bypassRetryDelay: (queued?.bypassRetryDelay ?? false) || bypassRetryDelay
560
573
  };
561
574
  return;
562
575
  }
@@ -565,14 +578,7 @@ var ReplayTracker = class {
565
578
  while (this.pending.length > 0) {
566
579
  const batch = this.pending[0];
567
580
  if (this.minLengthBlockedBatches.has(batch)) break;
568
- this.inFlightBatch = batch;
569
- let sent;
570
- try {
571
- sent = await this.sendBatch(batch, lowLatency);
572
- } finally {
573
- if (this.inFlightBatch === batch) this.inFlightBatch = null;
574
- }
575
- if (!sent) {
581
+ if (!await this.sendBatch(batch, lowLatency)) {
576
582
  this.log(`Failed to send replay batch ${batch.sequence}`);
577
583
  if (!this.disposed) this.scheduleRetry();
578
584
  break;
@@ -586,7 +592,7 @@ var ReplayTracker = class {
586
592
  if (this.queuedFlush) {
587
593
  const queued = this.queuedFlush;
588
594
  this.queuedFlush = null;
589
- this.flush(queued.lowLatency, queued.isFinal, queued.flushReason);
595
+ this.sendPending(queued.lowLatency, queued.bypassRetryDelay);
590
596
  }
591
597
  }
592
598
  }
package/package.json CHANGED
@@ -42,7 +42,7 @@
42
42
  "publishConfig": {
43
43
  "access": "public"
44
44
  },
45
- "version": "0.9.0",
45
+ "version": "0.10.0",
46
46
  "scripts": {
47
47
  "build": "tsdown && bun run check-size",
48
48
  "dev": "bun run build",
@@ -51,14 +51,15 @@
51
51
  "check-size": "node scripts/check-bundle-size.mjs"
52
52
  },
53
53
  "devDependencies": {
54
- "@rrweb/types": "^2.1.4",
54
+ "@rrweb/types": "^2.1.6",
55
55
  "@types/bun": "^1.4.2",
56
56
  "tsdown": "^0.23.0",
57
57
  "typescript": "^7.0.2"
58
58
  },
59
59
  "dependencies": {
60
- "@rrweb/record": "^2.1.4",
61
- "@rrweb/rrweb-plugin-console-record": "^2.1.4",
60
+ "@rrweb/record": "^2.1.6",
61
+ "@rrweb/rrweb-plugin-console-record": "^2.1.6",
62
+ "@rrweb/rrweb-plugin-network-record": "^2.1.6",
62
63
  "web-vitals": "^6.2.1"
63
64
  }
64
65
  }