@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/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.3.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",