@grainql/analytics-web 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ class GrainAnalytics {
12
12
  this.flushTimer = null;
13
13
  this.isDestroyed = false;
14
14
  this.globalUserId = null;
15
+ this.persistentAnonymousUserId = null;
15
16
  // Remote Config properties
16
17
  this.configCache = null;
17
18
  this.configRefreshTimer = null;
@@ -39,6 +40,7 @@ class GrainAnalytics {
39
40
  this.globalUserId = config.userId;
40
41
  }
41
42
  this.validateConfig();
43
+ this.initializePersistentAnonymousUserId();
42
44
  this.setupBeforeUnload();
43
45
  this.startFlushTimer();
44
46
  this.initializeConfigCache();
@@ -54,15 +56,188 @@ class GrainAnalytics {
54
56
  throw new Error('Grain Analytics: authProvider is required for JWT auth strategy');
55
57
  }
56
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
+ * Format UUID for anonymous user ID (remove dashes and prefix with 'temp:')
75
+ */
76
+ formatAnonymousUserId(uuid) {
77
+ return `temp:${uuid.replace(/-/g, '')}`;
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 anonymous user ID
94
+ const uuid = this.generateUUID();
95
+ this.persistentAnonymousUserId = this.formatAnonymousUserId(uuid);
96
+ localStorage.setItem(storageKey, this.persistentAnonymousUserId);
97
+ this.log('Generated new persistent anonymous user ID:', this.persistentAnonymousUserId);
98
+ }
99
+ }
100
+ catch (error) {
101
+ this.log('Failed to initialize persistent anonymous user ID:', error);
102
+ // Fallback: generate temporary ID without persistence
103
+ const uuid = this.generateUUID();
104
+ this.persistentAnonymousUserId = this.formatAnonymousUserId(uuid);
105
+ }
106
+ }
107
+ /**
108
+ * Get the effective user ID (global userId or persistent anonymous ID)
109
+ */
110
+ getEffectiveUserId() {
111
+ return this.globalUserId || this.persistentAnonymousUserId || 'anonymous';
112
+ }
57
113
  log(...args) {
58
114
  if (this.config.debug) {
59
115
  console.log('[Grain Analytics]', ...args);
60
116
  }
61
117
  }
