@grainql/analytics-web 2.0.0 → 2.1.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.
Files changed (173) hide show
  1. package/dist/activity.d.ts +59 -0
  2. package/dist/activity.d.ts.map +1 -0
  3. package/dist/cjs/activity.d.ts +59 -0
  4. package/dist/cjs/activity.d.ts.map +1 -0
  5. package/dist/cjs/activity.js +131 -0
  6. package/dist/cjs/activity.js.map +1 -0
  7. package/dist/cjs/consent.d.ts +68 -0
  8. package/dist/cjs/consent.d.ts.map +1 -0
  9. package/dist/cjs/consent.js +191 -0
  10. package/dist/cjs/consent.js.map +1 -0
  11. package/dist/cjs/cookies.d.ts +28 -0
  12. package/dist/cjs/cookies.d.ts.map +1 -0
  13. package/dist/cjs/cookies.js +95 -0
  14. package/dist/cjs/cookies.js.map +1 -0
  15. package/dist/cjs/heartbeat.d.ts +42 -0
  16. package/dist/cjs/heartbeat.d.ts.map +1 -0
  17. package/dist/cjs/heartbeat.js +92 -0
  18. package/dist/cjs/heartbeat.js.map +1 -0
  19. package/dist/cjs/index.d.ts +100 -3
  20. package/dist/cjs/index.d.ts.map +1 -1
  21. package/dist/cjs/index.js.map +1 -1
  22. package/dist/cjs/page-tracking.d.ts +60 -0
  23. package/dist/cjs/page-tracking.d.ts.map +1 -0
  24. package/dist/cjs/page-tracking.js +180 -0
  25. package/dist/cjs/page-tracking.js.map +1 -0
  26. package/dist/cjs/react/components/ConsentBanner.d.ts +16 -0
  27. package/dist/cjs/react/components/ConsentBanner.d.ts.map +1 -0
  28. package/dist/cjs/react/components/ConsentBanner.js +112 -0
  29. package/dist/cjs/react/components/ConsentBanner.js.map +1 -0
  30. package/dist/cjs/react/components/CookieNotice.d.ts +12 -0
  31. package/dist/cjs/react/components/CookieNotice.d.ts.map +1 -0
  32. package/dist/cjs/react/components/CookieNotice.js +62 -0
  33. package/dist/cjs/react/components/CookieNotice.js.map +1 -0
  34. package/dist/cjs/react/components/PrivacyPreferenceCenter.d.ts +12 -0
  35. package/dist/cjs/react/components/PrivacyPreferenceCenter.d.ts.map +1 -0
  36. package/dist/cjs/react/components/PrivacyPreferenceCenter.js +120 -0
  37. package/dist/cjs/react/components/PrivacyPreferenceCenter.js.map +1 -0
  38. package/dist/cjs/react/hooks/useConsent.d.ts +13 -0
  39. package/dist/cjs/react/hooks/useConsent.d.ts.map +1 -0
  40. package/dist/cjs/react/hooks/useConsent.js +84 -0
  41. package/dist/cjs/react/hooks/useConsent.js.map +1 -0
  42. package/dist/cjs/react/hooks/useDataDeletion.d.ts +17 -0
  43. package/dist/cjs/react/hooks/useDataDeletion.d.ts.map +1 -0
  44. package/dist/cjs/react/hooks/useDataDeletion.js +117 -0
  45. package/dist/cjs/react/hooks/useDataDeletion.js.map +1 -0
  46. package/dist/cjs/react/hooks/usePrivacyPreferences.d.ts +15 -0
  47. package/dist/cjs/react/hooks/usePrivacyPreferences.d.ts.map +1 -0
  48. package/dist/cjs/react/hooks/usePrivacyPreferences.js +82 -0
  49. package/dist/cjs/react/hooks/usePrivacyPreferences.js.map +1 -0
  50. package/dist/cjs/react/index.d.ts +11 -0
  51. package/dist/cjs/react/index.d.ts.map +1 -1
  52. package/dist/cjs/react/index.js +15 -1
  53. package/dist/cjs/react/index.js.map +1 -1
  54. package/dist/consent.d.ts +68 -0
  55. package/dist/consent.d.ts.map +1 -0
  56. package/dist/cookies.d.ts +28 -0
  57. package/dist/cookies.d.ts.map +1 -0
  58. package/dist/esm/activity.d.ts +59 -0
  59. package/dist/esm/activity.d.ts.map +1 -0
  60. package/dist/esm/activity.js +127 -0
  61. package/dist/esm/activity.js.map +1 -0
  62. package/dist/esm/consent.d.ts +68 -0
  63. package/dist/esm/consent.d.ts.map +1 -0
  64. package/dist/esm/consent.js +187 -0
  65. package/dist/esm/consent.js.map +1 -0
  66. package/dist/esm/cookies.d.ts +28 -0
  67. package/dist/esm/cookies.d.ts.map +1 -0
  68. package/dist/esm/cookies.js +89 -0
  69. package/dist/esm/cookies.js.map +1 -0
  70. package/dist/esm/heartbeat.d.ts +42 -0
  71. package/dist/esm/heartbeat.d.ts.map +1 -0
  72. package/dist/esm/heartbeat.js +88 -0
  73. package/dist/esm/heartbeat.js.map +1 -0
  74. package/dist/esm/index.d.ts +100 -3
  75. package/dist/esm/index.d.ts.map +1 -1
  76. package/dist/esm/index.js.map +1 -1
  77. package/dist/esm/page-tracking.d.ts +60 -0
  78. package/dist/esm/page-tracking.d.ts.map +1 -0
  79. package/dist/esm/page-tracking.js +176 -0
  80. package/dist/esm/page-tracking.js.map +1 -0
  81. package/dist/esm/react/components/ConsentBanner.d.ts +16 -0
  82. package/dist/esm/react/components/ConsentBanner.d.ts.map +1 -0
  83. package/dist/esm/react/components/ConsentBanner.js +76 -0
  84. package/dist/esm/react/components/ConsentBanner.js.map +1 -0
  85. package/dist/esm/react/components/CookieNotice.d.ts +12 -0
  86. package/dist/esm/react/components/CookieNotice.d.ts.map +1 -0
  87. package/dist/esm/react/components/CookieNotice.js +26 -0
  88. package/dist/esm/react/components/CookieNotice.js.map +1 -0
  89. package/dist/esm/react/components/PrivacyPreferenceCenter.d.ts +12 -0
  90. package/dist/esm/react/components/PrivacyPreferenceCenter.d.ts.map +1 -0
  91. package/dist/esm/react/components/PrivacyPreferenceCenter.js +84 -0
  92. package/dist/esm/react/components/PrivacyPreferenceCenter.js.map +1 -0
  93. package/dist/esm/react/hooks/useConsent.d.ts +13 -0
  94. package/dist/esm/react/hooks/useConsent.d.ts.map +1 -0
  95. package/dist/esm/react/hooks/useConsent.js +48 -0
  96. package/dist/esm/react/hooks/useConsent.js.map +1 -0
  97. package/dist/esm/react/hooks/useDataDeletion.d.ts +17 -0
  98. package/dist/esm/react/hooks/useDataDeletion.d.ts.map +1 -0
  99. package/dist/esm/react/hooks/useDataDeletion.js +81 -0
  100. package/dist/esm/react/hooks/useDataDeletion.js.map +1 -0
  101. package/dist/esm/react/hooks/usePrivacyPreferences.d.ts +15 -0
  102. package/dist/esm/react/hooks/usePrivacyPreferences.d.ts.map +1 -0
  103. package/dist/esm/react/hooks/usePrivacyPreferences.js +46 -0
  104. package/dist/esm/react/hooks/usePrivacyPreferences.js.map +1 -0
  105. package/dist/esm/react/index.d.ts +11 -0
  106. package/dist/esm/react/index.d.ts.map +1 -1
  107. package/dist/esm/react/index.js +8 -0
  108. package/dist/esm/react/index.js.map +1 -1
  109. package/dist/heartbeat.d.ts +42 -0
  110. package/dist/heartbeat.d.ts.map +1 -0
  111. package/dist/index.d.ts +100 -3
  112. package/dist/index.d.ts.map +1 -1
  113. package/dist/index.global.dev.js +903 -12
  114. package/dist/index.global.dev.js.map +3 -3
  115. package/dist/index.global.js +2 -2
  116. package/dist/index.global.js.map +4 -4
  117. package/dist/index.js +321 -11
  118. package/dist/index.mjs +321 -11
  119. package/dist/page-tracking.d.ts +60 -0
  120. package/dist/page-tracking.d.ts.map +1 -0
  121. package/dist/react/activity.d.ts +59 -0
  122. package/dist/react/activity.d.ts.map +1 -0
  123. package/dist/react/activity.js +130 -0
  124. package/dist/react/activity.mjs +126 -0
  125. package/dist/react/consent.d.ts +68 -0
  126. package/dist/react/consent.d.ts.map +1 -0
  127. package/dist/react/consent.js +190 -0
  128. package/dist/react/consent.mjs +186 -0
  129. package/dist/react/cookies.d.ts +28 -0
  130. package/dist/react/cookies.d.ts.map +1 -0
  131. package/dist/react/cookies.js +94 -0
  132. package/dist/react/cookies.mjs +88 -0
  133. package/dist/react/heartbeat.d.ts +42 -0
  134. package/dist/react/heartbeat.d.ts.map +1 -0
  135. package/dist/react/heartbeat.js +91 -0
  136. package/dist/react/heartbeat.mjs +87 -0
  137. package/dist/react/index.d.ts +100 -3
  138. package/dist/react/index.d.ts.map +1 -1
  139. package/dist/react/index.js +321 -11
  140. package/dist/react/index.mjs +321 -11
  141. package/dist/react/page-tracking.d.ts +60 -0
  142. package/dist/react/page-tracking.d.ts.map +1 -0
  143. package/dist/react/page-tracking.js +179 -0
  144. package/dist/react/page-tracking.mjs +175 -0
  145. package/dist/react/react/components/ConsentBanner.d.ts +16 -0
  146. package/dist/react/react/components/ConsentBanner.d.ts.map +1 -0
  147. package/dist/react/react/components/ConsentBanner.js +78 -0
  148. package/dist/react/react/components/ConsentBanner.mjs +75 -0
  149. package/dist/react/react/components/CookieNotice.d.ts +12 -0
  150. package/dist/react/react/components/CookieNotice.d.ts.map +1 -0
  151. package/dist/react/react/components/CookieNotice.js +28 -0
  152. package/dist/react/react/components/CookieNotice.mjs +25 -0
  153. package/dist/react/react/components/PrivacyPreferenceCenter.d.ts +12 -0
  154. package/dist/react/react/components/PrivacyPreferenceCenter.d.ts.map +1 -0
  155. package/dist/react/react/components/PrivacyPreferenceCenter.js +86 -0
  156. package/dist/react/react/components/PrivacyPreferenceCenter.mjs +83 -0
  157. package/dist/react/react/hooks/useConsent.d.ts +13 -0
  158. package/dist/react/react/hooks/useConsent.d.ts.map +1 -0
  159. package/dist/react/react/hooks/useConsent.js +50 -0
  160. package/dist/react/react/hooks/useConsent.mjs +47 -0
  161. package/dist/react/react/hooks/useDataDeletion.d.ts +17 -0
  162. package/dist/react/react/hooks/useDataDeletion.d.ts.map +1 -0
  163. package/dist/react/react/hooks/useDataDeletion.js +83 -0
  164. package/dist/react/react/hooks/useDataDeletion.mjs +80 -0
  165. package/dist/react/react/hooks/usePrivacyPreferences.d.ts +15 -0
  166. package/dist/react/react/hooks/usePrivacyPreferences.d.ts.map +1 -0
  167. package/dist/react/react/hooks/usePrivacyPreferences.js +48 -0
  168. package/dist/react/react/hooks/usePrivacyPreferences.mjs +45 -0
  169. package/dist/react/react/index.d.ts +11 -0
  170. package/dist/react/react/index.d.ts.map +1 -1
  171. package/dist/react/react/index.js +15 -1
  172. package/dist/react/react/index.mjs +8 -0
  173. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -2,9 +2,15 @@
