@faststats/web 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,224 +1,291 @@
1
- import { a as resolveBaseUrl, i as getPageContext, n as FEATURE_FLAGS_BASE, o as sanitizeUrl, r as URLS, t as ANALYTICS_BASE } from "./chunks/api-urls-CaNa8upY.js";
2
- import { c as touchActivity, l as getStorageItem, o as resetSession, r as getSessionContext, s as setCookielessMode, t as sendData } from "./chunks/send-data-DjdbGDDc.js";
3
- import { n as resetAnonymousId, t as getAnonymousId } from "./chunks/identifiers-cUymVK5H.js";
4
- //#region src/outbound-links.ts
5
- const DEDUPE_MS = 500;
6
- var OutboundLinkTracker = class {
7
- lastActivation = null;
8
- getHref(event) {
9
- if (!(event instanceof MouseEvent) || !isEligibleActivation(event)) return null;
10
- const anchor = findOutboundAnchor(event);
11
- if (!anchor || this.isDuplicate(event, anchor.href)) return null;
12
- return anchor.href;
1
+ import { a as sanitizeUrl, i as resolveBaseUrl, n as URLS, r as getPageContext, t as ANALYTICS_BASE } from "./chunks/api-urls-DWk43fu6.js";
2
+ import { a as resetSession, o as touchActivity, r as getSessionContext, t as sendData } from "./chunks/send-data-DFZnsaL2.js";
3
+ import { n as resetAnonymousId, t as getAnonymousId } from "./chunks/identifiers-KgQDX74Q.js";
4
+ //#region src/extensions.ts
5
+ var ExtensionHost = class {
6
+ runtime;
7
+ handlers = /* @__PURE__ */ new Map();
8
+ cleanups = [];
9
+ stopped = false;
10
+ constructor(extensions, runtime) {
11
+ this.runtime = runtime;
12
+ for (const extension of extensions) this.setup(extension);
13
+ }
14
+ emit(hook, event) {
15
+ if (this.stopped) return;
16
+ for (const handler of this.handlers.get(hook) ?? []) try {
17
+ handler(event)?.catch((error) => this.runtime.log(`${hook} failed`, error));
18
+ } catch (error) {
19
+ this.runtime.log(`${hook} failed`, error);
20
+ }
13
21
  }
14
- isDuplicate(event, href) {
15
- const activation = {
16
- button: event.button,
17
- href,
18
- clientX: event.clientX,
19
- clientY: event.clientY,
20
- at: Date.now()
21
- };
22
- const previous = this.lastActivation;
23
- this.lastActivation = activation;
24
- return !!(previous && previous.href === activation.href && previous.button === activation.button && previous.clientX === activation.clientX && previous.clientY === activation.clientY && activation.at - previous.at <= DEDUPE_MS);
22
+ stop(discard = false) {
23
+ if (this.stopped) return;
24
+ this.stopped = true;
25
+ for (const cleanup of this.cleanups.reverse()) try {
26
+ cleanup({ discard });
27
+ } catch (error) {
28
+ this.runtime.log("cleanup failed", error);
29
+ }
30
+ this.cleanups.length = 0;
31
+ this.handlers.clear();
32
+ }
33
+ async setup(extension) {
34
+ if (this.stopped) return;
35
+ const subscriptions = [];
36
+ try {
37
+ const cleanup = await extension.setup({
38
+ ...this.runtime,
39
+ on: (hook, handler) => {
40
+ if (this.stopped) return () => {};
41
+ let handlers = this.handlers.get(hook);
42
+ if (!handlers) {
43
+ handlers = /* @__PURE__ */ new Set();
44
+ this.handlers.set(hook, handlers);
45
+ }
46
+ handlers.add(handler);
47
+ const off = () => handlers.delete(handler);
48
+ subscriptions.push(off);
49
+ return off;
50
+ }
51
+ });
52
+ if (this.stopped) cleanup?.({ discard: true });
53
+ else if (cleanup) this.cleanups.push(cleanup);
54
+ } catch (error) {
55
+ for (const off of subscriptions) off();
56
+ this.runtime.log(`${extension.name} setup failed`, error);
57
+ }
25
58
  }
26
59
  };
27
- function isEligibleActivation(event) {
28
- if (event.defaultPrevented) return false;
29
- if (event.type === "click") return event.button === 0;
30
- if (event.type === "auxclick") return event.button === 1;
31
- return false;
32
- }
33
- function findOutboundAnchor(event) {
34
- for (const node of event.composedPath()) if (isOutboundAnchor(node)) return node;
35
- let node = event.target;
36
- while (node) {
37
- if (isOutboundAnchor(node)) return node;
38
- node = node.parentNode;
39
- }
40
- return null;
41
- }
42
- function isOutboundAnchor(node) {
43
- return node instanceof HTMLAnchorElement && !!node.href && node.host !== location.host;
44
- }
45
60
  //#endregion
46
61
  //#region src/analytics.ts
47
- let instance = null;
48
- let pendingConsentMode;
49
- function getInstance() {
50
- return instance;
51
- }
52
- function activeInstance() {
53
- if (typeof window === "undefined" || isTrackingDisabled()) return null;
54
- return instance;
55
- }
56
- function trackEvent(eventName, properties) {
57
- activeInstance()?.track(eventName, properties);
58
- }
59
- async function identify(externalId, email, options) {
60
- return activeInstance()?.identify(externalId, email, options) ?? false;
61
- }
62
- function logout(resetAnonymousIdentity = true) {
63
- activeInstance()?.logout(resetAnonymousIdentity);
64
- }
65
- function setConsentMode(mode) {
66
- if (instance) instance.setConsentMode(mode);
67
- else pendingConsentMode = mode;
68
- }
69
- function optIn() {
70
- setConsentMode("granted");
71
- }
72
- function optOut() {
73
- setConsentMode("denied");
74
- }
75
- function reportError(error) {
76
- activeInstance()?.reportError(error);
62
+ const navigationListeners = /* @__PURE__ */ new Set();
63
+ const historyPatches = {};
64
+ function patchHistory(method) {
65
+ if (historyPatches[method]?.owner === history) return;
66
+ const original = history[method];
67
+ const wrapped = function(data, unused, url) {
68
+ Reflect.apply(original, this, [
69
+ data,
70
+ unused,
71
+ url
72
+ ]);
73
+ for (const listener of navigationListeners) listener();
74
+ };
75
+ history[method] = wrapped;
76
+ historyPatches[method] = {
77
+ owner: history,
78
+ original,
79
+ wrapped
80
+ };
77
81
  }
78
- function isTrackingDisabled() {
79
- const value = getStorageItem("localStorage", "disable-faststats");
80
- return value === "true" || value === "1";
82
+ function observeHistory(listener) {
83
+ navigationListeners.add(listener);
84
+ patchHistory("pushState");
85
+ patchHistory("replaceState");
86
+ return () => {
87
+ navigationListeners.delete(listener);
88
+ if (navigationListeners.size) return;
89
+ for (const method of ["pushState", "replaceState"]) {
90
+ const patch = historyPatches[method];
91
+ if (patch && history[method] === patch.wrapped) {
92
+ history[method] = patch.original;
93
+ delete historyPatches[method];
94
+ }
95
+ }
96
+ };
81
97
  }
82
98
  var WebAnalytics = class {
83
99
  options;
84
100
  baseUrl;
85
101
  debug;
102
+ cleanup = [];
86
103
  started = false;
87
- destroyed = false;
88
- epoch = 0;
89
104
  heartbeatTimer = null;
105
+ extensions = null;
90
106
  pageKey = "";
91
107
  entry = {
92
108
  path: "",
93
109
  url: "",
94
- hash: ""
110
+ search: ""
95
111
  };
96
112
  left = false;
97
113
  visitDuration = 0;
98
114
  visitStartedAt = null;
99
115
  pendingPageviewTrigger = null;
100
116
  consentMode;
101
- pendingBehavior;
102
- cleanup = [];
103
- childTrackers = [];
104
- outboundLinks = new OutboundLinkTracker();
105
117
  constructor(options) {
106
118
  this.options = options;
119
+ if (!options.siteKey.trim()) throw new Error("siteKey is required");
107
120
  this.baseUrl = resolveBaseUrl(options.baseUrl, ANALYTICS_BASE);
108
121
  this.debug = options.debug ?? false;
109
- this.consentMode = pendingConsentMode ?? options.consent?.mode ?? "granted";
110
- pendingConsentMode = void 0;
111
- this.pendingBehavior = options.consent?.pendingBehavior ?? "anonymous";
112
- if (options.autoTrack ?? true) {
113
- if (typeof window === "undefined") return;
114
- if (isTrackingDisabled()) {
115
- this.log("disabled");
116
- return;
117
- }
118
- setTimeout(() => void this.start(), 0);
119
- }
122
+ this.consentMode = options.consent ?? "granted";
120
123
  }
121
- log(msg) {
122
- if (this.debug) console.log(`[Analytics] ${msg}`);
123
- }
124
- isTrackingBlocked() {
125
- return typeof window === "undefined" || this.destroyed || isTrackingDisabled();
124
+ start() {
125
+ if (this.started !== false || typeof window === "undefined") return;
126
+ this.started = true;
127
+ this.installBrowserLifecycle();
128
+ if (this.canTrack()) this.activate("load");
126
129
  }
127
- ensureActive() {
128
- if (this.isTrackingBlocked()) return false;
129
- if (!this.started) this.start();
130
- return this.started && !this.destroyed && instance === this;
130
+ destroy() {
131
+ if (this.started === null) return;
132
+ if (this.started && typeof window !== "undefined" && this.canTrack()) this.leavePage();
133
+ this.started = null;
134
+ this.pendingPageviewTrigger = null;
135
+ this.stopHeartbeat();
136
+ for (const cleanup of this.cleanup.splice(0).reverse()) cleanup();
137
+ this.stopExtensions(false);
131
138
  }
132
- canTrack() {
133
- return !(this.consentMode === "pending" && this.pendingBehavior === "disabled");
139
+ pageview(properties = {}) {
140
+ if (!this.ensureActive()) return;
141
+ const includeHash = this.options.trackHash ?? false;
142
+ const key = `${location.pathname}|${location.search}|${includeHash ? location.hash : ""}`;
143
+ if (key === this.pageKey) return;
144
+ this.pageKey = key;
145
+ this.send("pageview", properties);
134
146
  }
135
- canSendEvents() {
136
- return !this.isTrackingBlocked() && this.canTrack();
147
+ track(name, properties = {}) {
148
+ if (!this.ensureActive()) return;
149
+ const eventName = name.trim();
150
+ if (!eventName || eventName === "pageview" || eventName === "page_leave" || eventName === "error" || eventName === "outbound_link") return;
151
+ this.send(eventName, properties);
152
+ }
153
+ async identify(userOrExternalId, email, options = {}) {
154
+ if (!this.ensureActive() || !this.canTrack() || this.isCookieless()) return false;
155
+ const user = typeof userOrExternalId === "string" ? {
156
+ id: userOrExternalId,
157
+ email,
158
+ ...options
159
+ } : userOrExternalId;
160
+ const externalId = user.id.trim();
161
+ if (!externalId) return false;
162
+ const clearFields = [
163
+ "email",
164
+ "name",
165
+ "phone",
166
+ "avatarUrl"
167
+ ].filter((field) => user[field] === null);
168
+ return await sendData({
169
+ url: `${this.baseUrl}${URLS.identify}`,
170
+ data: JSON.stringify({
171
+ token: this.options.siteKey,
172
+ identifier: this.getAnonymousId(),
173
+ externalId,
174
+ email: user.email?.trim() || void 0,
175
+ name: user.name?.trim() || void 0,
176
+ phone: user.phone?.trim() || void 0,
177
+ avatarUrl: user.avatarUrl?.trim() || void 0,
178
+ traits: user.traits,
179
+ replaceTraits: user.traitMode === "replace",
180
+ unsetTraits: user.unsetTraits,
181
+ aliases: user.aliases,
182
+ clearFields
183
+ }),
184
+ contentType: "text/plain",
185
+ debug: this.debug,
186
+ debugPrefix: "[FastStats] identify",
187
+ useBeacon: false
188
+ });
137
189
  }
138
- cookieless() {
139
- return !!this.options.cookieless || this.consentMode === "denied" || this.consentMode === "pending" && this.pendingBehavior === "anonymous";
190
+ logout(resetAnonymousIdentity = true) {
191
+ if (!this.ensureActive()) return;
192
+ if (resetAnonymousIdentity) resetAnonymousId(this.options.siteKey, this.isCookieless());
193
+ resetSession(this.options.siteKey, this.isCookieless());
140
194
  }
141
- touch() {
142
- if (this.canTrack()) touchActivity(this.options.siteKey, this.cookieless());
195
+ setConsentMode(mode) {
196
+ if (mode === this.consentMode) return;
197
+ const wasTracking = this.canTrack();
198
+ const wasCookieless = this.isCookieless();
199
+ const willTrack = mode !== "denied";
200
+ const willBeCookieless = this.options.cookieless === true || mode === "anonymous";
201
+ if (wasTracking && willTrack && wasCookieless !== willBeCookieless) this.leavePage();
202
+ this.consentMode = mode;
203
+ if (!willTrack) {
204
+ this.pauseVisit();
205
+ this.stopHeartbeat();
206
+ this.pendingPageviewTrigger = null;
207
+ this.stopExtensions(true);
208
+ return;
209
+ }
210
+ if (wasTracking) {
211
+ this.extensions?.emit("consentChange", { cookieless: willBeCookieless });
212
+ if (wasCookieless !== willBeCookieless) {
213
+ this.enterPage();
214
+ this.pageKey = "";
215
+ this.pageview({ trigger: "consent" });
216
+ }
217
+ } else if (this.started) this.activate("consent");
143
218
  }
144
- bind(target, type, listener, options) {
145
- target.addEventListener(type, listener, options);
146
- this.cleanup.push(() => target.removeEventListener(type, listener, options));
219
+ getAnonymousId() {
220
+ return this.canTrack() ? getAnonymousId(this.options.siteKey, this.isCookieless()) : "";
147
221
  }
148
- stopHeartbeat() {
149
- if (!this.heartbeatTimer) return;
150
- clearInterval(this.heartbeatTimer);
151
- this.heartbeatTimer = null;
222
+ reportError(error) {
223
+ if (!this.ensureActive() || !this.canTrack()) return;
224
+ this.extensions?.emit("error", { error });
152
225
  }
153
- loadChild(name, create) {
154
- const epoch = this.epoch;
155
- create().then(async (tracker) => {
156
- if (this.epoch !== epoch || !this.started) return tracker.stop?.();
157
- await tracker.start();
158
- if (this.epoch !== epoch || !this.started) return tracker.stop?.();
159
- this.childTrackers.push(tracker);
160
- this.log(`${name} loaded`);
161
- }).catch((error) => this.log(`failed to initialize ${name} tracker: ${String(error)}`));
226
+ canTrack() {
227
+ return this.consentMode !== "denied";
162
228
  }
163
- loadOptionalTrackers() {
164
- const o = this.options;
165
- const base = {
166
- siteKey: o.siteKey,
167
- baseUrl: this.baseUrl,
168
- debug: this.debug
169
- };
170
- if (o.sessionReplays?.enabled) this.loadChild("replay", async () => {
171
- const { default: ReplayTracker } = await import("./replay.js");
172
- return new ReplayTracker({
173
- ...base,
174
- ...o.replayOptions
175
- });
176
- });
177
- if (o.errorTracking?.enabled) this.loadChild("error", async () => {
178
- const { default: ErrorTracker } = await import("./error.js");
179
- return new ErrorTracker({
180
- ...base,
181
- sdkName: o.sdkName,
182
- sdkVersion: o.sdkVersion
183
- });
184
- });
185
- if (o.webVitals?.enabled) this.loadChild("web-vitals", async () => {
186
- const { default: WebVitalsTracker } = await import("./web-vitals.js");
187
- return new WebVitalsTracker({
188
- ...base,
189
- attribution: o.webVitals?.attribution ?? false
190
- });
191
- });
229
+ isCookieless() {
230
+ return this.options.cookieless === true || this.consentMode === "anonymous";
192
231
  }
193
- enterPage() {
194
- this.entry = {
195
- path: location.pathname,
196
- url: sanitizeUrl(location.href),
197
- hash: location.hash
198
- };
199
- this.left = false;
200
- this.startVisit();
232
+ ensureActive() {
233
+ return this.started === true && typeof window !== "undefined";
201
234
  }
202
- beginTracking(trigger) {
235
+ activate(trigger) {
203
236
  this.touch();
204
- this.loadOptionalTrackers();
237
+ this.startExtensions();
205
238
  this.enterPage();
206
239
  this.pageKey = "";
207
- if (document.visibilityState === "visible") {
208
- this.pageview({ trigger });
209
- return;
210
- }
211
- this.pendingPageviewTrigger = trigger;
240
+ this.startHeartbeat();
241
+ if (document.visibilityState === "visible") this.pageview({ trigger });
242
+ else this.pendingPageviewTrigger = trigger;
212
243
  }
213
- onLinkClick = (event) => {
214
- const href = this.outboundLinks.getHref(event);
215
- if (href) this.track("outbound_link", { outbound_link: sanitizeUrl(href) });
216
- };
244
+ startExtensions() {
245
+ if (this.extensions || !this.options.extensions?.length) return;
246
+ const host = new ExtensionHost(this.options.extensions, {
247
+ siteKey: this.options.siteKey,
248
+ baseUrl: this.baseUrl,
249
+ debug: this.debug,
250
+ trackHash: this.options.trackHash ?? false,
251
+ sdkName: this.options.sdkName,
252
+ sdkVersion: this.options.sdkVersion,
253
+ isCookieless: () => this.isCookieless(),
254
+ getAnonymousId: () => this.getAnonymousId(),
255
+ getSession: () => getSessionContext(this.options.siteKey, this.isCookieless()),
256
+ capture: (event, properties) => this.send(event, properties),
257
+ log: (...details) => {
258
+ if (this.debug) console.log(...details);
259
+ }
260
+ });
261
+ this.extensions = host;
262
+ }
263
+ stopExtensions(discard) {
264
+ this.extensions?.stop(discard);
265
+ this.extensions = null;
266
+ }
267
+ installBrowserLifecycle() {
268
+ this.bind(document, "visibilitychange", this.onVisibilityChange);
269
+ this.bind(window, "pagehide", this.onPageHide);
270
+ this.bind(window, "pageshow", this.onPageShow);
271
+ this.bind(window, "popstate", this.onNavigate);
272
+ if (this.options.trackHash) this.bind(window, "hashchange", this.onNavigate);
273
+ this.cleanup.push(observeHistory(() => this.navigate()));
274
+ }
275
+ bind(target, type, listener, options) {
276
+ target.addEventListener(type, listener, options);
277
+ this.cleanup.push(() => target.removeEventListener(type, listener, options));
278
+ }
279
+ onNavigate = () => this.navigate();
217
280
  onVisibilityChange = () => {
281
+ if (!this.canTrack()) return;
218
282
  this.touch();
219
283
  if (document.visibilityState === "hidden") {
220
284
  this.pauseVisit();
221
- for (const tracker of this.childTrackers) tracker.onPageHidden?.();
285
+ this.extensions?.emit("pageHide", {
286
+ persisted: false,
287
+ terminal: false
288
+ });
222
289
  this.stopHeartbeat();
223
290
  return;
224
291
  }
@@ -229,216 +296,145 @@ var WebAnalytics = class {
229
296
  if (trigger) this.pageview({ trigger });
230
297
  };
231
298
  onPageHide = (event) => {
299
+ if (!this.canTrack()) return;
232
300
  const persisted = event.persisted;
233
301
  this.pauseVisit();
234
- for (const tracker of this.childTrackers) tracker.onPageHidden?.(persisted);
302
+ this.extensions?.emit("pageHide", {
303
+ persisted,
304
+ terminal: !persisted
305
+ });
235
306
  if (!persisted) this.leavePage();
236
307
  };
237
308
  onPageShow = (event) => {
309
+ if (!this.canTrack()) return;
238
310
  if (document.visibilityState === "visible") this.resumeVisit();
239
- for (const tracker of this.childTrackers) tracker.onPageShow?.(event.persisted);
311
+ this.extensions?.emit("pageShow", { persisted: event.persisted });
240
312
  };
241
- async start() {
242
- if (this.started) return;
243
- if (this.isTrackingBlocked()) {
244
- if (isTrackingDisabled()) this.log("disabled");
245
- return;
246
- }
247
- if (instance && instance !== this) {
248
- this.log("already started by another instance");
249
- return;
250
- }
251
- this.started = true;
252
- instance = this;
253
- setCookielessMode(this.cookieless());
254
- this.bind(document, "click", this.onLinkClick, true);
255
- this.bind(document, "auxclick", this.onLinkClick, true);
256
- this.bind(document, "visibilitychange", this.onVisibilityChange);
257
- this.bind(window, "pagehide", this.onPageHide);
258
- this.bind(window, "pageshow", this.onPageShow);
259
- this.bind(window, "popstate", () => this.navigate());
260
- if (this.options.trackHash ?? false) this.bind(window, "hashchange", () => this.navigate());
261
- for (const method of ["pushState", "replaceState"]) {
262
- const original = history[method];
263
- const wrapped = (data, unused, url) => {
264
- Reflect.apply(original, history, [
265
- data,
266
- unused,
267
- url
268
- ]);
269
- this.navigate();
270
- };
271
- history[method] = wrapped;
272
- this.cleanup.push(() => {
273
- if (history[method] === wrapped) history[method] = original;
274
- });
275
- }
276
- this.startHeartbeat();
277
- if (this.canTrack()) this.beginTracking("load");
278
- }
279
- destroy() {
280
- if (this.destroyed) return;
281
- this.epoch++;
282
- if (this.started && typeof window !== "undefined") this.leavePage();
283
- this.pendingPageviewTrigger = null;
284
- this.stopHeartbeat();
285
- for (const fn of this.cleanup.splice(0)) fn();
286
- for (const tracker of this.childTrackers.splice(0)) tracker.stop?.();
287
- if (instance === this) instance = null;
288
- this.started = false;
289
- this.destroyed = true;
290
- }
291
- pageview(extra = {}) {
292
- if (!this.ensureActive()) return;
293
- const trackHash = this.options.trackHash ?? false;
294
- const key = `${location.pathname}|${trackHash ? location.hash : ""}`;
295
- if (key === this.pageKey) return;
296
- this.pageKey = key;
297
- this.send("pageview", extra);
298
- }
299
- track(name, extra = {}) {
300
- if (!this.ensureActive()) return;
301
- const eventName = name.trim();
302
- if (!eventName) return;
303
- this.send(eventName, extra);
304
- }
305
- async identify(externalId, email, options = {}) {
306
- if (!this.ensureActive() || !this.canTrack() || this.cookieless()) return false;
307
- const trimmedExternalId = externalId.trim();
308
- const trimmedEmail = email.trim();
309
- if (!trimmedExternalId || !trimmedEmail) return false;
310
- return sendData({
311
- url: `${this.baseUrl}${URLS.identify}`,
312
- data: JSON.stringify({
313
- token: this.options.siteKey,
314
- identifier: this.getAnonymousId(),
315
- externalId: trimmedExternalId,
316
- email: trimmedEmail,
317
- name: options.name?.trim() || void 0,
318
- phone: options.phone?.trim() || void 0,
319
- avatarUrl: options.avatarUrl?.trim() || void 0,
320
- traits: options.traits ?? {}
321
- }),
322
- contentType: "text/plain",
323
- debug: this.debug,
324
- debugPrefix: "[Analytics] identify",
325
- useBeacon: false
326
- });
327
- }
328
- logout(resetAnonymousIdentity = true) {
329
- if (!this.ensureActive()) return;
330
- if (resetAnonymousIdentity) resetAnonymousId(this.cookieless());
331
- resetSession(this.options.siteKey);
332
- }
333
- setConsentMode(mode) {
334
- const wasBlocked = !this.canTrack();
335
- this.consentMode = mode;
336
- const cookieless = this.cookieless();
337
- setCookielessMode(cookieless);
338
- for (const tracker of this.childTrackers) tracker.setCookielessMode?.(cookieless);
339
- if (wasBlocked && this.canTrack() && this.started) this.beginTracking("consent");
340
- }
341
- getConsentMode() {
342
- return this.consentMode;
343
- }
344
- getAnonymousId() {
345
- if (!this.canTrack()) return "";
346
- return getAnonymousId(this.cookieless());
347
- }
348
- getSessionId() {
349
- return getSessionContext(this.options.siteKey, this.cookieless()).sessionId;
350
- }
351
- getWindowId() {
352
- return getSessionContext(this.options.siteKey, this.cookieless()).windowId;
353
- }
354
- async checkFeatureFlag(key, attributes, opts) {
355
- if (typeof window === "undefined" || isTrackingDisabled() || !this.canTrack()) return { value: "false" };
356
- const externalId = opts?.externalId?.trim();
357
- const identifier = this.getAnonymousId();
358
- if (!externalId && !identifier) return { value: "false" };
359
- const { fetchFeatureFlagEvaluation } = await import("./feature-flags.js");
360
- return fetchFeatureFlagEvaluation(key, {
361
- baseUrl: resolveBaseUrl(this.options.featureFlagsBaseUrl, FEATURE_FLAGS_BASE),
362
- projectToken: this.options.siteKey,
363
- ...identifier ? { identifier } : {},
364
- ...externalId ? { externalId } : {},
365
- sessionId: this.getSessionId(),
366
- attributes,
367
- signal: opts?.signal
368
- });
369
- }
370
- reportError(error) {
371
- if (!this.ensureActive() || !this.options.errorTracking?.enabled || !this.canTrack()) return;
372
- this.childTrackers.find((tracker) => tracker.captureError)?.captureError?.(error);
313
+ navigate() {
314
+ if (this.started !== true || !this.canTrack()) return;
315
+ const includeHash = this.options.trackHash ?? false;
316
+ if (`${location.pathname}${includeHash ? location.hash : ""}` === this.entry.path && location.search === this.entry.search) return;
317
+ const url = sanitizeUrl(location.href, includeHash);
318
+ this.extensions?.emit("pageChange", { url });
319
+ this.leavePage();
320
+ this.enterPage();
321
+ this.pageview({ trigger: "navigation" });
373
322
  }
374
- send(event, properties = {}, dims = {}) {
375
- if (!this.canSendEvents()) return;
376
- const cookieless = this.cookieless();
377
- const id = getAnonymousId(cookieless);
378
- const ctx = getSessionContext(this.options.siteKey, cookieless);
379
- this.log(event);
380
- sendData({
381
- url: `${this.baseUrl}${URLS.events}`,
382
- data: JSON.stringify({
383
- token: this.options.siteKey,
384
- ...id ? { userId: id } : {},
385
- sessionId: ctx.sessionId,
386
- windowId: ctx.windowId,
387
- event,
388
- ...getPageContext(),
389
- ...dims,
390
- properties
391
- }),
392
- contentType: "text/plain",
393
- debug: this.debug,
394
- debugPrefix: `[Analytics] ${event}`
395
- });
323
+ enterPage() {
324
+ const includeHash = this.options.trackHash ?? false;
325
+ const hash = includeHash ? location.hash : "";
326
+ this.entry = {
327
+ path: `${location.pathname}${hash}`,
328
+ url: sanitizeUrl(location.href, includeHash),
329
+ search: location.search
330
+ };
331
+ this.left = false;
332
+ this.visitDuration = 0;
333
+ this.visitStartedAt = document.visibilityState === "visible" ? Date.now() : null;
396
334
  }
397
335
  leavePage() {
398
- if (this.destroyed || this.left) return;
336
+ if (this.started !== true || this.left || !this.canTrack()) return;
399
337
  this.pauseVisit();
400
338
  this.left = true;
401
- const ctx = getSessionContext(this.options.siteKey, this.cookieless());
339
+ const session = getSessionContext(this.options.siteKey, this.isCookieless());
402
340
  this.send("page_leave", {
403
341
  time_on_page: this.visitDuration,
404
- session_duration: Date.now() - ctx.sessionStart
342
+ session_duration: Date.now() - session.sessionStart
405
343
  }, {
406
344
  page: this.entry.path,
407
345
  url: this.entry.url
408
346
  });
409
347
  }
410
- startVisit() {
411
- this.visitDuration = 0;
412
- this.visitStartedAt = document.visibilityState === "visible" ? Date.now() : null;
413
- }
414
348
  pauseVisit() {
415
349
  if (this.visitStartedAt === null) return;
416
350
  this.visitDuration += Math.max(0, Date.now() - this.visitStartedAt);
417
351
  this.visitStartedAt = null;
418
352
  }
419
353
  resumeVisit() {
420
- if (this.left || this.visitStartedAt !== null) return;
421
- this.visitStartedAt = Date.now();
354
+ if (!this.left && this.visitStartedAt === null) this.visitStartedAt = Date.now();
355
+ }
356
+ touch() {
357
+ if (this.canTrack()) touchActivity(this.options.siteKey, this.isCookieless());
422
358
  }
423
359
  startHeartbeat() {
424
360
  this.stopHeartbeat();
425
361
  this.heartbeatTimer = setInterval(() => {
426
- if (document.visibilityState === "hidden") {
427
- this.stopHeartbeat();
428
- return;
429
- }
362
+ if (document.visibilityState === "hidden") return this.stopHeartbeat();
430
363
  this.touch();
431
- }, 300 * 1e3);
364
+ }, 3e5);
432
365
  }
433
- navigate() {
434
- if (!this.started || this.destroyed) return;
435
- const trackHash = this.options.trackHash ?? false;
436
- if (location.pathname === this.entry.path && (!trackHash || location.hash === this.entry.hash)) return;
437
- for (const tracker of this.childTrackers) tracker.trackPageChange?.(sanitizeUrl(location.href));
438
- this.leavePage();
439
- this.enterPage();
440
- this.pageview({ trigger: "navigation" });
366
+ stopHeartbeat() {
367
+ if (!this.heartbeatTimer) return;
368
+ clearInterval(this.heartbeatTimer);
369
+ this.heartbeatTimer = null;
370
+ }
371
+ send(event, properties = {}, dimensions = {}) {
372
+ if (this.started !== true || !this.canTrack()) return;
373
+ const cookieless = this.isCookieless();
374
+ const userId = getAnonymousId(this.options.siteKey, cookieless);
375
+ const session = getSessionContext(this.options.siteKey, cookieless);
376
+ let data;
377
+ try {
378
+ data = JSON.stringify({
379
+ token: this.options.siteKey,
380
+ ...userId ? { userId } : {},
381
+ sessionId: session.sessionId,
382
+ windowId: session.windowId,
383
+ event,
384
+ ...getPageContext(this.options.trackHash ?? false),
385
+ ...dimensions,
386
+ properties
387
+ });
388
+ } catch (error) {
389
+ if (this.debug) console.log("serialization failed", event, error);
390
+ return;
391
+ }
392
+ sendData({
393
+ url: `${this.baseUrl}${URLS.events}`,
394
+ data,
395
+ contentType: "text/plain",
396
+ debug: this.debug,
397
+ debugPrefix: "[FastStats]"
398
+ });
441
399
  }
442
400
  };
443
401
  //#endregion
444
- export { WebAnalytics, getInstance, identify, isTrackingDisabled, logout, optIn, optOut, reportError, setConsentMode, trackEvent };
402
+ //#region src/client.ts
403
+ let client;
404
+ /** Creates an independent client. Call `start()` when it should begin tracking. */
405
+ function createClient(options) {
406
+ return new WebAnalytics(options);
407
+ }
408
+ /** Initializes and starts the shared client used by the convenience functions. */
409
+ function init(options) {
410
+ if (client) return client;
411
+ client = createClient(options);
412
+ client.start();
413
+ return client;
414
+ }
415
+ /** Stops and forgets the shared client. Independent clients are unaffected. */
416
+ function shutdown() {
417
+ client?.destroy();
418
+ client = void 0;
419
+ }
420
+ function pageview(properties) {
421
+ client?.pageview(properties);
422
+ }
423
+ function track(name, properties) {
424
+ client?.track(name, properties);
425
+ }
426
+ function identify(userOrExternalId, email, options) {
427
+ if (!client) return Promise.resolve(false);
428
+ return typeof userOrExternalId === "string" ? client.identify(userOrExternalId, email, options) : client.identify(userOrExternalId);
429
+ }
430
+ function logout(resetAnonymousIdentity) {
431
+ client?.logout(resetAnonymousIdentity);
432
+ }
433
+ function setConsentMode(mode) {
434
+ client?.setConsentMode(mode);
435
+ }
436
+ function reportError(error) {
437
+ client?.reportError(error);
438
+ }
439
+ //#endregion
440
+ export { WebAnalytics, createClient, identify, init, logout, pageview, reportError, setConsentMode, shutdown, track };