@faststats/web 0.2.10 → 0.2.12

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/chunks/error-CttYL43D.js +2 -0
  3. package/dist/chunks/identifiers-CQeWm7wi.js +1 -0
  4. package/dist/chunks/replay-BrMLCiBF.js +1 -0
  5. package/dist/chunks/{replay-IhsP2Ab4.d.ts → replay-BuX3_0xs.d.ts} +1 -0
  6. package/dist/chunks/send-data-DL_GlsQw.js +1 -0
  7. package/dist/chunks/types-CYzR5xtT.js +1 -0
  8. package/dist/chunks/web-vitals-CjA1bFLG.js +1 -0
  9. package/dist/error.d.ts +3 -0
  10. package/dist/error.js +1 -1
  11. package/dist/feature-flags.d.ts +1 -1
  12. package/dist/feature-flags.js +1 -1
  13. package/dist/index.d.ts +6 -3
  14. package/dist/index.js +1 -1
  15. package/dist/replay.d.ts +1 -1
  16. package/dist/replay.js +1 -1
  17. package/dist/web-vitals.d.ts +13 -3
  18. package/dist/web-vitals.js +1 -1
  19. package/package.json +1 -1
  20. package/src/analytics.ts +57 -115
  21. package/src/error.ts +38 -16
  22. package/src/replay.ts +13 -2
  23. package/src/utils/identifiers.ts +38 -26
  24. package/src/utils/send-data.ts +52 -0
  25. package/src/web-vitals.ts +157 -49
  26. package/tests/analytics.test.ts +35 -0
  27. package/tests/identifiers.test.ts +18 -0
  28. package/tests/replay.test.ts +25 -0
  29. package/tests/web-vitals.test.ts +208 -0
  30. package/dist/chunks/analytics-ur06JW5D.js +0 -1
  31. package/dist/chunks/error-ClMugucs.js +0 -2
  32. package/dist/chunks/replay-DQHUrcod.js +0 -1
  33. package/dist/chunks/types-C3vW7XGe.js +0 -1
  34. package/dist/chunks/web-vitals-BooBPWJC.js +0 -1
  35. /package/dist/chunks/{feature-flags-6rZlmhfu.d.ts → feature-flags-BxZz_lNm.d.ts} +0 -0
  36. /package/dist/chunks/{feature-flags-CqVtrpX2.js → feature-flags-CjnLZGxp.js} +0 -0
@@ -1,4 +1,8 @@
1
1
  const SESSION_TIMEOUT = 30 * 60 * 1000;
2
+ const ANONYMOUS_ID_KEY = "faststats_anon_id";
3
+ const SESSION_ID_KEY = "session_id";
4
+ const SESSION_TIMESTAMP_KEY = "session_timestamp";
5
+ const SESSION_START_KEY = "session_start";
2
6
 
3
7
  let cookielessMode = false;
4
8
 
@@ -13,65 +17,73 @@ export function getAnonymousId(cookieless?: boolean): string {
13
17
  }
14
18
 
15
19
  export function getOrCreateAnonymousId(): string {
16
- if (typeof localStorage === "undefined") return "";
17
- const existingId = localStorage.getItem("faststats_anon_id");
20
+ if (!localStorage) return "";
21
+ const existingId = localStorage.getItem(ANONYMOUS_ID_KEY);
18
22
  if (existingId) return existingId;
19
23
 
20
24
  const newId = crypto.randomUUID();
21
- localStorage.setItem("faststats_anon_id", newId);
25
+ localStorage.setItem(ANONYMOUS_ID_KEY, newId);
22
26
  return newId;
23
27
  }
24
28
 
25
29
  export function resetAnonymousId(cookieless?: boolean): string {
26
- if (cookieless || typeof localStorage === "undefined") return "";
27
- localStorage.removeItem("faststats_anon_id");
30
+ if (cookieless) return "";
31
+
32
+ if (!localStorage) return "";
33
+
34
+ localStorage.removeItem(ANONYMOUS_ID_KEY);
28
35
  return getOrCreateAnonymousId();
29
36
  }
30
37
 
