@grainql/analytics-web 1.7.2 → 2.0.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 (116) hide show
  1. package/README.md +71 -777
  2. package/dist/cjs/index.d.ts +35 -2
  3. package/dist/cjs/index.d.ts.map +1 -1
  4. package/dist/cjs/index.js.map +1 -1
  5. package/dist/cjs/react/GrainProvider.d.ts +11 -0
  6. package/dist/cjs/react/GrainProvider.d.ts.map +1 -0
  7. package/dist/cjs/react/GrainProvider.js +79 -0
  8. package/dist/cjs/react/GrainProvider.js.map +1 -0
  9. package/dist/cjs/react/context.d.ts +11 -0
  10. package/dist/cjs/react/context.d.ts.map +1 -0
  11. package/dist/cjs/react/context.js +43 -0
  12. package/dist/cjs/react/context.js.map +1 -0
  13. package/dist/cjs/react/hooks/useAllConfigs.d.ts +8 -0
  14. package/dist/cjs/react/hooks/useAllConfigs.d.ts.map +1 -0
  15. package/dist/cjs/react/hooks/useAllConfigs.js +112 -0
  16. package/dist/cjs/react/hooks/useAllConfigs.js.map +1 -0
  17. package/dist/cjs/react/hooks/useConfig.d.ts +9 -0
  18. package/dist/cjs/react/hooks/useConfig.d.ts.map +1 -0
  19. package/dist/cjs/react/hooks/useConfig.js +116 -0
  20. package/dist/cjs/react/hooks/useConfig.js.map +1 -0
  21. package/dist/cjs/react/hooks/useGrainAnalytics.d.ts +6 -0
  22. package/dist/cjs/react/hooks/useGrainAnalytics.d.ts.map +1 -0
  23. package/dist/cjs/react/hooks/useGrainAnalytics.js +50 -0
  24. package/dist/cjs/react/hooks/useGrainAnalytics.js.map +1 -0
  25. package/dist/cjs/react/hooks/useTrack.d.ts +9 -0
  26. package/dist/cjs/react/hooks/useTrack.d.ts.map +1 -0
  27. package/dist/cjs/react/hooks/useTrack.js +53 -0
  28. package/dist/cjs/react/hooks/useTrack.js.map +1 -0
  29. package/dist/cjs/react/index.d.ts +36 -0
  30. package/dist/cjs/react/index.d.ts.map +1 -0
  31. package/dist/cjs/react/index.js +45 -0
  32. package/dist/cjs/react/index.js.map +1 -0
  33. package/dist/cjs/react/types.d.ts +33 -0
  34. package/dist/cjs/react/types.d.ts.map +1 -0
  35. package/dist/cjs/react/types.js +6 -0
  36. package/dist/cjs/react/types.js.map +1 -0
  37. package/dist/esm/index.d.ts +35 -2
  38. package/dist/esm/index.d.ts.map +1 -1
  39. package/dist/esm/index.js.map +1 -1
  40. package/dist/esm/react/GrainProvider.d.ts +11 -0
  41. package/dist/esm/react/GrainProvider.d.ts.map +1 -0
  42. package/dist/esm/react/GrainProvider.js +43 -0
  43. package/dist/esm/react/GrainProvider.js.map +1 -0
  44. package/dist/esm/react/context.d.ts +11 -0
  45. package/dist/esm/react/context.d.ts.map +1 -0
  46. package/dist/esm/react/context.js +7 -0
  47. package/dist/esm/react/context.js.map +1 -0
  48. package/dist/esm/react/hooks/useAllConfigs.d.ts +8 -0
  49. package/dist/esm/react/hooks/useAllConfigs.d.ts.map +1 -0
  50. package/dist/esm/react/hooks/useAllConfigs.js +76 -0
  51. package/dist/esm/react/hooks/useAllConfigs.js.map +1 -0
  52. package/dist/esm/react/hooks/useConfig.d.ts +9 -0
  53. package/dist/esm/react/hooks/useConfig.d.ts.map +1 -0
  54. package/dist/esm/react/hooks/useConfig.js +80 -0
  55. package/dist/esm/react/hooks/useConfig.js.map +1 -0
  56. package/dist/esm/react/hooks/useGrainAnalytics.d.ts +6 -0
  57. package/dist/esm/react/hooks/useGrainAnalytics.d.ts.map +1 -0
  58. package/dist/esm/react/hooks/useGrainAnalytics.js +14 -0
  59. package/dist/esm/react/hooks/useGrainAnalytics.js.map +1 -0
  60. package/dist/esm/react/hooks/useTrack.d.ts +9 -0
  61. package/dist/esm/react/hooks/useTrack.d.ts.map +1 -0
  62. package/dist/esm/react/hooks/useTrack.js +17 -0
  63. package/dist/esm/react/hooks/useTrack.js.map +1 -0
  64. package/dist/esm/react/index.d.ts +36 -0
  65. package/dist/esm/react/index.d.ts.map +1 -0
  66. package/dist/esm/react/index.js +37 -0
  67. package/dist/esm/react/index.js.map +1 -0
  68. package/dist/esm/react/types.d.ts +33 -0
  69. package/dist/esm/react/types.d.ts.map +1 -0
  70. package/dist/esm/react/types.js +5 -0
  71. package/dist/esm/react/types.js.map +1 -0
  72. package/dist/index.d.ts +35 -2
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.global.dev.js +124 -14
  75. package/dist/index.global.dev.js.map +2 -2
  76. package/dist/index.global.js +2 -2
  77. package/dist/index.global.js.map +3 -3
  78. package/dist/index.js +147 -15
  79. package/dist/index.mjs +147 -15
  80. package/dist/react/index.d.ts +405 -0
  81. package/dist/react/index.d.ts.map +1 -0
  82. package/dist/react/index.js +1181 -0
  83. package/dist/react/index.mjs +1176 -0
  84. package/dist/react/react/GrainProvider.d.ts +11 -0
  85. package/dist/react/react/GrainProvider.d.ts.map +1 -0
  86. package/dist/react/react/GrainProvider.js +45 -0
  87. package/dist/react/react/GrainProvider.mjs +42 -0
  88. package/dist/react/react/context.d.ts +11 -0
  89. package/dist/react/react/context.d.ts.map +1 -0
  90. package/dist/react/react/context.js +9 -0
  91. package/dist/react/react/context.mjs +6 -0
  92. package/dist/react/react/hooks/useAllConfigs.d.ts +8 -0
  93. package/dist/react/react/hooks/useAllConfigs.d.ts.map +1 -0
  94. package/dist/react/react/hooks/useAllConfigs.js +78 -0
  95. package/dist/react/react/hooks/useAllConfigs.mjs +75 -0
  96. package/dist/react/react/hooks/useConfig.d.ts +9 -0
  97. package/dist/react/react/hooks/useConfig.d.ts.map +1 -0
  98. package/dist/react/react/hooks/useConfig.js +82 -0
  99. package/dist/react/react/hooks/useConfig.mjs +79 -0
  100. package/dist/react/react/hooks/useGrainAnalytics.d.ts +6 -0
  101. package/dist/react/react/hooks/useGrainAnalytics.d.ts.map +1 -0
  102. package/dist/react/react/hooks/useGrainAnalytics.js +16 -0
  103. package/dist/react/react/hooks/useGrainAnalytics.mjs +13 -0
  104. package/dist/react/react/hooks/useTrack.d.ts +9 -0
  105. package/dist/react/react/hooks/useTrack.d.ts.map +1 -0
  106. package/dist/react/react/hooks/useTrack.js +19 -0
  107. package/dist/react/react/hooks/useTrack.mjs +16 -0
  108. package/dist/react/react/index.d.ts +36 -0
  109. package/dist/react/react/index.d.ts.map +1 -0
  110. package/dist/react/react/index.js +44 -0
  111. package/dist/react/react/index.mjs +36 -0
  112. package/dist/react/react/types.d.ts +33 -0
  113. package/dist/react/react/types.d.ts.map +1 -0
  114. package/dist/react/react/types.js +5 -0
  115. package/dist/react/react/types.mjs +4 -0
  116. package/package.json +20 -2
