@faststats/web 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/replay.js CHANGED
@@ -1 +1,506 @@
1
- import{a as e,r as t,t as n}from"./chunks/api-urls-Bh77rElT.js";import{a as r,i,n as a,o,r as s,s as c,t as l,u}from"./chunks/send-data-Cc1nxIlg.js";import{t as d}from"./chunks/identifiers-uytEhnH9.js";import{record as f}from"@rrweb/record";const p=new TextEncoder,m=new WeakMap;function h(){return{mousemove:50,mouseInteraction:!0,scroll:150,media:800,input:`last`}}function g(e){return`faststats_replay_sampled_${e}`}function _(e,t){let n=l(t);if(n>=100)return!0;if(n<=0)return!1;let a=i(e).sessionId,o=g(e),s=`${a}:${n}`;try{let e=sessionStorage?.getItem(o);if(e===s)return!0;if(e?.startsWith(`${a}:`))return!1}catch{}let c=r(a)%100<n;try{sessionStorage?.setItem(o,c?s:`${a}:out`)}catch{}return c}var v=class{options;endpoint;compressionSupported=typeof window<`u`&&`CompressionStream`in window;flushInterval;maxEvents;maxBatchSizeBytes;maxPendingBatches;maxQueueSizeBytes;minReplayLengthMs;sampled;events=[];pending=[];queuedEventsSizeBytes=0;pendingSizeBytes=0;viewId=s();chunkStartedAt=0;started=!1;startGeneration=0;startTime=0;sequence=0;intervalId=null;flushTask=null;retryTask=null;minLengthFlushTask=null;stopRecording;sending=!1;flushQueued=!1;flushQueuedLowLatency=!1;unsubscribeRotation;lastCookielessMode=o();constructor(r){this.options=r,this.endpoint=`${e(r.baseUrl,n)}${t.replay}`,this.flushInterval=r.flushInterval??5e3,this.maxEvents=r.maxEvents??1e3,this.maxBatchSizeBytes=r.maxBatchSizeBytes??524288,this.maxPendingBatches=r.maxPendingBatches??30,this.maxQueueSizeBytes=r.maxQueueSizeBytes??2097152,this.minReplayLengthMs=r.minReplayLengthMs??3e3,this.sampled=_(r.siteKey,r.samplingPercentage??100)}log(...e){this.options.debug&&console.log(`[Replay]`,...e)}clearFlushTimers(){this.flushTask&&clearTimeout(this.flushTask),this.retryTask&&clearTimeout(this.retryTask),this.minLengthFlushTask&&clearTimeout(this.minLengthFlushTask),this.flushTask=null,this.retryTask=null,this.minLengthFlushTask=null}clearQueues(){this.events.length=0,this.queuedEventsSizeBytes=0,this.pending.length=0,this.pendingSizeBytes=0}setCookielessMode(e){this.lastCookielessMode!==e&&(this.lastCookielessMode=e,e&&(this.clearQueues(),this.clearFlushTimers()),this.startTime=i(this.options.siteKey).sessionStart,this.chunkStartedAt=Date.now())}start(){if(this.started||typeof window>`u`||!this.sampled)return;this.started=!0,this.viewId=s(),this.startTime=i(this.options.siteKey).sessionStart,this.chunkStartedAt=Date.now(),this.unsubscribeRotation=c(e=>{this.onSessionRotated(e)});let e=++this.startGeneration;this.beginRecording(e),this.intervalId=setInterval(()=>this.requestFlush(`interval`),this.flushInterval),window.addEventListener(`beforeunload`,this.onUnload),this.log(`Recording started`)}trackPageChange(e){this.started&&(this.viewId=s(),this.chunkStartedAt=Date.now(),this.onEvent({type:5,timestamp:Date.now(),data:{tag:`faststats:view`,payload:{href:e??window.location.href,viewId:this.viewId}}},!1))}onPageHidden(e=!1){e||this.flush(!0,!0,`pageHidden`)}onPageShow(e=!1){!e||!this.started||this.pending.length>0&&this.flush(!1,!1,`pageShow`)}async beginRecording(e){let t=this.options.recordConsole===!1?null:await import(`@rrweb/rrweb-plugin-console-record`);if(e!==this.startGeneration)return;let n=[];t&&n.push(t.getRecordConsolePlugin());let r=f({emit:this.onEvent,sampling:this.options.sampling??h(),slimDOMOptions:this.options.slimDOMOptions??{script:!0,comment:!0,headFavicon:!0,headWhitespace:!0,headMetaDescKeywords:!0,headMetaSocial:!0,headMetaRobots:!0,headMetaHttpEquiv:!0,headMetaAuthorship:!0},maskAllInputs:this.options.maskAllInputs??!0,maskInputOptions:this.options.maskInputOptions??{password:!0,email:!0,tel:!0},blockClass:this.options.blockClass,blockSelector:this.options.blockSelector,maskTextClass:this.options.maskTextClass,maskTextSelector:this.options.maskTextSelector,checkoutEveryNms:this.options.checkoutEveryNms??6e4,checkoutEveryNth:this.options.checkoutEveryNth,plugins:n});if(e!==this.startGeneration){r?.();return}this.stopRecording=r}stop(){this.started&&(this.started=!1,this.startGeneration++,this.unsubscribeRotation?.(),this.unsubscribeRotation=void 0,this.stopRecording?.(),this.stopRecording=void 0,this.intervalId&&clearInterval(this.intervalId),this.clearFlushTimers(),this.intervalId=null,window.removeEventListener(`beforeunload`,this.onUnload),this.hasReachedMinLength()||(this.events.length=0,this.queuedEventsSizeBytes=0,this.log(`Session too short (${Date.now()-this.startTime}ms), discarding events`)),this.flush(!0,!0,`stop`),this.log(`Recording stopped`))}onSessionRotated(e){this.started&&(this.enqueueEventsIfReady(e,!0,`sessionRotate`),this.pending.length>0&&queueMicrotask(()=>void this.flush(!1)))}onEvent=(e,t)=>{if(this.events.push(e),this.queuedEventsSizeBytes+=this.eventSizeBytes(e),t){this.requestFlush(`checkout`);return}if(this.events.length>=this.maxEvents){this.requestFlush(`maxEvents`);return}if(this.queuedEventsSizeBytes>=this.maxBatchSizeBytes){this.requestFlush(`maxBytes`);return}if(e.type===2&&this.hasReachedMinLength()){this.requestFlush(`fullSnapshot`);return}this.scheduleMinLengthFlush()};onUnload=()=>{this.flush(!0,!0,`unload`)};hasReachedMinLength(){return this.minReplayLengthMs<=0||Date.now()-this.startTime>=this.minReplayLengthMs}requestFlush=(e=`manual`)=>{this.flushTask||this.events.length===0||(this.minLengthFlushTask&&=(clearTimeout(this.minLengthFlushTask),null),this.flushTask=setTimeout(()=>{this.flushTask=null,this.flush(!1,!1,e)},0))};scheduleMinLengthFlush(){if(this.minLengthFlushTask||this.events.length===0||this.hasReachedMinLength())return;let e=Math.max(0,this.minReplayLengthMs-(Date.now()-this.startTime));this.minLengthFlushTask=setTimeout(()=>{this.minLengthFlushTask=null,this.requestFlush(`minLength`)},Math.min(e,2147483647))}enqueueEventsIfReady(e,t,n=`manual`){if(this.events.length!==0){if(!t&&!this.hasReachedMinLength()){this.scheduleMinLengthFlush();return}this.enqueueBatch(this.createBatch(this.events.splice(0),e,t,n)),this.queuedEventsSizeBytes=0}}createBatch(e,t,n,r=`manual`){let i=d(),a=this.sequence++,o=this.chunkStartedAt,s=Date.now();return this.chunkStartedAt=s,{token:this.options.siteKey,sessionId:t.sessionId,windowId:t.windowId,viewId:this.viewId,sessionStart:t.sessionStart,chunkStartedAt:o,chunkEndedAt:s,...i?{identifier:i}:{},batchId:`${t.sessionId}-${a}-${o}`,sequence:a,timestamp:s,url:window.location.href,...n?{isFinal:!0}:{},flushReason:r,events:e}}eventSizeBytes(e){return p.encode(JSON.stringify(e)).byteLength+1}batchSizeBytes(e){let t=m.get(e);if(t!==void 0)return t;let n=p.encode(JSON.stringify(e)).byteLength;return m.set(e,n),n}dropOldestPending(e){let t=this.pending.shift();t&&(this.pendingSizeBytes=Math.max(0,this.pendingSizeBytes-this.batchSizeBytes(t)),this.log(`${e}, dropping batch ${t.sequence}`))}enqueueBatch(e){let t=this.batchSizeBytes(e);if(t>this.maxQueueSizeBytes){this.log(`Replay batch ${e.sequence} is ${t}B, exceeding ${this.maxQueueSizeBytes}B queue limit; dropping`);return}for(;(this.pending.length>=this.maxPendingBatches||this.pendingSizeBytes+t>this.maxQueueSizeBytes)&&this.pending.length>0;)this.dropOldestPending(`Pending queue limit reached`);this.pending.push(e),this.pendingSizeBytes+=t}async flush(e,t,n=`manual`){let r=u(this.options.siteKey);if(r&&this.enqueueEventsIfReady(r.prev,!0,`sessionRotate`),this.enqueueEventsIfReady(i(this.options.siteKey),t,n),this.pending.length!==0){if(this.sending){this.flushQueued=!0,this.flushQueuedLowLatency||=e;return}this.sending=!0;try{for(;this.pending.length>0;){let t=this.pending[0];if(!t)break;if(!await this.sendBatch(t,e)){this.log(`Failed to send replay batch ${t.sequence}, retrying`),this.scheduleRetry();break}this.dropOldestPending(`Sent replay batch`),e=!1}}finally{if(this.sending=!1,this.flushQueued){let e=this.flushQueuedLowLatency;this.flushQueued=!1,this.flushQueuedLowLatency=!1,this.flush(e)}}}}scheduleRetry(){this.retryTask||=setTimeout(()=>{this.retryTask=null,this.flush(!1)},1e3)}async sendBatch(e,t){let n=JSON.stringify(e),r={debug:!1,useBeacon:t,keepalive:t};if(!t&&(this.options.compress??!0)&&this.compressionSupported)try{let e=new Blob([n]).stream().pipeThrough(new CompressionStream(`gzip`)),t=new Uint8Array(await new Response(e).arrayBuffer());return this.log(`Compressed ${n.length}B -> ${t.byteLength}B (${Math.round(t.byteLength/n.length*100)}%)`),a({url:`${this.endpoint}?encoding=gzip`,data:t,contentType:`application/octet-stream`,...r})}catch{this.log(`Compression failed, using uncompressed`)}return a({url:this.endpoint,data:n,contentType:`application/json`,...r})}};export{v as default};
1
+ import { a as resolveBaseUrl, o as sanitizeUrl, r as URLS, t as ANALYTICS_BASE } from "./chunks/api-urls-CaNa8upY.js";
2
+ import { a as onSessionRotated, c as touchActivity, d as setStorageItem, i as isCookielessMode, l as getStorageItem, n as createId, r as getSessionContext, t as sendData } from "./chunks/send-data-DjdbGDDc.js";
3
+ import { t as getAnonymousId } from "./chunks/identifiers-cUymVK5H.js";
4
+ import { record } from "@rrweb/record";
5
+ //#region src/replay.ts
6
+ const MAX_TIMEOUT_MS = 2147483647;
7
+ const DEFAULT_MAX_QUEUE_SIZE_BYTES = 2 * 1024 * 1024;
8
+ const DEFAULT_MAX_BATCH_SIZE_BYTES = 512 * 1024;
9
+ const DEFAULT_MAX_LOW_LATENCY_BATCH_SIZE_BYTES = 60 * 1024;
10
+ const RETRY_BASE_DELAY_MS = 2e3;
11
+ const RETRY_MAX_ATTEMPTS = 5;
12
+ const RETRY_COOLDOWN_MS = 5 * 6e4;
13
+ const VIEW_META_EVENT_TYPE = 5;
14
+ const RRWEB_EVENT_FULL_SNAPSHOT = 2;
15
+ const RRWEB_EVENT_META = 4;
16
+ const REPLAY_SEQUENTIAL_ID_KEY = "_faststatsSeqId";
17
+ const textEncoder = new TextEncoder();
18
+ const batchSizeCache = /* @__PURE__ */ new WeakMap();
19
+ var ReplayTracker = class {
20
+ options;
21
+ endpoint;
22
+ compressionSupported = typeof window !== "undefined" && "CompressionStream" in window;
23
+ flushInterval;
24
+ maxEvents;
25
+ maxBatchSizeBytes;
26
+ maxLowLatencyBatchSizeBytes;
27
+ maxPendingBatches;
28
+ maxQueueSizeBytes;
29
+ minReplayLengthMs;
30
+ events = [];
31
+ pending = [];
32
+ minLengthBlockedBatches = /* @__PURE__ */ new WeakSet();
33
+ queuedEventsSizeBytes = 0;
34
+ pendingSizeBytes = 0;
35
+ viewId = createId();
36
+ chunkStartedAt = 0;
37
+ started = false;
38
+ disposed = false;
39
+ startGeneration = 0;
40
+ startTime = 0;
41
+ sequence = 0;
42
+ intervalId = null;
43
+ flushTask = null;
44
+ retryTask = null;
45
+ retryAttempt = 0;
46
+ minLengthFlushTask = null;
47
+ stopRecording;
48
+ sending = false;
49
+ inFlightBatch = null;
50
+ queuedFlush = null;
51
+ unsubscribeRotation;
52
+ lastCookielessMode = isCookielessMode();
53
+ constructor(options) {
54
+ this.options = options;
55
+ this.endpoint = `${resolveBaseUrl(options.baseUrl, ANALYTICS_BASE)}${URLS.replay}`;
56
+ this.flushInterval = options.flushInterval ?? 5e3;
57
+ this.maxEvents = options.maxEvents ?? 1e3;
58
+ this.maxBatchSizeBytes = options.maxBatchSizeBytes ?? DEFAULT_MAX_BATCH_SIZE_BYTES;
59
+ this.maxLowLatencyBatchSizeBytes = options.maxLowLatencyBatchSizeBytes ?? DEFAULT_MAX_LOW_LATENCY_BATCH_SIZE_BYTES;
60
+ this.maxPendingBatches = options.maxPendingBatches ?? 30;
61
+ this.maxQueueSizeBytes = options.maxQueueSizeBytes ?? DEFAULT_MAX_QUEUE_SIZE_BYTES;
62
+ this.minReplayLengthMs = options.minReplayLengthMs ?? 3e3;
63
+ }
64
+ log(...args) {
65
+ if (this.options.debug) console.log("[Replay]", ...args);
66
+ }
67
+ clearFlushTimers() {
68
+ if (this.flushTask) clearTimeout(this.flushTask);
69
+ if (this.retryTask) clearTimeout(this.retryTask);
70
+ if (this.minLengthFlushTask) clearTimeout(this.minLengthFlushTask);
71
+ this.flushTask = null;
72
+ this.retryTask = null;
73
+ this.retryAttempt = 0;
74
+ this.minLengthFlushTask = null;
75
+ }
76
+ clearQueues() {
77
+ this.events.length = 0;
78
+ this.queuedEventsSizeBytes = 0;
79
+ this.pending.length = 0;
80
+ this.pendingSizeBytes = 0;
81
+ }
82
+ dropMinLengthBlockedBatches() {
83
+ for (let index = this.pending.length - 1; index >= 0; index--) {
84
+ const batch = this.pending[index];
85
+ if (batch && this.minLengthBlockedBatches.has(batch)) this.removePending(batch);
86
+ }
87
+ }
88
+ unblockSessionBatches(sessionId) {
89
+ for (const batch of this.pending) if (batch.sessionId === sessionId) this.minLengthBlockedBatches.delete(batch);
90
+ }
91
+ finalizeSessionPending(context, flushReason) {
92
+ const batch = this.pending.findLast((candidate) => candidate.sessionId === context.sessionId);
93
+ if (!batch || batch.isFinal) return;
94
+ if (batch === this.inFlightBatch) {
95
+ this.enqueueBatch(this.createBatch([], context, true, flushReason));
96
+ return;
97
+ }
98
+ const previousSize = this.batchSizeBytes(batch);
99
+ batch.isFinal = true;
100
+ batch.flushReason = flushReason;
101
+ batchSizeCache.delete(batch);
102
+ this.pendingSizeBytes += this.batchSizeBytes(batch) - previousSize;
103
+ }
104
+ nextBatchSequence(context) {
105
+ const key = `faststats_replay_batch_sequence_${this.options.siteKey}_${context.windowId}`;
106
+ let sequence = this.sequence;
107
+ const parsed = Number.parseInt(getStorageItem("sessionStorage", key) ?? "", 10);
108
+ if (Number.isFinite(parsed) && parsed >= 0) sequence = parsed;
109
+ setStorageItem("sessionStorage", key, String(sequence + 1));
110
+ this.sequence = sequence + 1;
111
+ return sequence;
112
+ }
113
+ setCookielessMode(cookieless) {
114
+ if (this.lastCookielessMode === cookieless) return;
115
+ this.lastCookielessMode = cookieless;
116
+ if (cookieless) {
117
+ this.clearQueues();
118
+ this.clearFlushTimers();
119
+ }
120
+ this.startTime = getSessionContext(this.options.siteKey).sessionStart;
121
+ this.chunkStartedAt = Date.now();
122
+ }
123
+ start() {
124
+ if (this.started || typeof window === "undefined") return;
125
+ this.started = true;
126
+ this.disposed = false;
127
+ this.viewId = createId();
128
+ this.startTime = getSessionContext(this.options.siteKey).sessionStart;
129
+ this.chunkStartedAt = Date.now();
130
+ this.unsubscribeRotation = onSessionRotated((prev, next) => {
131
+ this.onSessionRotated(prev, next);
132
+ });
133
+ const generation = ++this.startGeneration;
134
+ this.beginRecording(generation);
135
+ this.intervalId = setInterval(() => this.requestFlush("interval"), this.flushInterval);
136
+ window.addEventListener("beforeunload", this.onUnload);
137
+ this.log("Recording started");
138
+ }
139
+ trackPageChange(url) {
140
+ if (!this.started) return;
141
+ this.enqueueEventsIfReady(getSessionContext(this.options.siteKey), {
142
+ flushReason: "navigation",
143
+ sealBeforeMinLength: true
144
+ });
145
+ this.viewId = createId();
146
+ this.chunkStartedAt = Date.now();
147
+ const href = sanitizeUrl(url ?? window.location.href);
148
+ this.onEvent({
149
+ type: VIEW_META_EVENT_TYPE,
150
+ timestamp: Date.now(),
151
+ data: {
152
+ tag: "faststats:view",
153
+ payload: {
154
+ href,
155
+ viewId: this.viewId
156
+ }
157
+ }
158
+ }, false);
159
+ if (this.pending.length > 0) queueMicrotask(() => void this.flush(false));
160
+ }
161
+ onPageHidden(persisted = false) {
162
+ if (persisted) return;
163
+ this.flush(true, true, "pageHidden");
164
+ }
165
+ onPageShow(persisted = false) {
166
+ if (!persisted || !this.started) return;
167
+ if (this.pending.length > 0) this.flush(false, false, "pageShow");
168
+ }
169
+ async beginRecording(generation) {
170
+ const [consolePluginModule, sequentialIdPluginModule] = await Promise.all([this.options.recordConsole === true ? import("@rrweb/rrweb-plugin-console-record") : Promise.resolve(null), import("@rrweb/rrweb-plugin-sequential-id-record")]);
171
+ if (generation !== this.startGeneration) return;
172
+ const plugins = [sequentialIdPluginModule.getRecordSequentialIdPlugin({ key: REPLAY_SEQUENTIAL_ID_KEY })];
173
+ if (consolePluginModule) plugins.push(consolePluginModule.getRecordConsolePlugin({
174
+ level: this.options.consoleLevel ?? [
175
+ "error",
176
+ "warn",
177
+ "info",
178
+ "log"
179
+ ],
180
+ lengthThreshold: this.options.consoleLengthThreshold ?? 200,
181
+ stringifyOptions: {
182
+ numOfKeysLimit: 25,
183
+ depthOfLimit: 3,
184
+ stringLengthLimit: this.options.consoleStringLengthLimit ?? 500
185
+ }
186
+ }));
187
+ const stop = record({
188
+ emit: this.onEvent,
189
+ sampling: this.options.sampling ?? {
190
+ mousemove: 50,
191
+ mouseInteraction: true,
192
+ scroll: 150,
193
+ media: 800,
194
+ input: "last"
195
+ },
196
+ slimDOMOptions: this.options.slimDOMOptions ?? {
197
+ script: true,
198
+ comment: true,
199
+ headFavicon: true,
200
+ headWhitespace: true,
201
+ headMetaDescKeywords: true,
202
+ headMetaSocial: true,
203
+ headMetaRobots: true,
204
+ headMetaHttpEquiv: true,
205
+ headMetaAuthorship: true
206
+ },
207
+ maskAllInputs: this.options.maskAllInputs ?? true,
208
+ maskInputOptions: this.options.maskInputOptions ?? {
209
+ password: true,
210
+ email: true,
211
+ tel: true
212
+ },
213
+ blockClass: this.options.blockClass,
214
+ blockSelector: this.options.blockSelector,
215
+ maskTextClass: this.options.maskTextClass,
216
+ maskTextSelector: this.options.maskTextSelector,
217
+ checkoutEveryNms: this.options.checkoutEveryNms ?? 6e4,
218
+ checkoutEveryNth: this.options.checkoutEveryNth,
219
+ plugins
220
+ });
221
+ if (generation !== this.startGeneration) {
222
+ stop?.();
223
+ return;
224
+ }
225
+ this.stopRecording = stop;
226
+ }
227
+ stop() {
228
+ if (!this.started) return;
229
+ this.started = false;
230
+ this.disposed = true;
231
+ this.startGeneration++;
232
+ this.unsubscribeRotation?.();
233
+ this.unsubscribeRotation = void 0;
234
+ this.stopRecording?.();
235
+ this.stopRecording = void 0;
236
+ if (this.intervalId) clearInterval(this.intervalId);
237
+ this.clearFlushTimers();
238
+ this.intervalId = null;
239
+ window.removeEventListener("beforeunload", this.onUnload);
240
+ if (!this.hasReachedMinLength()) {
241
+ this.events.length = 0;
242
+ this.queuedEventsSizeBytes = 0;
243
+ this.dropMinLengthBlockedBatches();
244
+ this.log(`Session too short (${Date.now() - this.startTime}ms), discarding events`);
245
+ }
246
+ this.flush(true, true, "stop");
247
+ this.log("Recording stopped");
248
+ }
249
+ onSessionRotated(prev, next) {
250
+ if (!this.started) return;
251
+ this.applySessionRotation(prev, next);
252
+ if (this.pending.length > 0) queueMicrotask(() => void this.flush(false, false, "sessionRotate"));
253
+ }
254
+ applySessionRotation(prev, next) {
255
+ this.enqueueEventsIfReady(prev, {
256
+ isFinal: true,
257
+ flushReason: "sessionRotate"
258
+ });
259
+ this.finalizeSessionPending(prev, "sessionRotate");
260
+ this.unblockSessionBatches(prev.sessionId);
261
+ this.resetSessionTiming(next);
262
+ }
263
+ resetSessionTiming(next) {
264
+ if (this.minLengthFlushTask) clearTimeout(this.minLengthFlushTask);
265
+ this.minLengthFlushTask = null;
266
+ this.startTime = next.sessionStart;
267
+ this.chunkStartedAt = Date.now();
268
+ }
269
+ onEvent = (event, isCheckout) => {
270
+ let capturedEvent = event;
271
+ if (event.type === RRWEB_EVENT_META) {
272
+ const data = event.data;
273
+ if (typeof data.href === "string") capturedEvent = {
274
+ ...event,
275
+ data: {
276
+ ...data,
277
+ href: sanitizeUrl(data.href)
278
+ }
279
+ };
280
+ }
281
+ this.events.push(capturedEvent);
282
+ this.queuedEventsSizeBytes += this.eventSizeBytes(capturedEvent);
283
+ let reason;
284
+ if (isCheckout) reason = "checkout";
285
+ else if (this.events.length >= this.maxEvents) reason = "maxEvents";
286
+ else if (this.queuedEventsSizeBytes >= this.maxBatchSizeBytes) reason = "maxBytes";
287
+ else if (capturedEvent.type === RRWEB_EVENT_FULL_SNAPSHOT && this.hasReachedMinLength()) reason = "fullSnapshot";
288
+ if (reason) this.requestFlush(reason);
289
+ else this.scheduleMinLengthFlush();
290
+ };
291
+ onUnload = () => {
292
+ this.flush(true, true, "unload");
293
+ };
294
+ hasReachedMinLength() {
295
+ return this.minReplayLengthMs <= 0 || Date.now() - this.startTime >= this.minReplayLengthMs;
296
+ }
297
+ requestFlush = (reason = "manual") => {
298
+ if (this.flushTask || this.events.length === 0) return;
299
+ if (this.minLengthFlushTask) {
300
+ clearTimeout(this.minLengthFlushTask);
301
+ this.minLengthFlushTask = null;
302
+ }
303
+ this.flushTask = setTimeout(() => {
304
+ this.flushTask = null;
305
+ this.flush(false, false, reason);
306
+ }, 0);
307
+ };
308
+ scheduleMinLengthFlush() {
309
+ if (this.minLengthFlushTask || this.events.length === 0 || this.hasReachedMinLength()) return;
310
+ const remainingMs = Math.max(0, this.minReplayLengthMs - (Date.now() - this.startTime));
311
+ this.minLengthFlushTask = setTimeout(() => {
312
+ this.minLengthFlushTask = null;
313
+ this.requestFlush("minLength");
314
+ }, Math.min(remainingMs, MAX_TIMEOUT_MS));
315
+ }
316
+ enqueueEventsIfReady(context, options = {}) {
317
+ const { isFinal = false, flushReason = "manual", maxBatchSizeBytes = this.maxBatchSizeBytes, sealBeforeMinLength = false } = options;
318
+ if (this.events.length === 0) return;
319
+ if (!isFinal && !sealBeforeMinLength && !this.hasReachedMinLength()) {
320
+ this.scheduleMinLengthFlush();
321
+ return;
322
+ }
323
+ const batches = this.takeEventBatches(maxBatchSizeBytes);
324
+ const blockedByMinLength = sealBeforeMinLength && !this.hasReachedMinLength();
325
+ for (const [index, events] of batches.entries()) {
326
+ const batch = this.createBatch(events, context, isFinal && index === batches.length - 1, flushReason);
327
+ if (blockedByMinLength) this.minLengthBlockedBatches.add(batch);
328
+ this.enqueueBatch(batch);
329
+ }
330
+ this.queuedEventsSizeBytes = 0;
331
+ }
332
+ takeEventBatches(maxBatchSizeBytes) {
333
+ const batches = [];
334
+ let current = [];
335
+ let currentSize = 0;
336
+ for (const event of this.events.splice(0)) {
337
+ const eventSize = this.eventSizeBytes(event);
338
+ if (current.length > 0 && currentSize + eventSize > maxBatchSizeBytes) {
339
+ batches.push(current);
340
+ current = [];
341
+ currentSize = 0;
342
+ }
343
+ current.push(event);
344
+ currentSize += eventSize;
345
+ }
346
+ if (current.length > 0) batches.push(current);
347
+ return batches;
348
+ }
349
+ createBatch(events, context, isFinal, flushReason = "manual") {
350
+ const identifier = getAnonymousId();
351
+ const sequence = this.nextBatchSequence(context);
352
+ const chunkStartedAt = this.chunkStartedAt;
353
+ const now = Date.now();
354
+ this.chunkStartedAt = now;
355
+ return {
356
+ token: this.options.siteKey,
357
+ sessionId: context.sessionId,
358
+ windowId: context.windowId,
359
+ viewId: this.viewId,
360
+ sessionStart: context.sessionStart,
361
+ chunkStartedAt,
362
+ chunkEndedAt: now,
363
+ ...identifier ? { identifier } : {},
364
+ batchId: `${context.sessionId}-${sequence}-${chunkStartedAt}`,
365
+ sequence,
366
+ timestamp: now,
367
+ url: sanitizeUrl(window.location.href),
368
+ ...isFinal ? { isFinal: true } : {},
369
+ flushReason,
370
+ events
371
+ };
372
+ }
373
+ eventSizeBytes(event) {
374
+ return textEncoder.encode(JSON.stringify(event)).byteLength + 1;
375
+ }
376
+ batchSizeBytes(batch) {
377
+ const cached = batchSizeCache.get(batch);
378
+ if (cached !== void 0) return cached;
379
+ const size = textEncoder.encode(JSON.stringify(batch)).byteLength;
380
+ batchSizeCache.set(batch, size);
381
+ return size;
382
+ }
383
+ dropOldestPending(reason) {
384
+ const batch = this.pending[0];
385
+ if (batch) this.removePending(batch, reason);
386
+ }
387
+ removePending(batch, reason) {
388
+ const index = this.pending.indexOf(batch);
389
+ if (index < 0) return false;
390
+ this.pending.splice(index, 1);
391
+ this.pendingSizeBytes = Math.max(0, this.pendingSizeBytes - this.batchSizeBytes(batch));
392
+ if (reason) this.log(`${reason}, dropping batch ${batch.sequence}`);
393
+ return true;
394
+ }
395
+ enqueueBatch(batch) {
396
+ const size = this.batchSizeBytes(batch);
397
+ if (size > this.maxQueueSizeBytes) {
398
+ this.log(`Replay batch ${batch.sequence} is ${size}B, exceeding ${this.maxQueueSizeBytes}B queue limit; dropping`);
399
+ return;
400
+ }
401
+ while ((this.pending.length >= this.maxPendingBatches || this.pendingSizeBytes + size > this.maxQueueSizeBytes) && this.pending.length > 0) this.dropOldestPending("Pending queue limit reached");
402
+ this.pending.push(batch);
403
+ this.pendingSizeBytes += size;
404
+ }
405
+ async flush(lowLatency, isFinal = false, flushReason = "manual") {
406
+ const rotation = touchActivity(this.options.siteKey);
407
+ if (rotation) this.applySessionRotation(rotation.prev, rotation.next);
408
+ const context = getSessionContext(this.options.siteKey);
409
+ this.enqueueEventsIfReady(context, {
410
+ isFinal,
411
+ flushReason,
412
+ maxBatchSizeBytes: lowLatency ? this.maxLowLatencyBatchSizeBytes : this.maxBatchSizeBytes
413
+ });
414
+ if (isFinal) this.finalizeSessionPending(context, flushReason);
415
+ if (this.pending.length === 0) return;
416
+ if (isFinal) for (const batch of this.pending) this.minLengthBlockedBatches.delete(batch);
417
+ else if (!rotation && flushReason !== "sessionRotate" && this.hasReachedMinLength()) this.unblockSessionBatches(context.sessionId);
418
+ if (this.retryTask) {
419
+ if (!lowLatency || !isFinal) return;
420
+ clearTimeout(this.retryTask);
421
+ this.retryTask = null;
422
+ }
423
+ if (this.sending) {
424
+ const queued = this.queuedFlush;
425
+ this.queuedFlush = {
426
+ lowLatency: (queued?.lowLatency ?? false) || lowLatency,
427
+ isFinal: queued?.isFinal || isFinal,
428
+ flushReason: isFinal ? flushReason : queued?.flushReason ?? flushReason
429
+ };
430
+ return;
431
+ }
432
+ this.sending = true;
433
+ try {
434
+ while (this.pending.length > 0) {
435
+ const batch = this.pending[0];
436
+ if (this.minLengthBlockedBatches.has(batch)) break;
437
+ this.inFlightBatch = batch;
438
+ let sent;
439
+ try {
440
+ sent = await this.sendBatch(batch, lowLatency);
441
+ } finally {
442
+ if (this.inFlightBatch === batch) this.inFlightBatch = null;
443
+ }
444
+ if (!sent) {
445
+ this.log(`Failed to send replay batch ${batch.sequence}`);
446
+ if (!this.disposed) this.scheduleRetry();
447
+ break;
448
+ }
449
+ this.retryAttempt = 0;
450
+ this.removePending(batch, "Sent replay batch");
451
+ if (this.queuedFlush?.lowLatency) break;
452
+ }
453
+ } finally {
454
+ this.sending = false;
455
+ if (this.queuedFlush) {
456
+ const queued = this.queuedFlush;
457
+ this.queuedFlush = null;
458
+ this.flush(queued.lowLatency, queued.isFinal, queued.flushReason);
459
+ }
460
+ }
461
+ }
462
+ scheduleRetry() {
463
+ if (this.retryTask) return;
464
+ this.retryAttempt = Math.min(this.retryAttempt + 1, RETRY_MAX_ATTEMPTS);
465
+ const delay = this.retryDelay(this.retryAttempt);
466
+ this.log(this.retryAttempt === RETRY_MAX_ATTEMPTS ? `Replay delivery paused for ${delay}ms` : `Retrying replay delivery in ${delay}ms`);
467
+ this.retryTask = setTimeout(() => {
468
+ this.retryTask = null;
469
+ if (this.retryAttempt === RETRY_MAX_ATTEMPTS) this.retryAttempt = 0;
470
+ this.flush(false);
471
+ }, delay);
472
+ }
473
+ retryDelay(attempt) {
474
+ if (attempt >= RETRY_MAX_ATTEMPTS) return RETRY_COOLDOWN_MS;
475
+ const ceiling = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
476
+ return Math.round(ceiling * (.5 + Math.random() * .5));
477
+ }
478
+ async sendBatch(batch, lowLatency) {
479
+ const json = JSON.stringify(batch);
480
+ const sendOptions = {
481
+ useBeacon: lowLatency,
482
+ keepalive: lowLatency
483
+ };
484
+ if (!lowLatency && (this.options.compress ?? true) && this.compressionSupported) try {
485
+ const stream = new Blob([json]).stream().pipeThrough(new CompressionStream("gzip"));
486
+ const compressed = new Uint8Array(await new Response(stream).arrayBuffer());
487
+ this.log(`Compressed ${json.length}B -> ${compressed.byteLength}B (${Math.round(compressed.byteLength / json.length * 100)}%)`);
488
+ return sendData({
489
+ url: `${this.endpoint}?encoding=gzip`,
490
+ data: compressed,
491
+ contentType: "application/octet-stream",
492
+ ...sendOptions
493
+ });
494
+ } catch {
495
+ this.log("Compression failed, using uncompressed");
496
+ }
497
+ return sendData({
498
+ url: this.endpoint,
499
+ data: json,
500
+ contentType: "application/json",
501
+ ...sendOptions
502
+ });
503
+ }
504
+ };
505
+ //#endregion
506
+ export { ReplayTracker as default };
@@ -3,23 +3,23 @@ interface WebVitalsOptions {
3
3
  siteKey: string;
4
4
  baseUrl?: string;
5
5
  debug?: boolean;
6
- samplingPercentage?: number;
7
6
  attribution?: boolean;
8
7
  }
