@faststats/web 0.10.3 → 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-DAVUdfNm.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, settleRejected } = 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,17 +210,14 @@ 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
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
222
  if (debug) console.log(debugPrefix, ok ? "Sent via fetch" : status);
175
223
  return ok || !!settleRejected && status < 500 && status !== 429;
@@ -179,4 +227,4 @@ async function sendData(options) {
179
227
  }
180
228
  }
181
229
  //#endregion
182
- 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-DAVUdfNm.js";
3
- import { t as getAnonymousId } from "./chunks/identifiers-BgGtAQdp.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.3";
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-DAVUdfNm.js";
3
- import { n as resetAnonymousId, t as getAnonymousId } from "./chunks/identifiers-BgGtAQdp.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";
@@ -96,6 +96,7 @@ export default class ReplayTracker {
96
96
  constructor(options: ReplayTrackerOptions);
97
97
  private log;
98
98
  private clearFlushTimers;
99
+ private dropEvents;
99
100
  private clearQueues;
100
101
  private finalizeSession;
101
102
  setCookielessMode(cookieless: boolean): void;
@@ -112,7 +113,6 @@ export default class ReplayTracker {
112
113
  private beginReplayStream;
113
114
  private resetSessionTiming;
114
115
  private onEvent;
115
- private onUnload;
116
116
  private requestTerminalFlush;
117
117
  private hasReachedMinLength;
118
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-DAVUdfNm.js";
3
- import { t as getAnonymousId } from "./chunks/identifiers-BgGtAQdp.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,50 +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 };
65
70
  }
66
71
  const sequenceKey = (siteKey) => `faststats_replay_sequence_v1_${siteKey}`;
67
72
  /** Whether this recording already sent batches, e.g. from an earlier page load. */
68
73
  function hasReplaySequence(siteKey, context, cookieless) {
69
- const raw = cookieless ? null : getStorageItem("sessionStorage", sequenceKey(siteKey));
70
- return [counters.get(siteKey), raw === null ? void 0 : readCounter(raw)].some((counter) => sameRecording(counter, context) && counter.next > 0);
74
+ return [counters.get(siteKey), readCounter(siteKey, cookieless).counter].some((counter) => sameRecording(counter, context) && counter.next > 0);
71
75
  }
76
+ /** Next batch sequence for this recording, or undefined when it cannot be trusted. */
72
77
  function nextReplaySequence(siteKey, context, cookieless) {
73
- const key = sequenceKey(siteKey);
74
78
  const cached = counters.get(siteKey);
75
- const raw = cookieless ? null : getStorageItem("sessionStorage", key);
76
- const stored = raw === null ? void 0 : readCounter(raw);
77
- if (raw !== null && !stored && !sameRecording(cached, context)) return;
78
- let counter = sameRecording(cached, context) ? cached : void 0;
79
- if (sameRecording(stored, context) && stored && (!counter || stored.next > counter.next)) counter = stored;
80
- const legacyKey = `faststats_replay_batch_sequence_${siteKey}_${context.windowId}`;
81
- if (!counter) {
82
- const legacy = !cookieless && !stored && !sameRecording(cached, context) ? getStorageItem("sessionStorage", legacyKey) : null;
83
- const next = legacy === null ? 0 : Number(legacy);
84
- if (!Number.isSafeInteger(next) || next < 0 || legacy !== null && !/^\d+$/.test(legacy)) return;
85
- counter = {
86
- sessionId: context.sessionId,
87
- windowId: context.windowId,
88
- next,
89
- version: legacy === null ? 1 : 0
90
- };
91
- }
92
- if (!Number.isSafeInteger(counter.next + 1)) return;
93
- const sequence = counter.next;
94
- const updated = {
95
- ...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,
96
87
  next: sequence + 1
97
88
  };
98
- counters.set(siteKey, updated);
99
- if (!cookieless && setStorageItem("sessionStorage", key, JSON.stringify(updated))) removeStorageItem("sessionStorage", legacyKey);
100
- return {
101
- sequence,
102
- ...counter.version === 1 ? { sequenceContractVersion: 1 } : {}
103
- };
89
+ counters.set(siteKey, counter);
90
+ if (!cookieless) setStorageItem("sessionStorage", sequenceKey(siteKey), JSON.stringify(counter));
91
+ return sequence;
104
92
  }
105
93
  //#endregion
106
94
  //#region src/replay.ts
@@ -183,7 +171,7 @@ var ReplayTracker = class {
183
171
  constructor(options) {
184
172
  this.options = options;
185
173
  this.lastCookielessMode = options.cookieless ?? false;
186
- this.endpoint = `${resolveBaseUrl(options.baseUrl, ANALYTICS_BASE)}${URLS.replay}`;
174
+ this.endpoint = `${resolveBaseUrl(options.baseUrl)}${URLS.replay}`;
187
175
  this.flushInterval = boundedInteger(options.flushInterval, 15e3, 1, MAX_TIMEOUT_MS);
188
176
  this.maxEvents = boundedInteger(options.maxEvents, 1e3, 1, 1e5);
189
177
  this.maxBatchSizeBytes = boundedInteger(options.maxBatchSizeBytes, DEFAULT_MAX_BATCH_SIZE_BYTES, 1, 33554432);
@@ -205,10 +193,13 @@ var ReplayTracker = class {
205
193
  this.retryAttempt = 0;
206
194
  this.minLengthFlushTask = null;
207
195
  }
208
- clearQueues() {
209
- this.needsFinalization = false;
196
+ dropEvents() {
210
197
  this.events.length = 0;
211
198
  this.queuedEventsSizeBytes = 0;
199
+ }
200
+ clearQueues() {
201
+ this.needsFinalization = false;
202
+ this.dropEvents();
212
203
  this.pending.length = 0;
213
204
  this.pendingSizeBytes = 0;
214
205
  }
@@ -243,7 +234,6 @@ var ReplayTracker = class {
243
234
  this.subscribeToSessionRotation();
244
235
  this.beginRecording();
245
236
  this.intervalId = setInterval(() => this.requestFlush("interval"), this.flushInterval);
246
- window.addEventListener("beforeunload", this.onUnload);
247
237
  this.log("Recording started");
248
238
  }
249
239
  trackPageChange(url) {
@@ -293,7 +283,7 @@ var ReplayTracker = class {
293
283
  }));
294
284
  if (this.options.recordNetwork === true) {
295
285
  const options = this.options.networkOptions;
296
- const base = resolveBaseUrl(this.options.baseUrl, ANALYTICS_BASE);
286
+ const base = resolveBaseUrl(this.options.baseUrl);
297
287
  const endpoints = new Set(Object.values(URLS).map((path) => {
298
288
  const url = new URL(`${base}${path}`, window.location.href);
299
289
  return `${url.origin}${url.pathname}`;
@@ -356,15 +346,13 @@ var ReplayTracker = class {
356
346
  if (this.intervalId) clearInterval(this.intervalId);
357
347
  this.clearFlushTimers();
358
348
  this.intervalId = null;
359
- window.removeEventListener("beforeunload", this.onUnload);
360
349
  if (discard) {
361
350
  this.clearQueues();
362
351
  this.log("Recording discarded");
363
352
  return;
364
353
  }
365
354
  if (!this.hasReachedMinLength()) {
366
- this.events.length = 0;
367
- this.queuedEventsSizeBytes = 0;
355
+ this.dropEvents();
368
356
  this.needsFinalization = false;
369
357
  this.log(`Recording too short (${Date.now() - this.startTime}ms), discarding events`);
370
358
  }
@@ -383,10 +371,7 @@ var ReplayTracker = class {
383
371
  applySessionRotation(prev, next) {
384
372
  if (this.activeSessionId === next.sessionId) return;
385
373
  if (this.hasReachedMinLength()) this.enqueueEventsIfReady(prev, "sessionRotate");
386
- else {
387
- this.events.length = 0;
388
- this.queuedEventsSizeBytes = 0;
389
- }
374
+ else this.dropEvents();
390
375
  this.finalizeSession(prev, "sessionRotate");
391
376
  this.needsFinalization = false;
392
377
  this.activeSessionId = next.sessionId;
@@ -457,13 +442,9 @@ var ReplayTracker = class {
457
442
  if (reason) this.requestFlush(reason);
458
443
  else this.scheduleMinLengthFlush();
459
444
  };
460
- onUnload = () => {
461
- this.flush(true, false, "unload");
462
- };
463
445
  requestTerminalFlush(reason) {
464
446
  if (!this.hasReachedMinLength()) {
465
- this.events.length = 0;
466
- this.queuedEventsSizeBytes = 0;
447
+ this.dropEvents();
467
448
  this.needsFinalization = false;
468
449
  return;
469
450
  }
@@ -472,7 +453,7 @@ var ReplayTracker = class {
472
453
  hasReachedMinLength() {
473
454
  return this.resumed || this.minReplayLengthMs <= 0 || Date.now() - this.startTime >= this.minReplayLengthMs;
474
455
  }
475
- requestFlush = (reason = "manual") => {
456
+ requestFlush(reason) {
476
457
  if (this.flushTask || this.events.length === 0) return;
477
458
  if (this.minLengthFlushTask) {
478
459
  clearTimeout(this.minLengthFlushTask);
@@ -482,7 +463,7 @@ var ReplayTracker = class {
482
463
  this.flushTask = null;
483
464
  this.flush(false, false, reason);
484
465
  }, 0);
485
- };
466
+ }
486
467
  scheduleMinLengthFlush() {
487
468
  if (!this.initialFlushPending || this.events.length === 0 || this.minReplayLengthMs <= 0) return;
488
469
  if (this.hasReachedMinLength()) {
@@ -498,7 +479,7 @@ var ReplayTracker = class {
498
479
  this.requestFlush("minLength");
499
480
  }, Math.min(remainingMs, MAX_TIMEOUT_MS));
500
481
  }
501
- enqueueEventsIfReady(context, flushReason = "manual", maxBatchSizeBytes = this.maxBatchSizeBytes) {
482
+ enqueueEventsIfReady(context, flushReason, maxBatchSizeBytes = this.maxBatchSizeBytes) {
502
483
  if (this.events.length === 0) return;
503
484
  if (!this.hasReachedMinLength()) {
504
485
  this.scheduleMinLengthFlush();
@@ -532,10 +513,10 @@ var ReplayTracker = class {
532
513
  if (current.length > 0) batches.push(current);
533
514
  return batches;
534
515
  }
535
- createBatch(events, context, isFinal, flushReason = "manual") {
516
+ createBatch(events, context, isFinal, flushReason) {
536
517
  const identifier = getAnonymousId(this.options.siteKey, this.lastCookielessMode);
537
518
  const sequence = nextReplaySequence(this.options.siteKey, context, this.lastCookielessMode);
538
- if (!sequence) {
519
+ if (sequence === void 0) {
539
520
  this.stopCapture("Replay sequence state is invalid or exhausted");
540
521
  return;
541
522
  }
@@ -551,8 +532,9 @@ var ReplayTracker = class {
551
532
  chunkStartedAt,
552
533
  chunkEndedAt: now,
553
534
  ...identifier ? { identifier } : {},
554
- batchId: `${context.sessionId}-${sequence.sequence}-${chunkStartedAt}`,
555
- ...sequence,
535
+ batchId: `${context.sessionId}-${sequence}-${chunkStartedAt}`,
536
+ sequence,
537
+ sequenceContractVersion: 1,
556
538
  timestamp: now,
557
539
  url: this.viewUrl || sanitizeUrl(window.location.href, this.options.trackHash ?? false),
558
540
  ...isFinal ? { isFinal: true } : {},
@@ -647,7 +629,7 @@ var ReplayTracker = class {
647
629
  }
648
630
  async sendBatch(batch, lowLatency) {
649
631
  const json = JSON.stringify(batch);
650
- const lowLatencySafe = lowLatency && textEncoder.encode(json).byteLength <= 61440;
632
+ const lowLatencySafe = lowLatency && this.batchSizeBytes(batch) <= DEFAULT_MAX_LOW_LATENCY_BATCH_SIZE_BYTES;
651
633
  const sendOptions = {
652
634
  debug: this.options.debug,
653
635
  debugPrefix: "[Replay]",
@@ -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-DAVUdfNm.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.3",
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 };