@grainql/analytics-web 1.7.4 → 2.1.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.
Files changed (258) hide show
  1. package/README.md +71 -718
  2. package/dist/activity.d.ts +59 -0
  3. package/dist/activity.d.ts.map +1 -0
  4. package/dist/cjs/activity.d.ts +59 -0
  5. package/dist/cjs/activity.d.ts.map +1 -0
  6. package/dist/cjs/activity.js +131 -0
  7. package/dist/cjs/activity.js.map +1 -0
  8. package/dist/cjs/consent.d.ts +68 -0
  9. package/dist/cjs/consent.d.ts.map +1 -0
  10. package/dist/cjs/consent.js +191 -0
  11. package/dist/cjs/consent.js.map +1 -0
  12. package/dist/cjs/cookies.d.ts +28 -0
  13. package/dist/cjs/cookies.d.ts.map +1 -0
  14. package/dist/cjs/cookies.js +95 -0
  15. package/dist/cjs/cookies.js.map +1 -0
  16. package/dist/cjs/heartbeat.d.ts +42 -0
  17. package/dist/cjs/heartbeat.d.ts.map +1 -0
  18. package/dist/cjs/heartbeat.js +92 -0
  19. package/dist/cjs/heartbeat.js.map +1 -0
  20. package/dist/cjs/index.d.ts +100 -3
  21. package/dist/cjs/index.d.ts.map +1 -1
  22. package/dist/cjs/index.js.map +1 -1
  23. package/dist/cjs/page-tracking.d.ts +60 -0
  24. package/dist/cjs/page-tracking.d.ts.map +1 -0
  25. package/dist/cjs/page-tracking.js +180 -0
  26. package/dist/cjs/page-tracking.js.map +1 -0
  27. package/dist/cjs/react/GrainProvider.d.ts +11 -0
  28. package/dist/cjs/react/GrainProvider.d.ts.map +1 -0
  29. package/dist/cjs/react/GrainProvider.js +79 -0
  30. package/dist/cjs/react/GrainProvider.js.map +1 -0
  31. package/dist/cjs/react/components/ConsentBanner.d.ts +16 -0
  32. package/dist/cjs/react/components/ConsentBanner.d.ts.map +1 -0
  33. package/dist/cjs/react/components/ConsentBanner.js +112 -0
  34. package/dist/cjs/react/components/ConsentBanner.js.map +1 -0
  35. package/dist/cjs/react/components/CookieNotice.d.ts +12 -0
  36. package/dist/cjs/react/components/CookieNotice.d.ts.map +1 -0
  37. package/dist/cjs/react/components/CookieNotice.js +62 -0
  38. package/dist/cjs/react/components/CookieNotice.js.map +1 -0
  39. package/dist/cjs/react/components/PrivacyPreferenceCenter.d.ts +12 -0
  40. package/dist/cjs/react/components/PrivacyPreferenceCenter.d.ts.map +1 -0
  41. package/dist/cjs/react/components/PrivacyPreferenceCenter.js +120 -0
  42. package/dist/cjs/react/components/PrivacyPreferenceCenter.js.map +1 -0
  43. package/dist/cjs/react/context.d.ts +11 -0
  44. package/dist/cjs/react/context.d.ts.map +1 -0
  45. package/dist/cjs/react/context.js +43 -0
  46. package/dist/cjs/react/context.js.map +1 -0
  47. package/dist/cjs/react/hooks/useAllConfigs.d.ts +8 -0
  48. package/dist/cjs/react/hooks/useAllConfigs.d.ts.map +1 -0
  49. package/dist/cjs/react/hooks/useAllConfigs.js +112 -0
  50. package/dist/cjs/react/hooks/useAllConfigs.js.map +1 -0
  51. package/dist/cjs/react/hooks/useConfig.d.ts +9 -0
  52. package/dist/cjs/react/hooks/useConfig.d.ts.map +1 -0
  53. package/dist/cjs/react/hooks/useConfig.js +116 -0
  54. package/dist/cjs/react/hooks/useConfig.js.map +1 -0
  55. package/dist/cjs/react/hooks/useConsent.d.ts +13 -0
  56. package/dist/cjs/react/hooks/useConsent.d.ts.map +1 -0
  57. package/dist/cjs/react/hooks/useConsent.js +84 -0
  58. package/dist/cjs/react/hooks/useConsent.js.map +1 -0
  59. package/dist/cjs/react/hooks/useDataDeletion.d.ts +17 -0
  60. package/dist/cjs/react/hooks/useDataDeletion.d.ts.map +1 -0
  61. package/dist/cjs/react/hooks/useDataDeletion.js +117 -0
  62. package/dist/cjs/react/hooks/useDataDeletion.js.map +1 -0
  63. package/dist/cjs/react/hooks/useGrainAnalytics.d.ts +6 -0
  64. package/dist/cjs/react/hooks/useGrainAnalytics.d.ts.map +1 -0
  65. package/dist/cjs/react/hooks/useGrainAnalytics.js +50 -0
  66. package/dist/cjs/react/hooks/useGrainAnalytics.js.map +1 -0
  67. package/dist/cjs/react/hooks/usePrivacyPreferences.d.ts +15 -0
  68. package/dist/cjs/react/hooks/usePrivacyPreferences.d.ts.map +1 -0
  69. package/dist/cjs/react/hooks/usePrivacyPreferences.js +82 -0
  70. package/dist/cjs/react/hooks/usePrivacyPreferences.js.map +1 -0
  71. package/dist/cjs/react/hooks/useTrack.d.ts +9 -0
  72. package/dist/cjs/react/hooks/useTrack.d.ts.map +1 -0
  73. package/dist/cjs/react/hooks/useTrack.js +53 -0
  74. package/dist/cjs/react/hooks/useTrack.js.map +1 -0
  75. package/dist/cjs/react/index.d.ts +47 -0
  76. package/dist/cjs/react/index.d.ts.map +1 -0
  77. package/dist/cjs/react/index.js +59 -0
  78. package/dist/cjs/react/index.js.map +1 -0
  79. package/dist/cjs/react/types.d.ts +33 -0
  80. package/dist/cjs/react/types.d.ts.map +1 -0
  81. package/dist/cjs/react/types.js +6 -0
  82. package/dist/cjs/react/types.js.map +1 -0
  83. package/dist/consent.d.ts +68 -0
  84. package/dist/consent.d.ts.map +1 -0
  85. package/dist/cookies.d.ts +28 -0
  86. package/dist/cookies.d.ts.map +1 -0
  87. package/dist/esm/activity.d.ts +59 -0
  88. package/dist/esm/activity.d.ts.map +1 -0
  89. package/dist/esm/activity.js +127 -0
  90. package/dist/esm/activity.js.map +1 -0
  91. package/dist/esm/consent.d.ts +68 -0
  92. package/dist/esm/consent.d.ts.map +1 -0
  93. package/dist/esm/consent.js +187 -0
  94. package/dist/esm/consent.js.map +1 -0
  95. package/dist/esm/cookies.d.ts +28 -0
  96. package/dist/esm/cookies.d.ts.map +1 -0
  97. package/dist/esm/cookies.js +89 -0
  98. package/dist/esm/cookies.js.map +1 -0
  99. package/dist/esm/heartbeat.d.ts +42 -0
  100. package/dist/esm/heartbeat.d.ts.map +1 -0
  101. package/dist/esm/heartbeat.js +88 -0
  102. package/dist/esm/heartbeat.js.map +1 -0
  103. package/dist/esm/index.d.ts +100 -3
  104. package/dist/esm/index.d.ts.map +1 -1
  105. package/dist/esm/index.js.map +1 -1
  106. package/dist/esm/page-tracking.d.ts +60 -0
  107. package/dist/esm/page-tracking.d.ts.map +1 -0
  108. package/dist/esm/page-tracking.js +176 -0
  109. package/dist/esm/page-tracking.js.map +1 -0
  110. package/dist/esm/react/GrainProvider.d.ts +11 -0
  111. package/dist/esm/react/GrainProvider.d.ts.map +1 -0
  112. package/dist/esm/react/GrainProvider.js +43 -0
  113. package/dist/esm/react/GrainProvider.js.map +1 -0
  114. package/dist/esm/react/components/ConsentBanner.d.ts +16 -0
  115. package/dist/esm/react/components/ConsentBanner.d.ts.map +1 -0
  116. package/dist/esm/react/components/ConsentBanner.js +76 -0
  117. package/dist/esm/react/components/ConsentBanner.js.map +1 -0
  118. package/dist/esm/react/components/CookieNotice.d.ts +12 -0
  119. package/dist/esm/react/components/CookieNotice.d.ts.map +1 -0
  120. package/dist/esm/react/components/CookieNotice.js +26 -0
  121. package/dist/esm/react/components/CookieNotice.js.map +1 -0
  122. package/dist/esm/react/components/PrivacyPreferenceCenter.d.ts +12 -0
  123. package/dist/esm/react/components/PrivacyPreferenceCenter.d.ts.map +1 -0
  124. package/dist/esm/react/components/PrivacyPreferenceCenter.js +84 -0
  125. package/dist/esm/react/components/PrivacyPreferenceCenter.js.map +1 -0
  126. package/dist/esm/react/context.d.ts +11 -0
  127. package/dist/esm/react/context.d.ts.map +1 -0
  128. package/dist/esm/react/context.js +7 -0
  129. package/dist/esm/react/context.js.map +1 -0
  130. package/dist/esm/react/hooks/useAllConfigs.d.ts +8 -0
  131. package/dist/esm/react/hooks/useAllConfigs.d.ts.map +1 -0
  132. package/dist/esm/react/hooks/useAllConfigs.js +76 -0
  133. package/dist/esm/react/hooks/useAllConfigs.js.map +1 -0
  134. package/dist/esm/react/hooks/useConfig.d.ts +9 -0
  135. package/dist/esm/react/hooks/useConfig.d.ts.map +1 -0
  136. package/dist/esm/react/hooks/useConfig.js +80 -0
  137. package/dist/esm/react/hooks/useConfig.js.map +1 -0
  138. package/dist/esm/react/hooks/useConsent.d.ts +13 -0
  139. package/dist/esm/react/hooks/useConsent.d.ts.map +1 -0
  140. package/dist/esm/react/hooks/useConsent.js +48 -0
  141. package/dist/esm/react/hooks/useConsent.js.map +1 -0
  142. package/dist/esm/react/hooks/useDataDeletion.d.ts +17 -0
  143. package/dist/esm/react/hooks/useDataDeletion.d.ts.map +1 -0
  144. package/dist/esm/react/hooks/useDataDeletion.js +81 -0
  145. package/dist/esm/react/hooks/useDataDeletion.js.map +1 -0
  146. package/dist/esm/react/hooks/useGrainAnalytics.d.ts +6 -0
  147. package/dist/esm/react/hooks/useGrainAnalytics.d.ts.map +1 -0
  148. package/dist/esm/react/hooks/useGrainAnalytics.js +14 -0
  149. package/dist/esm/react/hooks/useGrainAnalytics.js.map +1 -0
  150. package/dist/esm/react/hooks/usePrivacyPreferences.d.ts +15 -0
  151. package/dist/esm/react/hooks/usePrivacyPreferences.d.ts.map +1 -0
  152. package/dist/esm/react/hooks/usePrivacyPreferences.js +46 -0
  153. package/dist/esm/react/hooks/usePrivacyPreferences.js.map +1 -0
  154. package/dist/esm/react/hooks/useTrack.d.ts +9 -0
  155. package/dist/esm/react/hooks/useTrack.d.ts.map +1 -0
  156. package/dist/esm/react/hooks/useTrack.js +17 -0
  157. package/dist/esm/react/hooks/useTrack.js.map +1 -0
  158. package/dist/esm/react/index.d.ts +47 -0
  159. package/dist/esm/react/index.d.ts.map +1 -0
  160. package/dist/esm/react/index.js +45 -0
  161. package/dist/esm/react/index.js.map +1 -0
  162. package/dist/esm/react/types.d.ts +33 -0
  163. package/dist/esm/react/types.d.ts.map +1 -0
  164. package/dist/esm/react/types.js +5 -0
  165. package/dist/esm/react/types.js.map +1 -0
  166. package/dist/heartbeat.d.ts +42 -0
  167. package/dist/heartbeat.d.ts.map +1 -0
  168. package/dist/index.d.ts +100 -3
  169. package/dist/index.d.ts.map +1 -1
  170. package/dist/index.global.dev.js +903 -12
  171. package/dist/index.global.dev.js.map +3 -3
  172. package/dist/index.global.js +2 -2
  173. package/dist/index.global.js.map +4 -4
  174. package/dist/index.js +321 -11
  175. package/dist/index.mjs +321 -11
  176. package/dist/page-tracking.d.ts +60 -0
  177. package/dist/page-tracking.d.ts.map +1 -0
  178. package/dist/react/activity.d.ts +59 -0
  179. package/dist/react/activity.d.ts.map +1 -0
  180. package/dist/react/activity.js +130 -0
  181. package/dist/react/activity.mjs +126 -0
  182. package/dist/react/consent.d.ts +68 -0
  183. package/dist/react/consent.d.ts.map +1 -0
  184. package/dist/react/consent.js +190 -0
  185. package/dist/react/consent.mjs +186 -0
  186. package/dist/react/cookies.d.ts +28 -0
  187. package/dist/react/cookies.d.ts.map +1 -0
  188. package/dist/react/cookies.js +94 -0
  189. package/dist/react/cookies.mjs +88 -0
  190. package/dist/react/heartbeat.d.ts +42 -0
  191. package/dist/react/heartbeat.d.ts.map +1 -0
  192. package/dist/react/heartbeat.js +91 -0
  193. package/dist/react/heartbeat.mjs +87 -0
  194. package/dist/react/index.d.ts +502 -0
  195. package/dist/react/index.d.ts.map +1 -0
  196. package/dist/react/index.js +1491 -0
  197. package/dist/react/index.mjs +1486 -0
  198. package/dist/react/page-tracking.d.ts +60 -0
  199. package/dist/react/page-tracking.d.ts.map +1 -0
  200. package/dist/react/page-tracking.js +179 -0
  201. package/dist/react/page-tracking.mjs +175 -0
  202. package/dist/react/react/GrainProvider.d.ts +11 -0
  203. package/dist/react/react/GrainProvider.d.ts.map +1 -0
  204. package/dist/react/react/GrainProvider.js +45 -0
  205. package/dist/react/react/GrainProvider.mjs +42 -0
  206. package/dist/react/react/components/ConsentBanner.d.ts +16 -0
  207. package/dist/react/react/components/ConsentBanner.d.ts.map +1 -0
  208. package/dist/react/react/components/ConsentBanner.js +78 -0
  209. package/dist/react/react/components/ConsentBanner.mjs +75 -0
  210. package/dist/react/react/components/CookieNotice.d.ts +12 -0
  211. package/dist/react/react/components/CookieNotice.d.ts.map +1 -0
  212. package/dist/react/react/components/CookieNotice.js +28 -0
  213. package/dist/react/react/components/CookieNotice.mjs +25 -0
  214. package/dist/react/react/components/PrivacyPreferenceCenter.d.ts +12 -0
  215. package/dist/react/react/components/PrivacyPreferenceCenter.d.ts.map +1 -0
  216. package/dist/react/react/components/PrivacyPreferenceCenter.js +86 -0
  217. package/dist/react/react/components/PrivacyPreferenceCenter.mjs +83 -0
  218. package/dist/react/react/context.d.ts +11 -0
  219. package/dist/react/react/context.d.ts.map +1 -0
  220. package/dist/react/react/context.js +9 -0
  221. package/dist/react/react/context.mjs +6 -0
  222. package/dist/react/react/hooks/useAllConfigs.d.ts +8 -0
  223. package/dist/react/react/hooks/useAllConfigs.d.ts.map +1 -0
  224. package/dist/react/react/hooks/useAllConfigs.js +78 -0
  225. package/dist/react/react/hooks/useAllConfigs.mjs +75 -0
  226. package/dist/react/react/hooks/useConfig.d.ts +9 -0
  227. package/dist/react/react/hooks/useConfig.d.ts.map +1 -0
  228. package/dist/react/react/hooks/useConfig.js +82 -0
  229. package/dist/react/react/hooks/useConfig.mjs +79 -0
  230. package/dist/react/react/hooks/useConsent.d.ts +13 -0
  231. package/dist/react/react/hooks/useConsent.d.ts.map +1 -0
  232. package/dist/react/react/hooks/useConsent.js +50 -0
  233. package/dist/react/react/hooks/useConsent.mjs +47 -0
  234. package/dist/react/react/hooks/useDataDeletion.d.ts +17 -0
  235. package/dist/react/react/hooks/useDataDeletion.d.ts.map +1 -0
  236. package/dist/react/react/hooks/useDataDeletion.js +83 -0
  237. package/dist/react/react/hooks/useDataDeletion.mjs +80 -0
  238. package/dist/react/react/hooks/useGrainAnalytics.d.ts +6 -0
  239. package/dist/react/react/hooks/useGrainAnalytics.d.ts.map +1 -0
  240. package/dist/react/react/hooks/useGrainAnalytics.js +16 -0
  241. package/dist/react/react/hooks/useGrainAnalytics.mjs +13 -0
  242. package/dist/react/react/hooks/usePrivacyPreferences.d.ts +15 -0
  243. package/dist/react/react/hooks/usePrivacyPreferences.d.ts.map +1 -0
  244. package/dist/react/react/hooks/usePrivacyPreferences.js +48 -0
  245. package/dist/react/react/hooks/usePrivacyPreferences.mjs +45 -0
  246. package/dist/react/react/hooks/useTrack.d.ts +9 -0
  247. package/dist/react/react/hooks/useTrack.d.ts.map +1 -0
  248. package/dist/react/react/hooks/useTrack.js +19 -0
  249. package/dist/react/react/hooks/useTrack.mjs +16 -0
  250. package/dist/react/react/index.d.ts +47 -0
  251. package/dist/react/react/index.d.ts.map +1 -0
  252. package/dist/react/react/index.js +58 -0
  253. package/dist/react/react/index.mjs +44 -0
  254. package/dist/react/react/types.d.ts +33 -0
  255. package/dist/react/react/types.d.ts.map +1 -0
  256. package/dist/react/react/types.js +5 -0
  257. package/dist/react/react/types.mjs +4 -0
  258. package/package.json +20 -2