118
+ /**
119
+ * Create error digest from events
120
+ */
121
+ createErrorDigest(events) {
122
+ const eventNames = [...new Set(events.map(e => e.eventName))];
123
+ const userIds = [...new Set(events.map(e => e.userId))];
124
+ let totalProperties = 0;
125
+ let totalSize = 0;
126
+ events.forEach(event => {
127
+ const properties = event.properties || {};
128
+ totalProperties += Object.keys(properties).length;
129
+ totalSize += JSON.stringify(event).length;
130
+ });
131
+ return {
132
+ eventCount: events.length,
133
+ totalProperties,
134
+ totalSize,
135
+ eventNames,
136
+ userIds,
137
+ };
138
+ }
139
+ /**
140
+ * Format error with beautiful structure
141
+ */
142
+ formatError(error, context, events) {
143
+ const digest = events ? this.createErrorDigest(events) : {
144
+ eventCount: 0,
145
+ totalProperties: 0,
146
+ totalSize: 0,
147
+ eventNames: [],
148
+ userIds: [],
149
+ };
150
+ let code = 'UNKNOWN_ERROR';
151
+ let message = 'An unknown error occurred';
152
+ if (error instanceof Error) {
153
+ message = error.message;
154
+ // Determine error code based on error type and message
155
+ if (message.includes('fetch failed') || message.includes('network error')) {
156
+ code = 'NETWORK_ERROR';
157
+ }
158
+ else if (message.includes('timeout')) {
159
+ code = 'TIMEOUT_ERROR';
160
+ }
161
+ else if (message.includes('HTTP 4')) {
162
+ code = 'CLIENT_ERROR';
163
+ }
164
+ else if (message.includes('HTTP 5')) {
165
+ code = 'SERVER_ERROR';
166
+ }
167
+ else if (message.includes('JSON')) {
168
+ code = 'PARSE_ERROR';
169
+ }
170
+ else if (message.includes('auth') || message.includes('unauthorized')) {
171
+ code = 'AUTH_ERROR';
172
+ }
173
+ else if (message.includes('rate limit') || message.includes('429')) {
174
+ code = 'RATE_LIMIT_ERROR';
175
+ }
176
+ else {
177
+ code = 'GENERAL_ERROR';
178
+ }
179
+ }
180
+ else if (typeof error === 'string') {
181
+ message = error;
182
+ code = 'STRING_ERROR';
183
+ }
184
+ else if (error && typeof error === 'object' && 'status' in error) {
185
+ const status = error.status;
186
+ code = `HTTP_${status}`;
187
+ message = `HTTP ${status} error`;
188
+ }
189
+ return {
190
+ code,
191
+ message,
192
+ digest,
193
+ timestamp: new Date().toISOString(),
194
+ context,
195
+ originalError: error,
196
+ };
197
+ }
198
+ /**
199
+ * Log formatted error gracefully
200
+ */
201
+ logError(formattedError) {
202
+ const { code, message, digest, timestamp, context } = formattedError;
203
+ const errorOutput = {
204
+ '🚨 Grain Analytics Error': {
205
+ 'Error Code': code,
206
+ 'Message': message,
207
+ 'Context': context,
208
+ 'Timestamp': timestamp,
209
+ 'Event Digest': {
210
+ 'Events': digest.eventCount,
211
+ 'Properties': digest.totalProperties,
212
+ 'Size (bytes)': digest.totalSize,
213
+ 'Event Names': digest.eventNames.length > 0 ? digest.eventNames.join(', ') : 'None',
214
+ 'User IDs': digest.userIds.length > 0 ? digest.userIds.slice(0, 3).join(', ') + (digest.userIds.length > 3 ? '...' : '') : 'None',
215
+ }
216
+ }
217
+ };
218
+ console.error('🚨 Grain Analytics Error:', errorOutput);
219
+ // Also log in a more compact format for debugging
220
+ if (this.config.debug) {
221
+ console.error(`[Grain Analytics] ${code}: ${message} (${context}) - Events: ${digest.eventCount}, Props: ${digest.totalProperties}, Size: ${digest.totalSize}B`);
222
+ }
223
+ }
224
+ /**
225
+ * Safely execute a function with error handling
226
+ */
227
+ async safeExecute(fn, context, events) {
228
+ try {
229
+ return await fn();
230
+ }
231
+ catch (error) {
232
+ const formattedError = this.formatError(error, context, events);
233
+ this.logError(formattedError);
234
+ return null;
235
+ }
236
+ }
62
237
  formatEvent(event) {
63
238
  return {
64
239
  eventName: event.eventName,
65
- userId: event.userId || this.globalUserId || 'anonymous',
240
+ userId: event.userId || this.getEffectiveUserId(),
66
241
  properties: event.properties || {},
67
242
  };
68
243
  }
@@ -146,20 +321,22 @@ class GrainAnalytics {
146
321
  catch (error) {
147
322
  lastError = error;
148
323
  if (attempt === this.config.retryAttempts) {
149
- // Last attempt, don't retry
150
- break;
324
+ // Last attempt, don't retry - log error gracefully
325
+ const formattedError = this.formatError(error, `sendEvents (attempt ${attempt + 1}/${this.config.retryAttempts + 1})`, events);
326
+ this.logError(formattedError);
327
+ return; // Don't throw, just return gracefully
151
328
  }
152
329
  if (!this.isRetriableError(error)) {
153
- // Non-retriable error, don't retry
154
- break;
330
+ // Non-retriable error, don't retry - log error gracefully
331
+ const formattedError = this.formatError(error, `sendEvents (non-retriable error)`, events);
332
+ this.logError(formattedError);
333
+ return; // Don't throw, just return gracefully
155
334
  }
156
335
  const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff
157
336
  this.log(`Retrying in ${delayMs}ms after error:`, error);
158
337
  await this.delay(delayMs);
159
338
  }
160
339
  }
