@faststats/web 0.6.0 → 0.7.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.
@@ -11,12 +11,12 @@ const URLS = {
11
11
  vitals: "/v1/vitals",
12
12
  flags: "/v1/check"
13
13
  };
14
- function sanitizeUrl(url) {
14
+ function sanitizeUrl(url, includeHash = false) {
15
15
  try {
16
16
  const parsed = new URL(url, location.href);
17
- return `${parsed.origin}${parsed.pathname}`;
17
+ return `${parsed.origin}${parsed.pathname}${includeHash ? parsed.hash : ""}`;
18
18
  } catch {
19
- return url.split(/[?#]/, 1)[0] ?? "";
19
+ return url.split(includeHash ? "?" : /[?#]/, 1)[0] ?? "";
20
20
  }
21
21
  }
22
22
  const UTM_KEYS = [
@@ -26,10 +26,11 @@ const UTM_KEYS = [
26
26
  "utm_term",
27
27
  "utm_content"
28
28
  ];
29
- function getPageContext() {
29
+ function getPageContext(includeHash = false) {
30
+ const hash = includeHash ? location.hash : "";
30
31
  const ctx = {
31
- page: location.pathname,
32
- url: sanitizeUrl(location.href),
32
+ page: `${location.pathname}${hash}`,
33
+ url: sanitizeUrl(location.href, includeHash),
33
34
  referrer: document.referrer ? sanitizeUrl(document.referrer) : null,
34
35
  title: document.title || ""
35
36
  };
@@ -0,0 +1,21 @@
1
+ import { d as setStorageItem, i as isCookielessMode, l as getStorageItem, n as createId, u as removeStorageItem } from "./send-data-DR1JUXka.js";
2
+ //#region src/utils/identifiers.ts
3
+ const ANONYMOUS_ID_KEY = "faststats_anon_id_";
4
+ function getOrCreateAnonymousId(siteKey) {
5
+ const key = `${ANONYMOUS_ID_KEY}${siteKey}`;
6
+ const existingId = getStorageItem("localStorage", key);
7
+ if (existingId) return existingId;
8
+ const newId = createId();
9
+ return setStorageItem("localStorage", key, newId) ? newId : "";
10
+ }
11
+ function getAnonymousId(siteKey, cookieless) {
12
+ if (cookieless ?? isCookielessMode()) return "";
13
+ return getOrCreateAnonymousId(siteKey);
14
+ }
15
+ function resetAnonymousId(siteKey, cookieless) {
16
+ if (cookieless) return "";
17
+ if (!removeStorageItem("localStorage", `${ANONYMOUS_ID_KEY}${siteKey}`)) return "";
18
+ return getOrCreateAnonymousId(siteKey);
19
+ }
20
+ //#endregion
21
+ export { resetAnonymousId as n, getAnonymousId as t };
@@ -1,12 +1,27 @@
1
1
  import { record } from "@rrweb/record";
2
2
  import { LogLevel } from "@rrweb/rrweb-plugin-console-record";
3
3
 
4
+ //#region ../../node_modules/.bun/@rrweb+types@2.1.0/node_modules/@rrweb/types/dist/index.d.ts
5
+ declare global {
6
+ interface Window {
7
+ FontFace: typeof FontFace;
8
+ }
9
+ }
10
+ //#endregion
11
+ //#region src/replay-mutation-throttler.d.ts
12
+ type ReplayMutationThrottleOptions = {
13
+ enabled?: boolean;
14
+ bucketSize?: number;
15
+ refillRate?: number;
16
+ };
17
+ //#endregion
4
18
  //#region src/replay.d.ts
5
19
  type RecordOptions = NonNullable<Parameters<typeof record>[0]>;
6
20
  interface ReplayTrackerOptions {
7
21
  siteKey: string;
8
22
  baseUrl?: string;
9
23
  debug?: boolean;
24
+ trackHash?: boolean;
10
25
  compress?: boolean;
11
26
  flushInterval?: number;
12
27
  maxEvents?: number;
@@ -29,6 +44,7 @@ interface ReplayTrackerOptions {
29
44
  consoleLevel?: LogLevel[];
30
45
  consoleLengthThreshold?: number;
31
46
  consoleStringLengthLimit?: number;
47
+ mutationThrottle?: ReplayMutationThrottleOptions;
32
48
  }
33
49
  declare class ReplayTracker {
34
50
  private readonly options;
@@ -47,6 +63,7 @@ declare class ReplayTracker {
47
63
  private queuedEventsSizeBytes;
48
64
  private pendingSizeBytes;
49
65
  private viewId;
66
+ private viewUrl;
50
67
  private chunkStartedAt;
51
68
  private started;
52
69
  private disposed;
@@ -64,6 +81,9 @@ declare class ReplayTracker {
64
81
  private queuedFlush;
65
82
  private unsubscribeRotation?;
66
83
  private lastCookielessMode;
84
+ private terminalFlushRequested;
85
+ private overflowed;
86
+ private readonly mutationThrottler;
67
87
  constructor(options: ReplayTrackerOptions);
68
88
  private log;
69
89
  private clearFlushTimers;
@@ -75,15 +95,16 @@ declare class ReplayTracker {
75
95
  setCookielessMode(cookieless: boolean): void;
76
96
  start(): void;
77
97
  trackPageChange(url?: string): void;
78
- onPageHidden(persisted?: boolean): void;
98
+ onPageHidden(persisted?: boolean, terminal?: boolean): void;
79
99
  onPageShow(persisted?: boolean): void;
80
100
  private beginRecording;
81
- stop(): void;
101
+ stop(discard?: boolean): void;
82
102
  private onSessionRotated;
83
103
  private applySessionRotation;
84
104
  private resetSessionTiming;
85
105
  private onEvent;
86
106
  private onUnload;
107
+ private requestTerminalFlush;
87
108
  private hasReachedMinLength;
88
109
  private requestFlush;
89
110
  private scheduleMinLengthFlush;
@@ -92,7 +113,6 @@ declare class ReplayTracker {
92
113
  private createBatch;
93
114
  private eventSizeBytes;
94
115
  private batchSizeBytes;
95
- private dropOldestPending;
96
116
  private removePending;
97
117
  private enqueueBatch;
98
118
  private flush;
@@ -30,29 +30,30 @@ function removeStorageItem(name, key) {
30
30
  //#region src/utils/session-manager.ts
31
31
  const IDLE_MS = 1800 * 1e3;
32
32
  const MAX_AGE_MS = 1440 * 60 * 1e3;
33
- const SESSION_KEY = "faststats_session";
33
+ const SESSION_KEY = "faststats_session_";
34
34
  let cookielessMode = false;
35
- let memorySession = null;
35
+ const memorySessions = /* @__PURE__ */ new Map();
36
36
  const rotationListeners = /* @__PURE__ */ new Set();
37
37
  function createId() {
38
38
  if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
39
39
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
40
40
  }
41
- function readSession() {
42
- const raw = getStorageItem("localStorage", SESSION_KEY);
43
- if (!raw) return memorySession;
41
+ function readSession(siteKey) {
42
+ const memory = memorySessions.get(siteKey) ?? null;
43
+ const raw = getStorageItem("localStorage", `${SESSION_KEY}${siteKey}`);
44
+ if (!raw) return memory;
44
45
  try {
45
46
  const session = JSON.parse(raw);
46
- return session.id ? session : memorySession;
47
+ return session.id ? session : memory;
47
48
  } catch {}
48
- return memorySession;
49
+ return memory;
49
50
  }
50
- function writeSession(session) {
51
- if (!setStorageItem("localStorage", SESSION_KEY, JSON.stringify(session))) memorySession = session;
51
+ function writeSession(siteKey, session) {
52
+ if (!setStorageItem("localStorage", `${SESSION_KEY}${siteKey}`, JSON.stringify(session))) memorySessions.set(siteKey, session);
52
53
  }
53
- function clearSession() {
54
- memorySession = null;
55
- removeStorageItem("localStorage", SESSION_KEY);
54
+ function clearSession(siteKey) {
55
+ memorySessions.delete(siteKey);
56
+ removeStorageItem("localStorage", `${SESSION_KEY}${siteKey}`);
56
57
  }
57
58
  function expired(session, now) {
58
59
  return now - session.activity >= IDLE_MS || now - session.start >= MAX_AGE_MS;
@@ -60,25 +61,28 @@ function expired(session, now) {
60
61
  function emitRotation(prev, next) {
61
62
  for (const listener of rotationListeners) listener(prev, next);
62
63
  }
63
- function resolveSession(cookieless, touch) {
64
+ function resolveSession(siteKey, cookieless, touch) {
64
65
  const now = Date.now();
65
66
  if (cookieless) {
66
- if (!memorySession) memorySession = {
67
- id: createId(),
68
- start: now,
69
- activity: now
70
- };
71
- else if (touch) memorySession.activity = now;
67
+ let session = memorySessions.get(siteKey);
68
+ if (!session) {
69
+ session = {
70
+ id: createId(),
71
+ start: now,
72
+ activity: now
73
+ };
74
+ memorySessions.set(siteKey, session);
75
+ } else if (touch) session.activity = now;
72
76
  return {
73
- session: memorySession,
77
+ session,
74
78
  rotatedFrom: null
75
79
  };
76
80
  }
77
- const existing = readSession();
81
+ const existing = readSession(siteKey);
78
82
  if (existing && !expired(existing, now)) {
79
83
  if (touch) {
80
84
  existing.activity = now;
81
- writeSession(existing);
85
+ writeSession(siteKey, existing);
82
86
  }
83
87
  return {
84
88
  session: existing,
@@ -90,7 +94,7 @@ function resolveSession(cookieless, touch) {
90
94
  start: now,
91
95
  activity: now
92
96
  };
93
- writeSession(session);
97
+ writeSession(siteKey, session);
94
98
  return {
95
99
  session,
96
100
  rotatedFrom: existing
@@ -112,22 +116,23 @@ function toContext(siteKey, session, cookieless, windowIdOverride) {
112
116
  };
113
117
  }
114
118
  function setCookielessMode(mode) {
119
+ if (cookielessMode === mode) return;
115
120
  cookielessMode = mode;
116
- memorySession = null;
121
+ memorySessions.clear();
117
122
  }
118
123
  function isCookielessMode() {
119
124
  return cookielessMode;
120
125
  }
121
126
  function getSessionContext(siteKey, cookieless) {
122
127
  const useCookieless = cookieless ?? cookielessMode;
123
- const { session, rotatedFrom } = resolveSession(useCookieless, false);
128
+ const { session, rotatedFrom } = resolveSession(siteKey, useCookieless, false);
124
129
  const next = toContext(siteKey, session, useCookieless);
125
130
  if (rotatedFrom) emitRotation(toContext(siteKey, rotatedFrom, useCookieless, next.windowId), next);
126
131
  return next;
127
132
  }
128
133
  function touchActivity(siteKey, cookieless) {
129
134
  const useCookieless = cookieless ?? cookielessMode;
130
- const { session, rotatedFrom } = resolveSession(useCookieless, true);
135
+ const { session, rotatedFrom } = resolveSession(siteKey, useCookieless, true);
131
136
  if (!rotatedFrom) return null;
132
137
  const wid = windowId(siteKey, useCookieless, session.id);
133
138
  const rotation = {
@@ -140,14 +145,14 @@ function touchActivity(siteKey, cookieless) {
140
145
  function resetSession(siteKey) {
141
146
  const useCookieless = cookielessMode;
142
147
  const previous = getSessionContext(siteKey, useCookieless);
143
- clearSession();
148
+ clearSession(siteKey);
144
149
  if (useCookieless) {
145
150
  const now = Date.now();
146
- memorySession = {
151
+ memorySessions.set(siteKey, {
147
152
  id: createId(),
148
153
  start: now,
149
154
  activity: now
150
- };
155
+ });
151
156
  } else removeStorageItem("sessionStorage", `faststats_window_id_${siteKey}`);
152
157
  const next = getSessionContext(siteKey, useCookieless);
153
158
  if (previous.sessionId !== next.sessionId) emitRotation(previous, next);
package/dist/error.d.ts CHANGED
@@ -9,6 +9,7 @@ interface ErrorTrackingOptions {
9
9
  siteKey: string;
10
10
  baseUrl?: string;
11
11
  debug?: boolean;
12
+ trackHash?: boolean;
12
13
  flushInterval?: number;
13
14
  maxQueueSize?: number;
14
15
  sdkName?: string;
@@ -24,12 +25,11 @@ declare class ErrorTracker {
24
25
  constructor(options: ErrorTrackingOptions);
25
26
  private log;
26
27
  start(): void;
27
- stop(): void;
28
+ stop(discard?: boolean): void;
28
29
  onPageHidden(persisted?: boolean): void;
29
30
  captureError(error: Error): void;
30
31
  private onError;
31
32
  private onRejection;
32
- private addToQueue;
33
33
  private enqueue;
34
34
  private flush;
35
35
  }
package/dist/error.js CHANGED
@@ -1,13 +1,13 @@
1
- import { a as resolveBaseUrl, i as getPageContext, 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
- import { t as getAnonymousId } from "./chunks/identifiers-cUymVK5H.js";
1
+ import { a as resolveBaseUrl, i as getPageContext, r as URLS, t as ANALYTICS_BASE } from "./chunks/api-urls-Dp2XNjRF.js";
2
+ import { r as getSessionContext, t as sendData } from "./chunks/send-data-DR1JUXka.js";
3
+ import { t as getAnonymousId } from "./chunks/identifiers-BvRNbiwA.js";
4
4
  //#region src/error.ts
5
5
  const SDK_NAME = "@faststats/web";
6
- const SDK_VERSION = "0.6.0";
6
+ const SDK_VERSION = "0.7.0";
7
7
  const EXTENSION_RE = /(?:^|\()(chrome|moz|ms-browser|safari-web)-extension:\/\//;
8
8
  function toEntry(kind, source, handled, fallback) {
9
9
  if (source instanceof Error) return {
10
- error: kind,
10
+ error: kind === "UnhandledRejection" ? kind : source.name.trim() || kind,
11
11
  message: source.message || fallback,
12
12
  handled,
13
13
  stack: source.stack?.split("\n").map((line) => line.trim()).filter(Boolean)
@@ -18,9 +18,6 @@ function toEntry(kind, source, handled, fallback) {
18
18
  handled
19
19
  };
20
20
  }
21
- function entryKey({ error, message, handled }) {
22
- return `${error}\0${message ?? ""}\0${handled}`;
23
- }
24
21
  function isExtensionError(filename, source) {
25
22
  const stack = source instanceof Error ? source.stack ?? "" : "";
26
23
  return EXTENSION_RE.test(`${filename}\n${stack}`);
@@ -28,7 +25,7 @@ function isExtensionError(filename, source) {
28
25
  var ErrorTracker = class {
29
26
  options;
30
27
  endpoint;
31
- queue = /* @__PURE__ */ new Map();
28
+ queue = [];
32
29
  timer = null;
33
30
  started = false;
34
31
  flushing = false;
@@ -47,19 +44,19 @@ var ErrorTracker = class {
47
44
  this.timer = setInterval(() => void this.flush(), this.options.flushInterval ?? 5e3);
48
45
  this.log("Started");
49
46
  }
50
- stop() {
47
+ stop(discard = false) {
51
48
  if (!this.started || typeof window === "undefined") return;
52
49
  this.started = false;
53
50
  window.removeEventListener("error", this.onError);
54
51
  window.removeEventListener("unhandledrejection", this.onRejection);
55
52
  if (this.timer) clearInterval(this.timer);
56
53
  this.timer = null;
57
- this.flush();
54
+ if (discard) this.queue = [];
55
+ else this.flush();
58
56
  this.log("Stopped");
59
57
  }
60
58
  onPageHidden(persisted = false) {
61
- if (persisted) return;
62
- this.flush();
59
+ if (!persisted) this.flush();
63
60
  }
64
61
  captureError(error) {
65
62
  this.enqueue(toEntry("Error", error, true, error.message));
@@ -72,55 +69,66 @@ var ErrorTracker = class {
72
69
  if (isExtensionError("", event.reason)) return;
73
70
  this.enqueue(toEntry("UnhandledRejection", event.reason, false, "Unhandled promise rejection"));
74
71
  };
75
- addToQueue(entry, count = 1) {
76
- const key = entryKey(entry);
77
- const existing = this.queue.get(key);
78
- if (existing) {
79
- existing.count += count;
80
- return false;
81
- }
82
- this.queue.set(key, {
83
- ...entry,
84
- count
72
+ enqueue(error) {
73
+ const buildId = globalThis.__SOURCEMAPS_BUILD__?.buildId;
74
+ const userId = getAnonymousId(this.options.siteKey);
75
+ const ctx = getSessionContext(this.options.siteKey);
76
+ this.log("Captured:", error);
77
+ this.queue.push({
78
+ error,
79
+ context: {
80
+ token: this.options.siteKey,
81
+ ...userId ? { userId } : {},
82
+ sessionId: ctx.sessionId,
83
+ windowId: ctx.windowId,
84
+ ...typeof buildId === "string" && buildId.trim() ? { buildId } : {},
85
+ sdkName: this.options.sdkName ?? SDK_NAME,
86
+ sdkVersion: this.options.sdkVersion ?? SDK_VERSION,
87
+ event: "error",
88
+ ...getPageContext(this.options.trackHash ?? false),
89
+ properties: {}
90
+ }
85
91
  });
86
- return true;
87
- }
88
- enqueue(entry) {
89
- if (this.addToQueue(entry)) this.log("Captured:", entry);
90
- if (this.queue.size >= (this.options.maxQueueSize ?? 50)) this.flush();
92
+ if (this.queue.length >= (this.options.maxQueueSize ?? 50)) this.flush();
91
93
  }
92
94
  async flush() {
93
- if (this.flushing || this.queue.size === 0) return;
95
+ if (this.flushing || !this.queue.length) return;
94
96
  this.flushing = true;
95
97
  const batch = this.queue;
96
- this.queue = /* @__PURE__ */ new Map();
97
- const errors = [...batch.values()];
98
- this.log("Flushing:", errors);
99
- try {
100
- const buildId = globalThis.__SOURCEMAPS_BUILD__?.buildId;
101
- const userId = getAnonymousId();
102
- const ctx = getSessionContext(this.options.siteKey);
103
- if (!await sendData({
98
+ this.queue = [];
99
+ const groups = /* @__PURE__ */ new Map();
100
+ for (const item of batch) {
101
+ const key = JSON.stringify(item.context);
102
+ const group = groups.get(key) ?? {
103
+ context: item.context,
104
+ items: []
105
+ };
106
+ group.items.push(item);
107
+ groups.set(key, group);
108
+ }
109
+ const failed = await Promise.all([...groups.values()].map(async (group) => {
110
+ const errors = /* @__PURE__ */ new Map();
111
+ for (const { error } of group.items) {
112
+ const key = JSON.stringify(error);
113
+ const existing = errors.get(key);
114
+ if (existing) existing.count++;
115
+ else errors.set(key, {
116
+ ...error,
117
+ count: 1
118
+ });
119
+ }
120
+ return await sendData({
104
121
  url: this.endpoint,
105
122
  data: JSON.stringify({
106
- token: this.options.siteKey,
107
- ...userId ? { userId } : {},
108
- sessionId: ctx.sessionId,
109
- windowId: ctx.windowId,
110
- ...typeof buildId === "string" && buildId.trim() ? { buildId } : {},
111
- sdkName: this.options.sdkName ?? SDK_NAME,
112
- sdkVersion: this.options.sdkVersion ?? SDK_VERSION,
113
- event: "error",
114
- ...getPageContext(),
115
- properties: {},
116
- errors
123
+ ...group.context,
124
+ errors: [...errors.values()]
117
125
  }),
118
126
  debug: this.options.debug,
119
127
  debugPrefix: "[ErrorTracker]"
120
- })) for (const item of batch.values()) this.addToQueue(item, item.count);
121
- } finally {
122
- this.flushing = false;
123
- }
128
+ }) ? [] : group.items;
129
+ }));
130
+ this.queue.unshift(...failed.flat());
131
+ this.flushing = false;
124
132
  }
125
133
  };
126
134
  //#endregion
@@ -1,4 +1,4 @@
1
- import { a as resolveBaseUrl, n as FEATURE_FLAGS_BASE, r as URLS } from "./chunks/api-urls-CaNa8upY.js";
1
+ import { a as resolveBaseUrl, n as FEATURE_FLAGS_BASE, r as URLS } from "./chunks/api-urls-Dp2XNjRF.js";
2
2
  //#region src/feature-flags.ts
3
3
  async function fetchFeatureFlagEvaluation(flagKey, context) {
4
4
  if (!context.projectToken) throw new Error("feature flag check requires projectToken");
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { FeatureFlagEvaluation } from "./feature-flags.js";
2
- import { n as ReplayTrackerOptions } from "./chunks/replay-DgodPHzA.js";
2
+ import { n as ReplayTrackerOptions } from "./chunks/replay-BeC2XA4N.js";
3
3
 
4
4
  //#region src/analytics.d.ts
5
5
  type ConsentMode = "pending" | "granted" | "denied";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { a as resolveBaseUrl, i as getPageContext, n as FEATURE_FLAGS_BASE, o as sanitizeUrl, r as URLS, t as ANALYTICS_BASE } from "./chunks/api-urls-CaNa8upY.js";
2
- import { c as touchActivity, l as getStorageItem, o as resetSession, r as getSessionContext, s as setCookielessMode, t as sendData } from "./chunks/send-data-DjdbGDDc.js";
3
- import { n as resetAnonymousId, t as getAnonymousId } from "./chunks/identifiers-cUymVK5H.js";
1
+ import { a as resolveBaseUrl, i as getPageContext, n as FEATURE_FLAGS_BASE, o as sanitizeUrl, r as URLS, t as ANALYTICS_BASE } from "./chunks/api-urls-Dp2XNjRF.js";
2
+ import { c as touchActivity, l as getStorageItem, o as resetSession, r as getSessionContext, s as setCookielessMode, t as sendData } from "./chunks/send-data-DR1JUXka.js";
3
+ import { n as resetAnonymousId, t as getAnonymousId } from "./chunks/identifiers-BvRNbiwA.js";
4
4
  //#region src/outbound-links.ts
5
5
  const DEDUPE_MS = 500;
6
6
  var OutboundLinkTracker = class {
@@ -40,7 +40,7 @@ function findOutboundAnchor(event) {
40
40
  return null;
41
41
  }
42
42
  function isOutboundAnchor(node) {
43
- return node instanceof HTMLAnchorElement && !!node.href && node.host !== location.host;
43
+ return node instanceof HTMLAnchorElement && (node.protocol === "http:" || node.protocol === "https:") && node.host !== location.host;
44
44
  }
45
45
  //#endregion
46
46
  //#region src/analytics.ts
@@ -91,7 +91,7 @@ var WebAnalytics = class {
91
91
  entry = {
92
92
  path: "",
93
93
  url: "",
94
- hash: ""
94
+ search: ""
95
95
  };
96
96
  left = false;
97
97
  visitDuration = 0;
@@ -130,13 +130,13 @@ var WebAnalytics = class {
130
130
  return this.started && !this.destroyed && instance === this;
131
131
  }
132
132
  canTrack() {
133
- return !(this.consentMode === "pending" && this.pendingBehavior === "disabled");
133
+ return this.consentMode === "granted" || this.consentMode === "pending" && this.pendingBehavior === "anonymous";
134
134
  }
135
135
  canSendEvents() {
136
136
  return !this.isTrackingBlocked() && this.canTrack();
137
137
  }
138
138
  cookieless() {
139
- return !!this.options.cookieless || this.consentMode === "denied" || this.consentMode === "pending" && this.pendingBehavior === "anonymous";
139
+ return !!this.options.cookieless || this.consentMode === "pending" && this.pendingBehavior === "anonymous";
140
140
  }
141
141
  touch() {
142
142
  if (this.canTrack()) touchActivity(this.options.siteKey, this.cookieless());
@@ -153,9 +153,9 @@ var WebAnalytics = class {
153
153
  loadChild(name, create) {
154
154
  const epoch = this.epoch;
155
155
  create().then(async (tracker) => {
156
- if (this.epoch !== epoch || !this.started) return tracker.stop?.();
156
+ if (this.epoch !== epoch || !this.started) return tracker.stop?.(true);
157
157
  await tracker.start();
158
- if (this.epoch !== epoch || !this.started) return tracker.stop?.();
158
+ if (this.epoch !== epoch || !this.started) return tracker.stop?.(true);
159
159
  this.childTrackers.push(tracker);
160
160
  this.log(`${name} loaded`);
161
161
  }).catch((error) => this.log(`failed to initialize ${name} tracker: ${String(error)}`));
@@ -165,7 +165,8 @@ var WebAnalytics = class {
165
165
  const base = {
166
166
  siteKey: o.siteKey,
167
167
  baseUrl: this.baseUrl,
168
- debug: this.debug
168
+ debug: this.debug,
169
+ trackHash: o.trackHash ?? false
169
170
  };
170
171
  if (o.sessionReplays?.enabled) this.loadChild("replay", async () => {
171
172
  const { default: ReplayTracker } = await import("./replay.js");
@@ -191,10 +192,12 @@ var WebAnalytics = class {
191
192
  });
192
193
  }
193
194
  enterPage() {
195
+ const includeHash = this.options.trackHash ?? false;
196
+ const hash = includeHash ? location.hash : "";
194
197
  this.entry = {
195
- path: location.pathname,
196
- url: sanitizeUrl(location.href),
197
- hash: location.hash
198
+ path: `${location.pathname}${hash}`,
199
+ url: sanitizeUrl(location.href, includeHash),
200
+ search: location.search
198
201
  };
199
202
  this.left = false;
200
203
  this.startVisit();
@@ -212,13 +215,13 @@ var WebAnalytics = class {
212
215
  }
213
216
  onLinkClick = (event) => {
214
217
  const href = this.outboundLinks.getHref(event);
215
- if (href) this.track("outbound_link", { outbound_link: sanitizeUrl(href) });
218
+ if (href) this.send("outbound_link", { outbound_link: sanitizeUrl(href) });
216
219
  };
217
220
  onVisibilityChange = () => {
218
221
  this.touch();
219
222
  if (document.visibilityState === "hidden") {
220
223
  this.pauseVisit();
221
- for (const tracker of this.childTrackers) tracker.onPageHidden?.();
224
+ for (const tracker of this.childTrackers) tracker.onPageHidden?.(false, false);
222
225
  this.stopHeartbeat();
223
226
  return;
224
227
  }
@@ -231,7 +234,7 @@ var WebAnalytics = class {
231
234
  onPageHide = (event) => {
232
235
  const persisted = event.persisted;
233
236
  this.pauseVisit();
234
- for (const tracker of this.childTrackers) tracker.onPageHidden?.(persisted);
237
+ for (const tracker of this.childTrackers) tracker.onPageHidden?.(persisted, !persisted);
235
238
  if (!persisted) this.leavePage();
236
239
  };
237
240
  onPageShow = (event) => {
@@ -291,7 +294,7 @@ var WebAnalytics = class {
291
294
  pageview(extra = {}) {
292
295
  if (!this.ensureActive()) return;
293
296
  const trackHash = this.options.trackHash ?? false;
294
- const key = `${location.pathname}|${trackHash ? location.hash : ""}`;
297
+ const key = `${location.pathname}|${location.search}|${trackHash ? location.hash : ""}`;
295
298
  if (key === this.pageKey) return;
296
299
  this.pageKey = key;
297
300
  this.send("pageview", extra);
@@ -299,7 +302,7 @@ var WebAnalytics = class {
299
302
  track(name, extra = {}) {
300
303
  if (!this.ensureActive()) return;
301
304
  const eventName = name.trim();
302
- if (!eventName) return;
305
+ if (!eventName || eventName === "pageview" || eventName === "page_leave" || eventName === "error" || eventName === "outbound_link") return;
303
306
  this.send(eventName, extra);
304
307
  }
305
308
  async identify(externalId, email, options = {}) {
@@ -327,28 +330,43 @@ var WebAnalytics = class {
327
330
  }
328
331
  logout(resetAnonymousIdentity = true) {
329
332
  if (!this.ensureActive()) return;
330
- if (resetAnonymousIdentity) resetAnonymousId(this.cookieless());
333
+ if (resetAnonymousIdentity) resetAnonymousId(this.options.siteKey, this.cookieless());
331
334
  resetSession(this.options.siteKey);
332
335
  }
333
336
  setConsentMode(mode) {
334
- const wasBlocked = !this.canTrack();
337
+ if (mode === this.consentMode) return;
338
+ const wasTracking = this.canTrack();
335
339
  this.consentMode = mode;
340
+ const tracking = this.canTrack();
336
341
  const cookieless = this.cookieless();
337
342
  setCookielessMode(cookieless);
343
+ if (!tracking) {
344
+ this.epoch++;
345
+ this.pauseVisit();
346
+ this.stopHeartbeat();
347
+ this.pendingPageviewTrigger = null;
348
+ for (const tracker of this.childTrackers.splice(0)) tracker.stop?.(true);
349
+ return;
350
+ }
338
351
  for (const tracker of this.childTrackers) tracker.setCookielessMode?.(cookieless);
339
- if (wasBlocked && this.canTrack() && this.started) this.beginTracking("consent");
352
+ if (!wasTracking && this.started) {
353
+ this.startHeartbeat();
354
+ this.beginTracking("consent");
355
+ }
340
356
  }
341
357
  getConsentMode() {
342
358
  return this.consentMode;
343
359
  }
344
360
  getAnonymousId() {
345
361
  if (!this.canTrack()) return "";
346
- return getAnonymousId(this.cookieless());
362
+ return getAnonymousId(this.options.siteKey, this.cookieless());
347
363
  }
348
364
  getSessionId() {
365
+ if (!this.canTrack()) return "";
349
366
  return getSessionContext(this.options.siteKey, this.cookieless()).sessionId;
350
367
  }
351
368
  getWindowId() {
369
+ if (!this.canTrack()) return "";
352
370
  return getSessionContext(this.options.siteKey, this.cookieless()).windowId;
353
371
  }
354
372
  async checkFeatureFlag(key, attributes, opts) {
@@ -374,7 +392,7 @@ var WebAnalytics = class {
374
392
  send(event, properties = {}, dims = {}) {
375
393
  if (!this.canSendEvents()) return;
376
394
  const cookieless = this.cookieless();
377
- const id = getAnonymousId(cookieless);
395
+ const id = getAnonymousId(this.options.siteKey, cookieless);
378
396
  const ctx = getSessionContext(this.options.siteKey, cookieless);
379
397
  this.log(event);
380
398
  sendData({
@@ -385,7 +403,7 @@ var WebAnalytics = class {
385
403
  sessionId: ctx.sessionId,
386
404
  windowId: ctx.windowId,
387
405
  event,
388
- ...getPageContext(),
406
+ ...getPageContext(this.options.trackHash ?? false),
389
407
  ...dims,
390
408
  properties
391
409
  }),
@@ -433,8 +451,8 @@ var WebAnalytics = class {
433
451
  navigate() {
434
452
  if (!this.started || this.destroyed) return;
435
453
  const trackHash = this.options.trackHash ?? false;
436
- if (location.pathname === this.entry.path && (!trackHash || location.hash === this.entry.hash)) return;
437
- for (const tracker of this.childTrackers) tracker.trackPageChange?.(sanitizeUrl(location.href));
454
+ if (`${location.pathname}${trackHash ? location.hash : ""}` === this.entry.path && location.search === this.entry.search) return;
455
+ for (const tracker of this.childTrackers) tracker.trackPageChange?.(sanitizeUrl(location.href, trackHash));
438
456
  this.leavePage();
439
457
  this.enterPage();
440
458
  this.pageview({ trigger: "navigation" });
package/dist/replay.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import { n as ReplayTrackerOptions, t as ReplayTracker } from "./chunks/replay-DgodPHzA.js";
1
+ import { n as ReplayTrackerOptions, t as ReplayTracker } from "./chunks/replay-BeC2XA4N.js";
2
2
  export { ReplayTrackerOptions, ReplayTracker as default };
package/dist/replay.js CHANGED
@@ -1,11 +1,59 @@
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";
1
+ import { a as resolveBaseUrl, o as sanitizeUrl, r as URLS, t as ANALYTICS_BASE } from "./chunks/api-urls-Dp2XNjRF.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-DR1JUXka.js";
3
+ import { t as getAnonymousId } from "./chunks/identifiers-BvRNbiwA.js";
4
4
  import { record } from "@rrweb/record";
5
+ //#region src/replay-mutation-throttler.ts
6
+ const INCREMENTAL_SNAPSHOT_EVENT_TYPE = 3;
7
+ const MUTATION_SOURCE_TYPE = 0;
8
+ const MAX_TRACKED_NODES = 1e4;
9
+ /** Bounds pathological per-node attribute churn without breaking the mutation chain. */
10
+ var ReplayMutationThrottler = class {
11
+ buckets = /* @__PURE__ */ new Map();
12
+ bucketSize;
13
+ refillRate;
14
+ constructor(options = {}) {
15
+ this.bucketSize = Math.max(1, options.bucketSize ?? 100);
16
+ this.refillRate = Math.max(1, options.refillRate ?? 10);
17
+ }
18
+ reset() {
19
+ this.buckets.clear();
20
+ }
21
+ throttle(event) {
22
+ if (event.type !== INCREMENTAL_SNAPSHOT_EVENT_TYPE) return event;
23
+ const data = event.data;
24
+ if (data.source !== MUTATION_SOURCE_TYPE || !data.attributes?.length) return event;
25
+ const attributes = data.attributes.filter(({ id }) => this.consume(id));
26
+ if (attributes.length === data.attributes.length) return event;
27
+ if (attributes.length === 0 && (data.adds?.length ?? 0) === 0 && (data.removes?.length ?? 0) === 0 && (data.texts?.length ?? 0) === 0) return;
28
+ return {
29
+ ...event,
30
+ data: {
31
+ ...data,
32
+ attributes
33
+ }
34
+ };
35
+ }
36
+ consume(nodeId) {
37
+ const now = Date.now();
38
+ const bucket = this.buckets.get(nodeId) ?? {
39
+ tokens: this.bucketSize,
40
+ updatedAt: now
41
+ };
42
+ const elapsedSeconds = Math.max(0, now - bucket.updatedAt) / 1e3;
43
+ bucket.tokens = Math.min(this.bucketSize, bucket.tokens + elapsedSeconds * this.refillRate);
44
+ bucket.updatedAt = now;
45
+ if (!this.buckets.has(nodeId) && this.buckets.size >= MAX_TRACKED_NODES) this.buckets.delete(this.buckets.keys().next().value);
46
+ this.buckets.set(nodeId, bucket);
47
+ if (bucket.tokens < 1) return false;
48
+ bucket.tokens -= 1;
49
+ return true;
50
+ }
51
+ };
52
+ //#endregion
5
53
  //#region src/replay.ts
6
54
  const MAX_TIMEOUT_MS = 2147483647;
7
55
  const DEFAULT_MAX_QUEUE_SIZE_BYTES = 2 * 1024 * 1024;
8
- const DEFAULT_MAX_BATCH_SIZE_BYTES = 512 * 1024;
56
+ const DEFAULT_MAX_BATCH_SIZE_BYTES = 900 * 1024;
9
57
  const DEFAULT_MAX_LOW_LATENCY_BATCH_SIZE_BYTES = 60 * 1024;
10
58
  const RETRY_BASE_DELAY_MS = 2e3;
11
59
  const RETRY_MAX_ATTEMPTS = 5;
@@ -33,6 +81,7 @@ var ReplayTracker = class {
33
81
  queuedEventsSizeBytes = 0;
34
82
  pendingSizeBytes = 0;
35
83
  viewId = createId();
84
+ viewUrl = "";
36
85
  chunkStartedAt = 0;
37
86
  started = false;
38
87
  disposed = false;
@@ -50,16 +99,20 @@ var ReplayTracker = class {
50
99
  queuedFlush = null;
51
100
  unsubscribeRotation;
52
101
  lastCookielessMode = isCookielessMode();
102
+ terminalFlushRequested = false;
103
+ overflowed = false;
104
+ mutationThrottler;
53
105
  constructor(options) {
54
106
  this.options = options;
55
107
  this.endpoint = `${resolveBaseUrl(options.baseUrl, ANALYTICS_BASE)}${URLS.replay}`;
56
- this.flushInterval = options.flushInterval ?? 5e3;
108
+ this.flushInterval = options.flushInterval ?? 15e3;
57
109
  this.maxEvents = options.maxEvents ?? 1e3;
58
110
  this.maxBatchSizeBytes = options.maxBatchSizeBytes ?? DEFAULT_MAX_BATCH_SIZE_BYTES;
59
111
  this.maxLowLatencyBatchSizeBytes = options.maxLowLatencyBatchSizeBytes ?? DEFAULT_MAX_LOW_LATENCY_BATCH_SIZE_BYTES;
60
112
  this.maxPendingBatches = options.maxPendingBatches ?? 30;
61
113
  this.maxQueueSizeBytes = options.maxQueueSizeBytes ?? DEFAULT_MAX_QUEUE_SIZE_BYTES;
62
114
  this.minReplayLengthMs = options.minReplayLengthMs ?? 3e3;
115
+ this.mutationThrottler = options.mutationThrottle?.enabled === false ? null : new ReplayMutationThrottler(options.mutationThrottle);
63
116
  }
64
117
  log(...args) {
65
118
  if (this.options.debug) console.log("[Replay]", ...args);
@@ -113,10 +166,8 @@ var ReplayTracker = class {
113
166
  setCookielessMode(cookieless) {
114
167
  if (this.lastCookielessMode === cookieless) return;
115
168
  this.lastCookielessMode = cookieless;
116
- if (cookieless) {
117
- this.clearQueues();
118
- this.clearFlushTimers();
119
- }
169
+ this.clearQueues();
170
+ this.clearFlushTimers();
120
171
  this.startTime = getSessionContext(this.options.siteKey).sessionStart;
121
172
  this.chunkStartedAt = Date.now();
122
173
  }
@@ -124,7 +175,10 @@ var ReplayTracker = class {
124
175
  if (this.started || typeof window === "undefined") return;
125
176
  this.started = true;
126
177
  this.disposed = false;
178
+ this.terminalFlushRequested = false;
179
+ this.overflowed = false;
127
180
  this.viewId = createId();
181
+ this.viewUrl = sanitizeUrl(window.location.href, this.options.trackHash ?? false);
128
182
  this.startTime = getSessionContext(this.options.siteKey).sessionStart;
129
183
  this.chunkStartedAt = Date.now();
130
184
  this.unsubscribeRotation = onSessionRotated((prev, next) => {
@@ -138,13 +192,9 @@ var ReplayTracker = class {
138
192
  }
139
193
  trackPageChange(url) {
140
194
  if (!this.started) return;
141
- this.enqueueEventsIfReady(getSessionContext(this.options.siteKey), {
142
- flushReason: "navigation",
143
- sealBeforeMinLength: true
144
- });
145
195
  this.viewId = createId();
146
- this.chunkStartedAt = Date.now();
147
- const href = sanitizeUrl(url ?? window.location.href);
196
+ const href = sanitizeUrl(url ?? window.location.href, this.options.trackHash ?? false);
197
+ this.viewUrl = href;
148
198
  this.onEvent({
149
199
  type: VIEW_META_EVENT_TYPE,
150
200
  timestamp: Date.now(),
@@ -156,11 +206,14 @@ var ReplayTracker = class {
156
206
  }
157
207
  }
158
208
  }, false);
159
- if (this.pending.length > 0) queueMicrotask(() => void this.flush(false));
160
209
  }
161
- onPageHidden(persisted = false) {
210
+ onPageHidden(persisted = false, terminal = false) {
162
211
  if (persisted) return;
163
- this.flush(true, true, "pageHidden");
212
+ if (terminal) {
213
+ this.requestTerminalFlush("pageHidden");
214
+ return;
215
+ }
216
+ this.flush(false, false, "pageHidden");
164
217
  }
165
218
  onPageShow(persisted = false) {
166
219
  if (!persisted || !this.started) return;
@@ -214,7 +267,7 @@ var ReplayTracker = class {
214
267
  blockSelector: this.options.blockSelector,
215
268
  maskTextClass: this.options.maskTextClass,
216
269
  maskTextSelector: this.options.maskTextSelector,
217
- checkoutEveryNms: this.options.checkoutEveryNms ?? 6e4,
270
+ checkoutEveryNms: this.options.checkoutEveryNms ?? 5 * 6e4,
218
271
  checkoutEveryNth: this.options.checkoutEveryNth,
219
272
  plugins
220
273
  });
@@ -224,7 +277,7 @@ var ReplayTracker = class {
224
277
  }
225
278
  this.stopRecording = stop;
226
279
  }
227
- stop() {
280
+ stop(discard = false) {
228
281
  if (!this.started) return;
229
282
  this.started = false;
230
283
  this.disposed = true;
@@ -237,6 +290,11 @@ var ReplayTracker = class {
237
290
  this.clearFlushTimers();
238
291
  this.intervalId = null;
239
292
  window.removeEventListener("beforeunload", this.onUnload);
293
+ if (discard) {
294
+ this.clearQueues();
295
+ this.log("Recording discarded");
296
+ return;
297
+ }
240
298
  if (!this.hasReachedMinLength()) {
241
299
  this.events.length = 0;
242
300
  this.queuedEventsSizeBytes = 0;
@@ -266,7 +324,8 @@ var ReplayTracker = class {
266
324
  this.startTime = next.sessionStart;
267
325
  this.chunkStartedAt = Date.now();
268
326
  }
269
- onEvent = (event, isCheckout) => {
327
+ onEvent = (event, _isCheckout) => {
328
+ if (this.overflowed) return;
270
329
  let capturedEvent = event;
271
330
  if (event.type === RRWEB_EVENT_META) {
272
331
  const data = event.data;
@@ -274,23 +333,38 @@ var ReplayTracker = class {
274
333
  ...event,
275
334
  data: {
276
335
  ...data,
277
- href: sanitizeUrl(data.href)
336
+ href: sanitizeUrl(data.href, this.options.trackHash ?? false)
278
337
  }
279
338
  };
280
339
  }
340
+ if (capturedEvent.type === RRWEB_EVENT_FULL_SNAPSHOT) this.mutationThrottler?.reset();
341
+ else {
342
+ const throttled = this.mutationThrottler?.throttle(capturedEvent);
343
+ if (this.mutationThrottler && !throttled) return;
344
+ if (throttled) capturedEvent = throttled;
345
+ }
281
346
  this.events.push(capturedEvent);
282
347
  this.queuedEventsSizeBytes += this.eventSizeBytes(capturedEvent);
283
348
  let reason;
284
- if (isCheckout) reason = "checkout";
285
- else if (this.events.length >= this.maxEvents) reason = "maxEvents";
349
+ if (this.events.length >= this.maxEvents) reason = "maxEvents";
286
350
  else if (this.queuedEventsSizeBytes >= this.maxBatchSizeBytes) reason = "maxBytes";
287
- else if (capturedEvent.type === RRWEB_EVENT_FULL_SNAPSHOT && this.hasReachedMinLength()) reason = "fullSnapshot";
288
351
  if (reason) this.requestFlush(reason);
289
352
  else this.scheduleMinLengthFlush();
290
353
  };
291
354
  onUnload = () => {
292
- this.flush(true, true, "unload");
355
+ this.requestTerminalFlush("unload");
293
356
  };
357
+ requestTerminalFlush(reason) {
358
+ if (this.terminalFlushRequested) return;
359
+ this.terminalFlushRequested = true;
360
+ if (!this.hasReachedMinLength()) {
361
+ this.events.length = 0;
362
+ this.queuedEventsSizeBytes = 0;
363
+ this.dropMinLengthBlockedBatches();
364
+ return;
365
+ }
366
+ this.flush(true, true, reason);
367
+ }
294
368
  hasReachedMinLength() {
295
369
  return this.minReplayLengthMs <= 0 || Date.now() - this.startTime >= this.minReplayLengthMs;
296
370
  }
@@ -347,7 +421,7 @@ var ReplayTracker = class {
347
421
  return batches;
348
422
  }
349
423
  createBatch(events, context, isFinal, flushReason = "manual") {
350
- const identifier = getAnonymousId();
424
+ const identifier = getAnonymousId(this.options.siteKey);
351
425
  const sequence = this.nextBatchSequence(context);
352
426
  const chunkStartedAt = this.chunkStartedAt;
353
427
  const now = Date.now();
@@ -364,7 +438,7 @@ var ReplayTracker = class {
364
438
  batchId: `${context.sessionId}-${sequence}-${chunkStartedAt}`,
365
439
  sequence,
366
440
  timestamp: now,
367
- url: sanitizeUrl(window.location.href),
441
+ url: this.viewUrl || sanitizeUrl(window.location.href, this.options.trackHash ?? false),
368
442
  ...isFinal ? { isFinal: true } : {},
369
443
  flushReason,
370
444
  events
@@ -380,10 +454,6 @@ var ReplayTracker = class {
380
454
  batchSizeCache.set(batch, size);
381
455
  return size;
382
456
  }
383
- dropOldestPending(reason) {
384
- const batch = this.pending[0];
385
- if (batch) this.removePending(batch, reason);
386
- }
387
457
  removePending(batch, reason) {
388
458
  const index = this.pending.indexOf(batch);
389
459
  if (index < 0) return false;
@@ -394,11 +464,13 @@ var ReplayTracker = class {
394
464
  }
395
465
  enqueueBatch(batch) {
396
466
  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`);
467
+ if (size > this.maxQueueSizeBytes || this.pending.length >= this.maxPendingBatches || this.pendingSizeBytes + size > this.maxQueueSizeBytes) {
468
+ this.log(`Replay queue limit reached at batch ${batch.sequence}; stopping recording at the last complete chain`);
469
+ this.overflowed = true;
470
+ this.stopRecording?.();
471
+ this.stopRecording = void 0;
399
472
  return;
400
473
  }
401
- while ((this.pending.length >= this.maxPendingBatches || this.pendingSizeBytes + size > this.maxQueueSizeBytes) && this.pending.length > 0) this.dropOldestPending("Pending queue limit reached");
402
474
  this.pending.push(batch);
403
475
  this.pendingSizeBytes += size;
404
476
  }
@@ -477,9 +549,10 @@ var ReplayTracker = class {
477
549
  }
478
550
  async sendBatch(batch, lowLatency) {
479
551
  const json = JSON.stringify(batch);
552
+ const lowLatencySafe = lowLatency && textEncoder.encode(json).byteLength <= 60 * 1024;
480
553
  const sendOptions = {
481
- useBeacon: lowLatency,
482
- keepalive: lowLatency
554
+ useBeacon: lowLatencySafe,
555
+ keepalive: lowLatencySafe
483
556
  };
484
557
  if (!lowLatency && (this.options.compress ?? true) && this.compressionSupported) try {
485
558
  const stream = new Blob([json]).stream().pipeThrough(new CompressionStream("gzip"));
@@ -4,6 +4,7 @@ interface WebVitalsOptions {
4
4
  baseUrl?: string;
5
5
  debug?: boolean;
6
6
  attribution?: boolean;
7
+ trackHash?: boolean;
7
8
  }
8
9
  declare class WebVitalsTracker {
9
10
  private readonly options;
@@ -12,14 +13,12 @@ declare class WebVitalsTracker {
12
13
  private started;
13
14
  private flushing;
14
15
  private initialUrl;
15
- private currentUrl;
16
+ private session;
16
17
  constructor(options: WebVitalsOptions);
17
18
  start(): Promise<void>;
18
- stop(): void;
19
+ stop(discard?: boolean): void;
19
20
  onPageHidden(persisted?: boolean): void;
20
- trackPageChange(url?: string): void;
21
21
  private onMetric;
22
- private getMetricUrl;
23
22
  private flush;
24
23
  }
25
24
  //#endregion
@@ -1,5 +1,5 @@
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";
1
+ import { a as resolveBaseUrl, o as sanitizeUrl, r as URLS, t as ANALYTICS_BASE } from "./chunks/api-urls-Dp2XNjRF.js";
2
+ import { r as getSessionContext, t as sendData } from "./chunks/send-data-DR1JUXka.js";
3
3
  //#region src/web-vitals.ts
4
4
  const METRIC_NAMES = /* @__PURE__ */ new Set([
5
5
  "CLS",
@@ -15,7 +15,7 @@ var WebVitalsTracker = class {
15
15
  started = false;
16
16
  flushing = false;
17
17
  initialUrl = "";
18
- currentUrl = "";
18
+ session = null;
19
19
  constructor(options) {
20
20
  this.options = options;
21
21
  this.endpoint = `${resolveBaseUrl(options.baseUrl, ANALYTICS_BASE)}${URLS.vitals}`;
@@ -23,8 +23,8 @@ var WebVitalsTracker = class {
23
23
  async start() {
24
24
  if (this.started || typeof window === "undefined") return;
25
25
  this.started = true;
26
- this.initialUrl = sanitizeUrl(window.location.href);
27
- this.currentUrl = this.initialUrl;
26
+ this.initialUrl = sanitizeUrl(window.location.href, this.options.trackHash ?? false);
27
+ this.session = getSessionContext(this.options.siteKey);
28
28
  const mod = await (this.options.attribution ? import("web-vitals/attribution") : import("web-vitals"));
29
29
  if (!this.started) return;
30
30
  const changes = { reportAllChanges: true };
@@ -34,31 +34,24 @@ var WebVitalsTracker = class {
34
34
  mod.onFCP(this.onMetric);
35
35
  mod.onTTFB(this.onMetric);
36
36
  }
37
- stop() {
37
+ stop(discard = false) {
38
38
  if (!this.started) return;
39
39
  this.started = false;
40
- this.flush();
40
+ if (discard) this.metrics.clear();
41
+ else this.flush();
41
42
  }
42
43
  onPageHidden(persisted = false) {
43
44
  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;
45
+ queueMicrotask(() => void this.flush());
51
46
  }
52
47
  onMetric = (metric) => {
53
48
  if (!this.started) return;
54
49
  const name = metric.name;
55
50
  if (!METRIC_NAMES.has(name) || !Number.isFinite(metric.value) || metric.value < 0) return;
56
- const url = this.getMetricUrl(name);
57
51
  const { id, rating, delta, navigationType } = metric;
58
52
  const attribution = "attribution" in metric && metric.attribution ? metric.attribution : void 0;
59
- this.metrics.set(`${url}\n${name}`, {
53
+ this.metrics.set(name, {
60
54
  metric: name,
61
- url,
62
55
  value: metric.value,
63
56
  attributes: {
64
57
  id,
@@ -69,46 +62,32 @@ var WebVitalsTracker = class {
69
62
  }
70
63
  });
71
64
  };
72
- getMetricUrl(metric) {
73
- if (metric === "FCP" || metric === "LCP" || metric === "TTFB") return this.initialUrl;
74
- return this.currentUrl || this.initialUrl;
75
- }
76
65
  async flush() {
77
66
  if (!this.metrics.size || this.flushing) return;
78
67
  this.flushing = true;
79
68
  const batch = this.metrics;
80
69
  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({
70
+ const session = this.session ?? getSessionContext(this.options.siteKey);
71
+ let sent = false;
72
+ try {
73
+ sent = await sendData({
90
74
  url: this.endpoint,
91
75
  data: JSON.stringify({
92
76
  token: this.options.siteKey,
93
77
  sessionId: session.sessionId,
94
78
  windowId: session.windowId,
95
- vitals: vitals.map(({ url: _url, ...vital }) => vital),
96
- metadata: { url }
79
+ vitals: [...batch.values()],
80
+ metadata: { url: this.initialUrl }
97
81
  }),
98
82
  debug: this.options.debug,
99
83
  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);
84
+ });
85
+ } finally {
86
+ if (!sent) {
87
+ for (const [name, vital] of batch) if (!this.metrics.has(name)) this.metrics.set(name, vital);
109
88
  }
89
+ this.flushing = false;
110
90
  }
111
- this.flushing = false;
112
91
  }
113
92
  };
114
93
  //#endregion
package/package.json CHANGED
@@ -42,7 +42,7 @@
42
42
  "publishConfig": {
43
43
  "access": "public"
44
44
  },
45
- "version": "0.6.0",
45
+ "version": "0.7.0",
46
46
  "scripts": {
47
47
  "build": "tsdown && bun run check-size",
48
48
  "dev": "bun run build",
@@ -1,20 +0,0 @@
1
- import { d as setStorageItem, i as isCookielessMode, l as getStorageItem, n as createId, u as removeStorageItem } from "./send-data-DjdbGDDc.js";
2
- //#region src/utils/identifiers.ts
3
- const ANONYMOUS_ID_KEY = "faststats_anon_id";
4
- function getOrCreateAnonymousId() {
5
- const existingId = getStorageItem("localStorage", ANONYMOUS_ID_KEY);
6
- if (existingId) return existingId;
7
- const newId = createId();
8
- return setStorageItem("localStorage", ANONYMOUS_ID_KEY, newId) ? newId : "";
9
- }
10
- function getAnonymousId(cookieless) {
11
- if (cookieless ?? isCookielessMode()) return "";
12
- return getOrCreateAnonymousId();
13
- }
14
- function resetAnonymousId(cookieless) {
15
- if (cookieless) return "";
16
- if (!removeStorageItem("localStorage", ANONYMOUS_ID_KEY)) return "";
17
- return getOrCreateAnonymousId();
18
- }
19
- //#endregion
20
- export { resetAnonymousId as n, getAnonymousId as t };