2
2
  * Grain Analytics Web SDK
3
3
  * A lightweight, dependency-free TypeScript SDK for sending analytics events to Grain's REST API
4
4
  */
5
+ import { ConsentManager } from './consent';
6
+ import { setCookie, getCookie, areCookiesEnabled } from './cookies';
7
+ import { ActivityDetector } from './activity';
8
+ import { HeartbeatManager } from './heartbeat';
9
+ import { PageTrackingManager } from './page-tracking';
5
10
  export class GrainAnalytics {
6
11
  constructor(config) {
7
12
  this.eventQueue = [];
13
+ this.waitingForConsentQueue = [];
8
14
  this.flushTimer = null;
9
15
  this.isDestroyed = false;
10
16
  this.globalUserId = null;
@@ -14,6 +20,13 @@ export class GrainAnalytics {
14
20
  this.configRefreshTimer = null;
15
21
  this.configChangeListeners = [];
16
22
  this.configFetchPromise = null;
23
+ this.cookiesEnabled = false;
24
+ // Automatic Tracking properties
25
+ this.activityDetector = null;
26
+ this.heartbeatManager = null;
27
+ this.pageTrackingManager = null;
28
+ this.ephemeralSessionId = null;
29
+ this.eventCountSinceLastHeartbeat = 0;
17
30
  this.config = {
18
31
  apiUrl: 'https://api.grainql.com',
19
32
  authStrategy: 'NONE',
@@ -28,9 +41,30 @@ export class GrainAnalytics {
28
41
  configCacheKey: 'grain_config',
29
42
  configRefreshInterval: 300000, // 5 minutes
30
43
  enableConfigCache: true,
44
+ // Privacy defaults
45
+ consentMode: 'opt-out',
46
+ waitForConsent: false,
47
+ enableCookies: false,
48
+ anonymizeIP: false,
49
+ disableAutoProperties: false,
50
+ // Automatic Tracking defaults
51
+ enableHeartbeat: true,
52
+ heartbeatActiveInterval: 120000, // 2 minutes
53
+ heartbeatInactiveInterval: 300000, // 5 minutes
54
+ enableAutoPageView: true,
55
+ stripQueryParams: true,
31
56
  ...config,
32
57
  tenantId: config.tenantId,
33
58
  };
59
+ // Initialize consent manager
60
+ this.consentManager = new ConsentManager(this.config.tenantId, this.config.consentMode);
61
+ // Check if cookies are enabled
62
+ if (this.config.enableCookies) {
63
+ this.cookiesEnabled = areCookiesEnabled();
64
+ if (!this.cookiesEnabled && this.config.debug) {
65
+ console.warn('[Grain Analytics] Cookies are not available, falling back to localStorage');
66
+ }
67
+ }
34
68
  // Set global userId if provided in config
35
69
  if (config.userId) {
36
70
  this.globalUserId = config.userId;
@@ -40,6 +74,18 @@ export class GrainAnalytics {
40
74
  this.setupBeforeUnload();
41
75
  this.startFlushTimer();
42
76
  this.initializeConfigCache();
77
+ // Initialize ephemeral session ID (memory-only, not persisted)
78
+ this.ephemeralSessionId = this.generateUUID();
79
+ // Initialize automatic tracking (browser only)
80
+ if (typeof window !== 'undefined') {
81
+ this.initializeAutomaticTracking();
82
+ }
83
+ // Set up consent change listener to flush waiting events and handle consent upgrade
84
+ this.consentManager.addListener((state) => {
85
+ if (state.granted) {
86
+ this.handleConsentGranted();
87
+ }
88
+ });
43
89
  }
44
90
  validateConfig() {
45
91
  if (!this.config.tenantId) {
@@ -73,22 +119,38 @@ export class GrainAnalytics {
73
119
  return this.generateUUID();
74
120
  }
75
121
  /**
76
- * Initialize persistent anonymous user ID from localStorage or create new one
122
+ * Initialize persistent anonymous user ID from cookies or localStorage
123
+ * Priority: Cookie → localStorage → generate new
77
124
  */
78
125
  initializePersistentAnonymousUserId() {
79
126
  if (typeof window === 'undefined')
80
127
  return;
81
128
  const storageKey = `grain_anonymous_user_id_${this.config.tenantId}`;
129
+ const cookieName = '_grain_uid';
82
130
  try {
131
+ // Try to load from cookie first if enabled
132
+ if (this.cookiesEnabled) {
133
+ const cookieValue = getCookie(cookieName);
134
+ if (cookieValue) {
135
+ this.persistentAnonymousUserId = cookieValue;
136
+ this.log('Loaded persistent anonymous user ID from cookie:', this.persistentAnonymousUserId);
137
+ return;
138
+ }
139
+ }
140
+ // Fallback to localStorage
83
141
  const stored = localStorage.getItem(storageKey);
84
142
  if (stored) {
85
143
  this.persistentAnonymousUserId = stored;
86
- this.log('Loaded persistent anonymous user ID:', this.persistentAnonymousUserId);
144
+ this.log('Loaded persistent anonymous user ID from localStorage:', this.persistentAnonymousUserId);
145
+ // Migrate to cookie if enabled
146
+ if (this.cookiesEnabled) {
147
+ this.savePersistentAnonymousUserId(stored);
148
+ }
87
149
  }
88
150
  else {
89
151
  // Generate new UUIDv4 anonymous user ID
90
152
  this.persistentAnonymousUserId = this.generateAnonymousUserId();
91
- localStorage.setItem(storageKey, this.persistentAnonymousUserId);
153
+ this.savePersistentAnonymousUserId(this.persistentAnonymousUserId);
92
154
  this.log('Generated new persistent anonymous user ID:', this.persistentAnonymousUserId);
93
155
  }
94
156
  }
@@ -98,10 +160,36 @@ export class GrainAnalytics {
98
160
  this.persistentAnonymousUserId = this.generateAnonymousUserId();
99
161
  }
100
162
  }
163
+ /**
164
+ * Save persistent anonymous user ID to cookie and/or localStorage
165
+ */
166
+ savePersistentAnonymousUserId(userId) {
167
+ if (typeof window === 'undefined')
168
+ return;
169
+ const storageKey = `grain_anonymous_user_id_${this.config.tenantId}`;
170
+ const cookieName = '_grain_uid';
171
+ try {
172
+ // Save to cookie if enabled
173
+ if (this.cookiesEnabled) {
174
+ const cookieOptions = {
175
+ maxAge: 365 * 24 * 60 * 60, // 365 days
176
+ sameSite: 'lax',
177
+ secure: window.location.protocol === 'https:',
178
+ ...this.config.cookieOptions,
179
+ };
180
+ setCookie(cookieName, userId, cookieOptions);
181
+ }
182
+ // Always save to localStorage as fallback
183
+ localStorage.setItem(storageKey, userId);
184
+ }
185
+ catch (error) {
186
+ this.log('Failed to save persistent anonymous user ID:', error);
187
+ }
188
+ }
101
189
  /**
102
190
  * Get the effective user ID (global userId or persistent anonymous ID)
103
191
  */
104
- getEffectiveUserId() {
192
+ getEffectiveUserIdInternal() {
105
193
  if (this.globalUserId) {
106
194
  return this.globalUserId;
107
195
  }
@@ -249,7 +337,7 @@ export class GrainAnalytics {
249
337
  formatEvent(event) {
250
338
  return {
251
339
  eventName: event.eventName,
252
- userId: event.userId || this.getEffectiveUserId(),
340
+ userId: event.userId || this.getEffectiveUserIdInternal(),
253
341
  properties: event.properties || {},
254
342
  };
255
343
  }
@@ -429,6 +517,120 @@ export class GrainAnalytics {
429
517
  }
430
518
  });
431
519
  }
520
+ /**
521
+ * Initialize automatic tracking (heartbeat and page views)
522
+ */
523
+ initializeAutomaticTracking() {
524
+ if (this.config.enableHeartbeat) {
525
+ try {
526
+ this.activityDetector = new ActivityDetector();
527
+ this.heartbeatManager = new HeartbeatManager(this, this.activityDetector, {
528
+ activeInterval: this.config.heartbeatActiveInterval,
529
+ inactiveInterval: this.config.heartbeatInactiveInterval,
530
+ debug: this.config.debug,
531
+ });
532
+ this.log('Heartbeat tracking initialized');
533
+ }
534
+ catch (error) {
535
+ this.log('Failed to initialize heartbeat tracking:', error);
536
+ }
537
+ }
538
+ if (this.config.enableAutoPageView) {
539
+ try {
540
+ this.pageTrackingManager = new PageTrackingManager(this, {
541
+ stripQueryParams: this.config.stripQueryParams,
542
+ debug: this.config.debug,
543
+ });
544
+ this.log('Auto page view tracking initialized');
545
+ }
546
+ catch (error) {
547
+ this.log('Failed to initialize page view tracking:', error);
548
+ }
549
+ }
550
+ }
551
+ /**
552
+ * Handle consent granted - upgrade ephemeral session to persistent user
553
+ */
554
+ handleConsentGranted() {
555
+ this.flushWaitingForConsentQueue();
556
+ // Track consent granted event with mapping
557
+ if (this.ephemeralSessionId) {
558
+ this.trackSystemEvent('_grain_consent_granted', {
559
+ previous_session_id: this.ephemeralSessionId,
560
+ new_user_id: this.getEffectiveUserId(),
561
+ timestamp: Date.now(),
562
+ });
563
+ }
564
+ }
565
+ /**
566
+ * Track system events that bypass consent checks (for necessary/functional tracking)
567
+ */
568
+ trackSystemEvent(eventName, properties) {
569
+ if (this.isDestroyed)
570
+ return;
571
+ const hasConsent = this.consentManager.hasConsent('analytics');
572
+ // Create event with appropriate user ID
573
+ const event = {
574
+ eventName,
575
+ userId: hasConsent ? this.getEffectiveUserId() : this.getEphemeralSessionId(),
576
+ properties: {
577
+ ...properties,
578
+ _minimal: !hasConsent, // Flag to indicate minimal tracking
579
+ _consent_status: hasConsent ? 'granted' : 'pending',
580
+ },
581
+ };
582
+ // Bypass consent check for necessary system events
583
+ this.eventQueue.push(event);
584
+ this.eventCountSinceLastHeartbeat++;
585
+ this.log(`Queued system event: ${eventName}`, properties);
586
+ // Consider flushing
587
+ if (this.eventQueue.length >= this.config.batchSize) {
588
+ this.flush().catch((error) => {
589
+ const formattedError = this.formatError(error, 'flush system event');
590
+ this.logError(formattedError);
591
+ });
592
+ }
593
+ }
594
+ /**
595
+ * Get ephemeral session ID (memory-only, not persisted)
596
+ */
597
+ getEphemeralSessionId() {
598
+ if (!this.ephemeralSessionId) {
599
+ this.ephemeralSessionId = this.generateUUID();
600
+ }
601
+ return this.ephemeralSessionId;
602
+ }
603
+ /**
604
+ * Get the current page path from page tracker
605
+ */
606
+ getCurrentPage() {
607
+ return this.pageTrackingManager?.getCurrentPage() || null;
608
+ }
609
+ /**
610
+ * Get event count since last heartbeat
611
+ */
612
+ getEventCountSinceLastHeartbeat() {
613
+ return this.eventCountSinceLastHeartbeat;
614
+ }
615
+ /**
616
+ * Reset event count since last heartbeat
617
+ */
618
+ resetEventCountSinceLastHeartbeat() {
619
+ this.eventCountSinceLastHeartbeat = 0;
620
+ }
621
+ /**
622
+ * Get the effective user ID (public method)
623
+ */
624
+ getEffectiveUserId() {
625
+ return this.getEffectiveUserIdInternal();
626
+ }
627
+ /**
628
+ * Get the session ID (ephemeral or persistent based on consent)
629
+ */
630
+ getSessionId() {
631
+ const hasConsent = this.consentManager.hasConsent('analytics');
632
+ return hasConsent ? this.getEffectiveUserId() : this.getEphemeralSessionId();
633
+ }
432
634
  async track(eventOrName, propertiesOrOptions, options) {
433
635
  try {
434
636
  if (this.isDestroyed) {
@@ -450,8 +652,30 @@ export class GrainAnalytics {
450
652
  event = eventOrName;
451
653
  opts = propertiesOrOptions || {};
452
654
  }
655
+ // Filter properties if whitelist is enabled
656
+ if (this.config.allowedProperties && event.properties) {
657
+ const filtered = {};
658
+ for (const key of this.config.allowedProperties) {
659
+ if (key in event.properties) {
660
+ filtered[key] = event.properties[key];
661
+ }
662
+ }
663
+ event.properties = filtered;
664
+ }
453
665
  const formattedEvent = this.formatEvent(event);
666
+ // Check consent before tracking
667
+ if (this.consentManager.shouldWaitForConsent() && this.config.waitForConsent) {
668
+ // Queue event until consent is granted
669
+ this.waitingForConsentQueue.push(formattedEvent);
670
+ this.log(`Event waiting for consent: ${event.eventName}`, event.properties);
671
+ return;
672
+ }
673
+ if (!this.consentManager.hasConsent('analytics')) {
674
+ this.log(`Event blocked by consent: ${event.eventName}`);
675
+ return;
676
+ }
454
677
  this.eventQueue.push(formattedEvent);
678
+ this.eventCountSinceLastHeartbeat++;
455
679
  this.log(`Queued event: ${event.eventName}`, event.properties);
456
680
  // Check if we should flush immediately
457
681
  if (opts.flush || this.eventQueue.length >= this.config.batchSize) {
@@ -463,6 +687,22 @@ export class GrainAnalytics {
463
687
  this.logError(formattedError);
464
688
  }
465
689
  }
690
+ /**
691
+ * Flush events that were waiting for consent
692
+ */
693
+ flushWaitingForConsentQueue() {
694
+ if (this.waitingForConsentQueue.length === 0)
695
+ return;
696
+ this.log(`Flushing ${this.waitingForConsentQueue.length} events waiting for consent`);
697
+ // Move waiting events to main queue
698
+ this.eventQueue.push(...this.waitingForConsentQueue);
699
+ this.waitingForConsentQueue = [];
700
+ // Flush immediately
701
+ this.flush().catch((error) => {
702
+ const formattedError = this.formatError(error, 'flush waiting for consent queue');
703
+ this.logError(formattedError);
704
+ });
705
+ }
466
706
  /**
467
707
  * Identify a user (sets userId for subsequent events)
468
708
  */
@@ -509,7 +749,7 @@ export class GrainAnalytics {
509
749
  * Get current effective user ID (global userId or persistent anonymous ID)
510
750
  */
511
751
  getEffectiveUserIdPublic() {
512
- return this.getEffectiveUserId();
752
+ return this.getEffectiveUserIdInternal();
513
753
  }
514
754
  /**
515
755
  * Login with auth token or userId on the fly
@@ -559,7 +799,7 @@ export class GrainAnalytics {
559
799
  this.log(`Login: Setting auth strategy to ${options.authStrategy}`);
560
800
  this.config.authStrategy = options.authStrategy;
561
801
  }
562
- this.log(`Login successful. Effective user ID: ${this.getEffectiveUserId()}`);
802
+ this.log(`Login successful. Effective user ID: ${this.getEffectiveUserIdInternal()}`);
563
803
  }
564
804
  catch (error) {
565
805
  const formattedError = this.formatError(error, 'login');
@@ -604,7 +844,7 @@ export class GrainAnalytics {
604
844
  }
605
845
  }
606
846
  }
607
- this.log(`Logout successful. Effective user ID: ${this.getEffectiveUserId()}`);
847
+ this.log(`Logout successful. Effective user ID: ${this.getEffectiveUserIdInternal()}`);
608
848
  }
609
849
  catch (error) {
610
850
  const formattedError = this.formatError(error, 'logout');
@@ -622,7 +862,7 @@ export class GrainAnalytics {
622
862
  this.logError(formattedError);
623
863
  return;
624
864
  }
625
- const userId = options?.userId || this.getEffectiveUserId();
865
+ const userId = options?.userId || this.getEffectiveUserIdInternal();
626
866
  // Validate property count (max 4 properties)
627
867
  const propertyKeys = Object.keys(properties);
628
868
  if (propertyKeys.length > 4) {
@@ -902,7 +1142,7 @@ export class GrainAnalytics {
902
1142
  this.logError(formattedError);
903
1143
  return null;
904
1144
  }
905
- const userId = options.userId || this.getEffectiveUserId();
1145
+ const userId = options.userId || this.getEffectiveUserIdInternal();
906
1146
  const immediateKeys = options.immediateKeys || [];
907
1147
  const properties = options.properties || {};
908
1148
  const request = {
@@ -1104,7 +1344,7 @@ export class GrainAnalytics {
1104
1344
  async preloadConfig(immediateKeys = [], properties) {
1105
1345
  try {
1106
1346
  // Use effective userId (will be generated if not set)
1107
- const effectiveUserId = this.getEffectiveUserId();
1347
+ const effectiveUserId = this.getEffectiveUserIdInternal();
1108
1348
  this.log(`Preloading config for user: ${effectiveUserId}`);
1109
1349
  const response = await this.fetchConfig({ immediateKeys, properties });
1110
1350
  if (response) {
@@ -1126,6 +1366,63 @@ export class GrainAnalytics {
1126
1366
  }
1127
1367
  return chunks;
1128
1368
  }
1369
+ // Privacy & Consent Methods
1370
+ /**
1371
+ * Grant consent for tracking
1372
+ * @param categories - Optional array of consent categories (e.g., ['analytics', 'functional'])
1373
+ */
1374
+ grantConsent(categories) {
1375
+ try {
1376
+ this.consentManager.grantConsent(categories);
1377
+ this.log('Consent granted', categories);
1378
+ }
1379
+ catch (error) {
1380
+ const formattedError = this.formatError(error, 'grantConsent');
1381
+ this.logError(formattedError);
1382
+ }
1383
+ }
1384
+ /**
1385
+ * Revoke consent for tracking (opt-out)
1386
+ * @param categories - Optional array of categories to revoke (if not provided, revokes all)
1387
+ */
1388
+ revokeConsent(categories) {
1389
+ try {
1390
+ this.consentManager.revokeConsent(categories);
1391
+ this.log('Consent revoked', categories);
1392
+ // Clear queued events when consent is revoked
1393
+ this.eventQueue = [];
1394
+ this.waitingForConsentQueue = [];
1395
+ }
1396
+ catch (error) {
1397
+ const formattedError = this.formatError(error, 'revokeConsent');
1398
+ this.logError(formattedError);
1399
+ }
1400
+ }
1401
+ /**
1402
+ * Get current consent state
1403
+ */
1404
+ getConsentState() {
1405
+ return this.consentManager.getConsentState();
1406
+ }
1407
+ /**
1408
+ * Check if user has granted consent
1409
+ * @param category - Optional category to check (if not provided, checks general consent)
1410
+ */
1411
+ hasConsent(category) {
1412
+ return this.consentManager.hasConsent(category);
1413
+ }
1414
+ /**
1415
+ * Add listener for consent state changes
1416
+ */
1417
+ onConsentChange(listener) {
1418
+ this.consentManager.addListener(listener);
1419
+ }
1420
+ /**
1421
+ * Remove consent change listener
1422
+ */
1423
+ offConsentChange(listener) {
1424
+ this.consentManager.removeListener(listener);
1425
+ }
1129
1426
  /**
1130
1427
  * Destroy the client and clean up resources
1131
1428
  */
@@ -1139,6 +1436,19 @@ export class GrainAnalytics {
1139
1436
  this.stopConfigRefreshTimer();
1140
1437
  // Clear config change listeners
1141
1438
  this.configChangeListeners = [];
1439
+ // Destroy automatic tracking managers
1440
+ if (this.heartbeatManager) {
1441
+ this.heartbeatManager.destroy();
1442
+ this.heartbeatManager = null;
1443
+ }
1444
+ if (this.pageTrackingManager) {
1445
+ this.pageTrackingManager.destroy();
1446
+ this.pageTrackingManager = null;
1447
+ }
1448
+ if (this.activityDetector) {
1449
+ this.activityDetector.destroy();
1450
+ this.activityDetector = null;
1451
+ }
1142
1452
  // Send any remaining events (in chunks if necessary)
1143
1453
  if (this.eventQueue.length > 0) {
1144
1454
  const eventsToSend = [...this.eventQueue];
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Page Tracking for Grain Analytics
3
+ * Automatically tracks page views with consent-aware behavior
4
+ */
5
+ export interface PageTrackingConfig {
6
+ stripQueryParams: boolean;
7
+ debug?: boolean;
8
+ }
9
+ export interface PageTracker {
10
+ trackSystemEvent(eventName: string, properties: Record<string, unknown>): void;
11
+ hasConsent(category?: string): boolean;
12
+ getEffectiveUserId(): string;
13
+ getEphemeralSessionId(): string;
14
+ }
15
+ export declare class PageTrackingManager {
16
+ private config;
17
+ private tracker;
18
+ private isDestroyed;
19
+ private currentPath;
20
+ private originalPushState;
21
+ private originalReplaceState;
22
+ constructor(tracker: PageTracker, config: PageTrackingConfig);
23
+ /**
24
+ * Setup History API listeners (pushState, replaceState, popstate)
25
+ */
26
+ private setupHistoryListeners;
27
+ /**
28
+ * Setup hash change listener
29
+ */
30
+ private setupHashChangeListener;
31
+ /**
32
+ * Handle popstate event (back/forward navigation)
33
+ */
34
+ private handlePopState;
35
+ /**
36
+ * Handle hash change event
37
+ */
38
+ private handleHashChange;
39
+ /**
40
+ * Track the current page
41
+ */
42
+ private trackCurrentPage;
43
+ /**
44
+ * Extract path from URL, optionally stripping query parameters
45
+ */
46
+ private extractPath;
47
+ /**
48
+ * Get the current page path
49
+ */
50
+ getCurrentPage(): string | null;
51
+ /**
52
+ * Manually track a page view (for custom navigation)
53
+ */
54
+ trackPage(page: string, properties?: Record<string, unknown>): void;
55
+ /**
56
+ * Destroy the page tracker
57
+ */
58
+ destroy(): void;
59
+ }
60
+ //# sourceMappingURL=page-tracking.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-tracking.d.ts","sourceRoot":"","sources":["../src/page-tracking.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,WAAW,kBAAkB;IACjC,gBAAgB,EAAE,OAAO,CAAC;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IAC/E,UAAU,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IACvC,kBAAkB,IAAI,MAAM,CAAC;IAC7B,qBAAqB,IAAI,MAAM,CAAC;CACjC;AAED,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,OAAO,CAAc;IAC7B,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,WAAW,CAAuB;IAC1C,OAAO,CAAC,iBAAiB,CAAyC;IAClE,OAAO,CAAC,oBAAoB,CAA4C;gBAE5D,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,kBAAkB;IAY5D;;OAEG;IACH,OAAO,CAAC,qBAAqB;IAqB7B;;OAEG;IACH,OAAO,CAAC,uBAAuB;IAK/B;;OAEG;IACH,OAAO,CAAC,cAAc,CAGpB;IAEF;;OAEG;IACH,OAAO,CAAC,gBAAgB,CAGtB;IAEF;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAmCxB;;OAEG;IACH,OAAO,CAAC,WAAW;IAmBnB;;OAEG;IACH,cAAc,IAAI,MAAM,GAAG,IAAI;IAI/B;;OAEG;IACH,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;IAgCnE;;OAEG;IACH,OAAO,IAAI,IAAI;CAyBhB"}
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Activity Detection for Grain Analytics
3
+ * Tracks user activity (mouse, keyboard, touch, scroll) to determine if user is active
4
+ */
5
+ export declare class ActivityDetector {
6
+ private lastActivityTime;
7
+ private activityThreshold;
8
+ private listeners;
9
+ private boundActivityHandler;
10
+ private isDestroyed;
11
+ private readonly activityEvents;
12
+ constructor();
13
+ /**
14
+ * Setup event listeners for activity detection
15
+ */
16
+ private setupListeners;
17
+ /**
18
+ * Handle activity event
19
+ */
20
+ private handleActivity;
21
+ /**
22
+ * Debounce function to limit how often activity handler is called
23
+ */
24
+ private debounce;
25
+ /**
26
+ * Check if user is currently active
27
+ * @param threshold Time in ms to consider user inactive (default: 30s)
28
+ */
29
+ isActive(threshold?: number): boolean;
30
+ /**
31
+ * Get time since last activity in milliseconds
32
+ */
33
+ getTimeSinceLastActivity(): number;
34
+ /**
35
+ * Get last activity timestamp
36
+ */
37
+ getLastActivityTime(): number;
38
+ /**
39
+ * Set activity threshold
40
+ */
41
+ setActivityThreshold(threshold: number): void;
42
+ /**
43
+ * Add listener for activity changes
44
+ */
45
+ addListener(listener: () => void): void;
46
+ /**
47
+ * Remove listener
48
+ */
49
+ removeListener(listener: () => void): void;
50
+ /**
51
+ * Notify all listeners
52
+ */
53
+ private notifyListeners;
54
+ /**
55
+ * Cleanup and remove listeners
56
+ */
57
+ destroy(): void;
58
+ }
59
+ //# sourceMappingURL=activity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"activity.d.ts","sourceRoot":"","sources":["../../src/activity.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,gBAAgB,CAAS;IACjC,OAAO,CAAC,iBAAiB,CAAiB;IAC1C,OAAO,CAAC,SAAS,CAAyB;IAC1C,OAAO,CAAC,oBAAoB,CAAa;IACzC,OAAO,CAAC,WAAW,CAAS;IAG5B,OAAO,CAAC,QAAQ,CAAC,cAAc,CAOpB;;IAQX;;OAEG;IACH,OAAO,CAAC,cAAc;IAQtB;;OAEG;IACH,OAAO,CAAC,cAAc;IAMtB;;OAEG;IACH,OAAO,CAAC,QAAQ;IAehB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO;IAMrC;;OAEG;IACH,wBAAwB,IAAI,MAAM;IAIlC;;OAEG;IACH,mBAAmB,IAAI,MAAM;IAI7B;;OAEG;IACH,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAI7C;;OAEG;IACH,WAAW,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI;IAIvC;;OAEG;IACH,cAAc,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI;IAO1C;;OAEG;IACH,OAAO,CAAC,eAAe;IAUvB;;OAEG;IACH,OAAO,IAAI,IAAI;CAYhB"}