@@ -0,0 +1,1491 @@
1
+ "use strict";
2
+ /**
3
+ * Grain Analytics Web SDK
4
+ * A lightweight, dependency-free TypeScript SDK for sending analytics events to Grain's REST API
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.GrainAnalytics = void 0;
8
+ exports.createGrainAnalytics = createGrainAnalytics;
9
+ const consent_1 = require("./consent");
10
+ const cookies_1 = require("./cookies");
11
+ const activity_1 = require("./activity");
12
+ const heartbeat_1 = require("./heartbeat");
13
+ const page_tracking_1 = require("./page-tracking");
14
+ class GrainAnalytics {
15
+ constructor(config) {
16
+ this.eventQueue = [];
17
+ this.waitingForConsentQueue = [];
18
+ this.flushTimer = null;
19
+ this.isDestroyed = false;
20
+ this.globalUserId = null;
21
+ this.persistentAnonymousUserId = null;
22
+ // Remote Config properties
23
+ this.configCache = null;
24
+ this.configRefreshTimer = null;
25
+ this.configChangeListeners = [];
26
+ this.configFetchPromise = null;
27
+ this.cookiesEnabled = false;
28
+ // Automatic Tracking properties
29
+ this.activityDetector = null;
30
+ this.heartbeatManager = null;
31
+ this.pageTrackingManager = null;
32
+ this.ephemeralSessionId = null;
33
+ this.eventCountSinceLastHeartbeat = 0;
34
+ this.config = {
35
+ apiUrl: 'https://api.grainql.com',
36
+ authStrategy: 'NONE',
37
+ batchSize: 50,
38
+ flushInterval: 5000, // 5 seconds
39
+ retryAttempts: 3,
40
+ retryDelay: 1000, // 1 second
41
+ maxEventsPerRequest: 160, // Maximum events per API request
42
+ debug: false,
43
+ // Remote Config defaults
44
+ defaultConfigurations: {},
45
+ configCacheKey: 'grain_config',
46
+ configRefreshInterval: 300000, // 5 minutes
47
+ enableConfigCache: true,
48
+ // Privacy defaults
49
+ consentMode: 'opt-out',
50
+ waitForConsent: false,
51
+ enableCookies: false,
52
+ anonymizeIP: false,
53
+ disableAutoProperties: false,
54
+ // Automatic Tracking defaults
55
+ enableHeartbeat: true,
56
+ heartbeatActiveInterval: 120000, // 2 minutes
57
+ heartbeatInactiveInterval: 300000, // 5 minutes
58
+ enableAutoPageView: true,
59
+ stripQueryParams: true,
60
+ ...config,
61
+ tenantId: config.tenantId,
62
+ };
63
+ // Initialize consent manager
64
+ this.consentManager = new consent_1.ConsentManager(this.config.tenantId, this.config.consentMode);
65
+ // Check if cookies are enabled
66
+ if (this.config.enableCookies) {
67
+ this.cookiesEnabled = (0, cookies_1.areCookiesEnabled)();
68
+ if (!this.cookiesEnabled && this.config.debug) {
69
+ console.warn('[Grain Analytics] Cookies are not available, falling back to localStorage');
70
+ }
71
+ }
72
+ // Set global userId if provided in config
73
+ if (config.userId) {
74
+ this.globalUserId = config.userId;
75
+ }
76
+ this.validateConfig();
77
+ this.initializePersistentAnonymousUserId();
78
+ this.setupBeforeUnload();
79
+ this.startFlushTimer();
80
+ this.initializeConfigCache();
81
+ // Initialize ephemeral session ID (memory-only, not persisted)
82
+ this.ephemeralSessionId = this.generateUUID();
83
+ // Initialize automatic tracking (browser only)
84
+ if (typeof window !== 'undefined') {
85
+ this.initializeAutomaticTracking();
86
+ }
87
+ // Set up consent change listener to flush waiting events and handle consent upgrade
88
+ this.consentManager.addListener((state) => {
89
+ if (state.granted) {
90
+ this.handleConsentGranted();
91
+ }
92
+ });
93
+ }
94
+ validateConfig() {
95
+ if (!this.config.tenantId) {
96
+ throw new Error('Grain Analytics: tenantId is required');
97
+ }
98
+ if (this.config.authStrategy === 'SERVER_SIDE' && !this.config.secretKey) {
99
+ throw new Error('Grain Analytics: secretKey is required for SERVER_SIDE auth strategy');
100
+ }
101
+ if (this.config.authStrategy === 'JWT' && !this.config.authProvider) {
102
+ throw new Error('Grain Analytics: authProvider is required for JWT auth strategy');
103
+ }
104
+ }
105
+ /**
106
+ * Generate a UUID v4 string
107
+ */
108
+ generateUUID() {
109
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
110
+ return crypto.randomUUID();
111
+ }
112
+ // Fallback for environments without crypto.randomUUID
113
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
114
+ const r = Math.random() * 16 | 0;
115
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
116
+ return v.toString(16);
117
+ });
118
+ }
119
+ /**
120
+ * Generate a proper UUIDv4 identifier for anonymous user ID
121
+ */
122
+ generateAnonymousUserId() {
123
+ return this.generateUUID();
124
+ }
125
+ /**
126
+ * Initialize persistent anonymous user ID from cookies or localStorage
127
+ * Priority: Cookie → localStorage → generate new
128
+ */
129
+ initializePersistentAnonymousUserId() {
130
+ if (typeof window === 'undefined')
131
+ return;
132
+ const storageKey = `grain_anonymous_user_id_${this.config.tenantId}`;
133
+ const cookieName = '_grain_uid';
134
+ try {
135
+ // Try to load from cookie first if enabled
136
+ if (this.cookiesEnabled) {
137
+ const cookieValue = (0, cookies_1.getCookie)(cookieName);
138
+ if (cookieValue) {
139
+ this.persistentAnonymousUserId = cookieValue;
140
+ this.log('Loaded persistent anonymous user ID from cookie:', this.persistentAnonymousUserId);
141
+ return;
142
+ }
143
+ }
144
+ // Fallback to localStorage
145
+ const stored = localStorage.getItem(storageKey);
146
+ if (stored) {
147
+ this.persistentAnonymousUserId = stored;
148
+ this.log('Loaded persistent anonymous user ID from localStorage:', this.persistentAnonymousUserId);
149
+ // Migrate to cookie if enabled
150
+ if (this.cookiesEnabled) {
151
+ this.savePersistentAnonymousUserId(stored);
152
+ }
153
+ }
154
+ else {
155
+ // Generate new UUIDv4 anonymous user ID
156
+ this.persistentAnonymousUserId = this.generateAnonymousUserId();
157
+ this.savePersistentAnonymousUserId(this.persistentAnonymousUserId);
158
+ this.log('Generated new persistent anonymous user ID:', this.persistentAnonymousUserId);
159
+ }
160
+ }
161
+ catch (error) {
162
+ this.log('Failed to initialize persistent anonymous user ID:', error);
163
+ // Fallback: generate temporary ID without persistence
164
+ this.persistentAnonymousUserId = this.generateAnonymousUserId();
165
+ }
166
+ }
167
+ /**
168
+ * Save persistent anonymous user ID to cookie and/or localStorage
169
+ */
170
+ savePersistentAnonymousUserId(userId) {
171
+ if (typeof window === 'undefined')
172
+ return;
173
+ const storageKey = `grain_anonymous_user_id_${this.config.tenantId}`;
174
+ const cookieName = '_grain_uid';
175
+ try {
176
+ // Save to cookie if enabled
177
+ if (this.cookiesEnabled) {
178
+ const cookieOptions = {
179
+ maxAge: 365 * 24 * 60 * 60, // 365 days
180
+ sameSite: 'lax',
181
+ secure: window.location.protocol === 'https:',
182
+ ...this.config.cookieOptions,
183
+ };
184
+ (0, cookies_1.setCookie)(cookieName, userId, cookieOptions);
185
+ }
186
+ // Always save to localStorage as fallback
187
+ localStorage.setItem(storageKey, userId);
188
+ }
189
+ catch (error) {
190
+ this.log('Failed to save persistent anonymous user ID:', error);
191
+ }
192
+ }
193
+ /**
194
+ * Get the effective user ID (global userId or persistent anonymous ID)
195
+ */
196
+ getEffectiveUserIdInternal() {
197
+ if (this.globalUserId) {
198
+ return this.globalUserId;
199
+ }
200
+ if (this.persistentAnonymousUserId) {
201
+ return this.persistentAnonymousUserId;
202
+ }
203
+ // Generate a new UUIDv4 identifier as fallback
204
+ this.persistentAnonymousUserId = this.generateAnonymousUserId();
205
+ // Try to persist it
206
+ if (typeof window !== 'undefined') {
207
+ try {
208
+ const storageKey = `grain_anonymous_user_id_${this.config.tenantId}`;
209
+ localStorage.setItem(storageKey, this.persistentAnonymousUserId);
210
+ }
211
+ catch (error) {
212
+ this.log('Failed to persist generated anonymous user ID:', error);
213
+ }
214
+ }
215
+ return this.persistentAnonymousUserId;
216
+ }
217
+ log(...args) {
218
+ if (this.config.debug) {
219
+ console.log('[Grain Analytics]', ...args);
220
+ }
221
+ }
222
+ /**
223
+ * Create error digest from events
224
+ */
225
+ createErrorDigest(events) {
226
+ const eventNames = [...new Set(events.map(e => e.eventName))];
227
+ const userIds = [...new Set(events.map(e => e.userId))];
228
+ let totalProperties = 0;
229
+ let totalSize = 0;
230
+ events.forEach(event => {
231
+ const properties = event.properties || {};
232
+ totalProperties += Object.keys(properties).length;
233
+ totalSize += JSON.stringify(event).length;
234
+ });
235
+ return {
236
+ eventCount: events.length,
237
+ totalProperties,
238
+ totalSize,
239
+ eventNames,
240
+ userIds,
241
+ };
242
+ }
243
+ /**
244
+ * Format error with beautiful structure
245
+ */
246
+ formatError(error, context, events) {
247
+ const digest = events ? this.createErrorDigest(events) : {
248
+ eventCount: 0,
249
+ totalProperties: 0,
250
+ totalSize: 0,
251
+ eventNames: [],
252
+ userIds: [],
253
+ };
254
+ let code = 'UNKNOWN_ERROR';
255
+ let message = 'An unknown error occurred';
256
+ if (error instanceof Error) {
257
+ message = error.message;
258
+ // Determine error code based on error type and message
259
+ if (message.includes('fetch failed') || message.includes('network error')) {
260
+ code = 'NETWORK_ERROR';
261
+ }
262
+ else if (message.includes('timeout')) {
263
+ code = 'TIMEOUT_ERROR';
264
+ }
265
+ else if (message.includes('HTTP 4')) {
266
+ code = 'CLIENT_ERROR';
267
+ }
268
+ else if (message.includes('HTTP 5')) {
269
+ code = 'SERVER_ERROR';
270
+ }
271
+ else if (message.includes('JSON')) {
272
+ code = 'PARSE_ERROR';
273
+ }
274
+ else if (message.includes('auth') || message.includes('unauthorized')) {
275
+ code = 'AUTH_ERROR';
276
+ }
277
+ else if (message.includes('rate limit') || message.includes('429')) {
278
+ code = 'RATE_LIMIT_ERROR';
279
+ }
280
+ else {
281
+ code = 'GENERAL_ERROR';
282
+ }
283
+ }
284
+ else if (typeof error === 'string') {
285
+ message = error;
286
+ code = 'STRING_ERROR';
287
+ }
288
+ else if (error && typeof error === 'object' && 'status' in error) {
289
+ const status = error.status;
290
+ code = `HTTP_${status}`;
291
+ message = `HTTP ${status} error`;
292
+ }
293
+ return {
294
+ code,
295
+ message,
296
+ digest,
297
+ timestamp: new Date().toISOString(),
298
+ context,
299
+ originalError: error,
300
+ };
301
+ }
302
+ /**
303
+ * Log formatted error gracefully
304
+ */
305
+ logError(formattedError) {
306
+ const { code, message, digest, timestamp, context } = formattedError;
307
+ const errorOutput = {
308
+ '🚨 Grain Analytics Error': {
309
+ 'Error Code': code,
310
+ 'Message': message,
311
+ 'Context': context,
312
+ 'Timestamp': timestamp,
313
+ 'Event Digest': {
314
+ 'Events': digest.eventCount,
315
+ 'Properties': digest.totalProperties,
316
+ 'Size (bytes)': digest.totalSize,
317
+ 'Event Names': digest.eventNames.length > 0 ? digest.eventNames.join(', ') : 'None',
318
+ 'User IDs': digest.userIds.length > 0 ? digest.userIds.slice(0, 3).join(', ') + (digest.userIds.length > 3 ? '...' : '') : 'None',
319
+ }
320
+ }
321
+ };
322
+ console.error('🚨 Grain Analytics Error:', errorOutput);
323
+ // Also log in a more compact format for debugging
324
+ if (this.config.debug) {
325
+ console.error(`[Grain Analytics] ${code}: ${message} (${context}) - Events: ${digest.eventCount}, Props: ${digest.totalProperties}, Size: ${digest.totalSize}B`);
326
+ }
327
+ }
328
+ /**
329
+ * Safely execute a function with error handling
330
+ */
331
+ async safeExecute(fn, context, events) {
332
+ try {
333
+ return await fn();
334
+ }
335
+ catch (error) {
336
+ const formattedError = this.formatError(error, context, events);
337
+ this.logError(formattedError);
338
+ return null;
339
+ }
340
+ }
341
+ formatEvent(event) {
342
+ return {
343
+ eventName: event.eventName,
344
+ userId: event.userId || this.getEffectiveUserIdInternal(),
345
+ properties: event.properties || {},
346
+ };
347
+ }
348
+ async getAuthHeaders() {
349
+ const headers = {
350
+ 'Content-Type': 'application/json',
351
+ };
352
+ switch (this.config.authStrategy) {
353
+ case 'NONE':
354
+ break;
355
+ case 'SERVER_SIDE':
356
+ headers['Authorization'] = `Chase ${this.config.secretKey}`;
357
+ break;
358
+ case 'JWT':
359
+ if (this.config.authProvider) {
360
+ const token = await this.config.authProvider.getToken();
361
+ headers['Authorization'] = `Bearer ${token}`;
362
+ }
363
+ break;
364
+ }
365
+ return headers;
366
+ }
367
+ async delay(ms) {
368
+ return new Promise(resolve => setTimeout(resolve, ms));
369
+ }
370
+ isRetriableError(error) {
371
+ if (error instanceof Error) {
372
+ // Check for specific network or fetch errors
373
+ const message = error.message.toLowerCase();
374
+ if (message.includes('fetch failed'))
375
+ return true;
376
+ if (message === 'network error')
377
+ return true; // Exact match to avoid "Non-network error"
378
+ if (message.includes('timeout'))
379
+ return true;
380
+ if (message.includes('connection'))
381
+ return true;
382
+ }
383
+ // Check for HTTP status codes that are retriable
384
+ if (typeof error === 'object' && error !== null && 'status' in error) {
385
+ const status = error.status;
386
+ return status >= 500 || status === 429; // Server errors or rate limiting
387
+ }
388
+ return false;
389
+ }
390
+ async sendEvents(events) {
391
+ if (events.length === 0)
392
+ return;
393
+ let lastError;
394
+ for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
395
+ try {
396
+ const headers = await this.getAuthHeaders();
397
+ const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/multi`;
398
+ this.log(`Sending ${events.length} events to ${url} (attempt ${attempt + 1})`);
399
+ const response = await fetch(url, {
400
+ method: 'POST',
401
+ headers,
402
+ body: JSON.stringify(events),
403
+ });
404
+ if (!response.ok) {
405
+ let errorMessage = `HTTP ${response.status}`;
406
+ try {
407
+ const errorBody = await response.json();
408
+ if (errorBody?.message) {
409
+ errorMessage = errorBody.message;
410
+ }
411
+ }
412
+ catch {
413
+ const errorText = await response.text();
414
+ if (errorText) {
415
+ errorMessage = errorText;
416
+ }
417
+ }
418
+ const error = new Error(`Failed to send events: ${errorMessage}`);
419
+ error.status = response.status;
420
+ throw error;
421
+ }
422
+ this.log(`Successfully sent ${events.length} events`);
423
+ return; // Success, exit retry loop
424
+ }
425
+ catch (error) {
426
+ lastError = error;
427
+ if (attempt === this.config.retryAttempts) {
428
+ // Last attempt, don't retry - log error gracefully
429
+ const formattedError = this.formatError(error, `sendEvents (attempt ${attempt + 1}/${this.config.retryAttempts + 1})`, events);
430
+ this.logError(formattedError);
431
+ return; // Don't throw, just return gracefully
432
+ }
433
+ if (!this.isRetriableError(error)) {
434
+ // Non-retriable error, don't retry - log error gracefully
435
+ const formattedError = this.formatError(error, `sendEvents (non-retriable error)`, events);
436
+ this.logError(formattedError);
437
+ return; // Don't throw, just return gracefully
438
+ }
439
+ const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff
440
+ this.log(`Retrying in ${delayMs}ms after error:`, error);
441
+ await this.delay(delayMs);
442
+ }
443
+ }
444
+ }
445
+ async sendEventsWithBeacon(events) {
446
+ if (events.length === 0)
447
+ return;
448
+ try {
449
+ const headers = await this.getAuthHeaders();
450
+ const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/multi`;
451
+ const body = JSON.stringify({ events });
452
+ // Try beacon API first (more reliable for page unload)
453
+ if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) {
454
+ const blob = new Blob([body], { type: 'application/json' });
455
+ const success = navigator.sendBeacon(url, blob);
456
+ if (success) {
457
+ this.log(`Successfully sent ${events.length} events via beacon`);
458
+ return;
459
+ }
460
+ }
461
+ // Fallback to fetch with keepalive
462
+ await fetch(url, {
463
+ method: 'POST',
464
+ headers,
465
+ body,
466
+ keepalive: true,
467
+ });
468
+ this.log(`Successfully sent ${events.length} events via fetch (keepalive)`);
469
+ }
470
+ catch (error) {
471
+ // Log error gracefully for beacon failures (page unload scenarios)
472
+ const formattedError = this.formatError(error, 'sendEventsWithBeacon', events);
473
+ this.logError(formattedError);
474
+ }
475
+ }
476
+ startFlushTimer() {
477
+ if (this.flushTimer) {
478
+ clearInterval(this.flushTimer);
479
+ }
480
+ this.flushTimer = window.setInterval(() => {
481
+ if (this.eventQueue.length > 0) {
482
+ this.flush().catch((error) => {
483
+ const formattedError = this.formatError(error, 'auto-flush');
484
+ this.logError(formattedError);
485
+ });
486
+ }
487
+ }, this.config.flushInterval);
488
+ }
489
+ setupBeforeUnload() {
490
+ if (typeof window === 'undefined')
491
+ return;
492
+ const handleBeforeUnload = () => {
493
+ if (this.eventQueue.length > 0) {
494
+ // Use beacon API for reliable delivery during page unload
495
+ const eventsToSend = [...this.eventQueue];
496
+ this.eventQueue = [];
497
+ const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);
498
+ // Send first chunk with beacon (most important for page unload)
499
+ if (chunks.length > 0) {
500
+ this.sendEventsWithBeacon(chunks[0]).catch(() => {
501
+ // Silently fail - page is unloading
502
+ });
503
+ }
504
+ }
505
+ };
506
+ // Handle page unload
507
+ window.addEventListener('beforeunload', handleBeforeUnload);
508
+ window.addEventListener('pagehide', handleBeforeUnload);
509
+ // Handle visibility change (page hidden)
510
+ document.addEventListener('visibilitychange', () => {
511
+ if (document.visibilityState === 'hidden' && this.eventQueue.length > 0) {
512
+ const eventsToSend = [...this.eventQueue];
513
+ this.eventQueue = [];
514
+ const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);
515
+ // Send first chunk with beacon (most important for page hidden)
516
+ if (chunks.length > 0) {
517
+ this.sendEventsWithBeacon(chunks[0]).catch(() => {
518
+ // Silently fail
519
+ });
520
+ }
521
+ }
522
+ });
523
+ }
524
+ /**
525
+ * Initialize automatic tracking (heartbeat and page views)
526
+ */
527
+ initializeAutomaticTracking() {
528
+ if (this.config.enableHeartbeat) {
529
+ try {
530
+ this.activityDetector = new activity_1.ActivityDetector();
531
+ this.heartbeatManager = new heartbeat_1.HeartbeatManager(this, this.activityDetector, {
532
+ activeInterval: this.config.heartbeatActiveInterval,
533
+ inactiveInterval: this.config.heartbeatInactiveInterval,
534
+ debug: this.config.debug,
535
+ });
536
+ this.log('Heartbeat tracking initialized');
537
+ }
538
+ catch (error) {
539
+ this.log('Failed to initialize heartbeat tracking:', error);
540
+ }
541
+ }
542
+ if (this.config.enableAutoPageView) {
543
+ try {
544
+ this.pageTrackingManager = new page_tracking_1.PageTrackingManager(this, {
545
+ stripQueryParams: this.config.stripQueryParams,
546
+ debug: this.config.debug,
547
+ });
548
+ this.log('Auto page view tracking initialized');
549
+ }
550
+ catch (error) {
551
+ this.log('Failed to initialize page view tracking:', error);
552
+ }
553
+ }
554
+ }
555
+ /**
556
+ * Handle consent granted - upgrade ephemeral session to persistent user
557
+ */
558
+ handleConsentGranted() {
559
+ this.flushWaitingForConsentQueue();
560
+ // Track consent granted event with mapping
561
+ if (this.ephemeralSessionId) {
562
+ this.trackSystemEvent('_grain_consent_granted', {
563
+ previous_session_id: this.ephemeralSessionId,
564
+ new_user_id: this.getEffectiveUserId(),
565
+ timestamp: Date.now(),
566
+ });
567
+ }
568
+ }
569
+ /**
570
+ * Track system events that bypass consent checks (for necessary/functional tracking)
571
+ */
572
+ trackSystemEvent(eventName, properties) {
573
+ if (this.isDestroyed)
574
+ return;
575
+ const hasConsent = this.consentManager.hasConsent('analytics');
576
+ // Create event with appropriate user ID
577
+ const event = {
578
+ eventName,
579
+ userId: hasConsent ? this.getEffectiveUserId() : this.getEphemeralSessionId(),
580
+ properties: {
581
+ ...properties,
582
+ _minimal: !hasConsent, // Flag to indicate minimal tracking
583
+ _consent_status: hasConsent ? 'granted' : 'pending',
584
+ },
585
+ };
586
+ // Bypass consent check for necessary system events
587
+ this.eventQueue.push(event);
588
+ this.eventCountSinceLastHeartbeat++;
589
+ this.log(`Queued system event: ${eventName}`, properties);
590
+ // Consider flushing
591
+ if (this.eventQueue.length >= this.config.batchSize) {
592
+ this.flush().catch((error) => {
593
+ const formattedError = this.formatError(error, 'flush system event');
594
+ this.logError(formattedError);
595
+ });
596
+ }
597
+ }
598
+ /**
599
+ * Get ephemeral session ID (memory-only, not persisted)
600
+ */
601
+ getEphemeralSessionId() {
602
+ if (!this.ephemeralSessionId) {
603
+ this.ephemeralSessionId = this.generateUUID();
604
+ }
605
+ return this.ephemeralSessionId;
606
+ }
607
+ /**
608
+ * Get the current page path from page tracker
609
+ */
610
+ getCurrentPage() {
611
+ return this.pageTrackingManager?.getCurrentPage() || null;
612
+ }
613
+ /**
614
+ * Get event count since last heartbeat
615
+ */
616
+ getEventCountSinceLastHeartbeat() {
617
+ return this.eventCountSinceLastHeartbeat;
618
+ }
619
+ /**
620
+ * Reset event count since last heartbeat
621
+ */
622
+ resetEventCountSinceLastHeartbeat() {
623
+ this.eventCountSinceLastHeartbeat = 0;
624
+ }
625
+ /**
626
+ * Get the effective user ID (public method)
627
+ */
628
+ getEffectiveUserId() {
629
+ return this.getEffectiveUserIdInternal();
630
+ }
631
+ /**
632
+ * Get the session ID (ephemeral or persistent based on consent)
633
+ */
634
+ getSessionId() {
635
+ const hasConsent = this.consentManager.hasConsent('analytics');
636
+ return hasConsent ? this.getEffectiveUserId() : this.getEphemeralSessionId();
637
+ }
638
+ async track(eventOrName, propertiesOrOptions, options) {
639
+ try {
640
+ if (this.isDestroyed) {
641
+ const error = new Error('Grain Analytics: Client has been destroyed');
642
+ const formattedError = this.formatError(error, 'track (client destroyed)');
643
+ this.logError(formattedError);
644
+ return;
645
+ }
646
+ let event;
647
+ let opts = {};
648
+ if (typeof eventOrName === 'string') {
649
+ event = {
650
+ eventName: eventOrName,
651
+ properties: propertiesOrOptions,
652
+ };
653
+ opts = options || {};
654
+ }
655
+ else {
656
+ event = eventOrName;
657
+ opts = propertiesOrOptions || {};
658
+ }
659
+ // Filter properties if whitelist is enabled
660
+ if (this.config.allowedProperties && event.properties) {
661
+ const filtered = {};
662
+ for (const key of this.config.allowedProperties) {
663
+ if (key in event.properties) {
664
+ filtered[key] = event.properties[key];
665
+ }
666
+ }
667
+ event.properties = filtered;
668
+ }
669
+ const formattedEvent = this.formatEvent(event);
670
+ // Check consent before tracking
671
+ if (this.consentManager.shouldWaitForConsent() && this.config.waitForConsent) {
672
+ // Queue event until consent is granted
673
+ this.waitingForConsentQueue.push(formattedEvent);
674
+ this.log(`Event waiting for consent: ${event.eventName}`, event.properties);
675
+ return;
676
+ }
677
+ if (!this.consentManager.hasConsent('analytics')) {
678
+ this.log(`Event blocked by consent: ${event.eventName}`);
679
+ return;
680
+ }
681
+ this.eventQueue.push(formattedEvent);
682
+ this.eventCountSinceLastHeartbeat++;
683
+ this.log(`Queued event: ${event.eventName}`, event.properties);
684
+ // Check if we should flush immediately
685
+ if (opts.flush || this.eventQueue.length >= this.config.batchSize) {
686
+ await this.flush();
687
+ }
688
+ }
689
+ catch (error) {
690
+ const formattedError = this.formatError(error, 'track');
691
+ this.logError(formattedError);
692
+ }
693
+ }
694
+ /**
695
+ * Flush events that were waiting for consent
696
+ */
697
+ flushWaitingForConsentQueue() {
698
+ if (this.waitingForConsentQueue.length === 0)
699
+ return;
700
+ this.log(`Flushing ${this.waitingForConsentQueue.length} events waiting for consent`);
701
+ // Move waiting events to main queue
702
+ this.eventQueue.push(...this.waitingForConsentQueue);
703
+ this.waitingForConsentQueue = [];
704
+ // Flush immediately
705
+ this.flush().catch((error) => {
706
+ const formattedError = this.formatError(error, 'flush waiting for consent queue');
707
+ this.logError(formattedError);
708
+ });
709
+ }
710
+ /**
711
+ * Identify a user (sets userId for subsequent events)
712
+ */
713
+ identify(userId) {
714
+ this.log(`Identified user: ${userId}`);
715
+ this.globalUserId = userId;
716
+ // Clear persistent anonymous user ID since we now have a real user ID
717
+ this.persistentAnonymousUserId = null;
718
+ }
719
+ /**
720
+ * Set global user ID for all subsequent events
721
+ */
722
+ setUserId(userId) {
723
+ this.log(`Set global user ID: ${userId}`);
724
+ this.globalUserId = userId;
725
+ if (userId) {
726
+ // Clear persistent anonymous user ID if setting a real user ID
727
+ this.persistentAnonymousUserId = null;
728
+ }
729
+ else {
730
+ // If clearing user ID, ensure we have a UUIDv4 identifier
731
+ if (!this.persistentAnonymousUserId) {
732
+ this.persistentAnonymousUserId = this.generateAnonymousUserId();
733
+ // Try to persist the new anonymous ID
734
+ if (typeof window !== 'undefined') {
735
+ try {
736
+ const storageKey = `grain_anonymous_user_id_${this.config.tenantId}`;
737
+ localStorage.setItem(storageKey, this.persistentAnonymousUserId);
738
+ }
739
+ catch (error) {
740
+ this.log('Failed to persist new anonymous user ID:', error);
741
+ }
742
+ }
743
+ }
744
+ }
745
+ }
746
+ /**
747
+ * Get current global user ID
748
+ */
749
+ getUserId() {
750
+ return this.globalUserId;
751
+ }
752
+ /**
753
+ * Get current effective user ID (global userId or persistent anonymous ID)
754
+ */
755
+ getEffectiveUserIdPublic() {
756
+ return this.getEffectiveUserIdInternal();
757
+ }
758
+ /**
759
+ * Login with auth token or userId on the fly
760
+ *
761
+ * @example
762
+ * // Login with userId only
763
+ * client.login({ userId: 'user123' });
764
+ *
765
+ * // Login with auth token (automatically sets authStrategy to JWT)
766
+ * client.login({ authToken: 'jwt-token-here' });
767
+ *
768
+ * // Login with both userId and auth token
769
+ * client.login({ userId: 'user123', authToken: 'jwt-token-here' });
770
+ *
771
+ * // Override auth strategy
772
+ * client.login({ userId: 'user123', authStrategy: 'SERVER_SIDE' });
773
+ */
774
+ login(options) {
775
+ try {
776
+ if (this.isDestroyed) {
777
+ const error = new Error('Grain Analytics: Client has been destroyed');
778
+ const formattedError = this.formatError(error, 'login (client destroyed)');
779
+ this.logError(formattedError);
780
+ return;
781
+ }
782
+ // Set userId if provided
783
+ if (options.userId) {
784
+ this.log(`Login: Setting user ID to ${options.userId}`);
785
+ this.globalUserId = options.userId;
786
+ // Clear persistent anonymous user ID since we now have a real user ID
787
+ this.persistentAnonymousUserId = null;
788
+ }
789
+ // Handle auth token if provided
790
+ if (options.authToken) {
791
+ this.log('Login: Setting auth token');
792
+ // Update auth strategy to JWT if not already set
793
+ if (this.config.authStrategy === 'NONE') {
794
+ this.config.authStrategy = 'JWT';
795
+ }
796
+ // Create a simple auth provider that returns the provided token
797
+ this.config.authProvider = {
798
+ getToken: () => options.authToken
799
+ };
800
+ }
801
+ // Override auth strategy if provided
802
+ if (options.authStrategy) {
803
+ this.log(`Login: Setting auth strategy to ${options.authStrategy}`);
804
+ this.config.authStrategy = options.authStrategy;
805
+ }
806
+ this.log(`Login successful. Effective user ID: ${this.getEffectiveUserIdInternal()}`);
807
+ }
808
+ catch (error) {
809
+ const formattedError = this.formatError(error, 'login');
810
+ this.logError(formattedError);
811
+ }
812
+ }
813
+ /**
814
+ * Logout and clear user session, fall back to UUIDv4 identifier
815
+ *
816
+ * @example
817
+ * // Logout user and return to anonymous mode
818
+ * client.logout();
819
+ *
820
+ * // After logout, events will use the persistent UUIDv4 identifier
821
+ * client.track('page_view', { page: 'home' });
822
+ */
823
+ logout() {
824
+ try {
825
+ if (this.isDestroyed) {
826
+ const error = new Error('Grain Analytics: Client has been destroyed');
827
+ const formattedError = this.formatError(error, 'logout (client destroyed)');
828
+ this.logError(formattedError);
829
+ return;
830
+ }
831
+ this.log('Logout: Clearing user session');
832
+ // Clear global user ID
833
+ this.globalUserId = null;
834
+ // Reset auth strategy to NONE
835
+ this.config.authStrategy = 'NONE';
836
+ this.config.authProvider = undefined;
837
+ // Generate new UUIDv4 identifier if we don't have one
838
+ if (!this.persistentAnonymousUserId) {
839
+ this.persistentAnonymousUserId = this.generateAnonymousUserId();
840
+ // Try to persist the new anonymous ID
841
+ if (typeof window !== 'undefined') {
842
+ try {
843
+ const storageKey = `grain_anonymous_user_id_${this.config.tenantId}`;
844
+ localStorage.setItem(storageKey, this.persistentAnonymousUserId);
845
+ }
846
+ catch (error) {
847
+ this.log('Failed to persist new anonymous user ID after logout:', error);
848
+ }
849
+ }
850
+ }
851
+ this.log(`Logout successful. Effective user ID: ${this.getEffectiveUserIdInternal()}`);
852
+ }
853
+ catch (error) {
854
+ const formattedError = this.formatError(error, 'logout');
855
+ this.logError(formattedError);
856
+ }
857
+ }
858
+ /**
859
+ * Set user properties
860
+ */
861
+ async setProperty(properties, options) {
862
+ try {
863
+ if (this.isDestroyed) {
864
+ const error = new Error('Grain Analytics: Client has been destroyed');
865
+ const formattedError = this.formatError(error, 'setProperty (client destroyed)');
866
+ this.logError(formattedError);
867
+ return;
868
+ }
869
+ const userId = options?.userId || this.getEffectiveUserIdInternal();
870
+ // Validate property count (max 4 properties)
871
+ const propertyKeys = Object.keys(properties);
872
+ if (propertyKeys.length > 4) {
873
+ const error = new Error('Grain Analytics: Maximum 4 properties allowed per request');
874
+ const formattedError = this.formatError(error, 'setProperty (validation)');
875
+ this.logError(formattedError);
876
+ return;
877
+ }
878
+ if (propertyKeys.length === 0) {
879
+ const error = new Error('Grain Analytics: At least one property is required');
880
+ const formattedError = this.formatError(error, 'setProperty (validation)');
881
+ this.logError(formattedError);
882
+ return;
883
+ }
884
+ // Serialize all values to strings
885
+ const serializedProperties = {};
886
+ for (const [key, value] of Object.entries(properties)) {
887
+ if (value === null || value === undefined) {
888
+ serializedProperties[key] = '';
889
+ }
890
+ else if (typeof value === 'string') {
891
+ serializedProperties[key] = value;
892
+ }
893
+ else {
894
+ serializedProperties[key] = JSON.stringify(value);
895
+ }
896
+ }
897
+ const payload = {
898
+ userId,
899
+ ...serializedProperties,
900
+ };
901
+ await this.sendProperties(payload);
902
+ }
903
+ catch (error) {
904
+ const formattedError = this.formatError(error, 'setProperty');
905
+ this.logError(formattedError);
906
+ }
907
+ }
908
+ /**
909
+ * Send properties to the API
910
+ */
911
+ async sendProperties(payload) {
912
+ let lastError;
913
+ for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
914
+ try {
915
+ const headers = await this.getAuthHeaders();
916
+ const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/properties`;
917
+ this.log(`Setting properties for user ${payload.userId} (attempt ${attempt + 1})`);
918
+ const response = await fetch(url, {
919
+ method: 'POST',
920
+ headers,
921
+ body: JSON.stringify(payload),
922
+ });
923
+ if (!response.ok) {
924
+ let errorMessage = `HTTP ${response.status}`;
925
+ try {
926
+ const errorBody = await response.json();
927
+ if (errorBody?.message) {
928
+ errorMessage = errorBody.message;
929
+ }
930
+ }
931
+ catch {
932
+ const errorText = await response.text();
933
+ if (errorText) {
934
+ errorMessage = errorText;
935
+ }
936
+ }
937
+ const error = new Error(`Failed to set properties: ${errorMessage}`);
938
+ error.status = response.status;
939
+ throw error;
940
+ }
941
+ this.log(`Successfully set properties for user ${payload.userId}`);
942
+ return; // Success, exit retry loop
943
+ }
944
+ catch (error) {
945
+ lastError = error;
946
+ if (attempt === this.config.retryAttempts) {
947
+ // Last attempt, don't retry - log error gracefully
948
+ const formattedError = this.formatError(error, `sendProperties (attempt ${attempt + 1}/${this.config.retryAttempts + 1})`);
949
+ this.logError(formattedError);
950
+ return; // Don't throw, just return gracefully
951
+ }
952
+ if (!this.isRetriableError(error)) {
953
+ // Non-retriable error, don't retry - log error gracefully
954
+ const formattedError = this.formatError(error, 'sendProperties (non-retriable error)');
955
+ this.logError(formattedError);
956
+ return; // Don't throw, just return gracefully
957
+ }
958
+ const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff
959
+ this.log(`Retrying in ${delayMs}ms after error:`, error);
960
+ await this.delay(delayMs);
961
+ }
962
+ }
963
+ }
964
+ // Template event methods
965
+ /**
966
+ * Track user login event
967
+ */
968
+ async trackLogin(properties, options) {
969
+ try {
970
+ return await this.track('login', properties, options);
971
+ }
972
+ catch (error) {
973
+ const formattedError = this.formatError(error, 'trackLogin');
974
+ this.logError(formattedError);
975
+ }
976
+ }
977
+ /**
978
+ * Track user signup event
979
+ */
980
+ async trackSignup(properties, options) {
981
+ try {
982
+ return await this.track('signup', properties, options);
983
+ }
984
+ catch (error) {
985
+ const formattedError = this.formatError(error, 'trackSignup');
986
+ this.logError(formattedError);
987
+ }
988
+ }
989
+ /**
990
+ * Track checkout event
991
+ */
992
+ async trackCheckout(properties, options) {
993
+ try {
994
+ return await this.track('checkout', properties, options);
995
+ }
996
+ catch (error) {
997
+ const formattedError = this.formatError(error, 'trackCheckout');
998
+ this.logError(formattedError);
999
+ }
1000
+ }
1001
+ /**
1002
+ * Track page view event
1003
+ */
1004
+ async trackPageView(properties, options) {
1005
+ try {
1006
+ return await this.track('page_view', properties, options);
1007
+ }
1008
+ catch (error) {
1009
+ const formattedError = this.formatError(error, 'trackPageView');
1010
+ this.logError(formattedError);
1011
+ }
1012
+ }
1013
+ /**
1014
+ * Track purchase event
1015
+ */
1016
+ async trackPurchase(properties, options) {
1017
+ try {
1018
+ return await this.track('purchase', properties, options);
1019
+ }
1020
+ catch (error) {
1021
+ const formattedError = this.formatError(error, 'trackPurchase');
1022
+ this.logError(formattedError);
1023
+ }
1024
+ }
1025
+ /**
1026
+ * Track search event
1027
+ */
1028
+ async trackSearch(properties, options) {
1029
+ try {
1030
+ return await this.track('search', properties, options);
1031
+ }
1032
+ catch (error) {
1033
+ const formattedError = this.formatError(error, 'trackSearch');
1034
+ this.logError(formattedError);
1035
+ }
1036
+ }
1037
+ /**
1038
+ * Track add to cart event
1039
+ */
1040
+ async trackAddToCart(properties, options) {
1041
+ try {
1042
+ return await this.track('add_to_cart', properties, options);
1043
+ }
1044
+ catch (error) {
1045
+ const formattedError = this.formatError(error, 'trackAddToCart');
1046
+ this.logError(formattedError);
1047
+ }
1048
+ }
1049
+ /**
1050
+ * Track remove from cart event
1051
+ */
1052
+ async trackRemoveFromCart(properties, options) {
1053
+ try {
1054
+ return await this.track('remove_from_cart', properties, options);
1055
+ }
1056
+ catch (error) {
1057
+ const formattedError = this.formatError(error, 'trackRemoveFromCart');
1058
+ this.logError(formattedError);
1059
+ }
1060
+ }
1061
+ /**
1062
+ * Manually flush all queued events
1063
+ */
1064
+ async flush() {
1065
+ try {
1066
+ if (this.eventQueue.length === 0)
1067
+ return;
1068
+ const eventsToSend = [...this.eventQueue];
1069
+ this.eventQueue = [];
1070
+ // Split events into chunks to respect maxEventsPerRequest limit
1071
+ const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);
1072
+ // Send all chunks sequentially to maintain order
1073
+ for (const chunk of chunks) {
1074
+ await this.sendEvents(chunk);
1075
+ }
1076
+ }
1077
+ catch (error) {
1078
+ const formattedError = this.formatError(error, 'flush');
1079
+ this.logError(formattedError);
1080
+ }
1081
+ }
1082
+ // Remote Config Methods
1083
+ /**
1084
+ * Initialize configuration cache from localStorage
1085
+ */
1086
+ initializeConfigCache() {
1087
+ if (!this.config.enableConfigCache || typeof window === 'undefined')
1088
+ return;
1089
+ try {
1090
+ const cached = localStorage.getItem(this.config.configCacheKey);
1091
+ if (cached) {
1092
+ this.configCache = JSON.parse(cached);
1093
+ this.log('Loaded configuration from cache:', this.configCache);
1094
+ }
1095
+ }
1096
+ catch (error) {
1097
+ this.log('Failed to load configuration cache:', error);
1098
+ }
1099
+ }
1100
+ /**
1101
+ * Save configuration cache to localStorage
1102
+ */
1103
+ saveConfigCache(cache) {
1104
+ if (!this.config.enableConfigCache || typeof window === 'undefined')
1105
+ return;
1106
+ try {
1107
+ localStorage.setItem(this.config.configCacheKey, JSON.stringify(cache));
1108
+ this.log('Saved configuration to cache:', cache);
1109
+ }
1110
+ catch (error) {
1111
+ this.log('Failed to save configuration cache:', error);
1112
+ }
1113
+ }
1114
+ /**
1115
+ * Get configuration value with fallback to defaults
1116
+ */
1117
+ getConfig(key) {
1118
+ // First check cache
1119
+ if (this.configCache?.configurations?.[key]) {
1120
+ return this.configCache.configurations[key];
1121
+ }
1122
+ // Then check defaults
1123
+ if (this.config.defaultConfigurations?.[key]) {
1124
+ return this.config.defaultConfigurations[key];
1125
+ }
1126
+ return undefined;
1127
+ }
1128
+ /**
1129
+ * Get all configurations with fallback to defaults
1130
+ */
1131
+ getAllConfigs() {
1132
+ const configs = { ...this.config.defaultConfigurations };
1133
+ if (this.configCache?.configurations) {
1134
+ Object.assign(configs, this.configCache.configurations);
1135
+ }
1136
+ return configs;
1137
+ }
1138
+ /**
1139
+ * Fetch configurations from API
1140
+ */
1141
+ async fetchConfig(options = {}) {
1142
+ try {
1143
+ if (this.isDestroyed) {
1144
+ const error = new Error('Grain Analytics: Client has been destroyed');
1145
+ const formattedError = this.formatError(error, 'fetchConfig (client destroyed)');
1146
+ this.logError(formattedError);
1147
+ return null;
1148
+ }
1149
+ const userId = options.userId || this.getEffectiveUserIdInternal();
1150
+ const immediateKeys = options.immediateKeys || [];
1151
+ const properties = options.properties || {};
1152
+ const request = {
1153
+ userId,
1154
+ immediateKeys,
1155
+ properties,
1156
+ };
1157
+ let lastError;
1158
+ for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
1159
+ try {
1160
+ const headers = await this.getAuthHeaders();
1161
+ const url = `${this.config.apiUrl}/v1/client/${encodeURIComponent(this.config.tenantId)}/config/configurations`;
1162
+ this.log(`Fetching configurations for user ${userId} (attempt ${attempt + 1})`);
1163
+ const response = await fetch(url, {
1164
+ method: 'POST',
1165
+ headers,
1166
+ body: JSON.stringify(request),
1167
+ });
1168
+ if (!response.ok) {
1169
+ let errorMessage = `HTTP ${response.status}`;
1170
+ try {
1171
+ const errorBody = await response.json();
1172
+ if (errorBody?.message) {
1173
+ errorMessage = errorBody.message;
1174
+ }
1175
+ }
1176
+ catch {
1177
+ const errorText = await response.text();
1178
+ if (errorText) {
1179
+ errorMessage = errorText;
1180
+ }
1181
+ }
1182
+ const error = new Error(`Failed to fetch configurations: ${errorMessage}`);
1183
+ error.status = response.status;
1184
+ throw error;
1185
+ }
1186
+ const configResponse = await response.json();
1187
+ // Update cache if successful
1188
+ if (configResponse.configurations) {
1189
+ this.updateConfigCache(configResponse, userId);
1190
+ }
1191
+ this.log(`Successfully fetched configurations for user ${userId}:`, configResponse);
1192
+ return configResponse;
1193
+ }
1194
+ catch (error) {
1195
+ lastError = error;
1196
+ if (attempt === this.config.retryAttempts) {
1197
+ // Last attempt, don't retry - log error gracefully
1198
+ const formattedError = this.formatError(error, `fetchConfig (attempt ${attempt + 1}/${this.config.retryAttempts + 1})`);
1199
+ this.logError(formattedError);
1200
+ return null;
1201
+ }
1202
+ if (!this.isRetriableError(error)) {
1203
+ // Non-retriable error, don't retry - log error gracefully
1204
+ const formattedError = this.formatError(error, 'fetchConfig (non-retriable error)');
1205
+ this.logError(formattedError);
1206
+ return null;
1207
+ }
1208
+ const delayMs = this.config.retryDelay * Math.pow(2, attempt);
1209
+ this.log(`Retrying config fetch in ${delayMs}ms after error:`, error);
1210
+ await this.delay(delayMs);
1211
+ }
1212
+ }
1213
+ return null;
1214
+ }
1215
+ catch (error) {
1216
+ const formattedError = this.formatError(error, 'fetchConfig');
1217
+ this.logError(formattedError);
1218
+ return null;
1219
+ }
1220
+ }
1221
+ /**
1222
+ * Get configuration asynchronously (cache-first with fallback to API)
1223
+ */
1224
+ async getConfigAsync(key, options = {}) {
1225
+ try {
1226
+ // Return immediately if we have it in cache and not forcing refresh
1227
+ if (!options.forceRefresh && this.configCache?.configurations?.[key]) {
1228
+ return this.configCache.configurations[key];
1229
+ }
1230
+ // Return default if available and not forcing refresh
1231
+ if (!options.forceRefresh && this.config.defaultConfigurations?.[key]) {
1232
+ return this.config.defaultConfigurations[key];
1233
+ }
1234
+ // Fetch from API
1235
+ const response = await this.fetchConfig(options);
1236
+ if (response) {
1237
+ return response.configurations[key];
1238
+ }
1239
+ // Return default as fallback
1240
+ return this.config.defaultConfigurations?.[key];
1241
+ }
1242
+ catch (error) {
1243
+ const formattedError = this.formatError(error, 'getConfigAsync');
1244
+ this.logError(formattedError);
1245
+ // Return default as fallback
1246
+ return this.config.defaultConfigurations?.[key];
1247
+ }
1248
+ }
1249
+ /**
1250
+ * Get all configurations asynchronously (cache-first with fallback to API)
1251
+ */
1252
+ async getAllConfigsAsync(options = {}) {
1253
+ try {
1254
+ // Return cache if available and not forcing refresh
1255
+ if (!options.forceRefresh && this.configCache?.configurations) {
1256
+ return { ...this.config.defaultConfigurations, ...this.configCache.configurations };
1257
+ }
1258
+ // Fetch from API
1259
+ const response = await this.fetchConfig(options);
1260
+ if (response) {
1261
+ return { ...this.config.defaultConfigurations, ...response.configurations };
1262
+ }
1263
+ // Return defaults as fallback
1264
+ return { ...this.config.defaultConfigurations };
1265
+ }
1266
+ catch (error) {
1267
+ const formattedError = this.formatError(error, 'getAllConfigsAsync');
1268
+ this.logError(formattedError);
1269
+ // Return defaults as fallback
1270
+ return { ...this.config.defaultConfigurations };
1271
+ }
1272
+ }
1273
+ /**
1274
+ * Update configuration cache and notify listeners
1275
+ */
1276
+ updateConfigCache(response, userId) {
1277
+ const newCache = {
1278
+ configurations: response.configurations,
1279
+ snapshotId: response.snapshotId,
1280
+ timestamp: response.timestamp,
1281
+ userId,
1282
+ };
1283
+ const oldConfigs = this.configCache?.configurations || {};
1284
+ this.configCache = newCache;
1285
+ this.saveConfigCache(newCache);
1286
+ // Notify listeners if configurations changed
1287
+ if (JSON.stringify(oldConfigs) !== JSON.stringify(response.configurations)) {
1288
+ this.notifyConfigChangeListeners(response.configurations);
1289
+ }
1290
+ }
1291
+ /**
1292
+ * Add configuration change listener
1293
+ */
1294
+ addConfigChangeListener(listener) {
1295
+ this.configChangeListeners.push(listener);
1296
+ }
1297
+ /**
1298
+ * Remove configuration change listener
1299
+ */
1300
+ removeConfigChangeListener(listener) {
1301
+ const index = this.configChangeListeners.indexOf(listener);
1302
+ if (index > -1) {
1303
+ this.configChangeListeners.splice(index, 1);
1304
+ }
1305
+ }
1306
+ /**
1307
+ * Notify all configuration change listeners
1308
+ */
1309
+ notifyConfigChangeListeners(configurations) {
1310
+ this.configChangeListeners.forEach(listener => {
1311
+ try {
1312
+ listener(configurations);
1313
+ }
1314
+ catch (error) {
1315
+ console.error('[Grain Analytics] Config change listener error:', error);
1316
+ }
1317
+ });
1318
+ }
1319
+ /**
1320
+ * Start automatic configuration refresh timer
1321
+ */
1322
+ startConfigRefreshTimer() {
1323
+ if (this.configRefreshTimer) {
1324
+ clearInterval(this.configRefreshTimer);
1325
+ }
1326
+ this.configRefreshTimer = window.setInterval(() => {
1327
+ if (!this.isDestroyed) {
1328
+ // Use effective userId (will be generated if not set)
1329
+ this.fetchConfig().catch((error) => {
1330
+ const formattedError = this.formatError(error, 'auto-config refresh');
1331
+ this.logError(formattedError);
1332
+ });
1333
+ }
1334
+ }, this.config.configRefreshInterval);
1335
+ }
1336
+ /**
1337
+ * Stop automatic configuration refresh timer
1338
+ */
1339
+ stopConfigRefreshTimer() {
1340
+ if (this.configRefreshTimer) {
1341
+ clearInterval(this.configRefreshTimer);
1342
+ this.configRefreshTimer = null;
1343
+ }
1344
+ }
1345
+ /**
1346
+ * Preload configurations for immediate access
1347
+ */
1348
+ async preloadConfig(immediateKeys = [], properties) {
1349
+ try {
1350
+ // Use effective userId (will be generated if not set)
1351
+ const effectiveUserId = this.getEffectiveUserIdInternal();
1352
+ this.log(`Preloading config for user: ${effectiveUserId}`);
1353
+ const response = await this.fetchConfig({ immediateKeys, properties });
1354
+ if (response) {
1355
+ this.startConfigRefreshTimer();
1356
+ }
1357
+ }
1358
+ catch (error) {
1359
+ const formattedError = this.formatError(error, 'preloadConfig');
1360
+ this.logError(formattedError);
1361
+ }
1362
+ }
1363
+ /**
1364
+ * Split events array into chunks of specified size
1365
+ */
1366
+ chunkEvents(events, chunkSize) {
1367
+ const chunks = [];
1368
+ for (let i = 0; i < events.length; i += chunkSize) {
1369
+ chunks.push(events.slice(i, i + chunkSize));
1370
+ }
1371
+ return chunks;
1372
+ }
1373
+ // Privacy & Consent Methods
1374
+ /**
1375
+ * Grant consent for tracking
1376
+ * @param categories - Optional array of consent categories (e.g., ['analytics', 'functional'])
1377
+ */
1378
+ grantConsent(categories) {
1379
+ try {
1380
+ this.consentManager.grantConsent(categories);
1381
+ this.log('Consent granted', categories);
1382
+ }
1383
+ catch (error) {
1384
+ const formattedError = this.formatError(error, 'grantConsent');
1385
+ this.logError(formattedError);
1386
+ }
1387
+ }
1388
+ /**
1389
+ * Revoke consent for tracking (opt-out)
1390
+ * @param categories - Optional array of categories to revoke (if not provided, revokes all)
1391
+ */
1392
+ revokeConsent(categories) {
1393
+ try {
1394
+ this.consentManager.revokeConsent(categories);
1395
+ this.log('Consent revoked', categories);
1396
+ // Clear queued events when consent is revoked
1397
+ this.eventQueue = [];
1398
+ this.waitingForConsentQueue = [];
1399
+ }
1400
+ catch (error) {
1401
+ const formattedError = this.formatError(error, 'revokeConsent');
1402
+ this.logError(formattedError);
1403
+ }
1404
+ }
1405
+ /**
1406
+ * Get current consent state
1407
+ */
1408
+ getConsentState() {
1409
+ return this.consentManager.getConsentState();
1410
+ }
1411
+ /**
1412
+ * Check if user has granted consent
1413
+ * @param category - Optional category to check (if not provided, checks general consent)
1414
+ */
1415
+ hasConsent(category) {
1416
+ return this.consentManager.hasConsent(category);
1417
+ }
1418
+ /**
1419
+ * Add listener for consent state changes
1420
+ */
1421
+ onConsentChange(listener) {
1422
+ this.consentManager.addListener(listener);
1423
+ }
1424
+ /**
1425
+ * Remove consent change listener
1426
+ */
1427
+ offConsentChange(listener) {
1428
+ this.consentManager.removeListener(listener);
1429
+ }
1430
+ /**
1431
+ * Destroy the client and clean up resources
1432
+ */
1433
+ destroy() {
1434
+ this.isDestroyed = true;
1435
+ if (this.flushTimer) {
1436
+ clearInterval(this.flushTimer);
1437
+ this.flushTimer = null;
1438
+ }
1439
+ // Stop config refresh timer
1440
+ this.stopConfigRefreshTimer();
1441
+ // Clear config change listeners
1442
+ this.configChangeListeners = [];
1443
+ // Destroy automatic tracking managers
1444
+ if (this.heartbeatManager) {
1445
+ this.heartbeatManager.destroy();
1446
+ this.heartbeatManager = null;
1447
+ }
1448
+ if (this.pageTrackingManager) {
1449
+ this.pageTrackingManager.destroy();
1450
+ this.pageTrackingManager = null;
1451
+ }
1452
+ if (this.activityDetector) {
1453
+ this.activityDetector.destroy();
1454
+ this.activityDetector = null;
1455
+ }
1456
+ // Send any remaining events (in chunks if necessary)
1457
+ if (this.eventQueue.length > 0) {
1458
+ const eventsToSend = [...this.eventQueue];
1459
+ this.eventQueue = [];
1460
+ const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);
1461
+ // Send first chunk with beacon (most important for page unload)
1462
+ if (chunks.length > 0) {
1463
+ this.sendEventsWithBeacon(chunks[0]).catch(() => {
1464
+ // Silently fail during cleanup
1465
+ });
1466
+ // If there are more chunks, try to send them with regular fetch
1467
+ for (let i = 1; i < chunks.length; i++) {
1468
+ this.sendEventsWithBeacon(chunks[i]).catch(() => {
1469
+ // Silently fail during cleanup
1470
+ });
1471
+ }
1472
+ }
1473
+ }
1474
+ }
1475
+ }
1476
+ exports.GrainAnalytics = GrainAnalytics;
1477
+ /**
1478
+ * Create a new Grain Analytics client
1479
+ */
1480
+ function createGrainAnalytics(config) {
1481
+ return new GrainAnalytics(config);
1482
+ }
1483
+ // Default export for convenience
1484
+ exports.default = GrainAnalytics;
1485
+ // Auto-setup for IIFE build
1486
+ if (typeof window !== 'undefined') {
1487
+ window.Grain = {
1488
+ GrainAnalytics,
1489
+ createGrainAnalytics,
1490
+ };
1491
+ }