161
- console.error('[Grain Analytics] Failed to send events after all retries:', lastError);
162
- throw lastError;
163
340
  }
164
341
  async sendEventsWithBeacon(events) {
165
342
  if (events.length === 0)
@@ -187,7 +364,9 @@ class GrainAnalytics {
187
364
  this.log(`Successfully sent ${events.length} events via fetch (keepalive)`);
188
365
  }
189
366
  catch (error) {
190
- console.error('[Grain Analytics] Failed to send events via beacon:', error);
367
+ // Log error gracefully for beacon failures (page unload scenarios)
368
+ const formattedError = this.formatError(error, 'sendEventsWithBeacon', events);
369
+ this.logError(formattedError);
191
370
  }
192
371
  }
193
372
  startFlushTimer() {
@@ -197,7 +376,8 @@ class GrainAnalytics {
197
376
  this.flushTimer = window.setInterval(() => {
198
377
  if (this.eventQueue.length > 0) {
199
378
  this.flush().catch((error) => {
200
- console.error('[Grain Analytics] Auto-flush failed:', error);
379
+ const formattedError = this.formatError(error, 'auto-flush');
380
+ this.logError(formattedError);
201
381
  });
202
382
  }
203
383
  }, this.config.flushInterval);
@@ -238,28 +418,37 @@ class GrainAnalytics {
238
418
  });
239
419
  }
