@loamly/tracker 2.0.2 → 2.1.1

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.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/config.ts
2
- var VERSION = "2.0.2";
2
+ var VERSION = "2.1.1";
3
3
  var DEFAULT_CONFIG = {
4
4
  apiHost: "https://app.loamly.ai",
5
5
  endpoints: {
@@ -858,11 +858,12 @@ var EventQueue = class {
858
858
  /**
859
859
  * Add event to queue
860
860
  */
861
- push(type, payload) {
861
+ push(type, payload, headers) {
862
862
  const event = {
863
863
  id: this.generateId(),
864
864
  type,
865
865
  payload,
866
+ headers,
866
867
  timestamp: Date.now(),
867
868
  retries: 0
868
869
  };
@@ -891,21 +892,25 @@ var EventQueue = class {
891
892
  */
892
893
  flushBeacon() {
893
894
  if (this.queue.length === 0) return true;
894
- const events = this.queue.map((e) => ({
895
- type: e.type,
896
- ...e.payload,
897
- _queue_id: e.id,
898
- _queue_timestamp: e.timestamp
899
- }));
900
- const success = navigator.sendBeacon?.(
901
- this.endpoint,
902
- JSON.stringify({ events, beacon: true })
903
- ) ?? false;
904
- if (success) {
895
+ const baseUrl = this.config.apiKey ? `${this.endpoint}?api_key=${encodeURIComponent(this.config.apiKey)}` : this.endpoint;
896
+ let allSent = true;
897
+ for (const event of this.queue) {
898
+ const payload = {
899
+ ...event.payload,
900
+ _queue_id: event.id,
901
+ _queue_timestamp: event.timestamp
902
+ };
903
+ const success = navigator.sendBeacon?.(baseUrl, JSON.stringify(payload)) ?? false;
904
+ if (!success) {
905
+ allSent = false;
906
+ break;
907
+ }
908
+ }
909
+ if (allSent) {
905
910
  this.queue = [];
906
911
  this.clearStorage();
907
912
  }
908
- return success;
913
+ return allSent;
909
914
  }
910
915
  /**
911
916
  * Get current queue length
@@ -932,24 +937,38 @@ var EventQueue = class {
932
937
  }
933
938
  async sendBatch(events) {
934
939
  if (events.length === 0) return;
935
- const payload = {
936
- events: events.map((e) => ({
937
- type: e.type,
938
- ...e.payload,
939
- _queue_id: e.id,
940
- _queue_timestamp: e.timestamp
941
- })),
942
- batch: true
943
- };
944
940
  try {
945
- const response = await fetch(this.endpoint, {
946
- method: "POST",
947
- headers: { "Content-Type": "application/json" },
948
- body: JSON.stringify(payload)
949
- });
950
- if (!response.ok) {
951
- throw new Error(`HTTP ${response.status}`);
941
+ const results = await Promise.allSettled(
942
+ events.map(async (event) => {
943
+ const response = await fetch(this.endpoint, {
944
+ method: "POST",
945
+ headers: {
946
+ "Content-Type": "application/json",
947
+ ...event.headers || {}
948
+ },
949
+ body: JSON.stringify({
950
+ ...event.payload,
951
+ _queue_id: event.id,
952
+ _queue_timestamp: event.timestamp
953
+ })
954
+ });
955
+ if (!response.ok) {
956
+ throw new Error(`HTTP ${response.status}`);
957
+ }
958
+ })
959
+ );
960
+ const failedEvents = events.filter((_, index) => results[index]?.status === "rejected");
961
+ if (failedEvents.length > 0) {
962
+ for (const event of failedEvents) {
963
+ if (event.retries < this.config.maxRetries) {
964
+ event.retries++;
965
+ this.queue.push(event);
966
+ }
967
+ }
968
+ const delay = this.config.retryDelayMs * Math.pow(2, failedEvents[0].retries - 1);
969
+ setTimeout(() => this.flush(), delay);
952
970
  }
971
+ return;
953
972
  } catch (error) {
954
973
  for (const event of events) {
955
974
  if (event.retries < this.config.maxRetries) {
@@ -1002,6 +1021,7 @@ var PingService = class {
1002
1021
  constructor(sessionId2, visitorId2, version, config2 = {}) {
1003
1022
  this.intervalId = null;
1004
1023
  this.isVisible = true;
1024
+ this.isFocused = true;
1005
1025
  this.currentScrollDepth = 0;
1006
1026
  this.ping = async () => {
1007
1027
  const data = this.getData();
@@ -1020,6 +1040,12 @@ var PingService = class {
1020
1040
  this.handleVisibilityChange = () => {
1021
1041
  this.isVisible = document.visibilityState === "visible";
1022
1042
  };
1043
+ this.handleFocusChange = () => {
1044
+ this.isFocused = typeof document.hasFocus === "function" ? document.hasFocus() : true;
1045
+ if (this.intervalId && this.isVisible && this.isFocused) {
1046
+ this.ping();
1047
+ }
1048
+ };
1023
1049
  this.handleScroll = () => {
1024
1050
  const scrollPercent = Math.round(
1025
1051
  (window.scrollY + window.innerHeight) / document.documentElement.scrollHeight * 100
@@ -1032,12 +1058,16 @@ var PingService = class {
1032
1058
  this.visitorId = visitorId2;
1033
1059
  this.version = version;
1034
1060
  this.pageLoadTime = Date.now();
1061
+ this.isVisible = document.visibilityState === "visible";
1062
+ this.isFocused = typeof document.hasFocus === "function" ? document.hasFocus() : true;
1035
1063
  this.config = {
1036
1064
  interval: DEFAULT_CONFIG.pingInterval,
1037
1065
  endpoint: "",
1038
1066
  ...config2
1039
1067
  };
1040
1068
  document.addEventListener("visibilitychange", this.handleVisibilityChange);
1069
+ window.addEventListener("focus", this.handleFocusChange);
1070
+ window.addEventListener("blur", this.handleFocusChange);
1041
1071
  window.addEventListener("scroll", this.handleScroll, { passive: true });
1042
1072
  }
1043
1073
  /**
@@ -1046,11 +1076,13 @@ var PingService = class {
1046
1076
  start() {
1047
1077
  if (this.intervalId) return;
1048
1078
  this.intervalId = setInterval(() => {
1049
- if (this.isVisible) {
1079
+ if (this.isVisible && this.isFocused) {
1050
1080
  this.ping();
1051
1081
  }
1052
1082
  }, this.config.interval);
1053
- this.ping();
1083
+ if (this.isVisible && this.isFocused) {
1084
+ this.ping();
1085
+ }
1054
1086
  }
1055
1087
  /**
1056
1088
  * Stop the ping service
@@ -1061,6 +1093,8 @@ var PingService = class {
1061
1093
  this.intervalId = null;
1062
1094
  }
1063
1095
  document.removeEventListener("visibilitychange", this.handleVisibilityChange);
1096
+ window.removeEventListener("focus", this.handleFocusChange);
1097
+ window.removeEventListener("blur", this.handleFocusChange);
1064
1098
  window.removeEventListener("scroll", this.handleScroll);
1065
1099
  }
1066
1100
  /**
@@ -1081,7 +1115,7 @@ var PingService = class {
1081
1115
  url: window.location.href,
1082
1116
  time_on_page_ms: Date.now() - this.pageLoadTime,
1083
1117
  scroll_depth: this.currentScrollDepth,
1084
- is_active: this.isVisible,
1118
+ is_active: this.isVisible && this.isFocused,
1085
1119
  tracker_version: this.version
1086
1120
  };
1087
1121
  }
@@ -1388,7 +1422,8 @@ var FormTracker = class {
1388
1422
  form_id: formId,
1389
1423
  form_type: this.detectFormType(form),
1390
1424
  time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
1391
- is_conversion: true
1425
+ is_conversion: true,
1426
+ submit_source: "submit"
1392
1427
  });
1393
1428
  };
1394
1429
  this.handleClick = (e) => {
@@ -1403,7 +1438,8 @@ var FormTracker = class {
1403
1438
  form_id: formId,
1404
1439
  form_type: "hubspot",
1405
1440
  time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
1406
- is_conversion: true
1441
+ is_conversion: true,
1442
+ submit_source: "click"
1407
1443
  });
1408
1444
  }
1409
1445
  }
@@ -1412,7 +1448,8 @@ var FormTracker = class {
1412
1448
  event_type: "form_submit",
1413
1449
  form_id: "typeform_embed",
1414
1450
  form_type: "typeform",
1415
- is_conversion: true
1451
+ is_conversion: true,
1452
+ submit_source: "click"
1416
1453
  });
1417
1454
  }
1418
1455
  };
@@ -1514,7 +1551,8 @@ var FormTracker = class {
1514
1551
  event_type: "form_success",
1515
1552
  form_id: "page_conversion",
1516
1553
  form_type: "unknown",
1517
- is_conversion: true
1554
+ is_conversion: true,
1555
+ submit_source: "thank_you"
1518
1556
  });
1519
1557
  break;
1520
1558
  }
@@ -1738,8 +1776,10 @@ var initialized = false;
1738
1776
  var debugMode = false;
1739
1777
  var visitorId = null;
1740
1778
  var sessionId = null;
1779
+ var workspaceId = null;
1741
1780
  var navigationTiming = null;
1742
1781
  var aiDetection = null;
1782
+ var pageStartTime = null;
1743
1783
  var behavioralClassifier = null;
1744
1784
  var behavioralMLResult = null;
1745
1785
  var focusBlurAnalyzer = null;
@@ -1759,6 +1799,28 @@ function log(...args) {
1759
1799
  function endpoint(path) {
1760
1800
  return `${config.apiHost}${path}`;
1761
1801
  }
1802
+ function buildHeaders(idempotencyKey) {
1803
+ const headers = {
1804
+ "Content-Type": "application/json"
1805
+ };
1806
+ if (config.apiKey) {
1807
+ headers["X-Loamly-Api-Key"] = config.apiKey;
1808
+ }
1809
+ if (idempotencyKey) {
1810
+ headers["X-Idempotency-Key"] = idempotencyKey;
1811
+ }
1812
+ return headers;
1813
+ }
1814
+ function buildBeaconUrl(path) {
1815
+ if (!config.apiKey) return path;
1816
+ const url = new URL(path, config.apiHost);
1817
+ url.searchParams.set("api_key", config.apiKey);
1818
+ return url.toString();
1819
+ }
1820
+ function buildIdempotencyKey(prefix) {
1821
+ const base = sessionId || visitorId || "unknown";
1822
+ return `${prefix}:${base}:${Date.now()}`;
1823
+ }
1762
1824
  function init(userConfig = {}) {
1763
1825
  if (initialized) {
1764
1826
  log("Already initialized");
@@ -1769,17 +1831,35 @@ function init(userConfig = {}) {
1769
1831
  ...userConfig,
1770
1832
  apiHost: userConfig.apiHost || DEFAULT_CONFIG.apiHost
1771
1833
  };
1834
+ workspaceId = userConfig.workspaceId ?? null;
1772
1835
  debugMode = userConfig.debug ?? false;
1836
+ if (config.apiKey && !workspaceId) {
1837
+ log("Workspace ID missing. Behavioral events require workspaceId.");
1838
+ }
1839
+ const features = {
1840
+ scroll: true,
1841
+ time: true,
1842
+ forms: true,
1843
+ spa: true,
1844
+ behavioralML: true,
1845
+ focusBlur: true,
1846
+ agentic: true,
1847
+ eventQueue: true,
1848
+ ping: false,
1849
+ // Opt-in only
1850
+ ...userConfig.features
1851
+ };
1773
1852
  log("Initializing Loamly Tracker v" + VERSION);
1853
+ log("Features:", features);
1774
1854
  visitorId = getVisitorId();
1775
1855
  log("Visitor ID:", visitorId);
1776
- const session = getSessionId();
1777
- sessionId = session.sessionId;
1778
- log("Session ID:", sessionId, session.isNew ? "(new)" : "(existing)");
1779
- eventQueue = new EventQueue(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
1780
- batchSize: DEFAULT_CONFIG.batchSize,
1781
- batchTimeout: DEFAULT_CONFIG.batchTimeout
1782
- });
1856
+ if (features.eventQueue) {
1857
+ eventQueue = new EventQueue(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
1858
+ batchSize: DEFAULT_CONFIG.batchSize,
1859
+ batchTimeout: DEFAULT_CONFIG.batchTimeout,
1860
+ apiKey: config.apiKey
1861
+ });
1862
+ }
1783
1863
  navigationTiming = detectNavigationType();
1784
1864
  log("Navigation timing:", navigationTiming);
1785
1865
  aiDetection = detectAIFromReferrer(document.referrer) || detectAIFromUTM(window.location.href);
@@ -1787,31 +1867,40 @@ function init(userConfig = {}) {
1787
1867
  log("AI detected:", aiDetection);
1788
1868
  }
1789
1869
  initialized = true;
1790
- if (!userConfig.disableAutoPageview) {
1791
- pageview();
1792
- }
1793
- if (!userConfig.disableBehavioral) {
1794
- setupAdvancedBehavioralTracking();
1795
- }
1796
- behavioralClassifier = new BehavioralClassifier(1e4);
1797
- behavioralClassifier.setOnClassify(handleBehavioralClassification);
1798
- setupBehavioralMLTracking();
1799
- focusBlurAnalyzer = new FocusBlurAnalyzer();
1800
- focusBlurAnalyzer.initTracking();
1801
- setTimeout(() => {
1802
- if (focusBlurAnalyzer) {
1803
- handleFocusBlurAnalysis(focusBlurAnalyzer.analyze());
1804
- }
1805
- }, 5e3);
1806
- agenticAnalyzer = new AgenticBrowserAnalyzer();
1807
- agenticAnalyzer.init();
1808
- if (visitorId && sessionId) {
1809
- pingService = new PingService(sessionId, visitorId, VERSION, {
1810
- interval: DEFAULT_CONFIG.pingInterval,
1811
- endpoint: endpoint(DEFAULT_CONFIG.endpoints.ping)
1812
- });
1813
- pingService.start();
1814
- }
1870
+ if (features.agentic) {
1871
+ agenticAnalyzer = new AgenticBrowserAnalyzer();
1872
+ agenticAnalyzer.init();
1873
+ }
1874
+ void initializeSession().finally(() => {
1875
+ void registerServiceWorker();
1876
+ if (!userConfig.disableAutoPageview) {
1877
+ pageview();
1878
+ }
1879
+ if (!userConfig.disableBehavioral) {
1880
+ setupAdvancedBehavioralTracking(features);
1881
+ }
1882
+ if (features.behavioralML) {
1883
+ behavioralClassifier = new BehavioralClassifier(1e4);
1884
+ behavioralClassifier.setOnClassify(handleBehavioralClassification);
1885
+ setupBehavioralMLTracking();
1886
+ }
1887
+ if (features.focusBlur) {
1888
+ focusBlurAnalyzer = new FocusBlurAnalyzer();
1889
+ focusBlurAnalyzer.initTracking();
1890
+ setTimeout(() => {
1891
+ if (focusBlurAnalyzer) {
1892
+ handleFocusBlurAnalysis(focusBlurAnalyzer.analyze());
1893
+ }
1894
+ }, 5e3);
1895
+ }
1896
+ if (features.ping && visitorId && sessionId) {
1897
+ pingService = new PingService(sessionId, visitorId, VERSION, {
1898
+ interval: DEFAULT_CONFIG.pingInterval,
1899
+ endpoint: endpoint(DEFAULT_CONFIG.endpoints.ping)
1900
+ });
1901
+ pingService.start();
1902
+ }
1903
+ });
1815
1904
  spaRouter = new SPARouter({
1816
1905
  onNavigate: handleSPANavigation
1817
1906
  });
@@ -1820,55 +1909,146 @@ function init(userConfig = {}) {
1820
1909
  reportHealth("initialized");
1821
1910
  log("Initialization complete");
1822
1911
  }
1823
- function setupAdvancedBehavioralTracking() {
1824
- scrollTracker = new ScrollTracker({
1825
- chunks: [30, 60, 90, 100],
1826
- onChunkReached: (event) => {
1827
- log("Scroll chunk:", event.chunk);
1828
- queueEvent("scroll_depth", {
1829
- depth: event.depth,
1830
- chunk: event.chunk,
1831
- time_to_reach_ms: event.time_to_reach_ms
1912
+ async function registerServiceWorker() {
1913
+ if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
1914
+ if (!config.apiKey || !workspaceId) return;
1915
+ try {
1916
+ const swUrl = new URL("/tracker/loamly-sw.js", window.location.origin);
1917
+ swUrl.searchParams.set("workspace_id", workspaceId);
1918
+ swUrl.searchParams.set("api_key", config.apiKey);
1919
+ const registration = await navigator.serviceWorker.register(swUrl.toString(), { scope: "/" });
1920
+ registration.addEventListener("updatefound", () => {
1921
+ const installing = registration.installing;
1922
+ installing?.addEventListener("statechange", () => {
1923
+ if (installing.state === "activated") {
1924
+ installing.postMessage({ type: "SKIP_WAITING" });
1925
+ }
1832
1926
  });
1833
- }
1834
- });
1835
- scrollTracker.start();
1836
- timeTracker = new TimeTracker({
1837
- updateIntervalMs: 1e4,
1838
- // Report every 10 seconds
1839
- onUpdate: (event) => {
1840
- if (event.active_time_ms >= DEFAULT_CONFIG.timeSpentThresholdMs) {
1841
- queueEvent("time_spent", {
1842
- active_time_ms: event.active_time_ms,
1843
- total_time_ms: event.total_time_ms,
1844
- idle_time_ms: event.idle_time_ms,
1845
- is_engaged: event.is_engaged
1846
- });
1927
+ });
1928
+ setInterval(() => {
1929
+ registration.update().catch(() => {
1930
+ });
1931
+ }, 24 * 60 * 60 * 1e3);
1932
+ } catch {
1933
+ }
1934
+ }
1935
+ async function initializeSession() {
1936
+ const now = Date.now();
1937
+ pageStartTime = now;
1938
+ try {
1939
+ const storedSession = sessionStorage.getItem("loamly_session");
1940
+ const storedStart = sessionStorage.getItem("loamly_start");
1941
+ const sessionTimeout = config.sessionTimeout ?? DEFAULT_CONFIG.sessionTimeout;
1942
+ if (storedSession && storedStart) {
1943
+ const startTime = parseInt(storedStart, 10);
1944
+ const elapsed = now - startTime;
1945
+ if (elapsed > 0 && elapsed < sessionTimeout) {
1946
+ sessionId = storedSession;
1947
+ log("Session ID:", sessionId, "(existing)");
1948
+ return;
1847
1949
  }
1848
1950
  }
1849
- });
1850
- timeTracker.start();
1851
- formTracker = new FormTracker({
1852
- onFormEvent: (event) => {
1853
- log("Form event:", event.event_type, event.form_id);
1854
- queueEvent(event.event_type, {
1855
- form_id: event.form_id,
1856
- form_type: event.form_type,
1857
- field_name: event.field_name,
1858
- field_type: event.field_type,
1859
- time_to_submit_ms: event.time_to_submit_ms,
1860
- is_conversion: event.is_conversion
1951
+ } catch {
1952
+ }
1953
+ if (config.apiKey && workspaceId && visitorId) {
1954
+ try {
1955
+ const response = await safeFetch(endpoint(DEFAULT_CONFIG.endpoints.session), {
1956
+ method: "POST",
1957
+ headers: buildHeaders(),
1958
+ body: JSON.stringify({
1959
+ workspace_id: workspaceId,
1960
+ visitor_id: visitorId
1961
+ })
1861
1962
  });
1963
+ if (response?.ok) {
1964
+ const data = await response.json();
1965
+ sessionId = data.session_id || sessionId;
1966
+ const startTime = data.start_time || now;
1967
+ if (sessionId) {
1968
+ try {
1969
+ sessionStorage.setItem("loamly_session", sessionId);
1970
+ sessionStorage.setItem("loamly_start", String(startTime));
1971
+ } catch {
1972
+ }
1973
+ log("Session ID:", sessionId, "(server)");
1974
+ return;
1975
+ }
1976
+ }
1977
+ } catch {
1862
1978
  }
1863
- });
1864
- formTracker.start();
1979
+ }
1980
+ const session = getSessionId();
1981
+ sessionId = session.sessionId;
1982
+ log("Session ID:", sessionId, session.isNew ? "(new)" : "(existing)");
1983
+ }
1984
+ function setupAdvancedBehavioralTracking(features) {
1985
+ if (features.scroll) {
1986
+ scrollTracker = new ScrollTracker({
1987
+ chunks: [30, 60, 90, 100],
1988
+ onChunkReached: (event) => {
1989
+ log("Scroll chunk:", event.chunk);
1990
+ queueEvent("scroll_depth", {
1991
+ scroll_depth: Math.round(event.depth / 100 * 100) / 100,
1992
+ milestone: Math.round(event.chunk / 100 * 100) / 100,
1993
+ time_to_reach_ms: event.time_to_reach_ms
1994
+ });
1995
+ }
1996
+ });
1997
+ scrollTracker.start();
1998
+ }
1999
+ if (features.time) {
2000
+ timeTracker = new TimeTracker({
2001
+ updateIntervalMs: 1e4,
2002
+ // Report every 10 seconds
2003
+ onUpdate: (event) => {
2004
+ if (event.active_time_ms >= DEFAULT_CONFIG.timeSpentThresholdMs) {
2005
+ queueEvent("time_spent", {
2006
+ visible_time_ms: event.total_time_ms,
2007
+ page_start_time: pageStartTime || Date.now()
2008
+ });
2009
+ }
2010
+ }
2011
+ });
2012
+ timeTracker.start();
2013
+ }
2014
+ if (features.forms) {
2015
+ formTracker = new FormTracker({
2016
+ onFormEvent: (event) => {
2017
+ log("Form event:", event.event_type, event.form_id);
2018
+ const isSubmitEvent = event.event_type === "form_submit";
2019
+ const isSuccessEvent = event.event_type === "form_success";
2020
+ const normalizedEventType = isSubmitEvent || isSuccessEvent ? "form_submit" : "form_focus";
2021
+ const submitSource = event.submit_source || (isSuccessEvent ? "thank_you" : isSubmitEvent ? "submit" : null);
2022
+ queueEvent(normalizedEventType, {
2023
+ form_id: event.form_id,
2024
+ form_provider: event.form_type || "unknown",
2025
+ form_field_type: event.field_type || null,
2026
+ form_field_name: event.field_name || null,
2027
+ form_event_type: event.event_type,
2028
+ submit_source: submitSource,
2029
+ is_inferred: isSuccessEvent,
2030
+ time_to_submit_seconds: event.time_to_submit_ms ? Math.round(event.time_to_submit_ms / 1e3) : null
2031
+ });
2032
+ }
2033
+ });
2034
+ formTracker.start();
2035
+ }
2036
+ if (features.spa) {
2037
+ spaRouter = new SPARouter({
2038
+ onNavigate: (event) => {
2039
+ log("SPA navigation:", event.navigation_type);
2040
+ pageview(event.to_url);
2041
+ }
2042
+ });
2043
+ spaRouter.start();
2044
+ }
1865
2045
  document.addEventListener("click", (e) => {
1866
2046
  const target = e.target;
1867
2047
  const link = target.closest("a");
1868
2048
  if (link && link.href) {
1869
2049
  const isExternal = link.hostname !== window.location.hostname;
1870
2050
  queueEvent("click", {
1871
- element: "link",
2051
+ element_type: "link",
1872
2052
  href: truncateText(link.href, 200),
1873
2053
  text: truncateText(link.textContent || "", 100),
1874
2054
  is_external: isExternal
@@ -1878,15 +2058,32 @@ function setupAdvancedBehavioralTracking() {
1878
2058
  }
1879
2059
  function queueEvent(eventType, data) {
1880
2060
  if (!eventQueue) return;
1881
- eventQueue.push(eventType, {
2061
+ if (!config.apiKey) {
2062
+ log("Missing apiKey, behavioral event skipped:", eventType);
2063
+ return;
2064
+ }
2065
+ if (!workspaceId) {
2066
+ log("Missing workspaceId, behavioral event skipped:", eventType);
2067
+ return;
2068
+ }
2069
+ if (!sessionId) {
2070
+ log("Missing sessionId, behavioral event skipped:", eventType);
2071
+ return;
2072
+ }
2073
+ const idempotencyKey = buildIdempotencyKey(eventType);
2074
+ const payload = {
1882
2075
  visitor_id: visitorId,
1883
2076
  session_id: sessionId,
1884
2077
  event_type: eventType,
1885
- ...data,
1886
- url: window.location.href,
2078
+ event_data: data,
2079
+ page_url: window.location.href,
2080
+ page_path: window.location.pathname,
1887
2081
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1888
- tracker_version: VERSION
1889
- });
2082
+ tracker_version: VERSION,
2083
+ idempotency_key: idempotencyKey
2084
+ };
2085
+ payload.workspace_id = workspaceId;
2086
+ eventQueue.push(eventType, payload, buildHeaders(idempotencyKey));
1890
2087
  }
1891
2088
  function handleSPANavigation(event) {
1892
2089
  log("SPA navigation:", event.navigation_type, event.to_url);
@@ -1914,34 +2111,42 @@ function handleSPANavigation(event) {
1914
2111
  }
1915
2112
  function setupUnloadHandlers() {
1916
2113
  const handleUnload = () => {
2114
+ if (!workspaceId || !config.apiKey || !sessionId) return;
1917
2115
  const scrollEvent = scrollTracker?.getFinalEvent();
1918
2116
  if (scrollEvent) {
1919
- sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
2117
+ sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG.endpoints.behavioral)), {
2118
+ workspace_id: workspaceId,
1920
2119
  visitor_id: visitorId,
1921
2120
  session_id: sessionId,
1922
2121
  event_type: "scroll_depth_final",
1923
- data: scrollEvent,
1924
- url: window.location.href
2122
+ event_data: {
2123
+ scroll_depth: Math.round(scrollEvent.depth / 100 * 100) / 100,
2124
+ milestone: Math.round(scrollEvent.chunk / 100 * 100) / 100,
2125
+ time_to_reach_ms: scrollEvent.time_to_reach_ms
2126
+ },
2127
+ page_url: window.location.href,
2128
+ page_path: window.location.pathname,
2129
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2130
+ tracker_version: VERSION,
2131
+ idempotency_key: buildIdempotencyKey("scroll_depth_final")
1925
2132
  });
1926
2133
  }
1927
2134
  const timeEvent = timeTracker?.getFinalMetrics();
1928
2135
  if (timeEvent) {
1929
- sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
1930
- visitor_id: visitorId,
1931
- session_id: sessionId,
1932
- event_type: "time_spent_final",
1933
- data: timeEvent,
1934
- url: window.location.href
1935
- });
1936
- }
1937
- const agenticResult = agenticAnalyzer?.getResult();
1938
- if (agenticResult && agenticResult.agenticProbability > 0) {
1939
- sendBeacon(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
2136
+ sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG.endpoints.behavioral)), {
2137
+ workspace_id: workspaceId,
1940
2138
  visitor_id: visitorId,
1941
2139
  session_id: sessionId,
1942
- event_type: "agentic_detection",
1943
- data: agenticResult,
1944
- url: window.location.href
2140
+ event_type: "time_spent",
2141
+ event_data: {
2142
+ visible_time_ms: timeEvent.total_time_ms,
2143
+ page_start_time: pageStartTime || Date.now()
2144
+ },
2145
+ page_url: window.location.href,
2146
+ page_path: window.location.pathname,
2147
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2148
+ tracker_version: VERSION,
2149
+ idempotency_key: buildIdempotencyKey("time_spent")
1945
2150
  });
1946
2151
  }
1947
2152
  eventQueue?.flushBeacon();
@@ -1964,30 +2169,55 @@ function pageview(customUrl) {
1964
2169
  log("Not initialized, call init() first");
1965
2170
  return;
1966
2171
  }
2172
+ if (!config.apiKey) {
2173
+ log("Missing apiKey, pageview skipped");
2174
+ return;
2175
+ }
1967
2176
  const url = customUrl || window.location.href;
2177
+ const utmParams = extractUTMParams(url);
2178
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2179
+ const idempotencyKey = buildIdempotencyKey("visit");
2180
+ const agenticResult = agenticAnalyzer?.getResult();
2181
+ const pagePath = (() => {
2182
+ try {
2183
+ return new URL(url).pathname;
2184
+ } catch {
2185
+ return window.location.pathname;
2186
+ }
2187
+ })();
1968
2188
  const payload = {
1969
2189
  visitor_id: visitorId,
1970
2190
  session_id: sessionId,
1971
- url,
2191
+ page_url: url,
2192
+ page_path: pagePath,
1972
2193
  referrer: document.referrer || null,
1973
2194
  title: document.title || null,
1974
- utm_source: extractUTMParams(url).utm_source || null,
1975
- utm_medium: extractUTMParams(url).utm_medium || null,
1976
- utm_campaign: extractUTMParams(url).utm_campaign || null,
2195
+ utm_source: utmParams.utm_source || null,
2196
+ utm_medium: utmParams.utm_medium || null,
2197
+ utm_campaign: utmParams.utm_campaign || null,
2198
+ utm_term: utmParams.utm_term || null,
2199
+ utm_content: utmParams.utm_content || null,
1977
2200
  user_agent: navigator.userAgent,
1978
2201
  screen_width: window.screen?.width,
1979
2202
  screen_height: window.screen?.height,
1980
2203
  language: navigator.language,
1981
2204
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
1982
2205
  tracker_version: VERSION,
2206
+ event_type: "pageview",
2207
+ event_data: null,
2208
+ timestamp,
1983
2209
  navigation_timing: navigationTiming,
1984
2210
  ai_platform: aiDetection?.platform || null,
1985
- is_ai_referrer: aiDetection?.isAI || false
2211
+ is_ai_referrer: aiDetection?.isAI || false,
2212
+ agentic_detection: agenticResult || null
1986
2213
  };
2214
+ if (workspaceId) {
2215
+ payload.workspace_id = workspaceId;
2216
+ }
1987
2217
  log("Pageview:", payload);
1988
2218
  safeFetch(endpoint(DEFAULT_CONFIG.endpoints.visit), {
1989
2219
  method: "POST",
1990
- headers: { "Content-Type": "application/json" },
2220
+ headers: buildHeaders(idempotencyKey),
1991
2221
  body: JSON.stringify(payload)
1992
2222
  });
1993
2223
  }
@@ -1996,6 +2226,11 @@ function track(eventName, options = {}) {
1996
2226
  log("Not initialized, call init() first");
1997
2227
  return;
1998
2228
  }
2229
+ if (!config.apiKey) {
2230
+ log("Missing apiKey, event skipped:", eventName);
2231
+ return;
2232
+ }
2233
+ const idempotencyKey = buildIdempotencyKey(`event:${eventName}`);
1999
2234
  const payload = {
2000
2235
  visitor_id: visitorId,
2001
2236
  session_id: sessionId,
@@ -2004,14 +2239,19 @@ function track(eventName, options = {}) {
2004
2239
  properties: options.properties || {},
2005
2240
  revenue: options.revenue,
2006
2241
  currency: options.currency || "USD",
2007
- url: window.location.href,
2242
+ page_url: window.location.href,
2243
+ referrer: document.referrer || null,
2008
2244
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2009
- tracker_version: VERSION
2245
+ tracker_version: VERSION,
2246
+ idempotency_key: idempotencyKey
2010
2247
  };
2248
+ if (workspaceId) {
2249
+ payload.workspace_id = workspaceId;
2250
+ }
2011
2251
  log("Event:", eventName, payload);
2012
2252
  safeFetch(endpoint("/api/ingest/event"), {
2013
2253
  method: "POST",
2014
- headers: { "Content-Type": "application/json" },
2254
+ headers: buildHeaders(idempotencyKey),
2015
2255
  body: JSON.stringify(payload)
2016
2256
  });
2017
2257
  }
@@ -2023,17 +2263,26 @@ function identify(userId, traits = {}) {
2023
2263
  log("Not initialized, call init() first");
2024
2264
  return;
2025
2265
  }
2266
+ if (!config.apiKey) {
2267
+ log("Missing apiKey, identify skipped");
2268
+ return;
2269
+ }
2026
2270
  log("Identify:", userId, traits);
2271
+ const idempotencyKey = buildIdempotencyKey("identify");
2027
2272
  const payload = {
2028
2273
  visitor_id: visitorId,
2029
2274
  session_id: sessionId,
2030
2275
  user_id: userId,
2031
2276
  traits,
2032
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2277
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2278
+ idempotency_key: idempotencyKey
2033
2279
  };
2280
+ if (workspaceId) {
2281
+ payload.workspace_id = workspaceId;
2282
+ }
2034
2283
  safeFetch(endpoint("/api/ingest/identify"), {
2035
2284
  method: "POST",
2036
- headers: { "Content-Type": "application/json" },
2285
+ headers: buildHeaders(idempotencyKey),
2037
2286
  body: JSON.stringify(payload)
2038
2287
  });
2039
2288
  }
@@ -2164,14 +2413,15 @@ function isTrackerInitialized() {
2164
2413
  return initialized;
2165
2414
  }
2166
2415
  function reportHealth(status, errorMessage) {
2167
- if (!config.apiKey) return;
2168
2416
  try {
2169
2417
  const healthData = {
2170
- workspace_id: config.apiKey,
2418
+ workspace_id: workspaceId,
2419
+ visitor_id: visitorId,
2420
+ session_id: sessionId,
2171
2421
  status,
2172
2422
  error_message: errorMessage || null,
2173
- version: VERSION,
2174
- url: typeof window !== "undefined" ? window.location.href : null,
2423
+ tracker_version: VERSION,
2424
+ page_url: typeof window !== "undefined" ? window.location.href : null,
2175
2425
  user_agent: typeof navigator !== "undefined" ? navigator.userAgent : null,
2176
2426
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2177
2427
  features: {
@@ -2289,7 +2539,7 @@ export {
2289
2539
  * See what AI tells your customers — and track when they click.
2290
2540
  *
2291
2541
  * @module @loamly/tracker
2292
- * @version 1.8.0
2542
+ * @version 2.1.0
2293
2543
  * @license MIT
2294
2544
  * @see https://github.com/loamly/loamly
2295
2545
  * @see https://loamly.ai