31
38
  export function getOrCreateSessionId(): string {
32
- if (typeof sessionStorage === "undefined") return "";
33
- const existingId = sessionStorage.getItem("session_id");
34
- const sessionTimestamp = sessionStorage.getItem("session_timestamp");
39
+ if (!sessionStorage) return "";
40
+
41
+ const existingId = sessionStorage.getItem(SESSION_ID_KEY);
42
+ const sessionTimestamp = sessionStorage.getItem(SESSION_TIMESTAMP_KEY);
35
43
 
36
44
  if (existingId && sessionTimestamp) {
37
45
  const sessionAge = Date.now() - Number.parseInt(sessionTimestamp, 10);
38
46
  if (sessionAge < SESSION_TIMEOUT) {
39
- sessionStorage.setItem("session_timestamp", Date.now().toString());
47
+ sessionStorage.setItem(SESSION_TIMESTAMP_KEY, Date.now().toString());
40
48
  return existingId;
41
49
  }
42
- sessionStorage.removeItem("session_id");
43
- sessionStorage.removeItem("session_timestamp");
44
- sessionStorage.removeItem("session_start");
50
+ clearSession(sessionStorage);
45
51
  }
46
52
 
47
53
  const now = Date.now().toString();
48
54
  const newId = crypto.randomUUID();
49
- sessionStorage.setItem("session_id", newId);
50
- sessionStorage.setItem("session_timestamp", now);
51
- sessionStorage.setItem("session_start", now);
55
+ sessionStorage.setItem(SESSION_ID_KEY, newId);
56
+ sessionStorage.setItem(SESSION_TIMESTAMP_KEY, now);
57
+ sessionStorage.setItem(SESSION_START_KEY, now);
52
58
  return newId;
53
59
  }
54
60
 
55
61
  export function resetSessionId(): string {
56
- if (typeof sessionStorage === "undefined") return "";
57
- sessionStorage.removeItem("session_id");
58
- sessionStorage.removeItem("session_timestamp");
59
- sessionStorage.removeItem("session_start");
62
+ if (!sessionStorage) return "";
63
+
64
+ clearSession(sessionStorage);
60
65
  return getOrCreateSessionId();
61
66
  }
62
67
 