9
8
  declare class WebVitalsTracker {
10
9
  private readonly options;
11
10
  private readonly endpoint;
12
- private readonly sampled;
13
11
  private metrics;
14
12
  private started;
15
13
  private flushing;
16
- private url;
14
+ private initialUrl;
15
+ private currentUrl;
17
16
  constructor(options: WebVitalsOptions);
18
17
  start(): Promise<void>;
19
18
  stop(): void;
20
19
  onPageHidden(persisted?: boolean): void;
21
20
  trackPageChange(url?: string): void;
22
21
  private onMetric;
22
+ private getMetricUrl;
23
23
  private flush;
24
24
  }
25
25
  //#endregion
@@ -1 +1,115 @@
1
- import{a as e,r as t,t as n}from"./chunks/api-urls-Bh77rElT.js";import{a as r,i,n as a,t as o}from"./chunks/send-data-Cc1nxIlg.js";var s=class{options;endpoint;sampled;metrics=new Map;started=!1;flushing=!1;url=``;constructor(a){this.options=a,this.endpoint=`${e(a.baseUrl,n)}${t.vitals}`;let s=o(a.samplingPercentage);this.sampled=s>=100||s>0&&r(i(a.siteKey).sessionId)%100<s}async start(){if(this.started||typeof window>`u`)return;this.started=!0,this.url=window.location.href;let e=await(this.options.attribution?import(`web-vitals/attribution`):import(`web-vitals`));if(!this.started)return;let t={reportAllChanges:!0};e.onCLS(this.onMetric,t),e.onINP(this.onMetric,t),e.onLCP(this.onMetric,t),e.onFCP(this.onMetric),e.onTTFB(this.onMetric)}stop(){this.started&&(this.started=!1,this.flush())}onPageHidden(e=!1){e||this.flush()}trackPageChange(e){if(!this.started)return;let t=e??window.location.href;t!==this.url&&(this.flush(),this.url=t)}onMetric=e=>{if(!this.started||!this.sampled)return;let t=e.name,{id:n,rating:r,delta:i,navigationType:a}=e,o=`attribution`in e&&e.attribution?e.attribution:void 0;this.metrics.set(t,{metric:t,value:e.value,attributes:{id:n,rating:r,delta:i,navigationType:a,...o}})};flush(){if(!this.metrics.size||this.flushing)return;this.flushing=!0;let e=this.metrics;this.metrics=new Map;let t=i(this.options.siteKey);a({url:this.endpoint,data:JSON.stringify({token:this.options.siteKey,sessionId:t.sessionId,windowId:t.windowId,vitals:[...e.values()],metadata:{url:this.url}}),debug:this.options.debug,debugPrefix:`[WebVitals]`}).then(t=>{if(!t)for(let[t,n]of e)this.metrics.has(t)||this.metrics.set(t,n);this.flushing=!1})}};export{s as default};
1
+ import { a as resolveBaseUrl, o as sanitizeUrl, r as URLS, t as ANALYTICS_BASE } from "./chunks/api-urls-CaNa8upY.js";
2
+ import { r as getSessionContext, t as sendData } from "./chunks/send-data-DjdbGDDc.js";
3
+ //#region src/web-vitals.ts
4
+ const METRIC_NAMES = /* @__PURE__ */ new Set([
5
+ "CLS",
6
+ "INP",
7
+ "LCP",
8
+ "FCP",
9
+ "TTFB"
10
+ ]);
11
+ var WebVitalsTracker = class {
12
+ options;
13
+ endpoint;
14
+ metrics = /* @__PURE__ */ new Map();
15
+ started = false;
16
+ flushing = false;
17
+ initialUrl = "";
18
+ currentUrl = "";
19
+ constructor(options) {
20
+ this.options = options;
21
+ this.endpoint = `${resolveBaseUrl(options.baseUrl, ANALYTICS_BASE)}${URLS.vitals}`;
22
+ }
23
+ async start() {
24
+ if (this.started || typeof window === "undefined") return;
25
+ this.started = true;
26
+ this.initialUrl = sanitizeUrl(window.location.href);
27
+ this.currentUrl = this.initialUrl;
28
+ const mod = await (this.options.attribution ? import("web-vitals/attribution") : import("web-vitals"));
29
+ if (!this.started) return;
30
+ const changes = { reportAllChanges: true };
31
+ mod.onCLS(this.onMetric, changes);
32
+ mod.onINP(this.onMetric, changes);
33
+ mod.onLCP(this.onMetric, changes);
34
+ mod.onFCP(this.onMetric);
35
+ mod.onTTFB(this.onMetric);
36
+ }
37
+ stop() {
38
+ if (!this.started) return;
39
+ this.started = false;
40
+ this.flush();
41
+ }
42
+ onPageHidden(persisted = false) {
43
+ if (persisted) return;
44
+ this.flush();
45
+ }
46
+ trackPageChange(url) {
47
+ if (!this.started) return;
48
+ const next = sanitizeUrl(url ?? window.location.href);
49
+ if (next === this.currentUrl) return;
50
+ this.currentUrl = next;
51
+ }
52
+ onMetric = (metric) => {
53
+ if (!this.started) return;
54
+ const name = metric.name;
55
+ if (!METRIC_NAMES.has(name) || !Number.isFinite(metric.value) || metric.value < 0) return;
56
+ const url = this.getMetricUrl(name);
57
+ const { id, rating, delta, navigationType } = metric;
58
+ const attribution = "attribution" in metric && metric.attribution ? metric.attribution : void 0;
59
+ this.metrics.set(`${url}\n${name}`, {
60
+ metric: name,
61
+ url,
62
+ value: metric.value,
63
+ attributes: {
64
+ id,
65
+ rating,
66
+ delta,
67
+ navigationType,
68
+ ...attribution
69
+ }
70
+ });
71
+ };
72
+ getMetricUrl(metric) {
73
+ if (metric === "FCP" || metric === "LCP" || metric === "TTFB") return this.initialUrl;
74
+ return this.currentUrl || this.initialUrl;
75
+ }
76
+ async flush() {
77
+ if (!this.metrics.size || this.flushing) return;
78
+ this.flushing = true;
79
+ const batch = this.metrics;
80
+ this.metrics = /* @__PURE__ */ new Map();
81
+ const session = getSessionContext(this.options.siteKey);
82
+ const byUrl = /* @__PURE__ */ new Map();
83
+ for (const vital of batch.values()) {
84
+ const vitals = byUrl.get(vital.url) ?? [];
85
+ vitals.push(vital);
86
+ byUrl.set(vital.url, vitals);
87
+ }
88
+ const results = await Promise.all([...byUrl].map(async ([url, vitals]) => ({
89
+ ok: await sendData({
90
+ url: this.endpoint,
91
+ data: JSON.stringify({
92
+ token: this.options.siteKey,
93
+ sessionId: session.sessionId,
94
+ windowId: session.windowId,
95
+ vitals: vitals.map(({ url: _url, ...vital }) => vital),
96
+ metadata: { url }
97
+ }),
98
+ debug: this.options.debug,
99
+ debugPrefix: "[WebVitals]"
100
+ }),
101
+ url,
102
+ vitals
103
+ })));
104
+ for (const result of results) {
105
+ if (result.ok) continue;
106
+ for (const vital of result.vitals) {
107
+ const key = `${result.url}\n${vital.metric}`;
108
+ if (!this.metrics.has(key)) this.metrics.set(key, vital);
109
+ }
110
+ }
111
+ this.flushing = false;
112
+ }
113
+ };
114
+ //#endregion
115
+ export { WebVitalsTracker as default };
package/package.json CHANGED
@@ -42,7 +42,7 @@
42
42
  "publishConfig": {
43
43
  "access": "public"
44
44
  },
