@grainql/analytics-web 1.3.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/README.md +421 -2
- package/dist/cjs/index.d.ts +107 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.d.ts +107 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/index.d.ts +107 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.global.dev.js +331 -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 +358 -2
- package/dist/index.mjs +358 -2
- package/package.json +2 -2
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) {
|
|
@@ -100,7 +111,7 @@ export class GrainAnalytics {
|
|
|
100
111
|
for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
|
|
101
112
|
try {
|
|
102
113
|
const headers = await this.getAuthHeaders();
|
|
103
|
-
const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}`;
|
|
114
|
+
const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/multi`;
|
|
104
115
|
this.log(`Sending ${events.length} events to ${url} (attempt ${attempt + 1})`);
|
|
105
116
|
const response = await fetch(url, {
|
|
106
117
|
method: 'POST',
|
|
@@ -151,7 +162,7 @@ export class GrainAnalytics {
|
|
|
151
162
|
return;
|
|
152
163
|
try {
|
|
153
164
|
const headers = await this.getAuthHeaders();
|
|
154
|
-
const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}`;
|
|
165
|
+
const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/multi`;
|
|
155
166
|
const body = JSON.stringify({ events });
|
|
156
167
|
// Try beacon API first (more reliable for page unload)
|
|
157
168
|
if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) {
|
|
@@ -267,6 +278,95 @@ export class GrainAnalytics {
|
|
|
267
278
|
getUserId() {
|
|
268
279
|
return this.globalUserId;
|
|
269
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Set user properties
|
|
283
|
+
*/
|
|
284
|
+
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] = '';
|
|
302
|
+
}
|
|
303
|
+
else if (typeof value === 'string') {
|
|
304
|
+
serializedProperties[key] = value;
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
serializedProperties[key] = JSON.stringify(value);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const payload = {
|
|
311
|
+
userId,
|
|
312
|
+
...serializedProperties,
|
|
313
|
+
};
|
|
314
|
+
await this.sendProperties(payload);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Send properties to the API
|
|
318
|
+
*/
|
|
319
|
+
async sendProperties(payload) {
|
|
320
|
+
let lastError;
|
|
321
|
+
for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
|
|
322
|
+
try {
|
|
323
|
+
const headers = await this.getAuthHeaders();
|
|
324
|
+
const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/properties`;
|
|
325
|
+
this.log(`Setting properties for user ${payload.userId} (attempt ${attempt + 1})`);
|
|
326
|
+
const response = await fetch(url, {
|
|
327
|
+
method: 'POST',
|
|
328
|
+
headers,
|
|
329
|
+
body: JSON.stringify(payload),
|
|
330
|
+
});
|
|
331
|
+
if (!response.ok) {
|
|
332
|
+
let errorMessage = `HTTP ${response.status}`;
|
|
333
|
+
try {
|
|
334
|
+
const errorBody = await response.json();
|
|
335
|
+
if (errorBody?.message) {
|
|
336
|
+
errorMessage = errorBody.message;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
const errorText = await response.text();
|
|
341
|
+
if (errorText) {
|
|
342
|
+
errorMessage = errorText;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const error = new Error(`Failed to set properties: ${errorMessage}`);
|
|
346
|
+
error.status = response.status;
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
this.log(`Successfully set properties for user ${payload.userId}`);
|
|
350
|
+
return; // Success, exit retry loop
|
|
351
|
+
}
|
|
352
|
+
catch (error) {
|
|
353
|
+
lastError = error;
|
|
354
|
+
if (attempt === this.config.retryAttempts) {
|
|
355
|
+
// Last attempt, don't retry
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
if (!this.isRetriableError(error)) {
|
|
359
|
+
// Non-retriable error, don't retry
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff
|
|
363
|
+
this.log(`Retrying in ${delayMs}ms after error:`, error);
|
|
364
|
+
await this.delay(delayMs);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
console.error('[Grain Analytics] Failed to set properties after all retries:', lastError);
|
|
368
|
+
throw lastError;
|
|
369
|
+
}
|
|
270
370
|
// Template event methods
|
|
271
371
|
/**
|
|
272
372
|
* Track user login event
|
|
@@ -331,6 +431,258 @@ export class GrainAnalytics {
|
|
|
331
431
|
await this.sendEvents(chunk);
|
|
332
432
|
}
|
|
333
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
|
+
}
|
|
334
686
|
/**
|
|
335
687
|
* Split events array into chunks of specified size
|
|
336
688
|
*/
|
|
@@ -350,6 +702,10 @@ export class GrainAnalytics {
|
|
|
350
702
|
clearInterval(this.flushTimer);
|
|
351
703
|
this.flushTimer = null;
|
|
352
704
|
}
|
|
705
|
+
// Stop config refresh timer
|
|
706
|
+
this.stopConfigRefreshTimer();
|
|
707
|
+
// Clear config change listeners
|
|
708
|
+
this.configChangeListeners = [];
|
|
353
709
|
// Send any remaining events (in chunks if necessary)
|
|
354
710
|
if (this.eventQueue.length > 0) {
|
|
355
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
|
-
"description": "Lightweight TypeScript SDK for sending analytics events
|
|
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",
|