@grainql/analytics-web 1.4.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -8,6 +8,12 @@ export class GrainAnalytics {
8
8
  this.flushTimer = null;
9
9
  this.isDestroyed = false;
10
10
  this.globalUserId = null;
11
+ this.persistentAnonymousUserId = null;
12
+ // Remote Config properties
13
+ this.configCache = null;
14
+ this.configRefreshTimer = null;
15
+ this.configChangeListeners = [];
16
+ this.configFetchPromise = null;
11
17
  this.config = {
12
18
  apiUrl: 'https://api.grainql.com',
13
19
  authStrategy: 'NONE',
@@ -17,6 +23,11 @@ export class GrainAnalytics {
17
23
  retryDelay: 1000, // 1 second
18
24
  maxEventsPerRequest: 160, // Maximum events per API request
19
25
  debug: false,
26
+ // Remote Config defaults
27
+ defaultConfigurations: {},
28
+ configCacheKey: 'grain_config',
29
+ configRefreshInterval: 300000, // 5 minutes
30
+ enableConfigCache: true,
20
31
  ...config,
21
32
  tenantId: config.tenantId,
22
33
  };
@@ -25,8 +36,10 @@ export class GrainAnalytics {
25
36
  this.globalUserId = config.userId;
26
37
  }
27
38
  this.validateConfig();
39
+ this.initializePersistentAnonymousUserId();
28
40
  this.setupBeforeUnload();
29
41
  this.startFlushTimer();
42
+ this.initializeConfigCache();
30
43
  }
31
44
  validateConfig() {
32
45
  if (!this.config.tenantId) {
@@ -39,6 +52,60 @@ export class GrainAnalytics {
39
52
  throw new Error('Grain Analytics: authProvider is required for JWT auth strategy');
40
53
  }
41
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
+ }
42
109
  log(...args) {
43
110
  if (this.config.debug) {
44
111
  console.log('[Grain Analytics]', ...args);
@@ -47,7 +114,7 @@ export class GrainAnalytics {
47
114
  formatEvent(event) {
48
115
  return {
49
116
  eventName: event.eventName,
50
- userId: event.userId || this.globalUserId || 'anonymous',
117
+ userId: event.userId || this.getEffectiveUserId(),
51
118
  properties: event.properties || {},
52
119
  };
53
120
  }
@@ -253,6 +320,8 @@ export class GrainAnalytics {
253
320
  identify(userId) {
254
321
  this.log(`Identified user: ${userId}`);
255
322
  this.globalUserId = userId;
323
+ // Clear persistent anonymous user ID since we now have a real user ID
324
+ this.persistentAnonymousUserId = null;
256
325
  }
257
326
  /**
258
327
  * Set global user ID for all subsequent events
@@ -260,6 +329,10 @@ export class GrainAnalytics {
260
329
  setUserId(userId) {
261
330
  this.log(`Set global user ID: ${userId}`);
262
331
  this.globalUserId = userId;
332
+ // Clear persistent anonymous user ID if setting a real user ID
333
+ if (userId) {
334
+ this.persistentAnonymousUserId = null;
335
+ }
263
336
  }
264
337
  /**
265
338
  * Get current global user ID
@@ -267,6 +340,12 @@ export class GrainAnalytics {
267
340
  getUserId() {
268
341
  return this.globalUserId;
269
342
  }
343
+ /**
344
+ * Get current effective user ID (global userId or persistent anonymous ID)
345
+ */
346
+ getEffectiveUserIdPublic() {
347
+ return this.getEffectiveUserId();
348
+ }
270
349
  /**
271
350
  * Set user properties
272
351
  */
@@ -274,7 +353,7 @@ export class GrainAnalytics {
274
353
  if (this.isDestroyed) {
275
354
  throw new Error('Grain Analytics: Client has been destroyed');
276
355
  }
277
- const userId = options?.userId || this.globalUserId || 'anonymous';
356
+ const userId = options?.userId || this.getEffectiveUserId();
278
357
  // Validate property count (max 4 properties)
279
358
  const propertyKeys = Object.keys(properties);
280
359
  if (propertyKeys.length > 4) {
@@ -420,6 +499,258 @@ export class GrainAnalytics {
420
499
  await this.sendEvents(chunk);
421
500
  }
422
501
  }
502
+ // Remote Config Methods
503
+ /**
504
+ * Initialize configuration cache from localStorage
505
+ */
506
+ initializeConfigCache() {
507
+ if (!this.config.enableConfigCache || typeof window === 'undefined')
508
+ return;
509
+ try {
510
+ const cached = localStorage.getItem(this.config.configCacheKey);
511
+ if (cached) {
512
+ this.configCache = JSON.parse(cached);
513
+ this.log('Loaded configuration from cache:', this.configCache);
514
+ }
515
+ }
516
+ catch (error) {
517
+ this.log('Failed to load configuration cache:', error);
518
+ }
519
+ }
520
+ /**
521
+ * Save configuration cache to localStorage
522
+ */
523
+ saveConfigCache(cache) {
524
+ if (!this.config.enableConfigCache || typeof window === 'undefined')
525
+ return;
526
+ try {
527
+ localStorage.setItem(this.config.configCacheKey, JSON.stringify(cache));
528
+ this.log('Saved configuration to cache:', cache);
529
+ }
530
+ catch (error) {
531
+ this.log('Failed to save configuration cache:', error);
532
+ }
533
+ }
534
+ /**
535
+ * Get configuration value with fallback to defaults
536
+ */
537
+ getConfig(key) {
538
+ // First check cache
539
+ if (this.configCache?.configurations?.[key]) {
540
+ return this.configCache.configurations[key];
541
+ }
542
+ // Then check defaults
543
+ if (this.config.defaultConfigurations?.[key]) {
544
+ return this.config.defaultConfigurations[key];
545
+ }
546
+ return undefined;
547
+ }
548
+ /**
549
+ * Get all configurations with fallback to defaults
550
+ */
551
+ getAllConfigs() {
552
+ const configs = { ...this.config.defaultConfigurations };
553
+ if (this.configCache?.configurations) {
554
+ Object.assign(configs, this.configCache.configurations);
555
+ }
556
+ return configs;
557
+ }
558
+ /**
559
+ * Fetch configurations from API
560
+ */
561
+ async fetchConfig(options = {}) {
562
+ if (this.isDestroyed) {
563
+ throw new Error('Grain Analytics: Client has been destroyed');
564
+ }
565
+ const userId = options.userId || this.getEffectiveUserId();
566
+ const immediateKeys = options.immediateKeys || [];
567
+ const properties = options.properties || {};
568
+ const request = {
569
+ userId,
570
+ immediateKeys,
571
+ properties,
572
+ };
573
+ let lastError;
574
+ for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
575
+ try {
576
+ const headers = await this.getAuthHeaders();
577
+ const url = `${this.config.apiUrl}/v1/client/${encodeURIComponent(this.config.tenantId)}/config/configurations`;
578
+ this.log(`Fetching configurations for user ${userId} (attempt ${attempt + 1})`);
579
+ const response = await fetch(url, {
580
+ method: 'POST',
581
+ headers,
582
+ body: JSON.stringify(request),
583
+ });
584
+ if (!response.ok) {
585
+ let errorMessage = `HTTP ${response.status}`;
586
+ try {
587
+ const errorBody = await response.json();
588
+ if (errorBody?.message) {
589
+ errorMessage = errorBody.message;
590
+ }
591
+ }
592
+ catch {
593
+ const errorText = await response.text();
594
+ if (errorText) {
595
+ errorMessage = errorText;
596
+ }
597
+ }
598
+ const error = new Error(`Failed to fetch configurations: ${errorMessage}`);
599
+ error.status = response.status;
600
+ throw error;
601
+ }
602
+ const configResponse = await response.json();
603
+ // Update cache if successful
604
+ if (configResponse.configurations) {
605
+ this.updateConfigCache(configResponse, userId);
606
+ }
607
+ this.log(`Successfully fetched configurations for user ${userId}:`, configResponse);
608
+ return configResponse;
609
+ }
610
+ catch (error) {
611
+ lastError = error;
612
+ if (attempt === this.config.retryAttempts) {
613
+ break;
614
+ }
615
+ if (!this.isRetriableError(error)) {
616
+ break;
617
+ }
618
+ const delayMs = this.config.retryDelay * Math.pow(2, attempt);
619
+ this.log(`Retrying config fetch in ${delayMs}ms after error:`, error);
620
+ await this.delay(delayMs);
621
+ }
622
+ }
623
+ console.error('[Grain Analytics] Failed to fetch configurations after all retries:', lastError);
624
+ throw lastError;
625
+ }
626
+ /**
627
+ * Get configuration asynchronously (cache-first with fallback to API)
628
+ */
629
+ async getConfigAsync(key, options = {}) {
630
+ // Return immediately if we have it in cache and not forcing refresh
631
+ if (!options.forceRefresh && this.configCache?.configurations?.[key]) {
632
+ return this.configCache.configurations[key];
633
+ }
634
+ // Return default if available and not forcing refresh
635
+ if (!options.forceRefresh && this.config.defaultConfigurations?.[key]) {
636
+ return this.config.defaultConfigurations[key];
637
+ }
638
+ // Fetch from API
639
+ try {
640
+ const response = await this.fetchConfig(options);
641
+ return response.configurations[key];
642
+ }
643
+ catch (error) {
644
+ this.log(`Failed to fetch config for key "${key}":`, error);
645
+ // Return default as fallback
646
+ return this.config.defaultConfigurations?.[key];
647
+ }
648
+ }
649
+ /**
650
+ * Get all configurations asynchronously (cache-first with fallback to API)
651
+ */
652
+ async getAllConfigsAsync(options = {}) {
653
+ // Return cache if available and not forcing refresh
654
+ if (!options.forceRefresh && this.configCache?.configurations) {
655
+ return { ...this.config.defaultConfigurations, ...this.configCache.configurations };
656
+ }
657
+ // Fetch from API
658
+ try {
659
+ const response = await this.fetchConfig(options);
660
+ return { ...this.config.defaultConfigurations, ...response.configurations };
661
+ }
662
+ catch (error) {
663
+ this.log('Failed to fetch all configs:', error);
664
+ // Return defaults as fallback
665
+ return { ...this.config.defaultConfigurations };
666
+ }
667
+ }
668
+ /**
669
+ * Update configuration cache and notify listeners
670
+ */
671
+ updateConfigCache(response, userId) {
672
+ const newCache = {
673
+ configurations: response.configurations,
674
+ snapshotId: response.snapshotId,
675
+ timestamp: response.timestamp,
676
+ userId,
677
+ };
678
+ const oldConfigs = this.configCache?.configurations || {};
679
+ this.configCache = newCache;
680
+ this.saveConfigCache(newCache);
681
+ // Notify listeners if configurations changed
682
+ if (JSON.stringify(oldConfigs) !== JSON.stringify(response.configurations)) {
683
+ this.notifyConfigChangeListeners(response.configurations);
684
+ }
685
+ }
686
+ /**
687
+ * Add configuration change listener
688
+ */
689
+ addConfigChangeListener(listener) {
690
+ this.configChangeListeners.push(listener);
691
+ }
692
+ /**
693
+ * Remove configuration change listener
694
+ */
695
+ removeConfigChangeListener(listener) {
696
+ const index = this.configChangeListeners.indexOf(listener);
697
+ if (index > -1) {
698
+ this.configChangeListeners.splice(index, 1);
699
+ }
700
+ }
701
+ /**
702
+ * Notify all configuration change listeners
703
+ */
704
+ notifyConfigChangeListeners(configurations) {
705
+ this.configChangeListeners.forEach(listener => {
706
+ try {
707
+ listener(configurations);
708
+ }
709
+ catch (error) {
710
+ console.error('[Grain Analytics] Config change listener error:', error);
711
+ }
712
+ });
713
+ }
714
+ /**
715
+ * Start automatic configuration refresh timer
716
+ */
717
+ startConfigRefreshTimer() {
718
+ if (this.configRefreshTimer) {
719
+ clearInterval(this.configRefreshTimer);
720
+ }
721
+ this.configRefreshTimer = window.setInterval(() => {
722
+ if (!this.isDestroyed && this.globalUserId) {
723
+ this.fetchConfig().catch((error) => {
724
+ console.error('[Grain Analytics] Auto-config refresh failed:', error);
725
+ });
726
+ }
727
+ }, this.config.configRefreshInterval);
728
+ }
729
+ /**
730
+ * Stop automatic configuration refresh timer
731
+ */
732
+ stopConfigRefreshTimer() {
733
+ if (this.configRefreshTimer) {
734
+ clearInterval(this.configRefreshTimer);
735
+ this.configRefreshTimer = null;
736
+ }
737
+ }
738
+ /**
739
+ * Preload configurations for immediate access
740
+ */
741
+ async preloadConfig(immediateKeys = [], properties) {
742
+ if (!this.globalUserId) {
743
+ this.log('Cannot preload config: no user ID set');
744
+ return;
745
+ }
746
+ try {
747
+ await this.fetchConfig({ immediateKeys, properties });
748
+ this.startConfigRefreshTimer();
749
+ }
750
+ catch (error) {
751
+ this.log('Failed to preload config:', error);
752
+ }
753
+ }
423
754
  /**
424
755
  * Split events array into chunks of specified size
425
756
  */
@@ -439,6 +770,10 @@ export class GrainAnalytics {
439
770
  clearInterval(this.flushTimer);
440
771
  this.flushTimer = null;
441
772
  }
773
+ // Stop config refresh timer
774
+ this.stopConfigRefreshTimer();
775
+ // Clear config change listeners
776
+ this.configChangeListeners = [];
442
777
  // Send any remaining events (in chunks if necessary)
443
778
  if (this.eventQueue.length > 0) {
444
779
  const eventsToSend = [...this.eventQueue];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@grainql/analytics-web",
3
- "version": "1.4.0",
4
- "description": "Lightweight TypeScript SDK for sending analytics events to Grain's REST API",
3
+ "version": "1.6.1",
4
+ "description": "Lightweight TypeScript SDK for sending analytics events and managing remote configurations via Grain's REST API",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
7
7
  "types": "dist/index.d.ts",