45
- "version": "0.4.2",
45
+ "version": "0.6.0",
46
46
  "scripts": {
47
47
  "build": "tsdown && bun run check-size",
48
48
  "dev": "bun run build",
@@ -57,8 +57,9 @@
57
57
  "typescript": "^6.0.3"
58
58
  },
59
59
  "dependencies": {
60
- "@rrweb/rrweb-plugin-console-record": "^2.0.1",
61
- "@rrweb/record": "^2.0.1",
62
- "web-vitals": "^5.3.0"
60
+ "@rrweb/record": "^2.1.0",
61
+ "@rrweb/rrweb-plugin-console-record": "^2.1.0",
62
+ "@rrweb/rrweb-plugin-sequential-id-record": "^2.1.0",
63
+ "web-vitals": "^6.0.0"
63
64
  }
64
65
  }
@@ -1 +0,0 @@
1
- const e=`https://metrics.faststats.dev`,t=`https://flags.faststats.dev`;function n(e,t){return e?.replace(/\/+$/,``)||t}const r={events:`/v1/web`,identify:`/v1/identify`,replay:`/v1/replay`,vitals:`/v1/vitals`,flags:`/v1/check`},i=[`utm_source`,`utm_medium`,`utm_campaign`,`utm_term`,`utm_content`];function a(){let e={page:location.pathname,url:location.href,referrer:document.referrer||null,title:document.title||``};if(!location.search)return e;let t=new URLSearchParams(location.search);for(let n of i){let r=t.get(n);r&&(e[n]=r)}return e}export{n as a,a as i,t as n,r,e as t};