@faststats/web 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,21 +3,20 @@
3
3
  > [!IMPORTANT]
4
4
  > This repository is in early development and not intended for production use.
5
5
 
6
- The base SDK only handles analytics, identity, sessions, navigation, and consent.
6
+ The base SDK handles analytics, identity, sessions, navigation, outbound link
7
+ clicks, and consent.
7
8
  Error tracking, Web Vitals, and session replay are independent extensions, so
8
9
  applications only download the products they use.
9
10
 
10
11
  ```ts
11
12
  import { init, track } from "@faststats/web";
12
13
  import { errorTracking } from "@faststats/web/error";
13
- import { outboundLinks } from "@faststats/web/outbound-links";
14
14
  import { sessionReplay } from "@faststats/web/replay";
15
15
  import { webVitals } from "@faststats/web/web-vitals";
16
16
 
17
17
  init({
18
18
  siteKey: "site_123",
19
19
  extensions: [
20
- outboundLinks(),
21
20
  errorTracking(),
22
21
  webVitals(),
23
22
  sessionReplay({ recordConsole: true }),
@@ -123,7 +122,7 @@ export function diagnostics(): AnalyticsExtension {
123
122
  name: "diagnostics",
124
123
  setup(context) {
125
124
  const off = context.on("pageChange", ({ url }) => {
126
- console.debug("page changed", url, context.getSession());
125
+ console.debug("page changed", url);
127
126
  });
128
127
 
129
128
  return () => off();
@@ -1,10 +1,3 @@
1
- //#region src/utils/session-manager.d.ts
2
- type SessionContext = {
3
- sessionId: string;
4
- windowId: string;
5
- sessionStart: number;
6
- };
7
- //#endregion
8
1
  //#region src/extensions.d.ts
9
2
  type ExtensionHooks = {
10
3
  pageChange: {
@@ -34,11 +27,7 @@ interface AnalyticsExtensionContext {
34
27
  readonly sdkName?: string;
35
28
  readonly sdkVersion?: string;
36
29
  isCookieless(): boolean;
37
- getAnonymousId(): string;
38
- getSession(): SessionContext;
39
- capture(event: string, properties?: Record<string, unknown>): void;
40
30
  on<K extends ExtensionHook>(hook: K, handler: ExtensionHandler<K>): () => void;
41
- log(message: string, ...details: unknown[]): void;
42
31
  }
43
32
  interface AnalyticsExtension {
44
33
  readonly name: string;
@@ -1,4 +1,4 @@
1
- import { c as removeStorageItem, l as setStorageItem, n as createId, s as getStorageItem } from "./send-data-Dplz7m4c.js";
1
+ import { c as removeStorageItem, l as setStorageItem, n as createId, s as getStorageItem } from "./send-data-D520oab1.js";
2
2
  //#region src/utils/identifiers.ts
3
3
  const ANONYMOUS_ID_KEY = "faststats_anon_id_";
4
4
  function getAnonymousId(siteKey, cookieless = false) {
@@ -1,3 +1,54 @@
1
+ //#region src/utils/api-urls.ts
2
+ function resolveBaseUrl(baseUrl) {
3
+ return baseUrl?.replace(/\/+$/, "") || "https://metrics.faststats.dev";
4
+ }
5
+ const URLS = {
6
+ events: "/v1/web",
7
+ identify: "/v1/identify",
8
+ replay: "/v1/replay",
9
+ vitals: "/v1/vitals"
10
+ };
11
+ function sanitizeUrl(url, includeHash = false) {
12
+ try {
13
+ const parsed = new URL(url, location.href);
14
+ return `${parsed.origin}${parsed.pathname}${includeHash ? parsed.hash : ""}`;
15
+ } catch {
16
+ return url.split(includeHash ? "?" : /[?#]/, 1)[0] ?? "";
17
+ }
18
+ }
19
+ const UTM_KEYS = [
20
+ "utm_source",
21
+ "utm_medium",
22
+ "utm_campaign",
23
+ "utm_term",
24
+ "utm_content"
25
+ ];
26
+ function getReferrer() {
27
+ const raw = document.referrer;
28
+ if (!raw) return null;
29
+ try {
30
+ const pageOrigin = new URL(location.href).origin;
31
+ if (new URL(raw, location.href).origin === pageOrigin) return null;
32
+ } catch {}
33
+ return sanitizeUrl(raw);
34
+ }
35
+ function getPageContext(includeHash = false) {
36
+ const hash = includeHash ? location.hash : "";
37
+ const ctx = {
38
+ page: `${location.pathname}${hash}`,
39
+ url: sanitizeUrl(location.href, includeHash),
40
+ referrer: getReferrer(),
41
+ title: document.title || ""
42
+ };
43
+ if (!location.search) return ctx;
44
+ const sp = new URLSearchParams(location.search);
45
+ for (const key of UTM_KEYS) {
46
+ const value = sp.get(key);
47
+ if (value) ctx[key] = value;
48
+ }
49
+ return ctx;
50
+ }
51
+ //#endregion
1
52
  //#region src/utils/storage.ts
2
53
  function getStorageItem(name, key) {
3
54
  try {
@@ -31,6 +82,7 @@ function removeStorageItem(name, key) {
31
82
  const IDLE_MS = 18e5;
32
83
  const MAX_AGE_MS = 864e5;
33
84
  const SESSION_KEY = "faststats_session_";
85
+ const WINDOW_KEY = "faststats_window_id_";
34
86
  const NO_STORAGE = {};
35
87
  const memorySessions = /* @__PURE__ */ new Map();
36
88
  const rotationListeners = /* @__PURE__ */ new Map();
@@ -64,7 +116,7 @@ function readSession(siteKey) {
64
116
  if (!raw) return memory;
65
117
  try {
66
118
  const session = JSON.parse(raw);
67
- return typeof session?.id === "string" && session.id && Number.isFinite(session.start) && Number.isFinite(session.activity) ? session : memory;
119
+ if (typeof session?.id === "string" && session.id && Number.isFinite(session.start) && Number.isFinite(session.activity)) return session;
68
120
  } catch {}
69
121
  return memory;
70
122
  }
@@ -104,7 +156,7 @@ function resolveSession(siteKey, cookieless, touch) {
104
156
  }
105
157
  function windowId(siteKey, cookieless, sessionId) {
106
158
  if (cookieless) return sessionId;
107
- const key = `faststats_window_id_${siteKey}`;
159
+ const key = `${WINDOW_KEY}${siteKey}`;
108
160
  const existing = getStorageItem("sessionStorage", key);
109
161
  if (existing) return existing;
110
162
  const id = createId();
@@ -128,7 +180,7 @@ function resetSession(siteKey, cookieless = false) {
128
180
  memorySessions.delete(memoryKey(siteKey, cookieless));
129
181
  if (!cookieless) {
130
182
  removeStorageItem("localStorage", `${SESSION_KEY}${siteKey}`);
131
- removeStorageItem("sessionStorage", `faststats_window_id_${siteKey}`);
183
+ removeStorageItem("sessionStorage", `${WINDOW_KEY}${siteKey}`);
132
184
  }
133
185
  const next = getSessionContext(siteKey, cookieless);
134
186
  if (previous.sessionId !== next.sessionId) emitRotation(siteKey, cookieless, previous, next);
@@ -150,8 +202,7 @@ function onSessionRotated(siteKey, cookieless, listener) {
150
202
  //#endregion
151
203
  //#region src/utils/send-data.ts
152
204
  async function sendData(options) {
153
- const { url, data, contentType = "application/json", headers = {}, debug = false, debugPrefix = "[Analytics]", useBeacon = true, keepalive = true, signal } = options;
154
- if (signal?.aborted) return false;
205
+ const { url, data, contentType = "application/json", debug = false, debugPrefix = "[Analytics]", useBeacon = true, keepalive = true, settleRejected } = options;
155
206
  if (useBeacon && typeof globalThis.navigator?.sendBeacon === "function") try {
156
207
  const beaconBody = data instanceof Blob || typeof Blob === "undefined" ? data : new Blob([data], { type: contentType });
157
208
  if (globalThis.navigator.sendBeacon(url, beaconBody)) {
@@ -159,28 +210,21 @@ async function sendData(options) {
159
210
  return true;
160
211
  }
161
212
  } catch {}
213
+ const size = typeof data === "string" ? data.length : data instanceof Blob ? data.size : data.byteLength;
162
214
  try {
163
- const response = await fetch(url, {
215
+ const { ok, status } = await fetch(url, {
164
216
  method: "POST",
165
217
  body: data,
166
- headers: {
167
- "Content-Type": contentType,
168
- ...headers
169
- },
170
- keepalive,
171
- credentials: "omit",
172
- signal
218
+ headers: { "Content-Type": contentType },
219
+ keepalive: keepalive && size <= 61440,
220
+ credentials: "omit"
173
221
  });
174
- const success = response.ok;
175
- if (debug) {
176
- const message = success ? `${debugPrefix} Sent via fetch` : `${debugPrefix} Failed: ${response.status}`;
177
- console[success ? "log" : "warn"](message);
178
- }
179
- return success;
222
+ if (debug) console.log(debugPrefix, ok ? "Sent via fetch" : status);
223
+ return ok || !!settleRejected && status < 500 && status !== 429;
180
224
  } catch {
181
- if (debug) console.warn(`${debugPrefix} Failed to send`);
225
+ if (debug) console.log(debugPrefix, "Failed to send");
182
226
  return false;
183
227
  }
184
228
  }
185
229
  //#endregion
186
- export { resetSession as a, removeStorageItem as c, onSessionRotated as i, setStorageItem as l, createId as n, touchActivity as o, getSessionContext as r, getStorageItem as s, sendData as t };
230
+ export { resetSession as a, removeStorageItem as c, getPageContext as d, resolveBaseUrl as f, onSessionRotated as i, setStorageItem as l, createId as n, touchActivity as o, sanitizeUrl as p, getSessionContext as r, getStorageItem as s, sendData as t, URLS as u };
package/dist/error.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as AnalyticsExtension } from "./chunks/extensions-DW7tJY0T.js";
1
+ import { t as AnalyticsExtension } from "./chunks/extensions-DZDQn8O8.js";
2
2
  //#region src/error.d.ts
3
3
  export type ErrorEntry = {
4
4
  error: string;
@@ -17,7 +17,7 @@ export interface ErrorTrackingOptions {
17
17
  sdkName?: string;
18
18
  sdkVersion?: string;
19
19
  }
20
- export type ErrorTrackingExtensionOptions = Omit<ErrorTrackingOptions, "siteKey" | "baseUrl" | "debug" | "trackHash" | "cookieless" | "sdkName" | "sdkVersion">;
20
+ export type ErrorTrackingExtensionOptions = Pick<ErrorTrackingOptions, "flushInterval" | "maxQueueSize">;
21
21
  export declare function errorTracking(options?: ErrorTrackingExtensionOptions): AnalyticsExtension;
22
22
  export default class ErrorTracker {
23
23
  private readonly options;
@@ -26,11 +26,14 @@ export default class ErrorTracker {
26
26
  private timer;
27
27
  private started;
28
28
  private flushing;
29
+ private generation;
29
30
  private cookieless;
31
+ private readonly maxQueueSize;
30
32
  constructor(options: ErrorTrackingOptions);
31
33
  setCookielessMode(cookieless: boolean): void;
32
34
  start(): void;
33
35
  stop(discard?: boolean): void;
36
+ private discard;
34
37
  onPageHidden(persisted?: boolean): void;
35
38
  captureError(error: Error): void;
36
39
  private onError;
package/dist/error.js CHANGED
@@ -1,9 +1,8 @@
1
- import { i as resolveBaseUrl, n as URLS, r as getPageContext, t as ANALYTICS_BASE } from "./chunks/api-urls-DWk43fu6.js";
2
- import { r as getSessionContext, t as sendData } from "./chunks/send-data-Dplz7m4c.js";
3
- import { t as getAnonymousId } from "./chunks/identifiers-I5UnTXGR.js";
1
+ import { d as getPageContext, f as resolveBaseUrl, r as getSessionContext, t as sendData, u as URLS } from "./chunks/send-data-D520oab1.js";
2
+ import { t as getAnonymousId } from "./chunks/identifiers-BwGNL11R.js";
4
3
  //#region src/error.ts
5
4
  const SDK_NAME = "@faststats/web";
6
- const SDK_VERSION = "0.10.2";
5
+ const SDK_VERSION = "0.11.0";
7
6
  function errorTracking(options = {}) {
8
7
  return {
9
8
  name: "error-tracking",
@@ -51,14 +50,17 @@ var ErrorTracker = class {
51
50
  timer = null;
52
51
  started = false;
53
52
  flushing = false;
53
+ generation = 0;
54
54
  cookieless;
55
+ maxQueueSize;
55
56
  constructor(options) {
56
57
  this.options = options;
57
- this.endpoint = `${resolveBaseUrl(options.baseUrl, ANALYTICS_BASE)}${URLS.events}`;
58
+ this.maxQueueSize = options.maxQueueSize ?? 50;
59
+ this.endpoint = `${resolveBaseUrl(options.baseUrl)}${URLS.events}`;
58
60
  this.cookieless = options.cookieless ?? false;
59
61
  }
60
62
  setCookielessMode(cookieless) {
61
- if (this.cookieless !== cookieless) this.queue = [];
63
+ if (this.cookieless !== cookieless) this.discard();
62
64
  this.cookieless = cookieless;
63
65
  }
64
66
  start() {
@@ -69,15 +71,19 @@ var ErrorTracker = class {
69
71
  this.timer = setInterval(() => void this.flush(), this.options.flushInterval ?? 5e3);
70
72
  }
71
73
  stop(discard = false) {
72
- if (!this.started || typeof window === "undefined") return;
74
+ if (!this.started) return;
73
75
  this.started = false;
74
76
  window.removeEventListener("error", this.onError);
75
77
  window.removeEventListener("unhandledrejection", this.onRejection);
76
78
  if (this.timer) clearInterval(this.timer);
77
79
  this.timer = null;
78
- if (discard) this.queue = [];
80
+ if (discard) this.discard();
79
81
  else this.flush();
80
82
  }
83
+ discard() {
84
+ this.generation++;
85
+ this.queue = [];
86
+ }
81
87
  onPageHidden(persisted = false) {
82
88
  if (!persisted) this.flush();
83
89
  }
@@ -111,11 +117,12 @@ var ErrorTracker = class {
111
117
  properties: {}
112
118
  }
113
119
  });
114
- if (this.queue.length >= (this.options.maxQueueSize ?? 50)) this.flush();
120
+ if (this.queue.length >= this.maxQueueSize) this.flush();
115
121
  }
116
122
  async flush() {
117
123
  if (this.flushing || !this.queue.length) return;
118
124
  this.flushing = true;
125
+ const generation = this.generation;
119
126
  const batch = this.queue;
120
127
  this.queue = [];
121
128
  const groups = /* @__PURE__ */ new Map();
@@ -128,29 +135,36 @@ var ErrorTracker = class {
128
135
  group.items.push(item);
129
136
  groups.set(key, group);
130
137
  }
131
- const failed = await Promise.all([...groups.values()].map(async (group) => {
132
- const errors = /* @__PURE__ */ new Map();
133
- for (const { error } of group.items) {
134
- const key = JSON.stringify(error);
135
- const existing = errors.get(key);
136
- if (existing) existing.count++;
137
- else errors.set(key, {
138
- ...error,
139
- count: 1
140
- });
141
- }
142
- return await sendData({
143
- url: this.endpoint,
144
- data: JSON.stringify({
145
- ...group.context,
146
- errors: [...errors.values()]
147
- }),
148
- debug: this.options.debug,
149
- debugPrefix: "[ErrorTracker]"
150
- }) ? [] : group.items;
151
- }));
152
- this.queue.unshift(...failed.flat());
153
- this.flushing = false;
138
+ let failed = [];
139
+ try {
140
+ failed = (await Promise.all([...groups.values()].map(async (group) => {
141
+ const errors = /* @__PURE__ */ new Map();
142
+ for (const { error } of group.items) {
143
+ const key = JSON.stringify(error);
144
+ const existing = errors.get(key);
145
+ if (existing) existing.count++;
146
+ else errors.set(key, {
147
+ ...error,
148
+ count: 1
149
+ });
150
+ }
151
+ return await sendData({
152
+ url: this.endpoint,
153
+ data: JSON.stringify({
154
+ ...group.context,
155
+ errors: [...errors.values()]
156
+ }),
157
+ debug: this.options.debug,
158
+ debugPrefix: "[ErrorTracker]",
159
+ settleRejected: true
160
+ }) ? [] : group.items;
161
+ }))).flat();
162
+ } finally {
163
+ this.flushing = false;
164
+ }
165
+ if (generation !== this.generation) return;
166
+ this.queue = [...failed, ...this.queue].slice(-this.maxQueueSize);
167
+ if (!this.started && !failed.length) this.flush();
154
168
  }
155
169
  };
156
170
  //#endregion
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as ExtensionHook, i as ExtensionHandler, n as AnalyticsExtensionContext, o as ExtensionHooks, r as ExtensionCleanup, t as AnalyticsExtension } from "./chunks/extensions-DW7tJY0T.js";
1
+ import { a as ExtensionHook, i as ExtensionHandler, n as AnalyticsExtensionContext, o as ExtensionHooks, r as ExtensionCleanup, t as AnalyticsExtension } from "./chunks/extensions-DZDQn8O8.js";
2
2
  //#region src/analytics.d.ts
3
3
  type ConsentMode = "anonymous" | "granted" | "denied";
4
4
  interface IdentifyOptions {
@@ -32,7 +32,7 @@ export declare class WebAnalytics {
32
32
  private readonly baseUrl;
33
33
  private readonly debug;
34
34
  private readonly cleanup;
35
- private started;
35
+ private state;
36
36
  private heartbeatTimer;
37
37
  private extensions;
38
38
  private pageKey;
@@ -53,6 +53,8 @@ export declare class WebAnalytics {
53
53
  private canTrack;
54
54
  private isCookieless;
55
55
  private isActive;
56
+ private session;
57
+ private context;
56
58
  private activate;
57
59
  private startExtensions;
58
60
  private stopExtensions;
package/dist/index.js CHANGED
@@ -1,22 +1,23 @@
1
- import { i as resolveBaseUrl, n as URLS, r as getPageContext, t as ANALYTICS_BASE } from "./chunks/api-urls-DWk43fu6.js";
2
- import { a as resetSession, n as createId, o as touchActivity, r as getSessionContext, t as sendData } from "./chunks/send-data-Dplz7m4c.js";
3
- import { n as resetAnonymousId, t as getAnonymousId } from "./chunks/identifiers-I5UnTXGR.js";
1
+ import { a as resetSession, d as getPageContext, f as resolveBaseUrl, n as createId, o as touchActivity, p as sanitizeUrl, r as getSessionContext, t as sendData, u as URLS } from "./chunks/send-data-D520oab1.js";
2
+ import { n as resetAnonymousId, t as getAnonymousId } from "./chunks/identifiers-BwGNL11R.js";
4
3
  //#region src/extensions.ts
5
4
  var ExtensionHost = class {
6
- runtime;
5
+ context;
6
+ log;
7
7
  handlers = /* @__PURE__ */ new Map();
8
8
  cleanups = [];
9
9
  stopped = false;
10
- constructor(extensions, runtime) {
11
- this.runtime = runtime;
10
+ constructor(extensions, context, log) {
11
+ this.context = context;
12
+ this.log = log;
12
13
  for (const extension of extensions) this.setup(extension);
13
14
  }
14
15
  emit(hook, event) {
15
16
  if (this.stopped) return;
16
17
  for (const handler of this.handlers.get(hook) ?? []) try {
17
- handler(event)?.catch((error) => this.runtime.log(`${hook} failed`, error));
18
+ handler(event)?.catch((error) => this.log(`${hook} failed`, error));
18
19
  } catch (error) {
19
- this.runtime.log(`${hook} failed`, error);
20
+ this.log(`${hook} failed`, error);
20
21
  }
21
22
  }
22
23
  stop(discard = false) {
@@ -25,7 +26,7 @@ var ExtensionHost = class {
25
26
  for (const cleanup of this.cleanups.reverse()) try {
26
27
  cleanup({ discard });
27
28
  } catch (error) {
28
- this.runtime.log("cleanup failed", error);
29
+ this.log("cleanup failed", error);
29
30
  }
30
31
  this.cleanups.length = 0;
31
32
  this.handlers.clear();
@@ -35,7 +36,7 @@ var ExtensionHost = class {
35
36
  const subscriptions = [];
36
37
  try {
37
38
  const cleanup = await extension.setup({
38
- ...this.runtime,
39
+ ...this.context,
39
40
  on: (hook, handler) => {
40
41
  if (this.stopped) return () => {};
41
42
  let handlers = this.handlers.get(hook);
@@ -53,11 +54,40 @@ var ExtensionHost = class {
53
54
  else if (cleanup) this.cleanups.push(cleanup);
54
55
  } catch (error) {
55
56
  for (const off of subscriptions) off();
56
- this.runtime.log(`${extension.name} setup failed`, error);
57
+ this.log(`${extension.name} setup failed`, error);
57
58
  }
58
59
  }
59
60
  };
60
61
  //#endregion
62
+ //#region src/outbound-links.ts
63
+ const DEDUPE_MS = 500;
64
+ function outboundHref(event) {
65
+ for (const node of event.composedPath()) if (node instanceof HTMLAnchorElement && (node.protocol === "http:" || node.protocol === "https:") && node.host !== location.host) return node.href;
66
+ }
67
+ function trackOutboundLinks(report) {
68
+ let last;
69
+ const onClick = (event) => {
70
+ if (!(event instanceof MouseEvent) || event.defaultPrevented) return;
71
+ if (event.button !== (event.type === "click" ? 0 : 1)) return;
72
+ const href = outboundHref(event);
73
+ if (!href) return;
74
+ const key = `${event.button}:${event.clientX}:${event.clientY}:${href}`;
75
+ const now = Date.now();
76
+ const duplicate = last?.key === key && now - last.at <= DEDUPE_MS;
77
+ last = {
78
+ key,
79
+ at: now
80
+ };
81
+ if (!duplicate) report(sanitizeUrl(href));
82
+ };
83
+ document.addEventListener("click", onClick, true);
84
+ document.addEventListener("auxclick", onClick, true);
85
+ return () => {
86
+ document.removeEventListener("click", onClick, true);
87
+ document.removeEventListener("auxclick", onClick, true);
88
+ };
89
+ }
90
+ //#endregion
61
91
  //#region src/analytics.ts
62
92
  const navigationListeners = /* @__PURE__ */ new Set();
63
93
  const historyPatches = {};
@@ -95,12 +125,18 @@ function observeHistory(listener) {
95
125
  }
96
126
  };
97
127
  }
128
+ const RESERVED_EVENTS = /* @__PURE__ */ new Set([
129
+ "pageview",
130
+ "page_leave",
131
+ "error",
132
+ "outbound_link"
133
+ ]);
98
134
  var WebAnalytics = class {
99
135
  options;
100
136
  baseUrl;
101
137
  debug;
102
138
  cleanup = [];
103
- started = false;
139
+ state = "idle";
104
140
  heartbeatTimer = null;
105
141
  extensions = null;
106
142
  pageKey = "";
@@ -110,20 +146,20 @@ var WebAnalytics = class {
110
146
  constructor(options) {
111
147
  this.options = options;
112
148
  if (!options.siteKey.trim()) throw new Error("siteKey is required");
113
- this.baseUrl = resolveBaseUrl(options.baseUrl, ANALYTICS_BASE);
149
+ this.baseUrl = resolveBaseUrl(options.baseUrl);
114
150
  this.debug = options.debug ?? false;
115
151
  this.consentMode = options.consent ?? "granted";
116
152
  }
117
153
  start() {
118
- if (this.started !== false || typeof window === "undefined") return;
119
- this.started = true;
154
+ if (this.state !== "idle" || typeof window === "undefined") return;
155
+ this.state = "started";
120
156
  this.installBrowserLifecycle();
121
157
  if (this.canTrack()) this.activate("load");
122
158
  }
123
159
  destroy() {
124
- if (this.started === null) return;
160
+ if (this.state === "destroyed") return;
125
161
  this.leavePage();
126
- this.started = null;
162
+ this.state = "destroyed";
127
163
  this.pendingPageview = null;
128
164
  for (const cleanup of this.cleanup.splice(0).reverse()) cleanup();
129
165
  this.stopExtensions(false);
@@ -135,26 +171,22 @@ var WebAnalytics = class {
135
171
  this.leavePage();
136
172
  if (this.pageKey) this.extensions?.emit("pageChange", { url: getPageContext(this.options.trackHash).url ?? "" });
137
173
  this.pageKey = key;
174
+ this.pendingPageview = null;
138
175
  }
139
176
  if (document.visibilityState !== "visible") {
140
- this.pendingPageview = properties;
177
+ this.pendingPageview ??= properties;
141
178
  return;
142
179
  }
143
180
  this.pendingPageview = null;
144
181
  if (document.hasFocus()) touchActivity(this.options.siteKey, this.isCookieless());
145
- const session = getSessionContext(this.options.siteKey, this.isCookieless());
146
- const userId = this.getAnonymousId();
147
- if (this.visit?.session.sessionId === session.sessionId && this.visit.context.userId === (userId || void 0)) return;
182
+ const session = this.session();
183
+ const context = this.context(session);
184
+ if (this.visit?.session.sessionId === session.sessionId && this.visit.context.userId === context.userId) return;
148
185
  this.leavePage();
149
186
  this.visit = {
150
187
  id: createId(),
151
188
  session,
152
- context: {
153
- ...getPageContext(this.options.trackHash),
154
- userId: userId || void 0,
155
- sessionId: session.sessionId,
156
- windowId: session.windowId
157
- },
189
+ context,
158
190
  duration: 0,
159
191
  startedAt: null,
160
192
  reportedDuration: -1
@@ -168,7 +200,7 @@ var WebAnalytics = class {
168
200
  track(name, properties = {}) {
169
201
  if (!this.isActive()) return;
170
202
  const eventName = name.trim();
171
- if (!eventName || eventName === "pageview" || eventName === "page_leave" || eventName === "error" || eventName === "outbound_link") return;
203
+ if (!eventName || RESERVED_EVENTS.has(eventName)) return;
172
204
  this.pageview({ trigger: "session" });
173
205
  this.send(eventName, properties);
174
206
  }
@@ -233,7 +265,7 @@ var WebAnalytics = class {
233
265
  if (wasTracking) {
234
266
  this.extensions?.emit("consentChange", { cookieless: willBeCookieless });
235
267
  if (wasCookieless !== willBeCookieless) this.pageview({ trigger: "consent" });
236
- } else if (this.started) this.activate("consent");
268
+ } else if (this.state === "started") this.activate("consent");
237
269
  }
238
270
  getAnonymousId() {
239
271
  return this.canTrack() ? getAnonymousId(this.options.siteKey, this.isCookieless()) : "";
@@ -249,7 +281,18 @@ var WebAnalytics = class {
249
281
  return this.options.cookieless === true || this.consentMode === "anonymous";
250
282
  }
251
283
  isActive() {
252
- return this.started === true && this.canTrack();
284
+ return this.state === "started" && this.canTrack();
285
+ }
286
+ session() {
287
+ return getSessionContext(this.options.siteKey, this.isCookieless());
288
+ }
289
+ context(session = this.session()) {
290
+ return {
291
+ ...getPageContext(this.options.trackHash),
292
+ userId: this.getAnonymousId() || void 0,
293
+ sessionId: session.sessionId,
294
+ windowId: session.windowId
295
+ };
253
296
  }
254
297
  activate(trigger) {
255
298
  this.startExtensions();
@@ -257,22 +300,17 @@ var WebAnalytics = class {
257
300
  }
258
301
  startExtensions() {
259
302
  if (this.extensions || !this.options.extensions?.length) return;
260
- const host = new ExtensionHost(this.options.extensions, {
303
+ this.extensions = new ExtensionHost(this.options.extensions, {
261
304
  siteKey: this.options.siteKey,
262
305
  baseUrl: this.baseUrl,
263
306
  debug: this.debug,
264
307
  trackHash: this.options.trackHash ?? false,
265
308
  sdkName: this.options.sdkName,
266
309
  sdkVersion: this.options.sdkVersion,
267
- isCookieless: () => this.isCookieless(),
268
- getAnonymousId: () => this.getAnonymousId(),
269
- getSession: () => getSessionContext(this.options.siteKey, this.isCookieless()),
270
- capture: (event, properties) => this.send(event, properties),
271
- log: (...details) => {
272
- if (this.debug) console.log(...details);
273
- }
310
+ isCookieless: () => this.isCookieless()
311
+ }, (...details) => {
312
+ if (this.debug) console.log(...details);
274
313
  });
275
- this.extensions = host;
276
314
  }
277
315
  stopExtensions(discard) {
278
316
  this.extensions?.stop(discard);
@@ -286,7 +324,7 @@ var WebAnalytics = class {
286
324
  this.bind(window, "blur", () => this.pauseVisit());
287
325
  this.bind(window, "popstate", this.onNavigate);
288
326
  if (this.options.trackHash) this.bind(window, "hashchange", this.onNavigate);
289
- this.cleanup.push(observeHistory(this.onNavigate));
327
+ this.cleanup.push(observeHistory(this.onNavigate), trackOutboundLinks((url) => this.send("outbound_link", { outbound_link: url })));
290
328
  }
291
329
  bind(target, type, listener) {
292
330
  target.addEventListener(type, listener);
@@ -359,21 +397,14 @@ var WebAnalytics = class {
359
397
  this.pauseVisit();
360
398
  return;
361
399
  }
400
+ const visit = this.visit;
362
401
  this.pageview({ trigger: "session" });
363
- this.checkpoint();
402
+ if (this.visit === visit) this.checkpoint();
364
403
  }, 15e3);
365
404
  }
366
405
  send(event, properties = {}, context) {
367
406
  if (!this.isActive()) return;
368
- if (!context) {
369
- const session = getSessionContext(this.options.siteKey, this.isCookieless());
370
- context = {
371
- ...getPageContext(this.options.trackHash),
372
- userId: this.getAnonymousId() || void 0,
373
- sessionId: session.sessionId,
374
- windowId: session.windowId
375
- };
376
- }
407
+ context ??= this.context();
377
408
  let data;
378
409
  try {
379
410
  data = JSON.stringify({
package/dist/replay.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as AnalyticsExtension } from "./chunks/extensions-DW7tJY0T.js";
1
+ import { t as AnalyticsExtension } from "./chunks/extensions-DZDQn8O8.js";
2
2
  import { record } from "@rrweb/record";
3
3
  import { LogLevel } from "@rrweb/rrweb-plugin-console-record";
4
4
  import { NetworkRecordOptions } from "@rrweb/rrweb-plugin-network-record";
@@ -89,12 +89,14 @@ export default class ReplayTracker {
89
89
  private lastCookielessMode;
90
90
  private needsFinalization;
91
91
  private initialFlushPending;
92
+ private resumed;
92
93
  private overflowed;
93
94
  private replayEventSequence;
94
95
  private readonly mutationThrottler;
95
96
  constructor(options: ReplayTrackerOptions);
96
97
  private log;
97
98
  private clearFlushTimers;
99
+ private dropEvents;
98
100
  private clearQueues;
99
101
  private finalizeSession;
100
102
  setCookielessMode(cookieless: boolean): void;
@@ -111,7 +113,6 @@ export default class ReplayTracker {
111
113
  private beginReplayStream;
112
114
  private resetSessionTiming;
113
115
  private onEvent;
114
- private onUnload;
115
116
  private requestTerminalFlush;
116
117
  private hasReachedMinLength;
117
118
  private requestFlush;
package/dist/replay.js CHANGED
@@ -1,6 +1,5 @@
1
- import { a as sanitizeUrl, i as resolveBaseUrl, n as URLS, t as ANALYTICS_BASE } from "./chunks/api-urls-DWk43fu6.js";
2
- import { c as removeStorageItem, i as onSessionRotated, l as setStorageItem, n as createId, o as touchActivity, r as getSessionContext, s as getStorageItem, t as sendData } from "./chunks/send-data-Dplz7m4c.js";
3
- import { t as getAnonymousId } from "./chunks/identifiers-I5UnTXGR.js";
1
+ import { f as resolveBaseUrl, i as onSessionRotated, l as setStorageItem, n as createId, o as touchActivity, p as sanitizeUrl, r as getSessionContext, s as getStorageItem, t as sendData, u as URLS } from "./chunks/send-data-D520oab1.js";
2
+ import { t as getAnonymousId } from "./chunks/identifiers-BwGNL11R.js";
4
3
  import { record } from "@rrweb/record";
5
4
  import { getRecordConsolePlugin } from "@rrweb/rrweb-plugin-console-record";
6
5
  import { getRecordNetworkPlugin } from "@rrweb/rrweb-plugin-network-record";
@@ -57,44 +56,39 @@ const counters = /* @__PURE__ */ new Map();
57
56
  function sameRecording(counter, context) {
58
57
  return counter?.sessionId === context.sessionId && counter.windowId === context.windowId;
59
58
  }
60
- function readCounter(value) {
59
+ function readCounter(siteKey, cookieless) {
60
+ const raw = cookieless ? null : getStorageItem("sessionStorage", sequenceKey(siteKey));
61
+ if (raw === null) return { corrupt: false };
61
62
  try {
62
- const parsed = JSON.parse(value);
63
- if (parsed && typeof parsed.sessionId === "string" && typeof parsed.windowId === "string" && typeof parsed.next === "number" && Number.isSafeInteger(parsed.next) && parsed.next >= 0 && (parsed.version === 0 || parsed.version === 1)) return parsed;
63
+ const parsed = JSON.parse(raw);
64
+ if (parsed && typeof parsed.sessionId === "string" && typeof parsed.windowId === "string" && typeof parsed.next === "number" && Number.isSafeInteger(parsed.next) && parsed.next >= 0) return {
65
+ corrupt: false,
66
+ counter: parsed
67
+ };
64
68
  } catch {}
69
+ return { corrupt: true };
70
+ }
71
+ const sequenceKey = (siteKey) => `faststats_replay_sequence_v1_${siteKey}`;
72
+ /** Whether this recording already sent batches, e.g. from an earlier page load. */
73
+ function hasReplaySequence(siteKey, context, cookieless) {
74
+ return [counters.get(siteKey), readCounter(siteKey, cookieless).counter].some((counter) => sameRecording(counter, context) && counter.next > 0);
65
75
  }
76
+ /** Next batch sequence for this recording, or undefined when it cannot be trusted. */
66
77
  function nextReplaySequence(siteKey, context, cookieless) {
67
- const key = `faststats_replay_sequence_v1_${siteKey}`;
68
78
  const cached = counters.get(siteKey);
69
- const raw = cookieless ? null : getStorageItem("sessionStorage", key);
70
- const stored = raw === null ? void 0 : readCounter(raw);
71
- if (raw !== null && !stored && !sameRecording(cached, context)) return;
72
- let counter = sameRecording(cached, context) ? cached : void 0;
73
- if (sameRecording(stored, context) && stored && (!counter || stored.next > counter.next)) counter = stored;
74
- const legacyKey = `faststats_replay_batch_sequence_${siteKey}_${context.windowId}`;
75
- if (!counter) {
76
- const legacy = !cookieless && !stored && !sameRecording(cached, context) ? getStorageItem("sessionStorage", legacyKey) : null;
77
- const next = legacy === null ? 0 : Number(legacy);
78
- if (!Number.isSafeInteger(next) || next < 0 || legacy !== null && !/^\d+$/.test(legacy)) return;
79
- counter = {
80
- sessionId: context.sessionId,
81
- windowId: context.windowId,
82
- next,
83
- version: legacy === null ? 1 : 0
84
- };
85
- }
86
- if (!Number.isSafeInteger(counter.next + 1)) return;
87
- const sequence = counter.next;
88
- const updated = {
89
- ...counter,
79
+ const stored = readCounter(siteKey, cookieless);
80
+ if (stored.corrupt && !sameRecording(cached, context)) return;
81
+ let sequence = sameRecording(cached, context) ? cached.next : 0;
82
+ if (sameRecording(stored.counter, context)) sequence = Math.max(sequence, stored.counter.next);
83
+ if (!Number.isSafeInteger(sequence + 1)) return;
84
+ const counter = {
85
+ sessionId: context.sessionId,
86
+ windowId: context.windowId,
90
87
  next: sequence + 1
91
88
  };
92
- counters.set(siteKey, updated);
93
- if (!cookieless && setStorageItem("sessionStorage", key, JSON.stringify(updated))) removeStorageItem("sessionStorage", legacyKey);
94
- return {
95
- sequence,
96
- ...counter.version === 1 ? { sequenceContractVersion: 1 } : {}
97
- };
89
+ counters.set(siteKey, counter);
90
+ if (!cookieless) setStorageItem("sessionStorage", sequenceKey(siteKey), JSON.stringify(counter));
91
+ return sequence;
98
92
  }
99
93
  //#endregion
100
94
  //#region src/replay.ts
@@ -170,13 +164,14 @@ var ReplayTracker = class {
170
164
  lastCookielessMode;
171
165
  needsFinalization = false;
172
166
  initialFlushPending = true;
167
+ resumed = false;
173
168
  overflowed = false;
174
169
  replayEventSequence = 0;
175
170
  mutationThrottler;
176
171
  constructor(options) {
177
172
  this.options = options;
178
173
  this.lastCookielessMode = options.cookieless ?? false;
179
- this.endpoint = `${resolveBaseUrl(options.baseUrl, ANALYTICS_BASE)}${URLS.replay}`;
174
+ this.endpoint = `${resolveBaseUrl(options.baseUrl)}${URLS.replay}`;
180
175
  this.flushInterval = boundedInteger(options.flushInterval, 15e3, 1, MAX_TIMEOUT_MS);
181
176
  this.maxEvents = boundedInteger(options.maxEvents, 1e3, 1, 1e5);
182
177
  this.maxBatchSizeBytes = boundedInteger(options.maxBatchSizeBytes, DEFAULT_MAX_BATCH_SIZE_BYTES, 1, 33554432);
@@ -198,10 +193,13 @@ var ReplayTracker = class {
198
193
  this.retryAttempt = 0;
199
194
  this.minLengthFlushTask = null;
200
195
  }
201
- clearQueues() {
202
- this.needsFinalization = false;
196
+ dropEvents() {
203
197
  this.events.length = 0;
204
198
  this.queuedEventsSizeBytes = 0;
199
+ }
200
+ clearQueues() {
201
+ this.needsFinalization = false;
202
+ this.dropEvents();
205
203
  this.pending.length = 0;
206
204
  this.pendingSizeBytes = 0;
207
205
  }
@@ -218,8 +216,7 @@ var ReplayTracker = class {
218
216
  this.clearFlushTimers();
219
217
  const context = getSessionContext(this.options.siteKey, this.lastCookielessMode);
220
218
  this.activeSessionId = context.sessionId;
221
- this.startTime = Date.now();
222
- this.chunkStartedAt = Date.now();
219
+ this.resetSessionTiming(context);
223
220
  this.beginReplayStream();
224
221
  }
225
222
  start() {
@@ -233,12 +230,10 @@ var ReplayTracker = class {
233
230
  this.viewUrl = sanitizeUrl(window.location.href, this.options.trackHash ?? false);
234
231
  const context = getSessionContext(this.options.siteKey, this.lastCookielessMode);
235
232
  this.activeSessionId = context.sessionId;
236
- this.startTime = Date.now();
237
- this.chunkStartedAt = Date.now();
233
+ this.resetSessionTiming(context);
238
234
  this.subscribeToSessionRotation();
239
235
  this.beginRecording();
240
236
  this.intervalId = setInterval(() => this.requestFlush("interval"), this.flushInterval);
241
- window.addEventListener("beforeunload", this.onUnload);
242
237
  this.log("Recording started");
243
238
  }
244
239
  trackPageChange(url) {
@@ -288,7 +283,7 @@ var ReplayTracker = class {
288
283
  }));
289
284
  if (this.options.recordNetwork === true) {
290
285
  const options = this.options.networkOptions;
291
- const base = resolveBaseUrl(this.options.baseUrl, ANALYTICS_BASE);
286
+ const base = resolveBaseUrl(this.options.baseUrl);
292
287
  const endpoints = new Set(Object.values(URLS).map((path) => {
293
288
  const url = new URL(`${base}${path}`, window.location.href);
294
289
  return `${url.origin}${url.pathname}`;
@@ -351,15 +346,13 @@ var ReplayTracker = class {
351
346
  if (this.intervalId) clearInterval(this.intervalId);
352
347
  this.clearFlushTimers();
353
348
  this.intervalId = null;
354
- window.removeEventListener("beforeunload", this.onUnload);
355
349
  if (discard) {
356
350
  this.clearQueues();
357
351
  this.log("Recording discarded");
358
352
  return;
359
353
  }
360
354
  if (!this.hasReachedMinLength()) {
361
- this.events.length = 0;
362
- this.queuedEventsSizeBytes = 0;
355
+ this.dropEvents();
363
356
  this.needsFinalization = false;
364
357
  this.log(`Recording too short (${Date.now() - this.startTime}ms), discarding events`);
365
358
  }
@@ -377,14 +370,8 @@ var ReplayTracker = class {
377
370
  }
378
371
  applySessionRotation(prev, next) {
379
372
  if (this.activeSessionId === next.sessionId) return;
380
- if (this.hasReachedMinLength()) this.enqueueEventsIfReady(prev, {
381
- isFinal: true,
382
- flushReason: "sessionRotate"
383
- });
384
- else {
385
- this.events.length = 0;
386
- this.queuedEventsSizeBytes = 0;
387
- }
373
+ if (this.hasReachedMinLength()) this.enqueueEventsIfReady(prev, "sessionRotate");
374
+ else this.dropEvents();
388
375
  this.finalizeSession(prev, "sessionRotate");
389
376
  this.needsFinalization = false;
390
377
  this.activeSessionId = next.sessionId;
@@ -411,9 +398,10 @@ var ReplayTracker = class {
411
398
  }
412
399
  }
413
400
  }
414
- resetSessionTiming(_next) {
401
+ resetSessionTiming(next) {
415
402
  if (this.minLengthFlushTask) clearTimeout(this.minLengthFlushTask);
416
403
  this.minLengthFlushTask = null;
404
+ this.resumed = hasReplaySequence(this.options.siteKey, next, this.lastCookielessMode);
417
405
  this.startTime = Date.now();
418
406
  this.chunkStartedAt = Date.now();
419
407
  this.initialFlushPending = true;
@@ -454,22 +442,18 @@ var ReplayTracker = class {
454
442
  if (reason) this.requestFlush(reason);
455
443
  else this.scheduleMinLengthFlush();
456
444
  };
457
- onUnload = () => {
458
- this.flush(true, false, "unload");
459
- };
460
445
  requestTerminalFlush(reason) {
461
446
  if (!this.hasReachedMinLength()) {
462
- this.events.length = 0;
463
- this.queuedEventsSizeBytes = 0;
447
+ this.dropEvents();
464
448
  this.needsFinalization = false;
465
449
  return;
466
450
  }
467
451
  this.flush(true, true, reason);
468
452
  }
469
453
  hasReachedMinLength() {
470
- return this.minReplayLengthMs <= 0 || Date.now() - this.startTime >= this.minReplayLengthMs;
454
+ return this.resumed || this.minReplayLengthMs <= 0 || Date.now() - this.startTime >= this.minReplayLengthMs;
471
455
  }
472
- requestFlush = (reason = "manual") => {
456
+ requestFlush(reason) {
473
457
  if (this.flushTask || this.events.length === 0) return;
474
458
  if (this.minLengthFlushTask) {
475
459
  clearTimeout(this.minLengthFlushTask);
@@ -479,7 +463,7 @@ var ReplayTracker = class {
479
463
  this.flushTask = null;
480
464
  this.flush(false, false, reason);
481
465
  }, 0);
482
- };
466
+ }
483
467
  scheduleMinLengthFlush() {
484
468
  if (!this.initialFlushPending || this.events.length === 0 || this.minReplayLengthMs <= 0) return;
485
469
  if (this.hasReachedMinLength()) {
@@ -495,17 +479,20 @@ var ReplayTracker = class {
495
479
  this.requestFlush("minLength");
496
480
  }, Math.min(remainingMs, MAX_TIMEOUT_MS));
497
481
  }
498
- enqueueEventsIfReady(context, options = {}) {
499
- const { isFinal = false, flushReason = "manual", maxBatchSizeBytes = this.maxBatchSizeBytes } = options;
482
+ enqueueEventsIfReady(context, flushReason, maxBatchSizeBytes = this.maxBatchSizeBytes) {
500
483
  if (this.events.length === 0) return;
501
- if (!isFinal && !this.hasReachedMinLength()) {
484
+ if (!this.hasReachedMinLength()) {
502
485
  this.scheduleMinLengthFlush();
503
486
  return;
504
487
  }
505
- const batches = this.takeEventBatches(maxBatchSizeBytes);
506
- for (const [index, events] of batches.entries()) {
507
- const batch = this.createBatch(events, context, isFinal && index === batches.length - 1, flushReason);
508
- if (!batch || !this.enqueueBatch(batch)) break;
488
+ for (const events of this.takeEventBatches(maxBatchSizeBytes)) {
489
+ if (this.pending.length >= this.maxPendingBatches) {
490
+ this.stopCapture("Replay queue limit reached");
491
+ break;
492
+ }
493
+ const batch = this.createBatch(events, context, false, flushReason);
494
+ if (!batch) break;
495
+ this.enqueueBatch(batch);
509
496
  }
510
497
  this.queuedEventsSizeBytes = 0;
511
498
  }
@@ -526,10 +513,10 @@ var ReplayTracker = class {
526
513
  if (current.length > 0) batches.push(current);
527
514
  return batches;
528
515
  }
529
- createBatch(events, context, isFinal, flushReason = "manual") {
516
+ createBatch(events, context, isFinal, flushReason) {
530
517
  const identifier = getAnonymousId(this.options.siteKey, this.lastCookielessMode);
531
518
  const sequence = nextReplaySequence(this.options.siteKey, context, this.lastCookielessMode);
532
- if (!sequence) {
519
+ if (sequence === void 0) {
533
520
  this.stopCapture("Replay sequence state is invalid or exhausted");
534
521
  return;
535
522
  }
@@ -545,8 +532,9 @@ var ReplayTracker = class {
545
532
  chunkStartedAt,
546
533
  chunkEndedAt: now,
547
534
  ...identifier ? { identifier } : {},
548
- batchId: `${context.sessionId}-${sequence.sequence}-${chunkStartedAt}`,
549
- ...sequence,
535
+ batchId: `${context.sessionId}-${sequence}-${chunkStartedAt}`,
536
+ sequence,
537
+ sequenceContractVersion: 1,
550
538
  timestamp: now,
551
539
  url: this.viewUrl || sanitizeUrl(window.location.href, this.options.trackHash ?? false),
552
540
  ...isFinal ? { isFinal: true } : {},
@@ -578,45 +566,27 @@ var ReplayTracker = class {
578
566
  this.stopRecording = void 0;
579
567
  }
580
568
  enqueueBatch(batch) {
581
- const size = this.batchSizeBytes(batch);
582
- if (size > this.maxQueueSizeBytes || this.pending.length >= this.maxPendingBatches || this.pendingSizeBytes + size > this.maxQueueSizeBytes) {
583
- this.stopCapture(`Replay queue limit reached at batch ${batch.sequence}`);
584
- return false;
585
- }
586
569
  this.needsFinalization = !batch.isFinal;
587
570
  this.pending.push(batch);
588
- this.pendingSizeBytes += size;
589
- return true;
571
+ this.pendingSizeBytes += this.batchSizeBytes(batch);
590
572
  }
591
573
  async flush(lowLatency, isFinal = false, flushReason = "manual") {
592
574
  if (this.events.length === 0 && !isFinal) {
593
- await this.sendPending(lowLatency, false);
575
+ await this.sendPending(lowLatency);
594
576
  return;
595
577
  }
596
578
  const rotation = touchActivity(this.options.siteKey, this.lastCookielessMode);
597
579
  if (rotation) this.applySessionRotation(rotation.prev, rotation.next);
598
580
  const context = getSessionContext(this.options.siteKey, this.lastCookielessMode);
599
- this.enqueueEventsIfReady(context, {
600
- isFinal,
601
- flushReason,
602
- maxBatchSizeBytes: lowLatency ? this.maxLowLatencyBatchSizeBytes : this.maxBatchSizeBytes
603
- });
604
- if (isFinal) this.finalizeSession(context, flushReason);
605
- if (this.pending.length === 0) return;
606
- await this.sendPending(lowLatency, lowLatency && isFinal);
607
- }
608
- async sendPending(lowLatency, bypassRetryDelay) {
609
- if (this.retryTask) {
610
- if (!bypassRetryDelay) return;
611
- clearTimeout(this.retryTask);
612
- this.retryTask = null;
613
- }
581
+ this.enqueueEventsIfReady(context, flushReason, lowLatency ? this.maxLowLatencyBatchSizeBytes : this.maxBatchSizeBytes);
582
+ if (!isFinal) return this.sendPending(lowLatency);
583
+ this.finalizeSession(context, flushReason);
584
+ for (const batch of [...this.pending].reverse()) this.sendBatch(batch, true).then((sent) => sent && this.removePending(batch));
585
+ }
586
+ async sendPending(lowLatency) {
587
+ if (this.retryTask) return;
614
588
  if (this.sending) {
615
- const queued = this.queuedFlush;
616
- this.queuedFlush = {
617
- lowLatency: (queued?.lowLatency ?? false) || lowLatency,
618
- bypassRetryDelay: (queued?.bypassRetryDelay ?? false) || bypassRetryDelay
619
- };
589
+ this.queuedFlush = !!this.queuedFlush || lowLatency;
620
590
  return;
621
591
  }
622
592
  this.sending = true;
@@ -630,14 +600,14 @@ var ReplayTracker = class {
630
600
  }
631
601
  this.retryAttempt = 0;
632
602
  this.removePending(batch);
633
- if (this.queuedFlush?.lowLatency) break;
603
+ if (this.queuedFlush) break;
634
604
  }
635
605
  } finally {
636
606
  this.sending = false;
637
- if (this.queuedFlush) {
638
- const queued = this.queuedFlush;
607
+ if (this.queuedFlush !== null) {
608
+ const lowLatency = this.queuedFlush;
639
609
  this.queuedFlush = null;
640
- this.sendPending(queued.lowLatency, queued.bypassRetryDelay);
610
+ this.sendPending(lowLatency);
641
611
  }
642
612
  }
643
613
  }
@@ -649,7 +619,7 @@ var ReplayTracker = class {
649
619
  this.retryTask = setTimeout(() => {
650
620
  this.retryTask = null;
651
621
  if (this.retryAttempt === RETRY_MAX_ATTEMPTS) this.retryAttempt = 0;
652
- this.sendPending(false, false);
622
+ this.sendPending(false);
653
623
  }, delay);
654
624
  }
655
625
  retryDelay(attempt) {
@@ -659,12 +629,13 @@ var ReplayTracker = class {
659
629
  }
660
630
  async sendBatch(batch, lowLatency) {
661
631
  const json = JSON.stringify(batch);
662
- const lowLatencySafe = lowLatency && textEncoder.encode(json).byteLength <= 61440;
632
+ const lowLatencySafe = lowLatency && this.batchSizeBytes(batch) <= DEFAULT_MAX_LOW_LATENCY_BATCH_SIZE_BYTES;
663
633
  const sendOptions = {
664
634
  debug: this.options.debug,
665
635
  debugPrefix: "[Replay]",
666
636
  useBeacon: lowLatencySafe,
667
- keepalive: lowLatencySafe
637
+ keepalive: lowLatencySafe,
638
+ settleRejected: true
668
639
  };
669
640
  if (!lowLatency && (this.options.compress ?? true) && this.compressionSupported) try {
670
641
  const stream = new Blob([json]).stream().pipeThrough(new CompressionStream("gzip"));
@@ -1,4 +1,4 @@
1
- import { t as AnalyticsExtension } from "./chunks/extensions-DW7tJY0T.js";
1
+ import { t as AnalyticsExtension } from "./chunks/extensions-DZDQn8O8.js";
2
2
  //#region src/web-vitals.d.ts
3
3
  export interface WebVitalsOptions {
4
4
  siteKey: string;
@@ -1,5 +1,4 @@
1
- import { a as sanitizeUrl, i as resolveBaseUrl, n as URLS, t as ANALYTICS_BASE } from "./chunks/api-urls-DWk43fu6.js";
2
- import { r as getSessionContext, t as sendData } from "./chunks/send-data-Dplz7m4c.js";
1
+ import { f as resolveBaseUrl, p as sanitizeUrl, r as getSessionContext, t as sendData, u as URLS } from "./chunks/send-data-D520oab1.js";
3
2
  import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals/attribution";
4
3
  //#region src/utils/web-vitals-collector.ts
5
4
  const collectors = /* @__PURE__ */ new WeakMap();
@@ -72,7 +71,7 @@ var WebVitalsTracker = class {
72
71
  cookieless;
73
72
  constructor(options) {
74
73
  this.options = options;
75
- this.endpoint = `${resolveBaseUrl(options.baseUrl, ANALYTICS_BASE)}${URLS.vitals}`;
74
+ this.endpoint = `${resolveBaseUrl(options.baseUrl)}${URLS.vitals}`;
76
75
  this.cookieless = options.cookieless ?? false;
77
76
  }
78
77
  setCookielessMode(cookieless) {
@@ -104,10 +103,8 @@ var WebVitalsTracker = class {
104
103
  });
105
104
  }
106
105
  onMetric = (metric) => {
107
- if (!this.started) return;
108
- const name = metric.name;
109
- if (!METRIC_NAMES.has(name) || !Number.isFinite(metric.value) || metric.value < 0) return;
110
- this.metrics.set(name, metric);
106
+ if (!this.started || !METRIC_NAMES.has(metric.name) || !Number.isFinite(metric.value) || metric.value < 0) return;
107
+ this.metrics.set(metric.name, metric);
111
108
  };
112
109
  discard() {
113
110
  this.generation++;
package/package.json CHANGED
@@ -13,11 +13,6 @@
13
13
  "import": "./dist/index.js",
14
14
  "default": "./dist/index.js"
15
15
  },
16
- "./outbound-links": {
17
- "types": "./dist/outbound-links.d.ts",
18
- "import": "./dist/outbound-links.js",
19
- "default": "./dist/outbound-links.js"
20
- },
21
16
  "./replay": {
22
17
  "types": "./dist/replay.d.ts",
23
18
  "import": "./dist/replay.js",
@@ -42,7 +37,7 @@
42
37
  "publishConfig": {
43
38
  "access": "public"
44
39
  },
45
- "version": "0.10.2",
40
+ "version": "0.11.0",
46
41
  "scripts": {
47
42
  "build": "tsdown && bun run check-size",
48
43
  "dev": "bun run build",
@@ -1,53 +0,0 @@
1
- //#region src/utils/api-urls.ts
2
- const ANALYTICS_BASE = "https://metrics.faststats.dev";
3
- function resolveBaseUrl(input, fallback) {
4
- return input?.replace(/\/+$/, "") || fallback;
5
- }
6
- const URLS = {
7
- events: "/v1/web",
8
- identify: "/v1/identify",
9
- replay: "/v1/replay",
10
- vitals: "/v1/vitals"
11
- };
12
- function sanitizeUrl(url, includeHash = false) {
13
- try {
14
- const parsed = new URL(url, location.href);
15
- return `${parsed.origin}${parsed.pathname}${includeHash ? parsed.hash : ""}`;
16
- } catch {
17
- return url.split(includeHash ? "?" : /[?#]/, 1)[0] ?? "";
18
- }
19
- }
20
- const UTM_KEYS = [
21
- "utm_source",
22
- "utm_medium",
23
- "utm_campaign",
24
- "utm_term",
25
- "utm_content"
26
- ];
27
- function getReferrer() {
28
- const raw = document.referrer;
29
- if (!raw) return null;
30
- try {
31
- const pageOrigin = new URL(location.href).origin;
32
- if (new URL(raw, location.href).origin === pageOrigin) return null;
33
- } catch {}
34
- return sanitizeUrl(raw);
35
- }
36
- function getPageContext(includeHash = false) {
37
- const hash = includeHash ? location.hash : "";
38
- const ctx = {
39
- page: `${location.pathname}${hash}`,
40
- url: sanitizeUrl(location.href, includeHash),
41
- referrer: getReferrer(),
42
- title: document.title || ""
43
- };
44
- if (!location.search) return ctx;
45
- const sp = new URLSearchParams(location.search);
46
- for (const key of UTM_KEYS) {
47
- const value = sp.get(key);
48
- if (value) ctx[key] = value;
49
- }
50
- return ctx;
51
- }
52
- //#endregion
53
- export { sanitizeUrl as a, resolveBaseUrl as i, URLS as n, getPageContext as r, ANALYTICS_BASE as t };
@@ -1,9 +0,0 @@
1
- import { t as AnalyticsExtension } from "./chunks/extensions-DW7tJY0T.js";
2
- //#region src/outbound-links.d.ts
3
- export declare function outboundLinks(): AnalyticsExtension;
4
- export declare class OutboundLinkTracker {
5
- private lastActivation;
6
- getHref(event: Event): string | null;
7
- private isDuplicate;
8
- }
9
- //#endregion
@@ -1,62 +0,0 @@
1
- import { a as sanitizeUrl } from "./chunks/api-urls-DWk43fu6.js";
2
- //#region src/outbound-links.ts
3
- function outboundLinks() {
4
- return {
5
- name: "outbound-links",
6
- setup(context) {
7
- const tracker = new OutboundLinkTracker();
8
- const onClick = (event) => {
9
- const href = tracker.getHref(event);
10
- if (href) context.capture("outbound_link", { outbound_link: sanitizeUrl(href) });
11
- };
12
- document.addEventListener("click", onClick, true);
13
- document.addEventListener("auxclick", onClick, true);
14
- return () => {
15
- document.removeEventListener("click", onClick, true);
16
- document.removeEventListener("auxclick", onClick, true);
17
- };
18
- }
19
- };
20
- }
21
- const DEDUPE_MS = 500;
22
- var OutboundLinkTracker = class {
23
- lastActivation = null;
24
- getHref(event) {
25
- if (!(event instanceof MouseEvent) || !isEligibleActivation(event)) return null;
26
- const anchor = findOutboundAnchor(event);
27
- if (!anchor || this.isDuplicate(event, anchor.href)) return null;
28
- return anchor.href;
29
- }
30
- isDuplicate(event, href) {
31
- const activation = {
32
- button: event.button,
33
- href,
34
- clientX: event.clientX,
35
- clientY: event.clientY,
36
- at: Date.now()
37
- };
38
- const previous = this.lastActivation;
39
- this.lastActivation = activation;
40
- return !!(previous && previous.href === activation.href && previous.button === activation.button && previous.clientX === activation.clientX && previous.clientY === activation.clientY && activation.at - previous.at <= DEDUPE_MS);
41
- }
42
- };
43
- function isEligibleActivation(event) {
44
- if (event.defaultPrevented) return false;
45
- if (event.type === "click") return event.button === 0;
46
- if (event.type === "auxclick") return event.button === 1;
47
- return false;
48
- }
49
- function findOutboundAnchor(event) {
50
- for (const node of event.composedPath()) if (isOutboundAnchor(node)) return node;
51
- let node = event.target;
52
- while (node) {
53
- if (isOutboundAnchor(node)) return node;
54
- node = node.parentNode;
55
- }
56
- return null;
57
- }
58
- function isOutboundAnchor(node) {
59
- return node instanceof HTMLAnchorElement && (node.protocol === "http:" || node.protocol === "https:") && node.host !== location.host;
60
- }
61
- //#endregion
62
- export { OutboundLinkTracker, outboundLinks };