@grainql/analytics-web 1.4.0 → 1.6.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,11 @@ class GrainAnalytics {
12
12
  this.flushTimer = null;
13
13
  this.isDestroyed = false;
14
14
  this.globalUserId = null;
15
+ // Remote Config properties
16
+ this.configCache = null;
17
+ this.configRefreshTimer = null;
18
+ this.configChangeListeners = [];
19
+ this.configFetchPromise = null;
15
20
  this.config = {
16
21
  apiUrl: 'https://api.grainql.com',
17
22
  authStrategy: 'NONE',
@@ -21,6 +26,11 @@ class GrainAnalytics {
21
26
  retryDelay: 1000, // 1 second
22
27
  maxEventsPerRequest: 160, // Maximum events per API request
23
28
  debug: false,
29
+ // Remote Config defaults
30
+ defaultConfigurations: {},
31
+ configCacheKey: 'grain_config',
32
+ configRefreshInterval: 300000, // 5 minutes
33
+ enableConfigCache: true,
24
34
  ...config,
25
35
  tenantId: config.tenantId,
26
36
  };
@@ -31,6 +41,7 @@ class GrainAnalytics {
31
41
  this.validateConfig();
32
42
  this.setupBeforeUnload();
33
43
  this.startFlushTimer();
44
+ this.initializeConfigCache();
34
45
  }
35
46
  validateConfig() {
36
47
  if (!this.config.tenantId) {
@@ -424,6 +435,258 @@ class GrainAnalytics {
424
435
  await this.sendEvents(chunk);
425
436
  }
426
437
  }
438
+ // Remote Config Methods
439
+ /**
440
+ * Initialize configuration cache from localStorage
441
+ */
442
+ initializeConfigCache() {
443
+ if (!this.config.enableConfigCache || typeof window === 'undefined')
444
+ return;
445
+ try {
446
+ const cached = localStorage.getItem(this.config.configCacheKey);
447
+ if (cached) {
448
+ this.configCache = JSON.parse(cached);
449
+ this.log('Loaded configuration from cache:', this.configCache);
450
+ }
451
+ }
452
+ catch (error) {
453
+ this.log('Failed to load configuration cache:', error);
454
+ }
455
+ }
456
+ /**
457
+ * Save configuration cache to localStorage
458
+ */
459
+ saveConfigCache(cache) {
460
+ if (!this.config.enableConfigCache || typeof window === 'undefined')
461
+ return;
462
+ try {
463
+ localStorage.setItem(this.config.configCacheKey, JSON.stringify(cache));
464
+ this.log('Saved configuration to cache:', cache);
465
+ }
466
+ catch (error) {
467
+ this.log('Failed to save configuration cache:', error);
468
+ }
469
+ }
470
+ /**
471
+ * Get configuration value with fallback to defaults
472
+ */
473
+ getConfig(key) {
474
+ // First check cache
475
+ if (this.configCache?.configurations?.[key]) {
476
+ return this.configCache.configurations[key];
477
+ }
478
+ // Then check defaults
479
+ if (this.config.defaultConfigurations?.[key]) {
480
+ return this.config.defaultConfigurations[key];
481
+ }
482
+ return undefined;
483
+ }
484
+ /**
485
+ * Get all configurations with fallback to defaults
486
+ */
487
+ getAllConfigs() {
488
+ const configs = { ...this.config.defaultConfigurations };
489
+ if (this.configCache?.configurations) {
490
+ Object.assign(configs, this.configCache.configurations);
491
+ }
492
+ return configs;
493
+ }
494
+ /**
495
+ * Fetch configurations from API
496
+ */
497
+ 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;
526
+ }
527
+ }
528
+ catch {
529
+ const errorText = await response.text();
530
+ if (errorText) {
531
+ errorMessage = errorText;
532
+ }
533
+ }
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;
550
+ }
551
+ if (!this.isRetriableError(error)) {
552
+ break;
553
+ }
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
+ }
558
+ }
559
+ console.error('[Grain Analytics] Failed to fetch configurations after all retries:', lastError);
560
+ throw lastError;
561
+ }
562
+ /**
563
+ * Get configuration asynchronously (cache-first with fallback to API)
564
+ */
565
+ 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
+ try {
576
+ const response = await this.fetchConfig(options);
577
+ return response.configurations[key];
578
+ }
579
+ catch (error) {
580
+ this.log(`Failed to fetch config for key "${key}":`, error);
581
+ // Return default as fallback
582
+ return this.config.defaultConfigurations?.[key];
583
+ }
584
+ }
585
+ /**
586
+ * Get all configurations asynchronously (cache-first with fallback to API)
587
+ */
588
+ 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
+ try {
595
+ const response = await this.fetchConfig(options);
596
+ return { ...this.config.defaultConfigurations, ...response.configurations };
597
+ }
598
+ catch (error) {
599
+ this.log('Failed to fetch all configs:', error);
600
+ // Return defaults as fallback
601
+ return { ...this.config.defaultConfigurations };
602
+ }
603
+ }
604
+ /**
605
+ * Update configuration cache and notify listeners
606
+ */
607
+ updateConfigCache(response, userId) {
608
+ const newCache = {
609
+ configurations: response.configurations,
610
+ snapshotId: response.snapshotId,
611
+ timestamp: response.timestamp,
612
+ userId,
613
+ };
614
+ const oldConfigs = this.configCache?.configurations || {};
615
+ this.configCache = newCache;
616
+ this.saveConfigCache(newCache);
617
+ // Notify listeners if configurations changed
618
+ if (JSON.stringify(oldConfigs) !== JSON.stringify(response.configurations)) {
619
+ this.notifyConfigChangeListeners(response.configurations);
620
+ }
621
+ }
622
+ /**
623
+ * Add configuration change listener
624
+ */
625
+ addConfigChangeListener(listener) {
626
+ this.configChangeListeners.push(listener);
627
+ }
628
+ /**
629
+ * Remove configuration change listener
630
+ */
631
+ removeConfigChangeListener(listener) {
632
+ const index = this.configChangeListeners.indexOf(listener);
633
+ if (index > -1) {
634
+ this.configChangeListeners.splice(index, 1);
635
+ }
636
+ }
637
+ /**
638
+ * Notify all configuration change listeners
639
+ */
640
+ notifyConfigChangeListeners(configurations) {
641
+ this.configChangeListeners.forEach(listener => {
642
+ try {
643
+ listener(configurations);
644
+ }
645
+ catch (error) {
646
+ console.error('[Grain Analytics] Config change listener error:', error);
647
+ }
648
+ });
649
+ }
650
+ /**
651
+ * Start automatic configuration refresh timer
652
+ */
653
+ startConfigRefreshTimer() {
654
+ if (this.configRefreshTimer) {
655
+ clearInterval(this.configRefreshTimer);
656
+ }
657
+ this.configRefreshTimer = window.setInterval(() => {
658
+ if (!this.isDestroyed && this.globalUserId) {
659
+ this.fetchConfig().catch((error) => {
660
+ console.error('[Grain Analytics] Auto-config refresh failed:', error);
661
+ });
662
+ }
663
+ }, this.config.configRefreshInterval);
664
+ }
665
+ /**
666
+ * Stop automatic configuration refresh timer
667
+ */
668
+ stopConfigRefreshTimer() {
669
+ if (this.configRefreshTimer) {
670
+ clearInterval(this.configRefreshTimer);
671
+ this.configRefreshTimer = null;
672
+ }
673
+ }
674
+ /**
675
+ * Preload configurations for immediate access
676
+ */
677
+ async preloadConfig(immediateKeys = [], properties) {
678
+ if (!this.globalUserId) {
679
+ this.log('Cannot preload config: no user ID set');
680
+ return;
681
+ }
682
+ try {
683
+ await this.fetchConfig({ immediateKeys, properties });
684
+ this.startConfigRefreshTimer();
685
+ }
686
+ catch (error) {
687
+ this.log('Failed to preload config:', error);
688
+ }
689
+ }
427
690
  /**
428
691
  * Split events array into chunks of specified size
429
692
  */
