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