240
420
  async track(eventOrName, propertiesOrOptions, options) {
241
- if (this.isDestroyed) {
242
- throw new Error('Grain Analytics: Client has been destroyed');
243
- }
244
- let event;
245
- let opts = {};
246
- if (typeof eventOrName === 'string') {
247
- event = {
248
- eventName: eventOrName,
249
- properties: propertiesOrOptions,
250
- };
251
- opts = options || {};
252
- }
253
- else {
254
- event = eventOrName;
255
- opts = propertiesOrOptions || {};
421
+ try {
422
+ if (this.isDestroyed) {
423
+ const error = new Error('Grain Analytics: Client has been destroyed');
424
+ const formattedError = this.formatError(error, 'track (client destroyed)');
425
+ this.logError(formattedError);
426
+ return;
427
+ }
428
+ let event;
429
+ let opts = {};
430
+ if (typeof eventOrName === 'string') {
431
+ event = {
432
+ eventName: eventOrName,
433
+ properties: propertiesOrOptions,
434
+ };
435
+ opts = options || {};
436
+ }
437
+ else {
438
+ event = eventOrName;
439
+ opts = propertiesOrOptions || {};
440
+ }
441
+ const formattedEvent = this.formatEvent(event);
442
+ this.eventQueue.push(formattedEvent);
443
+ this.log(`Queued event: ${event.eventName}`, event.properties);
444
+ // Check if we should flush immediately
445
+ if (opts.flush || this.eventQueue.length >= this.config.batchSize) {
446
+ await this.flush();
447
+ }
256
448
  }
257
- const formattedEvent = this.formatEvent(event);
258
- this.eventQueue.push(formattedEvent);
259
- this.log(`Queued event: ${event.eventName}`, event.properties);
260
- // Check if we should flush immediately
261
- if (opts.flush || this.eventQueue.length >= this.config.batchSize) {
262
- await this.flush();
449
+ catch (error) {
450
+ const formattedError = this.formatError(error, 'track');
451
+ this.logError(formattedError);
263
452
  }
264
453
  }
265
454
  /**
@@ -268,6 +457,8 @@ class GrainAnalytics {
268
457
  identify(userId) {
269
458
  this.log(`Identified user: ${userId}`);
270
459
  this.globalUserId = userId;
460
+ // Clear persistent anonymous user ID since we now have a real user ID
461
+ this.persistentAnonymousUserId = null;
271
462
  }
272
463
  /**
273
464
  * Set global user ID for all subsequent events
@@ -275,6 +466,10 @@ class GrainAnalytics {
275
466
  setUserId(userId) {
276
467
  this.log(`Set global user ID: ${userId}`);
277
468
  this.globalUserId = userId;
469
+ // Clear persistent anonymous user ID if setting a real user ID
470
+ if (userId) {
471
+ this.persistentAnonymousUserId = null;
472
+ }
278
473
  }
279
474
  /**
280
475
  * Get current global user ID
@@ -282,40 +477,61 @@ class GrainAnalytics {
282
477
  getUserId() {
283
478
  return this.globalUserId;
284
479
  }
480
+ /**
481
+ * Get current effective user ID (global userId or persistent anonymous ID)
482
+ */
483
+ getEffectiveUserIdPublic() {
484
+ return this.getEffectiveUserId();
485
+ }
285
486
  /**
286
487
  * Set user properties
287
488
  */
288
489
  async setProperty(properties, options) {
289
- if (this.isDestroyed) {
290
- throw new Error('Grain Analytics: Client has been destroyed');
291
- }
292
- const userId = options?.userId || this.globalUserId || 'anonymous';
293
- // Validate property count (max 4 properties)
294
- const propertyKeys = Object.keys(properties);
295
- if (propertyKeys.length > 4) {
296
- throw new Error('Grain Analytics: Maximum 4 properties allowed per request');
297
- }
298
- if (propertyKeys.length === 0) {
299
- throw new Error('Grain Analytics: At least one property is required');
300
- }
301
- // Serialize all values to strings
302
- const serializedProperties = {};
303
- for (const [key, value] of Object.entries(properties)) {
304
- if (value === null || value === undefined) {
305
- serializedProperties[key] = '';
490
+ try {
491
+ if (this.isDestroyed) {
492
+ const error = new Error('Grain Analytics: Client has been destroyed');
493
+ const formattedError = this.formatError(error, 'setProperty (client destroyed)');
494
+ this.logError(formattedError);
495
+ return;
306
496
  }
307
- else if (typeof value === 'string') {
308
- serializedProperties[key] = value;
497
+ const userId = options?.userId || this.getEffectiveUserId();
498
+ // Validate property count (max 4 properties)
499
+ const propertyKeys = Object.keys(properties);
500
+ if (propertyKeys.length > 4) {
501
+ const error = new Error('Grain Analytics: Maximum 4 properties allowed per request');
502
+ const formattedError = this.formatError(error, 'setProperty (validation)');
503
+ this.logError(formattedError);
504
+ return;
309
505
  }
310
- else {
311
- serializedProperties[key] = JSON.stringify(value);
506
+ if (propertyKeys.length === 0) {
507
+ const error = new Error('Grain Analytics: At least one property is required');
508
+ const formattedError = this.formatError(error, 'setProperty (validation)');
509
+ this.logError(formattedError);
510
+ return;
511
+ }
512
+ // Serialize all values to strings
513
+ const serializedProperties = {};
514
+ for (const [key, value] of Object.entries(properties)) {
515
+ if (value === null || value === undefined) {
516
+ serializedProperties[key] = '';
517
+ }
518
+ else if (typeof value === 'string') {
519
+ serializedProperties[key] = value;
520
+ }
521
+ else {
522
+ serializedProperties[key] = JSON.stringify(value);
523
+ }
312
524
  }
525
+ const payload = {
526
+ userId,
527
+ ...serializedProperties,
528
+ };
529
+ await this.sendProperties(payload);
530
+ }
531
+ catch (error) {
532
+ const formattedError = this.formatError(error, 'setProperty');
533
+ this.logError(formattedError);
313
534
  }
314
- const payload = {
315
- userId,
316
- ...serializedProperties,
317
- };
318
- await this.sendProperties(payload);
319
535
  }
320
536
  /**
321
537
  * Send properties to the API
@@ -356,83 +572,139 @@ class GrainAnalytics {
356
572
  catch (error) {
357
573
  lastError = error;
358
574
  if (attempt === this.config.retryAttempts) {
359
- // Last attempt, don't retry
360
- break;
575
+ // Last attempt, don't retry - log error gracefully
576
+ const formattedError = this.formatError(error, `sendProperties (attempt ${attempt + 1}/${this.config.retryAttempts + 1})`);
577
+ this.logError(formattedError);
578
+ return; // Don't throw, just return gracefully
361
579
  }
362
580
  if (!this.isRetriableError(error)) {
363
- // Non-retriable error, don't retry
364
- break;
581
+ // Non-retriable error, don't retry - log error gracefully
582
+ const formattedError = this.formatError(error, 'sendProperties (non-retriable error)');
583
+ this.logError(formattedError);
584
+ return; // Don't throw, just return gracefully
365
585
  }
366
586
  const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff
367
587
  this.log(`Retrying in ${delayMs}ms after error:`, error);
368
588
  await this.delay(delayMs);
369
589
  }
370
590
  }
371
- console.error('[Grain Analytics] Failed to set properties after all retries:', lastError);
372
- throw lastError;
373
591
  }
374
592
  // Template event methods
375
593
  /**
376
594
  * Track user login event
377
595
  */
378
596
  async trackLogin(properties, options) {
379
- return this.track('login', properties, options);
597
+ try {
598
+ return await this.track('login', properties, options);
599
+ }
600
+ catch (error) {
601
+ const formattedError = this.formatError(error, 'trackLogin');
602
+ this.logError(formattedError);
603
+ }
380
604
  }
381
605
  /**
382
606
  * Track user signup event
383
607
  */
384
608
  async trackSignup(properties, options) {
385
- return this.track('signup', properties, options);
609
+ try {
610
+ return await this.track('signup', properties, options);
611
+ }
612
+ catch (error) {
613
+ const formattedError = this.formatError(error, 'trackSignup');
614
+ this.logError(formattedError);
615
+ }
386
616
  }
387
617
  /**
388
618
  * Track checkout event
389
619
  */
390
620
  async trackCheckout(properties, options) {
391
- return this.track('checkout', properties, options);
621
+ try {
622
+ return await this.track('checkout', properties, options);
623
+ }
624
+ catch (error) {
625
+ const formattedError = this.formatError(error, 'trackCheckout');
626
+ this.logError(formattedError);
627
+ }
392
628
  }
393
629
  /**
394
630
  * Track page view event
395
631
  */
396
632
  async trackPageView(properties, options) {
397
- return this.track('page_view', properties, options);
633
+ try {
634
+ return await this.track('page_view', properties, options);
635
+ }
636
+ catch (error) {
637
+ const formattedError = this.formatError(error, 'trackPageView');
638
+ this.logError(formattedError);
639
+ }
398
640
  }
399
641
  /**
400
642
  * Track purchase event
401
643
  */
402
644
  async trackPurchase(properties, options) {
403
- return this.track('purchase', properties, options);
645
+ try {
646
+ return await this.track('purchase', properties, options);
647
+ }
648
+ catch (error) {
649
+ const formattedError = this.formatError(error, 'trackPurchase');
650
+ this.logError(formattedError);
651
+ }
404
652
  }
405
653
  /**
406
654
  * Track search event
407
655
  */
408
656
  async trackSearch(properties, options) {
409
- return this.track('search', properties, options);
657
+ try {
658
+ return await this.track('search', properties, options);
659
+ }
660
+ catch (error) {
661
+ const formattedError = this.formatError(error, 'trackSearch');
662
+ this.logError(formattedError);
663
+ }
410
664
  }
411
665
  /**
412
666
  * Track add to cart event
413
667
  */
414
668
  async trackAddToCart(properties, options) {
415
- return this.track('add_to_cart', properties, options);
669
+ try {
670
+ return await this.track('add_to_cart', properties, options);
671
+ }
672
+ catch (error) {
673
+ const formattedError = this.formatError(error, 'trackAddToCart');
674
+ this.logError(formattedError);
675
+ }
416
676
  }
417
677
  /**
418
678
  * Track remove from cart event
419
679
  */
420
680
  async trackRemoveFromCart(properties, options) {
421
- return this.track('remove_from_cart', properties, options);
681
+ try {
682
+ return await this.track('remove_from_cart', properties, options);
683
+ }
684
+ catch (error) {
685
+ const formattedError = this.formatError(error, 'trackRemoveFromCart');
686
+ this.logError(formattedError);
687
+ }
422
688
  }
423
689
  /**
424
690
  * Manually flush all queued events
425
691
  */
426
692
  async flush() {
427
- if (this.eventQueue.length === 0)
428
- return;
429
- const eventsToSend = [...this.eventQueue];
430
- this.eventQueue = [];
431
- // Split events into chunks to respect maxEventsPerRequest limit
432
- const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);
433
- // Send all chunks sequentially to maintain order
434
- for (const chunk of chunks) {
435
- await this.sendEvents(chunk);
693
+ try {
694
+ if (this.eventQueue.length === 0)
695
+ return;
696
+ const eventsToSend = [...this.eventQueue];
697
+ this.eventQueue = [];
698
+ // Split events into chunks to respect maxEventsPerRequest limit
699
+ const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);
700
+ // Send all chunks sequentially to maintain order
701
+ for (const chunk of chunks) {
702
+ await this.sendEvents(chunk);
703
+ }
704
+ }
705
+ catch (error) {
706
+ const formattedError = this.formatError(error, 'flush');
707
+ this.logError(formattedError);
436
708
  }
437
709
  }
438
710
  // Remote Config Methods
@@ -495,89 +767,109 @@ class GrainAnalytics {
495
767
  * Fetch configurations from API
496
768
  */
497
769
  async fetchConfig(options = {}) {
498
- if (this.isDestroyed) {
499
- throw new Error('Grain Analytics: Client has been destroyed');
500
- }
501
- const userId = options.userId || this.globalUserId || 'anonymous';
502
- const immediateKeys = options.immediateKeys || [];
503
- const properties = options.properties || {};
504
- const request = {
505
- userId,
506
- immediateKeys,
507
- properties,
508
- };
509
- let lastError;
510
- for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
511
- try {
512
- const headers = await this.getAuthHeaders();
513
- const url = `${this.config.apiUrl}/v1/client/${encodeURIComponent(this.config.tenantId)}/config/configurations`;
514
- this.log(`Fetching configurations for user ${userId} (attempt ${attempt + 1})`);
515
- const response = await fetch(url, {
516
- method: 'POST',
517
- headers,
518
- body: JSON.stringify(request),
519
- });
520
- if (!response.ok) {
521
- let errorMessage = `HTTP ${response.status}`;
522
- try {
523
- const errorBody = await response.json();
524
- if (errorBody?.message) {
525
- errorMessage = errorBody.message;
770
+ try {
771
+ if (this.isDestroyed) {
772
+ const error = new Error('Grain Analytics: Client has been destroyed');
773
+ const formattedError = this.formatError(error, 'fetchConfig (client destroyed)');
774
+ this.logError(formattedError);
775
+ return null;
776
+ }
777
+ const userId = options.userId || this.getEffectiveUserId();
778
+ const immediateKeys = options.immediateKeys || [];
779
+ const properties = options.properties || {};
780
+ const request = {
781
+ userId,
782
+ immediateKeys,
783
+ properties,
784
+ };
785
+ let lastError;
786
+ for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
787
+ try {
788
+ const headers = await this.getAuthHeaders();
789
+ const url = `${this.config.apiUrl}/v1/client/${encodeURIComponent(this.config.tenantId)}/config/configurations`;
790
+ this.log(`Fetching configurations for user ${userId} (attempt ${attempt + 1})`);
791
+ const response = await fetch(url, {
792
+ method: 'POST',
793
+ headers,
794
+ body: JSON.stringify(request),
795
+ });
796
+ if (!response.ok) {
797
+ let errorMessage = `HTTP ${response.status}`;
798
+ try {
799
+ const errorBody = await response.json();
800
+ if (errorBody?.message) {
801
+ errorMessage = errorBody.message;
802
+ }
526
803
  }
527
- }
528
- catch {
529
- const errorText = await response.text();
530
- if (errorText) {
531
- errorMessage = errorText;
804
+ catch {
805
+ const errorText = await response.text();
806
+ if (errorText) {
807
+ errorMessage = errorText;
808
+ }
532
809
  }
810
+ const error = new Error(`Failed to fetch configurations: ${errorMessage}`);
811
+ error.status = response.status;
812
+ throw error;
533
813
  }
534
- const error = new Error(`Failed to fetch configurations: ${errorMessage}`);
535
- error.status = response.status;
536
- throw error;
537
- }
538
- const configResponse = await response.json();
539
- // Update cache if successful
540
- if (configResponse.configurations) {
541
- this.updateConfigCache(configResponse, userId);
542
- }
543
- this.log(`Successfully fetched configurations for user ${userId}:`, configResponse);
544
- return configResponse;
545
- }
546
- catch (error) {
547
- lastError = error;
548
- if (attempt === this.config.retryAttempts) {
549
- break;
814
+ const configResponse = await response.json();
815
+ // Update cache if successful
816
+ if (configResponse.configurations) {
817
+ this.updateConfigCache(configResponse, userId);
818
+ }
819
+ this.log(`Successfully fetched configurations for user ${userId}:`, configResponse);
820
+ return configResponse;
550
821
  }
551
- if (!this.isRetriableError(error)) {
552
- break;
822
+ catch (error) {
823
+ lastError = error;
824
+ if (attempt === this.config.retryAttempts) {
825
+ // Last attempt, don't retry - log error gracefully
826
+ const formattedError = this.formatError(error, `fetchConfig (attempt ${attempt + 1}/${this.config.retryAttempts + 1})`);
827
+ this.logError(formattedError);
828
+ return null;
829
+ }
830
+ if (!this.isRetriableError(error)) {
831
+ // Non-retriable error, don't retry - log error gracefully
832
+ const formattedError = this.formatError(error, 'fetchConfig (non-retriable error)');
833
+ this.logError(formattedError);
834
+ return null;
835
+ }
836
+ const delayMs = this.config.retryDelay * Math.pow(2, attempt);
837
+ this.log(`Retrying config fetch in ${delayMs}ms after error:`, error);
838
+ await this.delay(delayMs);
553
839
  }
554
- const delayMs = this.config.retryDelay * Math.pow(2, attempt);
555
- this.log(`Retrying config fetch in ${delayMs}ms after error:`, error);
556
- await this.delay(delayMs);
557
840
  }
841
+ return null;
842
+ }
843
+ catch (error) {
844
+ const formattedError = this.formatError(error, 'fetchConfig');
845
+ this.logError(formattedError);
846
+ return null;
558
847
  }
559
- console.error('[Grain Analytics] Failed to fetch configurations after all retries:', lastError);
560
- throw lastError;
561
848
  }
562
849
  /**
563
850
  * Get configuration asynchronously (cache-first with fallback to API)
564
851
  */
565
852
  async getConfigAsync(key, options = {}) {
566
- // Return immediately if we have it in cache and not forcing refresh
567
- if (!options.forceRefresh && this.configCache?.configurations?.[key]) {
568
- return this.configCache.configurations[key];
569
- }
570
- // Return default if available and not forcing refresh
571
- if (!options.forceRefresh && this.config.defaultConfigurations?.[key]) {
572
- return this.config.defaultConfigurations[key];
573
- }
574
- // Fetch from API
575
853
  try {
854
+ // Return immediately if we have it in cache and not forcing refresh
855
+ if (!options.forceRefresh && this.configCache?.configurations?.[key]) {
856
+ return this.configCache.configurations[key];
857
+ }
858
+ // Return default if available and not forcing refresh
859
+ if (!options.forceRefresh && this.config.defaultConfigurations?.[key]) {
860
+ return this.config.defaultConfigurations[key];
861
+ }
862
+ // Fetch from API
576
863
  const response = await this.fetchConfig(options);
577
- return response.configurations[key];
864
+ if (response) {
865
+ return response.configurations[key];
866
+ }
867
+ // Return default as fallback
868
+ return this.config.defaultConfigurations?.[key];
578
869
  }
579
870
  catch (error) {
580
- this.log(`Failed to fetch config for key "${key}":`, error);
871
+ const formattedError = this.formatError(error, 'getConfigAsync');
872
+ this.logError(formattedError);
581
873
  // Return default as fallback
582
874
  return this.config.defaultConfigurations?.[key];
583
875
  }
@@ -586,17 +878,22 @@ class GrainAnalytics {
586
878
  * Get all configurations asynchronously (cache-first with fallback to API)
587
879
  */
588
880
  async getAllConfigsAsync(options = {}) {
589
- // Return cache if available and not forcing refresh
590
- if (!options.forceRefresh && this.configCache?.configurations) {
591
- return { ...this.config.defaultConfigurations, ...this.configCache.configurations };
592
- }
593
- // Fetch from API
594
881
  try {
882
+ // Return cache if available and not forcing refresh
883
+ if (!options.forceRefresh && this.configCache?.configurations) {
884
+ return { ...this.config.defaultConfigurations, ...this.configCache.configurations };
885
+ }
886
+ // Fetch from API
595
887
  const response = await this.fetchConfig(options);
596
- return { ...this.config.defaultConfigurations, ...response.configurations };
888
+ if (response) {
889
+ return { ...this.config.defaultConfigurations, ...response.configurations };
890
+ }
891
+ // Return defaults as fallback
892
+ return { ...this.config.defaultConfigurations };
597
893
  }
598
894
  catch (error) {
599
- this.log('Failed to fetch all configs:', error);
895
+ const formattedError = this.formatError(error, 'getAllConfigsAsync');
896
+ this.logError(formattedError);
600
897
  // Return defaults as fallback
601
898
  return { ...this.config.defaultConfigurations };
602
899
  }
@@ -657,7 +954,8 @@ class GrainAnalytics {
657
954
  this.configRefreshTimer = window.setInterval(() => {
658
955
  if (!this.isDestroyed && this.globalUserId) {
659
956
  this.fetchConfig().catch((error) => {
660
- console.error('[Grain Analytics] Auto-config refresh failed:', error);
957
+ const formattedError = this.formatError(error, 'auto-config refresh');
958
+ this.logError(formattedError);
661
959
  });
662
960
  }
663
961
  }, this.config.configRefreshInterval);
@@ -675,16 +973,19 @@ class GrainAnalytics {
675
973
  * Preload configurations for immediate access
676
974
  */
677
975
  async preloadConfig(immediateKeys = [], properties) {
678
- if (!this.globalUserId) {
679
- this.log('Cannot preload config: no user ID set');
680
- return;
681
- }
682
976
  try {
683
- await this.fetchConfig({ immediateKeys, properties });
684
- this.startConfigRefreshTimer();
977
+ if (!this.globalUserId) {
978
+ this.log('Cannot preload config: no user ID set');
979
+ return;
980
+ }
981
+ const response = await this.fetchConfig({ immediateKeys, properties });
982
+ if (response) {
983
+ this.startConfigRefreshTimer();
984
+ }
685
985
  }
686
986
  catch (error) {
687
- this.log('Failed to preload config:', error);
987
+ const formattedError = this.formatError(error, 'preloadConfig');
988
+ this.logError(formattedError);
688
989
  }
689
990
  }
690
991
  /**