@@ -443,6 +706,10 @@ class GrainAnalytics {
443
706
  clearInterval(this.flushTimer);
444
707
  this.flushTimer = null;
445
708
  }
709
+ // Stop config refresh timer
710
+ this.stopConfigRefreshTimer();
711
+ // Clear config change listeners
712
+ this.configChangeListeners = [];
446
713
  // Send any remaining events (in chunks if necessary)
447
714
  if (this.eventQueue.length > 0) {
448
715
  const eventsToSend = [...this.eventQueue];
package/dist/index.mjs CHANGED
@@ -8,6 +8,11 @@ export class GrainAnalytics {
8
8
  this.flushTimer = null;
9
9
  this.isDestroyed = false;
10
10
  this.globalUserId = null;
11
+ // Remote Config properties
12
+ this.configCache = null;
13
+ this.configRefreshTimer = null;
14
+ this.configChangeListeners = [];
15
+ this.configFetchPromise = null;
11
16
  this.config = {
12
17
  apiUrl: 'https://api.grainql.com',
13
18
  authStrategy: 'NONE',
@@ -17,6 +22,11 @@ export class GrainAnalytics {
17
22
  retryDelay: 1000, // 1 second
18
23
  maxEventsPerRequest: 160, // Maximum events per API request
19
24
  debug: false,
25
+ // Remote Config defaults
26
+ defaultConfigurations: {},
27
+ configCacheKey: 'grain_config',
28
+ configRefreshInterval: 300000, // 5 minutes
29
+ enableConfigCache: true,
20
30
  ...config,
21
31
  tenantId: config.tenantId,
22
32
  };
@@ -27,6 +37,7 @@ export class GrainAnalytics {
27
37
  this.validateConfig();
28
38
  this.setupBeforeUnload();
29
39
  this.startFlushTimer();
40
+ this.initializeConfigCache();
30
41
  }
31
42
  validateConfig() {
32
43
  if (!this.config.tenantId) {
@@ -420,6 +431,258 @@ export class GrainAnalytics {
420
431
  await this.sendEvents(chunk);
421
432
  }
422
433
  }
434
+ // Remote Config Methods
435
+ /**
436
+ * Initialize configuration cache from localStorage
437
+ */
438
+ initializeConfigCache() {
439
+ if (!this.config.enableConfigCache || typeof window === 'undefined')
440
+ return;
441
+ try {
442
+ const cached = localStorage.getItem(this.config.configCacheKey);
443
+ if (cached) {
444
+ this.configCache = JSON.parse(cached);
445
+ this.log('Loaded configuration from cache:', this.configCache);
446
+ }
447
+ }
448
+ catch (error) {
449
+ this.log('Failed to load configuration cache:', error);
450
+ }
451
+ }
452
+ /**
453
+ * Save configuration cache to localStorage
454
+ */
455
+ saveConfigCache(cache) {
456
+ if (!this.config.enableConfigCache || typeof window === 'undefined')
457
+ return;
458
+ try {
459
+ localStorage.setItem(this.config.configCacheKey, JSON.stringify(cache));
460
+ this.log('Saved configuration to cache:', cache);
461
+ }
462
+ catch (error) {
463
+ this.log('Failed to save configuration cache:', error);
464
+ }
465
+ }
466
+ /**
467
+ * Get configuration value with fallback to defaults
468
+ */
469
+ getConfig(key) {
470
+ // First check cache
471
+ if (this.configCache?.configurations?.[key]) {
472
+ return this.configCache.configurations[key];
473
+ }
474
+ // Then check defaults
475
+ if (this.config.defaultConfigurations?.[key]) {
476
+ return this.config.defaultConfigurations[key];
477
+ }
478
+ return undefined;
479
+ }
480
+ /**
481
+ * Get all configurations with fallback to defaults
482
+ */
483
+ getAllConfigs() {
484
+ const configs = { ...this.config.defaultConfigurations };
485
+ if (this.configCache?.configurations) {
486
+ Object.assign(configs, this.configCache.configurations);
487
+ }
488
+ return configs;
489
+ }
490
+ /**
491
+ * Fetch configurations from API
492
+ */
493
+ 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;
522
+ }
523
+ }
524
+ catch {
525
+ const errorText = await response.text();
526
+ if (errorText) {
527
+ errorMessage = errorText;
528
+ }
529
+ }
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;
546
+ }
547
+ if (!this.isRetriableError(error)) {
548
+ break;
549
+ }
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
+ }
554
+ }
555
+ console.error('[Grain Analytics] Failed to fetch configurations after all retries:', lastError);
556
+ throw lastError;
557
+ }
558
+ /**
559
+ * Get configuration asynchronously (cache-first with fallback to API)
560
+ */
561
+ 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
+ try {
572
+ const response = await this.fetchConfig(options);
573
+ return response.configurations[key];
574
+ }
575
+ catch (error) {
576
+ this.log(`Failed to fetch config for key "${key}":`, error);
577
+ // Return default as fallback
578
+ return this.config.defaultConfigurations?.[key];
579
+ }
580
+ }
581
+ /**
582
+ * Get all configurations asynchronously (cache-first with fallback to API)
583
+ */
584
+ 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
+ try {
591
+ const response = await this.fetchConfig(options);
592
+ return { ...this.config.defaultConfigurations, ...response.configurations };
593
+ }
594
+ catch (error) {
595
+ this.log('Failed to fetch all configs:', error);
596
+ // Return defaults as fallback
597
+ return { ...this.config.defaultConfigurations };
598
+ }
599
+ }
600
+ /**
601
+ * Update configuration cache and notify listeners
602
+ */
603
+ updateConfigCache(response, userId) {
604
+ const newCache = {
605
+ configurations: response.configurations,
606
+ snapshotId: response.snapshotId,
607
+ timestamp: response.timestamp,
608
+ userId,
609
+ };
610
+ const oldConfigs = this.configCache?.configurations || {};
611
+ this.configCache = newCache;
612
+ this.saveConfigCache(newCache);
613
+ // Notify listeners if configurations changed
614
+ if (JSON.stringify(oldConfigs) !== JSON.stringify(response.configurations)) {
615
+ this.notifyConfigChangeListeners(response.configurations);
616
+ }
617
+ }
618
+ /**
619
+ * Add configuration change listener
620
+ */
621
+ addConfigChangeListener(listener) {
622
+ this.configChangeListeners.push(listener);
623
+ }
624
+ /**
625
+ * Remove configuration change listener
626
+ */
627
+ removeConfigChangeListener(listener) {
628
+ const index = this.configChangeListeners.indexOf(listener);
629
+ if (index > -1) {
630
+ this.configChangeListeners.splice(index, 1);
631
+ }
632
+ }
633
+ /**
634
+ * Notify all configuration change listeners
635
+ */
636
+ notifyConfigChangeListeners(configurations) {
637
+ this.configChangeListeners.forEach(listener => {
638
+ try {
639
+ listener(configurations);
640
+ }
641
+ catch (error) {
642
+ console.error('[Grain Analytics] Config change listener error:', error);
643
+ }
644
+ });
645
+ }
646
+ /**
647
+ * Start automatic configuration refresh timer
648
+ */
649
+ startConfigRefreshTimer() {
650
+ if (this.configRefreshTimer) {
651
+ clearInterval(this.configRefreshTimer);
652
+ }
653
+ this.configRefreshTimer = window.setInterval(() => {
654
+ if (!this.isDestroyed && this.globalUserId) {
655
+ this.fetchConfig().catch((error) => {
656
+ console.error('[Grain Analytics] Auto-config refresh failed:', error);
657
+ });
658
+ }
659
+ }, this.config.configRefreshInterval);
660
+ }
661
+ /**
662
+ * Stop automatic configuration refresh timer
663
+ */
664
+ stopConfigRefreshTimer() {
665
+ if (this.configRefreshTimer) {
666
+ clearInterval(this.configRefreshTimer);
667
+ this.configRefreshTimer = null;
668
+ }
669
+ }
670
+ /**
671
+ * Preload configurations for immediate access
672
+ */
673
+ async preloadConfig(immediateKeys = [], properties) {
674
+ if (!this.globalUserId) {
675
+ this.log('Cannot preload config: no user ID set');
676
+ return;
677
+ }
678
+ try {
679
+ await this.fetchConfig({ immediateKeys, properties });
680
+ this.startConfigRefreshTimer();
681
+ }
682
+ catch (error) {
683
+ this.log('Failed to preload config:', error);
684
+ }
685
+ }
423
686
  /**
424
687
  * Split events array into chunks of specified size
425
688
  */
@@ -439,6 +702,10 @@ export class GrainAnalytics {
439
702
  clearInterval(this.flushTimer);
440
703
  this.flushTimer = null;
441
704
  }
705
+ // Stop config refresh timer
706
+ this.stopConfigRefreshTimer();
707
+ // Clear config change listeners
708
+ this.configChangeListeners = [];
442
709
  // Send any remaining events (in chunks if necessary)
443
710
  if (this.eventQueue.length > 0) {
444
711
  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.0",
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",