@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/README.md +338 -3
- package/dist/cjs/index.d.ts +113 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.d.ts +113 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/index.d.ts +113 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.global.dev.js +311 -3
- package/dist/index.global.dev.js.map +2 -2
- package/dist/index.global.js +2 -2
- package/dist/index.global.js.map +3 -3
- package/dist/index.js +337 -2
- package/dist/index.mjs +337 -2
- package/package.json +2 -2
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.
|
|
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.
|
|
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
|
-
"description": "Lightweight TypeScript SDK for sending analytics events
|
|
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",
|