63
68
  export function refreshSessionTimestamp(): void {
64
- if (typeof sessionStorage === "undefined") return;
65
- const existingId = sessionStorage.getItem("session_id");
66
- if (existingId) {
67
- sessionStorage.setItem("session_timestamp", Date.now().toString());
69
+ if (!sessionStorage) return;
70
+
71
+ if (sessionStorage.getItem(SESSION_ID_KEY)) {
72
+ sessionStorage.setItem(SESSION_TIMESTAMP_KEY, Date.now().toString());
68
73
  }
69
74
  }
70
75
 
71
76
  export function getSessionStart(): number {
72
- if (typeof sessionStorage === "undefined") return Date.now();
73
- const start = sessionStorage.getItem("session_start");
77
+ if (!sessionStorage) return Date.now();
78
+
79
+ const start = sessionStorage.getItem(SESSION_START_KEY);
74
80
  if (start) return Number.parseInt(start, 10);
75
- const ts = sessionStorage.getItem("session_timestamp");
81
+ const ts = sessionStorage.getItem(SESSION_TIMESTAMP_KEY);
76
82
  return ts ? Number.parseInt(ts, 10) : Date.now();
77
83
  }
84
+
85
+ function clearSession(storage: Storage): void {
86
+ storage.removeItem(SESSION_ID_KEY);
87
+ storage.removeItem(SESSION_TIMESTAMP_KEY);
88
+ storage.removeItem(SESSION_START_KEY);
89
+ }
@@ -0,0 +1,52 @@
1
+ import type { SendDataOptions } from "./types";
2
+
3
+ export async function sendData(options: SendDataOptions): Promise<boolean> {
4
+ const {
5
+ url,
6
+ data,
7
+ contentType = "application/json",
8
+ headers = {},
9
+ debug = false,
10
+ debugPrefix = "[Analytics]",
11
+ useBeacon = true,
12
+ keepalive = true,
13
+ } = options;
14
+
15
+ if (useBeacon && typeof globalThis.navigator?.sendBeacon === "function") {
16
+ try {
17
+ const beaconBody =
18
+ data instanceof Blob
19
+ ? data
20
+ : typeof Blob !== "undefined"
21
+ ? new Blob([data as BlobPart], { type: contentType })
22
+ : data;
23
+ if (globalThis.navigator.sendBeacon(url, beaconBody as BodyInit)) {
24
+ if (debug) console.log(`${debugPrefix} Sent via beacon`);
25
+ return true;
26
+ }
27
+ } catch {}
28
+ }
29
+
30
+ try {
31
+ const response = await fetch(url, {
32
+ method: "POST",
33
+ body: data as BodyInit,
34
+ headers: {
35
+ "Content-Type": contentType,
36
+ ...headers,
37
+ },
38
+ keepalive,
39
+ });
40
+ const success = response.ok;
41
+ if (debug) {
42
+ const message = success
43
+ ? `${debugPrefix} Sent via fetch`
44
+ : `${debugPrefix} Failed: ${response.status}`;
45
+ console[success ? "log" : "warn"](message);
46
+ }
47
+ return success;
48
+ } catch {
49
+ if (debug) console.warn(`${debugPrefix} Failed to send`);
50
+ return false;
51
+ }
52
+ }
package/src/web-vitals.ts CHANGED
@@ -19,25 +19,54 @@ type MetricState = {
19
19
  };
20
20
 
21
21
  type AnyMetric = Metric | MetricWithAttribution;
22
- type Observer = (cb: (metric: AnyMetric) => void) => void;
23
-
24
- async function loadObservers(attribution: boolean): Promise<Observer[]> {
25
- if (attribution) {
26
- const { onCLS, onFCP, onINP, onLCP, onTTFB } = await import(
27
- "web-vitals/attribution"
28
- );
29
- return [onCLS, onFCP, onINP, onLCP, onTTFB] as Observer[];
30
- }
31
- const { onCLS, onFCP, onINP, onLCP, onTTFB } = await import("web-vitals");
32
- return [onCLS, onFCP, onINP, onLCP, onTTFB] as Observer[];
22
+ type ObserverOptions = {
23
+ reportAllChanges?: boolean;
24
+ };
25
+ type Observer = (
26
+ cb: (metric: AnyMetric) => void,
27
+ opts?: ObserverOptions,
28
+ ) => void;
29
+ type ObserverTuple = [Observer, Observer, Observer, Observer, Observer];
30
+ type ObserverConfig = {
31
+ observe: Observer;
32
+ options?: ObserverOptions;
33
+ };
34
+
35
+ async function loadObservers(attribution: boolean): Promise<ObserverConfig[]> {
36
+ const { onCLS, onFCP, onINP, onLCP, onTTFB } = attribution
37
+ ? await import("web-vitals/attribution")
38
+ : await import("web-vitals");
39
+ return createObserverConfigs([onCLS, onFCP, onINP, onLCP, onTTFB]);
40
+ }
41
+
42
+ function createObserverConfigs([
43
+ onCLS,
44
+ onFCP,
45
+ onINP,
46
+ onLCP,
47
+ onTTFB,
48
+ ]: ObserverTuple): ObserverConfig[] {
49
+ return [
50
+ { observe: onCLS, options: { reportAllChanges: true } },
51
+ { observe: onFCP },
52
+ { observe: onINP, options: { reportAllChanges: true } },
53
+ { observe: onLCP, options: { reportAllChanges: true } },
54
+ { observe: onTTFB },
55
+ ];
33
56
  }
34
57
 
35
58
  export default class WebVitalsTracker {
36
59
  private readonly endpoint: string;
37
- private readonly metrics = new Map<MetricName, MetricState>();
60
+ private readonly metricsByUrl = new Map<
61
+ string,
62
+ Map<MetricName, MetricState>
63
+ >();
64
+ private readonly pendingFlushUrls = new Set<string>();
38
65
  private readonly sampled: boolean;
39
66
  private started = false;
40
- private flushed = false;
67
+ private flushing = false;
68
+ private finalFlushRequested = false;
69
+ private currentUrl = "";
41
70
 
42
71
  constructor(private readonly options: WebVitalsOptions) {
43
72
  this.endpoint = webVitalsEventsUrl(options.baseUrl);
@@ -56,33 +85,50 @@ export default class WebVitalsTracker {
56
85
 
57
86
  start(): void {
58
87
  if (this.started || typeof window === "undefined") return;
88
+
59
89
  this.started = true;
60
- this.flushed = false;
90
+ this.currentUrl = window.location.href;
61
91
 
62
- document.addEventListener("visibilitychange", this.onVisibilityChange);
63
- window.addEventListener("pagehide", this.flush);
92
+ document.addEventListener("visibilitychange", this.onVisibilityChange, {
93
+ passive: true,
94
+ });
95
+ window.addEventListener("pagehide", this.onPageHide, { passive: true });
64
96
 
65
97
  this.log("Tracking started");
66
-
67
98
  void this.observe();
68
99
  }
69
100
 
70
101
  stop(): void {
71
102
  if (!this.started || typeof window === "undefined") return;
103
+
72
104
  this.started = false;
105
+ this.cleanupListeners();
106
+ this.queueFlush(this.currentUrl, true);
107
+ }
73
108
 
74
- document.removeEventListener("visibilitychange", this.onVisibilityChange);
75
- window.removeEventListener("pagehide", this.flush);
109
+ trackPageChange(url?: string): void {
110
+ if (!this.started || typeof window === "undefined") return;
111
+ const nextUrl = url ?? window.location.href;
112
+ if (nextUrl === this.currentUrl) return;
76
113
 
77
- this.flush();
114
+ const previousUrl = this.currentUrl;
115
+ this.currentUrl = nextUrl;
116
+ this.queueFlush(previousUrl);
117
+ }
118
+
119
+ private cleanupListeners(): void {
120
+ document.removeEventListener("visibilitychange", this.onVisibilityChange);
121
+ window.removeEventListener("pagehide", this.onPageHide);
78
122
  }
79
123
 
80
124
  private async observe(): Promise<void> {
81
125
  try {
82
126
  const observers = await loadObservers(this.options.attribution ?? false);
127
+
83
128
  if (!this.started) return;
84
- for (const observe of observers) {
85
- observe(this.captureMetric);
129
+
130
+ for (const { observe, options } of observers) {
131
+ observe(this.captureMetric, options);
86
132
  }
87
133
  } catch (error) {
88
134
  this.log("Failed to load web-vitals", error);
@@ -90,17 +136,24 @@ export default class WebVitalsTracker {
90
136
  }
91
137
 
92
138
  private onVisibilityChange = (): void => {
93
- if (document.visibilityState === "hidden") this.flush();
139
+ if (document.visibilityState === "hidden") {
140
+ this.queueFlush(this.currentUrl, true);
141
+ }
142
+ };
143
+
144
+ private onPageHide = (): void => {
145
+ this.queueFlush(this.currentUrl, true);
94
146
  };
95
147
 
96
148
  private captureMetric = (metric: AnyMetric): void => {
97
- if (this.flushed || !this.sampled) return;
149
+ if (!this.started || !this.sampled) return;
98
150
 
99
151
  const name = metric.name as MetricName;
100
152
  const attribution =
101
153
  (metric as MetricWithAttribution).attribution ?? undefined;
154
+ const metrics = this.metricsForUrl(this.currentUrl);
102
155
 
103
- this.metrics.set(name, {
156
+ metrics.set(name, {
104
157
  value: metric.value,
105
158
  attributes: {
106
159
  id: metric.id,
@@ -114,40 +167,95 @@ export default class WebVitalsTracker {
114
167
  this.log(`${name} captured: ${metric.value}`);
115
168
  };
116
169
 
117
- private flush = (): void => {
118
- if (this.flushed || this.metrics.size === 0) return;
119
- this.flushed = true;
170
+ private metricsForUrl(url: string): Map<MetricName, MetricState> {
171
+ let metrics = this.metricsByUrl.get(url);
172
+ if (!metrics) {
173
+ metrics = new Map();
174
+ this.metricsByUrl.set(url, metrics);
175
+ }
176
+ return metrics;
177
+ }
178
+
179
+ private queueFlush(url: string, final = false): void {
180
+ this.finalFlushRequested ||= final;
181
+ this.pendingFlushUrls.add(url);
182
+ void this.flushPending();
183
+ }
120
184
 
121
- const vitals = [...this.metrics.entries()].map(([metric, data]) => ({
185
+ private async flushPending(): Promise<void> {
186
+ if (this.flushing) return;
187
+ if (typeof window === "undefined" || typeof fetch !== "function") {
188
+ this.finishFinalFlushIfNeeded();
189
+ return;
190
+ }
191
+
192
+ this.flushing = true;
193
+
194
+ try {
195
+ const urls = [...this.pendingFlushUrls];
196
+ this.pendingFlushUrls.clear();
197
+ for (const url of urls) {
198
+ if (!(await this.sendMetricsForUrl(url)))
199
+ this.pendingFlushUrls.add(url);
200
+ }
201
+ } finally {
202
+ this.flushing = false;
203
+ this.finishFinalFlushIfNeeded();
204
+ }
205
+ }
206
+
207
+ private async sendMetricsForUrl(url: string): Promise<boolean> {
208
+ const metrics = this.metricsByUrl.get(url);
209
+ if (!metrics || metrics.size === 0) return true;
210
+
211
+ const vitals = [...metrics.entries()].map(([metric, data]) => ({
122
212
  metric,
123
213
  value: data.value,
124
214
  attributes: data.attributes,
125
215
  }));
126
- const metricNames = vitals.map((v) => v.metric).join(", ");
127
- this.metrics.clear();
128
-
129
216
  const body = JSON.stringify({
130
217
  sessionId: getOrCreateSessionId(),
131
218
  vitals,
132
- metadata: {
133
- url: window.location.href,
134
- },
219
+ metadata: { url },
135
220
  });
136
221
 
137
- this.log(`Sending final metrics (${vitals.length}): ${metricNames}`);
222
+ this.log(`Sending metrics for ${url} (${vitals.length})`);
223
+
224
+ try {
225
+ const controller = new AbortController();
226
+ const timeout = window.setTimeout(() => controller.abort(), 3000);
138
227
 
139
- if (typeof fetch !== "function") return;
228
+ try {
229
+ const response = await fetch(this.endpoint, {
230
+ method: "POST",
231
+ body,
232
+ headers: {
233
+ "Content-Type": "application/json",
234
+ Authorization: `Bearer ${this.options.siteKey}`,
235
+ },
236
+ keepalive: true,
237
+ signal: controller.signal,
238
+ });
140
239
 
141
- fetch(this.endpoint, {
142
- method: "POST",
143
- body,
144
- headers: {
145
- "Content-Type": "application/json",
146
- Authorization: `Bearer ${this.options.siteKey}`,
147
- },
148
- keepalive: true,
149
- }).catch(() => {
150
- this.log("Failed to send metrics");
151
- });
152
- };
240
+ if (!response.ok) {
241
+ throw new Error(`HTTP ${response.status}`);
242
+ }
243
+
244
+ this.metricsByUrl.delete(url);
245
+ } finally {
246
+ clearTimeout(timeout);
247
+ }
248
+ return true;
249
+ } catch (error) {
250
+ this.log("Failed to send metrics", error);
251
+ return false;
252
+ }
253
+ }
254
+
255
+ private finishFinalFlushIfNeeded(): void {
256
+ if (!this.finalFlushRequested) return;
257
+ this.finalFlushRequested = false;
258
+ this.started = false;
259
+ this.cleanupListeners();
260
+ }
153
261
  }
@@ -391,6 +391,41 @@ describe("WebAnalytics lifecycle", () => {
391
391
  ).toBe(true);
392
392
  });
393
393
 
394
+ test("visibility hide refreshes stale session before page leave", async () => {
395
+ const analytics = new WebAnalytics({
396
+ siteKey: "site_test",
397
+ autoTrack: false,
398
+ });
399
+
400
+ await analytics.start();
401
+ const storage = globalThis.sessionStorage as unknown as MockStorage;
402
+ const sessionId = storage.getItem("session_id");
403
+ expect(sessionId).toBeTruthy();
404
+ storage.setItem("session_timestamp", "0");
405
+ harness.fetchCalls.length = 0;
406
+
407
+ Object.defineProperty(globalThis.document, "visibilityState", {
408
+ configurable: true,
409
+ value: "hidden",
410
+ });
411
+ for (const listener of harness.documentTarget.listeners.get(
412
+ "visibilitychange",
413
+ ) ?? []) {
414
+ if (typeof listener === "function") {
415
+ listener(new Event("visibilitychange"));
416
+ } else {
417
+ listener.handleEvent(new Event("visibilitychange"));
418
+ }
419
+ }
420
+ await wait(0);
421
+
422
+ expect(storage.getItem("session_id")).toBe(sessionId);
423
+ const pageLeaveCall = harness.fetchCalls.find((call) =>
424
+ call.body?.includes('"event":"page_leave"'),
425
+ );
426
+ expect(pageLeaveCall?.body).toContain(`"sessionId":"${sessionId}"`);
427
+ });
428
+
394
429
  test("resolveReplayTrackerOptions honors sessionReplays config", () => {
395
430
  const options = resolveReplayTrackerOptions(
396
431
  {
@@ -74,4 +74,22 @@ describe("identifiers", () => {
74
74
  expect(second.length).toBeGreaterThan(0);
75
75
  expect(second).not.toBe(first);
76
76
  });
77
+
78
+ test("storage access failures disable persisted identifiers", () => {
79
+ Object.defineProperty(globalThis, "localStorage", {
80
+ configurable: true,
81
+ get() {
82
+ throw new Error("storage blocked");
83
+ },
84
+ });
85
+ Object.defineProperty(globalThis, "sessionStorage", {
86
+ configurable: true,
87
+ get() {
88
+ throw new Error("storage blocked");
89
+ },
90
+ });
91
+
92
+ expect(getOrCreateAnonymousId()).toBe("");
93
+ expect(getOrCreateSessionId()).toBe("");
94
+ });
77
95
  });
@@ -34,6 +34,7 @@ type ReplayBatch = {
34
34
  token: string;
35
35
  sessionId?: string;
36
36
  identifier?: string;
37
+ batchId: string;
37
38
  sequence: number;
38
39
  timestamp: number;
39
40
  url: string;
@@ -191,11 +192,35 @@ describe("ReplayTracker", () => {
191
192
  expect(captured.payload).not.toBeNull();
192
193
  expect(captured.payload?.token).toBe("site_test");
193
194
  expect(captured.payload?.sessionId).toBeTruthy();
195
+ expect(captured.payload?.batchId).toBeTruthy();
194
196
  expect(captured.payload?.events).toHaveLength(1);
195
197
  expect(captured.lowLatency).toBe(true);
196
198
  expect(tracker.pending.length).toBe(0);
197
199
  });
198
200
 
201
+ test("emits stable batch ids while queued for retry", async () => {
202
+ const tracker = createTracker({
203
+ minReplayLengthMs: 0,
204
+ });
205
+
206
+ const payloads: ReplayBatch[] = [];
207
+ tracker.send = async (data) => {
208
+ payloads.push(JSON.parse(decodeBody(data)) as ReplayBatch);
209
+ return payloads.length > 1;
210
+ };
211
+ tracker.startTime = Date.now() - 1000;
212
+ tracker.onEvent({ type: 2, timestamp: Date.now(), data: {} }, false);
213
+
214
+ await tracker.flush(false);
215
+ await tracker.flush(false);
216
+
217
+ expect(payloads).toHaveLength(2);
218
+ expect(payloads[1]?.batchId).toBe(payloads[0]?.batchId);
219
+ expect(payloads[1]?.sequence).toBe(payloads[0]?.sequence);
220
+
221
+ if (tracker.retryTask) clearTimeout(tracker.retryTask);
222
+ });
223
+
199
224
  test("keeps the same replay session id across later batches", async () => {
200
225
  const tracker = createTracker({
201
226
  minReplayLengthMs: 0,