@@ -0,0 +1,1181 @@
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
+ class GrainAnalytics {
10
+ constructor(config) {
11
+ this.eventQueue = [];
12
+ this.flushTimer = null;
13
+ this.isDestroyed = false;
14
+ this.globalUserId = null;
15
+ this.persistentAnonymousUserId = null;
16
+ // Remote Config properties
17
+ this.configCache = null;
18
+ this.configRefreshTimer = null;
19
+ this.configChangeListeners = [];
20
+ this.configFetchPromise = null;
21
+ this.config = {
22
+ apiUrl: 'https://api.grainql.com',
23
+ authStrategy: 'NONE',
24
+ batchSize: 50,
25
+ flushInterval: 5000, // 5 seconds
26
+ retryAttempts: 3,
27
+ retryDelay: 1000, // 1 second
28
+ maxEventsPerRequest: 160, // Maximum events per API request
29
+ debug: false,
30
+ // Remote Config defaults
31
+ defaultConfigurations: {},
32
+ configCacheKey: 'grain_config',
33
+ configRefreshInterval: 300000, // 5 minutes
34
+ enableConfigCache: true,
35
+ ...config,
36
+ tenantId: config.tenantId,
37
+ };
38
+ // Set global userId if provided in config
39
+ if (config.userId) {
40
+ this.globalUserId = config.userId;
41
+ }
42
+ this.validateConfig();
43
+ this.initializePersistentAnonymousUserId();
44
+ this.setupBeforeUnload();
45
+ this.startFlushTimer();
46
+ this.initializeConfigCache();
47
+ }
48
+ validateConfig() {
49
+ if (!this.config.tenantId) {
50
+ throw new Error('Grain Analytics: tenantId is required');
51
+ }
52
+ if (this.config.authStrategy === 'SERVER_SIDE' && !this.config.secretKey) {
53
+ throw new Error('Grain Analytics: secretKey is required for SERVER_SIDE auth strategy');
54
+ }
55
+ if (this.config.authStrategy === 'JWT' && !this.config.authProvider) {
56
+ throw new Error('Grain Analytics: authProvider is required for JWT auth strategy');
57
+ }
58
+ }
59
+ /**
60
+ * Generate a UUID v4 string
61
+ */
62
+ generateUUID() {
63
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
64
+ return crypto.randomUUID();
65
+ }
66
+ // Fallback for environments without crypto.randomUUID
67
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
68
+ const r = Math.random() * 16 | 0;
69
+ const v = c === 'x' ? r : (r & 0x3 | 0x8);
70
+ return v.toString(16);
71
+ });
72
+ }
73
+ /**
74
+ * Generate a proper UUIDv4 identifier for anonymous user ID
75
+ */
76
+ generateAnonymousUserId() {
77
+ return this.generateUUID();
78
+ }
79
+ /**
80
+ * Initialize persistent anonymous user ID from localStorage or create new one
81
+ */
82
+ initializePersistentAnonymousUserId() {
83
+ if (typeof window === 'undefined')
84
+ return;
85
+ const storageKey = `grain_anonymous_user_id_${this.config.tenantId}`;
86
+ try {
87
+ const stored = localStorage.getItem(storageKey);
88
+ if (stored) {
89
+ this.persistentAnonymousUserId = stored;
90
+ this.log('Loaded persistent anonymous user ID:', this.persistentAnonymousUserId);
91
+ }
92
+ else {
93
+ // Generate new UUIDv4 anonymous user ID
94
+ this.persistentAnonymousUserId = this.generateAnonymousUserId();
95
+ localStorage.setItem(storageKey, this.persistentAnonymousUserId);
96
+ this.log('Generated new persistent anonymous user ID:', this.persistentAnonymousUserId);
97
+ }
98
+ }
99
+ catch (error) {
100
+ this.log('Failed to initialize persistent anonymous user ID:', error);
101
+ // Fallback: generate temporary ID without persistence
102
+ this.persistentAnonymousUserId = this.generateAnonymousUserId();
103
+ }
104
+ }
105
+ /**
106
+ * Get the effective user ID (global userId or persistent anonymous ID)
107
+ */
108
+ getEffectiveUserId() {
109
+ if (this.globalUserId) {
110
+ return this.globalUserId;
111
+ }
112
+ if (this.persistentAnonymousUserId) {
113
+ return this.persistentAnonymousUserId;
114
+ }
115
+ // Generate a new UUIDv4 identifier as fallback
116
+ this.persistentAnonymousUserId = this.generateAnonymousUserId();
117
+ // Try to persist it
118
+ if (typeof window !== 'undefined') {
119
+ try {
120
+ const storageKey = `grain_anonymous_user_id_${this.config.tenantId}`;
121
+ localStorage.setItem(storageKey, this.persistentAnonymousUserId);
122
+ }
123
+ catch (error) {
124
+ this.log('Failed to persist generated anonymous user ID:', error);
125
+ }
126
+ }
127
+ return this.persistentAnonymousUserId;
128
+ }
129
+ log(...args) {
130
+ if (this.config.debug) {
131
+ console.log('[Grain Analytics]', ...args);
132
+ }
133
+ }
134
+ /**
135
+ * Create error digest from events
136
+ */
137
+ createErrorDigest(events) {
138
+ const eventNames = [...new Set(events.map(e => e.eventName))];
139
+ const userIds = [...new Set(events.map(e => e.userId))];
140
+ let totalProperties = 0;
141
+ let totalSize = 0;
142
+ events.forEach(event => {
143
+ const properties = event.properties || {};
144
+ totalProperties += Object.keys(properties).length;
145
+ totalSize += JSON.stringify(event).length;
146
+ });
147
+ return {
148
+ eventCount: events.length,
149
+ totalProperties,
150
+ totalSize,
151
+ eventNames,
152
+ userIds,
153
+ };
154
+ }
155
+ /**
156
+ * Format error with beautiful structure
157
+ */
158
+ formatError(error, context, events) {
159
+ const digest = events ? this.createErrorDigest(events) : {
160
+ eventCount: 0,
161
+ totalProperties: 0,
162
+ totalSize: 0,
163
+ eventNames: [],
164
+ userIds: [],
165
+ };
166
+ let code = 'UNKNOWN_ERROR';
167
+ let message = 'An unknown error occurred';
168
+ if (error instanceof Error) {
169
+ message = error.message;
170
+ // Determine error code based on error type and message
171
+ if (message.includes('fetch failed') || message.includes('network error')) {
172
+ code = 'NETWORK_ERROR';
173
+ }
174
+ else if (message.includes('timeout')) {
175
+ code = 'TIMEOUT_ERROR';
176
+ }
177
+ else if (message.includes('HTTP 4')) {
178
+ code = 'CLIENT_ERROR';
179
+ }
180
+ else if (message.includes('HTTP 5')) {
181
+ code = 'SERVER_ERROR';
182
+ }
183
+ else if (message.includes('JSON')) {
184
+ code = 'PARSE_ERROR';
185
+ }
186
+ else if (message.includes('auth') || message.includes('unauthorized')) {
187
+ code = 'AUTH_ERROR';
188
+ }
189
+ else if (message.includes('rate limit') || message.includes('429')) {
190
+ code = 'RATE_LIMIT_ERROR';
191
+ }
192
+ else {
193
+ code = 'GENERAL_ERROR';
194
+ }
195
+ }
196
+ else if (typeof error === 'string') {
197
+ message = error;
198
+ code = 'STRING_ERROR';
199
+ }
200
+ else if (error && typeof error === 'object' && 'status' in error) {
201
+ const status = error.status;
202
+ code = `HTTP_${status}`;
203
+ message = `HTTP ${status} error`;
204
+ }
205
+ return {
206
+ code,
207
+ message,
208
+ digest,
209
+ timestamp: new Date().toISOString(),
210
+ context,
211
+ originalError: error,
212
+ };
213
+ }
214
+ /**
215
+ * Log formatted error gracefully
216
+ */
217
+ logError(formattedError) {
218
+ const { code, message, digest, timestamp, context } = formattedError;
219
+ const errorOutput = {
220
+ '🚨 Grain Analytics Error': {
221
+ 'Error Code': code,
222
+ 'Message': message,
223
+ 'Context': context,
224
+ 'Timestamp': timestamp,
225
+ 'Event Digest': {
226
+ 'Events': digest.eventCount,
227
+ 'Properties': digest.totalProperties,
228
+ 'Size (bytes)': digest.totalSize,
229
+ 'Event Names': digest.eventNames.length > 0 ? digest.eventNames.join(', ') : 'None',
230
+ 'User IDs': digest.userIds.length > 0 ? digest.userIds.slice(0, 3).join(', ') + (digest.userIds.length > 3 ? '...' : '') : 'None',
231
+ }
232
+ }
233
+ };
234
+ console.error('🚨 Grain Analytics Error:', errorOutput);
235
+ // Also log in a more compact format for debugging
236
+ if (this.config.debug) {
237
+ console.error(`[Grain Analytics] ${code}: ${message} (${context}) - Events: ${digest.eventCount}, Props: ${digest.totalProperties}, Size: ${digest.totalSize}B`);
238
+ }
239
+ }
240
+ /**
241
+ * Safely execute a function with error handling
242
+ */
243
+ async safeExecute(fn, context, events) {
244
+ try {
245
+ return await fn();
246
+ }
247
+ catch (error) {
248
+ const formattedError = this.formatError(error, context, events);
249
+ this.logError(formattedError);
250
+ return null;
251
+ }
252
+ }
253
+ formatEvent(event) {
254
+ return {
255
+ eventName: event.eventName,
256
+ userId: event.userId || this.getEffectiveUserId(),
257
+ properties: event.properties || {},
258
+ };
259
+ }
260
+ async getAuthHeaders() {
261
+ const headers = {
262
+ 'Content-Type': 'application/json',
263
+ };
264
+ switch (this.config.authStrategy) {
265
+ case 'NONE':
266
+ break;
267
+ case 'SERVER_SIDE':
268
+ headers['Authorization'] = `Chase ${this.config.secretKey}`;
269
+ break;
270
+ case 'JWT':
271
+ if (this.config.authProvider) {
272
+ const token = await this.config.authProvider.getToken();
273
+ headers['Authorization'] = `Bearer ${token}`;
274
+ }
275
+ break;
276
+ }
277
+ return headers;
278
+ }
279
+ async delay(ms) {
280
+ return new Promise(resolve => setTimeout(resolve, ms));
281
+ }
282
+ isRetriableError(error) {
283
+ if (error instanceof Error) {
284
+ // Check for specific network or fetch errors
285
+ const message = error.message.toLowerCase();
286
+ if (message.includes('fetch failed'))
287
+ return true;
288
+ if (message === 'network error')
289
+ return true; // Exact match to avoid "Non-network error"
290
+ if (message.includes('timeout'))
291
+ return true;
292
+ if (message.includes('connection'))
293
+ return true;
294
+ }
295
+ // Check for HTTP status codes that are retriable
296
+ if (typeof error === 'object' && error !== null && 'status' in error) {
297
+ const status = error.status;
298
+ return status >= 500 || status === 429; // Server errors or rate limiting
299
+ }
300
+ return false;
301
+ }
302
+ async sendEvents(events) {
303
+ if (events.length === 0)
304
+ return;
305
+ let lastError;
306
+ for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
307
+ try {
308
+ const headers = await this.getAuthHeaders();
309
+ const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/multi`;
310
+ this.log(`Sending ${events.length} events to ${url} (attempt ${attempt + 1})`);
311
+ const response = await fetch(url, {
312
+ method: 'POST',
313
+ headers,
314
+ body: JSON.stringify(events),
315
+ });
316
+ if (!response.ok) {
317
+ let errorMessage = `HTTP ${response.status}`;
318
+ try {
319
+ const errorBody = await response.json();
320
+ if (errorBody?.message) {
321
+ errorMessage = errorBody.message;
322
+ }
323
+ }
324
+ catch {
325
+ const errorText = await response.text();
326
+ if (errorText) {
327
+ errorMessage = errorText;
328
+ }
329
+ }
330
+ const error = new Error(`Failed to send events: ${errorMessage}`);
331
+ error.status = response.status;
332
+ throw error;
333
+ }
334
+ this.log(`Successfully sent ${events.length} events`);
335
+ return; // Success, exit retry loop
336
+ }
337
+ catch (error) {
338
+ lastError = error;
339
+ if (attempt === this.config.retryAttempts) {
340
+ // Last attempt, don't retry - log error gracefully
341
+ const formattedError = this.formatError(error, `sendEvents (attempt ${attempt + 1}/${this.config.retryAttempts + 1})`, events);
342
+ this.logError(formattedError);
343
+ return; // Don't throw, just return gracefully
344
+ }
345
+ if (!this.isRetriableError(error)) {
346
+ // Non-retriable error, don't retry - log error gracefully
347
+ const formattedError = this.formatError(error, `sendEvents (non-retriable error)`, events);
348
+ this.logError(formattedError);
349
+ return; // Don't throw, just return gracefully
350
+ }
351
+ const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff
352
+ this.log(`Retrying in ${delayMs}ms after error:`, error);
353
+ await this.delay(delayMs);
354
+ }
355
+ }
356
+ }
357
+ async sendEventsWithBeacon(events) {
358
+ if (events.length === 0)
359
+ return;
360
+ try {
361
+ const headers = await this.getAuthHeaders();
362
+ const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/multi`;
363
+ const body = JSON.stringify({ events });
364
+ // Try beacon API first (more reliable for page unload)
365
+ if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) {
366
+ const blob = new Blob([body], { type: 'application/json' });
367
+ const success = navigator.sendBeacon(url, blob);
368
+ if (success) {
369
+ this.log(`Successfully sent ${events.length} events via beacon`);
370
+ return;
371
+ }
372
+ }
373
+ // Fallback to fetch with keepalive
374
+ await fetch(url, {
375
+ method: 'POST',
376
+ headers,
377
+ body,
378
+ keepalive: true,
379
+ });
380
+ this.log(`Successfully sent ${events.length} events via fetch (keepalive)`);
381
+ }
382
+ catch (error) {
383
+ // Log error gracefully for beacon failures (page unload scenarios)
384
+ const formattedError = this.formatError(error, 'sendEventsWithBeacon', events);
385
+ this.logError(formattedError);
386
+ }
387
+ }
388
+ startFlushTimer() {
389
+ if (this.flushTimer) {
390
+ clearInterval(this.flushTimer);
391
+ }
392
+ this.flushTimer = window.setInterval(() => {
393
+ if (this.eventQueue.length > 0) {
394
+ this.flush().catch((error) => {
395
+ const formattedError = this.formatError(error, 'auto-flush');
396
+ this.logError(formattedError);
397
+ });
398
+ }
399
+ }, this.config.flushInterval);
400
+ }
401
+ setupBeforeUnload() {
402
+ if (typeof window === 'undefined')
403
+ return;
404
+ const handleBeforeUnload = () => {
405
+ if (this.eventQueue.length > 0) {
406
+ // Use beacon API for reliable delivery during page unload
407
+ const eventsToSend = [...this.eventQueue];
408
+ this.eventQueue = [];
409
+ const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);
410
+ // Send first chunk with beacon (most important for page unload)
411
+ if (chunks.length > 0) {
412
+ this.sendEventsWithBeacon(chunks[0]).catch(() => {
413
+ // Silently fail - page is unloading
414
+ });
415
+ }
416
+ }
417
+ };
418
+ // Handle page unload
419
+ window.addEventListener('beforeunload', handleBeforeUnload);
420
+ window.addEventListener('pagehide', handleBeforeUnload);
421
+ // Handle visibility change (page hidden)
422
+ document.addEventListener('visibilitychange', () => {
423
+ if (document.visibilityState === 'hidden' && this.eventQueue.length > 0) {
424
+ const eventsToSend = [...this.eventQueue];
425
+ this.eventQueue = [];
426
+ const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);
427
+ // Send first chunk with beacon (most important for page hidden)
428
+ if (chunks.length > 0) {
429
+ this.sendEventsWithBeacon(chunks[0]).catch(() => {
430
+ // Silently fail
431
+ });
432
+ }
433
+ }
434
+ });
435
+ }
436
+ async track(eventOrName, propertiesOrOptions, options) {
437
+ try {
438
+ if (this.isDestroyed) {
439
+ const error = new Error('Grain Analytics: Client has been destroyed');
440
+ const formattedError = this.formatError(error, 'track (client destroyed)');
441
+ this.logError(formattedError);
442
+ return;
443
+ }
444
+ let event;
445
+ let opts = {};
446
+ if (typeof eventOrName === 'string') {
447
+ event = {
448
+ eventName: eventOrName,
449
+ properties: propertiesOrOptions,
450
+ };
451
+ opts = options || {};
452
+ }
453
+ else {
454
+ event = eventOrName;
455
+ opts = propertiesOrOptions || {};
456
+ }
457
+ const formattedEvent = this.formatEvent(event);
458
+ this.eventQueue.push(formattedEvent);
459
+ this.log(`Queued event: ${event.eventName}`, event.properties);
460
+ // Check if we should flush immediately
461
+ if (opts.flush || this.eventQueue.length >= this.config.batchSize) {
462
+ await this.flush();
463
+ }
464
+ }
465
+ catch (error) {
466
+ const formattedError = this.formatError(error, 'track');
467
+ this.logError(formattedError);
468
+ }
469
+ }
470
+ /**
471
+ * Identify a user (sets userId for subsequent events)
472
+ */
473
+ identify(userId) {
474
+ this.log(`Identified user: ${userId}`);
475
+ this.globalUserId = userId;
476
+ // Clear persistent anonymous user ID since we now have a real user ID
477
+ this.persistentAnonymousUserId = null;
478
+ }
479
+ /**
480
+ * Set global user ID for all subsequent events
481
+ */
482
+ setUserId(userId) {
483
+ this.log(`Set global user ID: ${userId}`);
484
+ this.globalUserId = userId;
485
+ if (userId) {
486
+ // Clear persistent anonymous user ID if setting a real user ID
487
+ this.persistentAnonymousUserId = null;
488
+ }
489
+ else {
490
+ // If clearing user ID, ensure we have a UUIDv4 identifier
491
+ if (!this.persistentAnonymousUserId) {
492
+ this.persistentAnonymousUserId = this.generateAnonymousUserId();
493
+ // Try to persist the new anonymous ID
494
+ if (typeof window !== 'undefined') {
495
+ try {
496
+ const storageKey = `grain_anonymous_user_id_${this.config.tenantId}`;
497
+ localStorage.setItem(storageKey, this.persistentAnonymousUserId);
498
+ }
499
+ catch (error) {
500
+ this.log('Failed to persist new anonymous user ID:', error);
501
+ }
502
+ }
503
+ }
504
+ }
505
+ }
506
+ /**
507
+ * Get current global user ID
508
+ */
509
+ getUserId() {
510
+ return this.globalUserId;
511
+ }
512
+ /**
513
+ * Get current effective user ID (global userId or persistent anonymous ID)
514
+ */
515
+ getEffectiveUserIdPublic() {
516
+ return this.getEffectiveUserId();
517
+ }
518
+ /**
519
+ * Login with auth token or userId on the fly
520
+ *
521
+ * @example
522
+ * // Login with userId only
523
+ * client.login({ userId: 'user123' });
524
+ *
525
+ * // Login with auth token (automatically sets authStrategy to JWT)
526
+ * client.login({ authToken: 'jwt-token-here' });
527
+ *
528
+ * // Login with both userId and auth token
529
+ * client.login({ userId: 'user123', authToken: 'jwt-token-here' });
530
+ *
531
+ * // Override auth strategy
532
+ * client.login({ userId: 'user123', authStrategy: 'SERVER_SIDE' });
533
+ */
534
+ login(options) {
535
+ try {
536
+ if (this.isDestroyed) {
537
+ const error = new Error('Grain Analytics: Client has been destroyed');
538
+ const formattedError = this.formatError(error, 'login (client destroyed)');
539
+ this.logError(formattedError);
540
+ return;
541
+ }
542
+ // Set userId if provided
543
+ if (options.userId) {
544
+ this.log(`Login: Setting user ID to ${options.userId}`);
545
+ this.globalUserId = options.userId;
546
+ // Clear persistent anonymous user ID since we now have a real user ID
547
+ this.persistentAnonymousUserId = null;
548
+ }
549
+ // Handle auth token if provided
550
+ if (options.authToken) {
551
+ this.log('Login: Setting auth token');
552
+ // Update auth strategy to JWT if not already set
553
+ if (this.config.authStrategy === 'NONE') {
554
+ this.config.authStrategy = 'JWT';
555
+ }
556
+ // Create a simple auth provider that returns the provided token
557
+ this.config.authProvider = {
558
+ getToken: () => options.authToken
559
+ };
560
+ }
561
+ // Override auth strategy if provided
562
+ if (options.authStrategy) {
563
+ this.log(`Login: Setting auth strategy to ${options.authStrategy}`);
564
+ this.config.authStrategy = options.authStrategy;
565
+ }
566
+ this.log(`Login successful. Effective user ID: ${this.getEffectiveUserId()}`);
567
+ }
568
+ catch (error) {
569
+ const formattedError = this.formatError(error, 'login');
570
+ this.logError(formattedError);
571
+ }
572
+ }
573
+ /**
574
+ * Logout and clear user session, fall back to UUIDv4 identifier
575
+ *
576
+ * @example
577
+ * // Logout user and return to anonymous mode
578
+ * client.logout();
579
+ *
580
+ * // After logout, events will use the persistent UUIDv4 identifier
581
+ * client.track('page_view', { page: 'home' });
582
+ */
583
+ logout() {
584
+ try {
585
+ if (this.isDestroyed) {
586
+ const error = new Error('Grain Analytics: Client has been destroyed');
587
+ const formattedError = this.formatError(error, 'logout (client destroyed)');
588
+ this.logError(formattedError);
589
+ return;
590
+ }
591
+ this.log('Logout: Clearing user session');
592
+ // Clear global user ID
593
+ this.globalUserId = null;
594
+ // Reset auth strategy to NONE
595
+ this.config.authStrategy = 'NONE';
596
+ this.config.authProvider = undefined;
597
+ // Generate new UUIDv4 identifier if we don't have one
598
+ if (!this.persistentAnonymousUserId) {
599
+ this.persistentAnonymousUserId = this.generateAnonymousUserId();
600
+ // Try to persist the new anonymous ID
601
+ if (typeof window !== 'undefined') {
602
+ try {
603
+ const storageKey = `grain_anonymous_user_id_${this.config.tenantId}`;
604
+ localStorage.setItem(storageKey, this.persistentAnonymousUserId);
605
+ }
606
+ catch (error) {
607
+ this.log('Failed to persist new anonymous user ID after logout:', error);
608
+ }
609
+ }
610
+ }
611
+ this.log(`Logout successful. Effective user ID: ${this.getEffectiveUserId()}`);
612
+ }
613
+ catch (error) {
614
+ const formattedError = this.formatError(error, 'logout');
615
+ this.logError(formattedError);
616
+ }
617
+ }
618
+ /**
619
+ * Set user properties
620
+ */
621
+ async setProperty(properties, options) {
622
+ try {
623
+ if (this.isDestroyed) {
624
+ const error = new Error('Grain Analytics: Client has been destroyed');
625
+ const formattedError = this.formatError(error, 'setProperty (client destroyed)');
626
+ this.logError(formattedError);
627
+ return;
628
+ }
629
+ const userId = options?.userId || this.getEffectiveUserId();
630
+ // Validate property count (max 4 properties)
631
+ const propertyKeys = Object.keys(properties);
632
+ if (propertyKeys.length > 4) {
633
+ const error = new Error('Grain Analytics: Maximum 4 properties allowed per request');
634
+ const formattedError = this.formatError(error, 'setProperty (validation)');
635
+ this.logError(formattedError);
636
+ return;
637
+ }
638
+ if (propertyKeys.length === 0) {
639
+ const error = new Error('Grain Analytics: At least one property is required');
640
+ const formattedError = this.formatError(error, 'setProperty (validation)');
641
+ this.logError(formattedError);
642
+ return;
643
+ }
644
+ // Serialize all values to strings
645
+ const serializedProperties = {};
646
+ for (const [key, value] of Object.entries(properties)) {
647
+ if (value === null || value === undefined) {
648
+ serializedProperties[key] = '';
649
+ }
650
+ else if (typeof value === 'string') {
651
+ serializedProperties[key] = value;
652
+ }
653
+ else {
654
+ serializedProperties[key] = JSON.stringify(value);
655
+ }
656
+ }
657
+ const payload = {
658
+ userId,
659
+ ...serializedProperties,
660
+ };
661
+ await this.sendProperties(payload);
662
+ }
663
+ catch (error) {
664
+ const formattedError = this.formatError(error, 'setProperty');
665
+ this.logError(formattedError);
666
+ }
667
+ }
668
+ /**
669
+ * Send properties to the API
670
+ */
671
+ async sendProperties(payload) {
672
+ let lastError;
673
+ for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
674
+ try {
675
+ const headers = await this.getAuthHeaders();
676
+ const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/properties`;
677
+ this.log(`Setting properties for user ${payload.userId} (attempt ${attempt + 1})`);
678
+ const response = await fetch(url, {
679
+ method: 'POST',
680
+ headers,
681
+ body: JSON.stringify(payload),
682
+ });
683
+ if (!response.ok) {
684
+ let errorMessage = `HTTP ${response.status}`;
685
+ try {
686
+ const errorBody = await response.json();
687
+ if (errorBody?.message) {
688
+ errorMessage = errorBody.message;
689
+ }
690
+ }
691
+ catch {
692
+ const errorText = await response.text();
693
+ if (errorText) {
694
+ errorMessage = errorText;
695
+ }
696
+ }
697
+ const error = new Error(`Failed to set properties: ${errorMessage}`);
698
+ error.status = response.status;
699
+ throw error;
700
+ }
701
+ this.log(`Successfully set properties for user ${payload.userId}`);
702
+ return; // Success, exit retry loop
703
+ }
704
+ catch (error) {
705
+ lastError = error;
706
+ if (attempt === this.config.retryAttempts) {
707
+ // Last attempt, don't retry - log error gracefully
708
+ const formattedError = this.formatError(error, `sendProperties (attempt ${attempt + 1}/${this.config.retryAttempts + 1})`);
709
+ this.logError(formattedError);
710
+ return; // Don't throw, just return gracefully
711
+ }
712
+ if (!this.isRetriableError(error)) {
713
+ // Non-retriable error, don't retry - log error gracefully
714
+ const formattedError = this.formatError(error, 'sendProperties (non-retriable error)');
715
+ this.logError(formattedError);
716
+ return; // Don't throw, just return gracefully
717
+ }
718
+ const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff
719
+ this.log(`Retrying in ${delayMs}ms after error:`, error);
720
+ await this.delay(delayMs);
721
+ }
722
+ }
723
+ }
724
+ // Template event methods
725
+ /**
726
+ * Track user login event
727
+ */
728
+ async trackLogin(properties, options) {
729
+ try {
730
+ return await this.track('login', properties, options);
731
+ }
732
+ catch (error) {
733
+ const formattedError = this.formatError(error, 'trackLogin');
734
+ this.logError(formattedError);
735
+ }
736
+ }
737
+ /**
738
+ * Track user signup event
739
+ */
740
+ async trackSignup(properties, options) {
741
+ try {
742
+ return await this.track('signup', properties, options);
743
+ }
744
+ catch (error) {
745
+ const formattedError = this.formatError(error, 'trackSignup');
746
+ this.logError(formattedError);
747
+ }
748
+ }
749
+ /**
750
+ * Track checkout event
751
+ */
752
+ async trackCheckout(properties, options) {
753
+ try {
754
+ return await this.track('checkout', properties, options);
755
+ }
756
+ catch (error) {
757
+ const formattedError = this.formatError(error, 'trackCheckout');
758
+ this.logError(formattedError);
759
+ }
760
+ }
761
+ /**
762
+ * Track page view event
763
+ */
764
+ async trackPageView(properties, options) {
765
+ try {
766
+ return await this.track('page_view', properties, options);
767
+ }
768
+ catch (error) {
769
+ const formattedError = this.formatError(error, 'trackPageView');
770
+ this.logError(formattedError);
771
+ }
772
+ }
773
+ /**
774
+ * Track purchase event
775
+ */
776
+ async trackPurchase(properties, options) {
777
+ try {
778
+ return await this.track('purchase', properties, options);
779
+ }
780
+ catch (error) {
781
+ const formattedError = this.formatError(error, 'trackPurchase');
782
+ this.logError(formattedError);
783
+ }
784
+ }
785
+ /**
786
+ * Track search event
787
+ */
788
+ async trackSearch(properties, options) {
789
+ try {
790
+ return await this.track('search', properties, options);
791
+ }
792
+ catch (error) {
793
+ const formattedError = this.formatError(error, 'trackSearch');
794
+ this.logError(formattedError);
795
+ }
796
+ }
797
+ /**
798
+ * Track add to cart event
799
+ */
800
+ async trackAddToCart(properties, options) {
801
+ try {
802
+ return await this.track('add_to_cart', properties, options);
803
+ }
804
+ catch (error) {
805
+ const formattedError = this.formatError(error, 'trackAddToCart');
806
+ this.logError(formattedError);
807
+ }
808
+ }
809
+ /**
810
+ * Track remove from cart event
811
+ */
812
+ async trackRemoveFromCart(properties, options) {
813
+ try {
814
+ return await this.track('remove_from_cart', properties, options);
815
+ }
816
+ catch (error) {
817
+ const formattedError = this.formatError(error, 'trackRemoveFromCart');
818
+ this.logError(formattedError);
819
+ }
820
+ }
821
+ /**
822
+ * Manually flush all queued events
823
+ */
824
+ async flush() {
825
+ try {
826
+ if (this.eventQueue.length === 0)
827
+ return;
828
+ const eventsToSend = [...this.eventQueue];
829
+ this.eventQueue = [];
830
+ // Split events into chunks to respect maxEventsPerRequest limit
831
+ const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);
832
+ // Send all chunks sequentially to maintain order
833
+ for (const chunk of chunks) {
834
+ await this.sendEvents(chunk);
835
+ }
836
+ }
837
+ catch (error) {
838
+ const formattedError = this.formatError(error, 'flush');
839
+ this.logError(formattedError);
840
+ }
841
+ }
842
+ // Remote Config Methods
843
+ /**
844
+ * Initialize configuration cache from localStorage
845
+ */
846
+ initializeConfigCache() {
847
+ if (!this.config.enableConfigCache || typeof window === 'undefined')
848
+ return;
849
+ try {
850
+ const cached = localStorage.getItem(this.config.configCacheKey);
851
+ if (cached) {
852
+ this.configCache = JSON.parse(cached);
853
+ this.log('Loaded configuration from cache:', this.configCache);
854
+ }
855
+ }
856
+ catch (error) {
857
+ this.log('Failed to load configuration cache:', error);
858
+ }
859
+ }
860
+ /**
861
+ * Save configuration cache to localStorage
862
+ */
863
+ saveConfigCache(cache) {
864
+ if (!this.config.enableConfigCache || typeof window === 'undefined')
865
+ return;
866
+ try {
867
+ localStorage.setItem(this.config.configCacheKey, JSON.stringify(cache));
868
+ this.log('Saved configuration to cache:', cache);
869
+ }
870
+ catch (error) {
871
+ this.log('Failed to save configuration cache:', error);
872
+ }
873
+ }
874
+ /**
875
+ * Get configuration value with fallback to defaults
876
+ */
877
+ getConfig(key) {
878
+ // First check cache
879
+ if (this.configCache?.configurations?.[key]) {
880
+ return this.configCache.configurations[key];
881
+ }
882
+ // Then check defaults
883
+ if (this.config.defaultConfigurations?.[key]) {
884
+ return this.config.defaultConfigurations[key];
885
+ }
886
+ return undefined;
887
+ }
888
+ /**
889
+ * Get all configurations with fallback to defaults
890
+ */
891
+ getAllConfigs() {
892
+ const configs = { ...this.config.defaultConfigurations };
893
+ if (this.configCache?.configurations) {
894
+ Object.assign(configs, this.configCache.configurations);
895
+ }
896
+ return configs;
897
+ }
898
+ /**
899
+ * Fetch configurations from API
900
+ */
901
+ async fetchConfig(options = {}) {
902
+ try {
903
+ if (this.isDestroyed) {
904
+ const error = new Error('Grain Analytics: Client has been destroyed');
905
+ const formattedError = this.formatError(error, 'fetchConfig (client destroyed)');
906
+ this.logError(formattedError);
907
+ return null;
908
+ }
909
+ const userId = options.userId || this.getEffectiveUserId();
910
+ const immediateKeys = options.immediateKeys || [];
911
+ const properties = options.properties || {};
912
+ const request = {
913
+ userId,
914
+ immediateKeys,
915
+ properties,
916
+ };
917
+ let lastError;
918
+ for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
919
+ try {
920
+ const headers = await this.getAuthHeaders();
921
+ const url = `${this.config.apiUrl}/v1/client/${encodeURIComponent(this.config.tenantId)}/config/configurations`;
922
+ this.log(`Fetching configurations for user ${userId} (attempt ${attempt + 1})`);
923
+ const response = await fetch(url, {
924
+ method: 'POST',
925
+ headers,
926
+ body: JSON.stringify(request),
927
+ });
928
+ if (!response.ok) {
929
+ let errorMessage = `HTTP ${response.status}`;
930
+ try {
931
+ const errorBody = await response.json();
932
+ if (errorBody?.message) {
933
+ errorMessage = errorBody.message;
934
+ }
935
+ }
936
+ catch {
937
+ const errorText = await response.text();
938
+ if (errorText) {
939
+ errorMessage = errorText;
940
+ }
941
+ }
942
+ const error = new Error(`Failed to fetch configurations: ${errorMessage}`);
943
+ error.status = response.status;
944
+ throw error;
945
+ }
946
+ const configResponse = await response.json();
947
+ // Update cache if successful
948
+ if (configResponse.configurations) {
949
+ this.updateConfigCache(configResponse, userId);
950
+ }
951
+ this.log(`Successfully fetched configurations for user ${userId}:`, configResponse);
952
+ return configResponse;
953
+ }
954
+ catch (error) {
955
+ lastError = error;
956
+ if (attempt === this.config.retryAttempts) {
957
+ // Last attempt, don't retry - log error gracefully
958
+ const formattedError = this.formatError(error, `fetchConfig (attempt ${attempt + 1}/${this.config.retryAttempts + 1})`);
959
+ this.logError(formattedError);
960
+ return null;
961
+ }
962
+ if (!this.isRetriableError(error)) {
963
+ // Non-retriable error, don't retry - log error gracefully
964
+ const formattedError = this.formatError(error, 'fetchConfig (non-retriable error)');
965
+ this.logError(formattedError);
966
+ return null;
967
+ }
968
+ const delayMs = this.config.retryDelay * Math.pow(2, attempt);
969
+ this.log(`Retrying config fetch in ${delayMs}ms after error:`, error);
970
+ await this.delay(delayMs);
971
+ }
972
+ }
973
+ return null;
974
+ }
975
+ catch (error) {
976
+ const formattedError = this.formatError(error, 'fetchConfig');
977
+ this.logError(formattedError);
978
+ return null;
979
+ }
980
+ }
981
+ /**
982
+ * Get configuration asynchronously (cache-first with fallback to API)
983
+ */
984
+ async getConfigAsync(key, options = {}) {
985
+ try {
986
+ // Return immediately if we have it in cache and not forcing refresh
987
+ if (!options.forceRefresh && this.configCache?.configurations?.[key]) {
988
+ return this.configCache.configurations[key];
989
+ }
990
+ // Return default if available and not forcing refresh
991
+ if (!options.forceRefresh && this.config.defaultConfigurations?.[key]) {
992
+ return this.config.defaultConfigurations[key];
993
+ }
994
+ // Fetch from API
995
+ const response = await this.fetchConfig(options);
996
+ if (response) {
997
+ return response.configurations[key];
998
+ }
999
+ // Return default as fallback
1000
+ return this.config.defaultConfigurations?.[key];
1001
+ }
1002
+ catch (error) {
1003
+ const formattedError = this.formatError(error, 'getConfigAsync');
1004
+ this.logError(formattedError);
1005
+ // Return default as fallback
1006
+ return this.config.defaultConfigurations?.[key];
1007
+ }
1008
+ }
1009
+ /**
1010
+ * Get all configurations asynchronously (cache-first with fallback to API)
1011
+ */
1012
+ async getAllConfigsAsync(options = {}) {
1013
+ try {
1014
+ // Return cache if available and not forcing refresh
1015
+ if (!options.forceRefresh && this.configCache?.configurations) {
1016
+ return { ...this.config.defaultConfigurations, ...this.configCache.configurations };
1017
+ }
1018
+ // Fetch from API
1019
+ const response = await this.fetchConfig(options);
1020
+ if (response) {
1021
+ return { ...this.config.defaultConfigurations, ...response.configurations };
1022
+ }
1023
+ // Return defaults as fallback
1024
+ return { ...this.config.defaultConfigurations };
1025
+ }
1026
+ catch (error) {
1027
+ const formattedError = this.formatError(error, 'getAllConfigsAsync');
1028
+ this.logError(formattedError);
1029
+ // Return defaults as fallback
1030
+ return { ...this.config.defaultConfigurations };
1031
+ }
1032
+ }
1033
+ /**
1034
+ * Update configuration cache and notify listeners
1035
+ */
1036
+ updateConfigCache(response, userId) {
1037
+ const newCache = {
1038
+ configurations: response.configurations,
1039
+ snapshotId: response.snapshotId,
1040
+ timestamp: response.timestamp,
1041
+ userId,
1042
+ };
1043
+ const oldConfigs = this.configCache?.configurations || {};
1044
+ this.configCache = newCache;
1045
+ this.saveConfigCache(newCache);
1046
+ // Notify listeners if configurations changed
1047
+ if (JSON.stringify(oldConfigs) !== JSON.stringify(response.configurations)) {
1048
+ this.notifyConfigChangeListeners(response.configurations);
1049
+ }
1050
+ }
1051
+ /**
1052
+ * Add configuration change listener
1053
+ */
1054
+ addConfigChangeListener(listener) {
1055
+ this.configChangeListeners.push(listener);
1056
+ }
1057
+ /**
1058
+ * Remove configuration change listener
1059
+ */
1060
+ removeConfigChangeListener(listener) {
1061
+ const index = this.configChangeListeners.indexOf(listener);
1062
+ if (index > -1) {
1063
+ this.configChangeListeners.splice(index, 1);
1064
+ }
1065
+ }
1066
+ /**
1067
+ * Notify all configuration change listeners
1068
+ */
1069
+ notifyConfigChangeListeners(configurations) {
1070
+ this.configChangeListeners.forEach(listener => {
1071
+ try {
1072
+ listener(configurations);
1073
+ }
1074
+ catch (error) {
1075
+ console.error('[Grain Analytics] Config change listener error:', error);
1076
+ }
1077
+ });
1078
+ }
1079
+ /**
1080
+ * Start automatic configuration refresh timer
1081
+ */
1082
+ startConfigRefreshTimer() {
1083
+ if (this.configRefreshTimer) {
1084
+ clearInterval(this.configRefreshTimer);
1085
+ }
1086
+ this.configRefreshTimer = window.setInterval(() => {
1087
+ if (!this.isDestroyed) {
1088
+ // Use effective userId (will be generated if not set)
1089
+ this.fetchConfig().catch((error) => {
1090
+ const formattedError = this.formatError(error, 'auto-config refresh');
1091
+ this.logError(formattedError);
1092
+ });
1093
+ }
1094
+ }, this.config.configRefreshInterval);
1095
+ }
1096
+ /**
1097
+ * Stop automatic configuration refresh timer
1098
+ */
1099
+ stopConfigRefreshTimer() {
1100
+ if (this.configRefreshTimer) {
1101
+ clearInterval(this.configRefreshTimer);
1102
+ this.configRefreshTimer = null;
1103
+ }
1104
+ }
1105
+ /**
1106
+ * Preload configurations for immediate access
1107
+ */
1108
+ async preloadConfig(immediateKeys = [], properties) {
1109
+ try {
1110
+ // Use effective userId (will be generated if not set)
1111
+ const effectiveUserId = this.getEffectiveUserId();
1112
+ this.log(`Preloading config for user: ${effectiveUserId}`);
1113
+ const response = await this.fetchConfig({ immediateKeys, properties });
1114
+ if (response) {
1115
+ this.startConfigRefreshTimer();
1116
+ }
1117
+ }
1118
+ catch (error) {
1119
+ const formattedError = this.formatError(error, 'preloadConfig');
1120
+ this.logError(formattedError);
1121
+ }
1122
+ }
1123
+ /**
1124
+ * Split events array into chunks of specified size
1125
+ */
1126
+ chunkEvents(events, chunkSize) {
1127
+ const chunks = [];
1128
+ for (let i = 0; i < events.length; i += chunkSize) {
1129
+ chunks.push(events.slice(i, i + chunkSize));
1130
+ }
1131
+ return chunks;
1132
+ }
1133
+ /**
1134
+ * Destroy the client and clean up resources
1135
+ */
1136
+ destroy() {
1137
+ this.isDestroyed = true;
1138
+ if (this.flushTimer) {
1139
+ clearInterval(this.flushTimer);
1140
+ this.flushTimer = null;
1141
+ }
1142
+ // Stop config refresh timer
1143
+ this.stopConfigRefreshTimer();
1144
+ // Clear config change listeners
1145
+ this.configChangeListeners = [];
1146
+ // Send any remaining events (in chunks if necessary)
1147
+ if (this.eventQueue.length > 0) {
1148
+ const eventsToSend = [...this.eventQueue];
1149
+ this.eventQueue = [];
1150
+ const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);
1151
+ // Send first chunk with beacon (most important for page unload)
1152
+ if (chunks.length > 0) {
1153
+ this.sendEventsWithBeacon(chunks[0]).catch(() => {
1154
+ // Silently fail during cleanup
1155
+ });
1156
+ // If there are more chunks, try to send them with regular fetch
1157
+ for (let i = 1; i < chunks.length; i++) {
1158
+ this.sendEventsWithBeacon(chunks[i]).catch(() => {
1159
+ // Silently fail during cleanup
1160
+ });
1161
+ }
1162
+ }
1163
+ }
1164
+ }
1165
+ }
1166
+ exports.GrainAnalytics = GrainAnalytics;
1167
+ /**
1168
+ * Create a new Grain Analytics client
1169
+ */
1170
+ function createGrainAnalytics(config) {
1171
+ return new GrainAnalytics(config);
1172
+ }
1173
+ // Default export for convenience
1174
+ exports.default = GrainAnalytics;
1175
+ // Auto-setup for IIFE build
1176
+ if (typeof window !== 'undefined') {
1177
+ window.Grain = {
1178
+ GrainAnalytics,
1179
+ createGrainAnalytics,
1180
+ };
1181
+ }