@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
@@ -6,9 +6,15 @@
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.GrainAnalytics = void 0;
8
8
  exports.createGrainAnalytics = createGrainAnalytics;
9
+ const consent_1 = require("./consent");
10
+ const cookies_1 = require("./cookies");
11
+ const activity_1 = require("./activity");
12
+ const heartbeat_1 = require("./heartbeat");
13
+ const page_tracking_1 = require("./page-tracking");
9
14
  class GrainAnalytics {
10
15
  constructor(config) {
11
16
  this.eventQueue = [];
17
+ this.waitingForConsentQueue = [];
12
18
  this.flushTimer = null;
13
19
  this.isDestroyed = false;
14
20
  this.globalUserId = null;
@@ -18,6 +24,13 @@ class GrainAnalytics {
18
24
  this.configRefreshTimer = null;
19
25
  this.configChangeListeners = [];
20
26
  this.configFetchPromise = null;
27
+ this.cookiesEnabled = false;
28
+ // Automatic Tracking properties
29
+ this.activityDetector = null;
30
+ this.heartbeatManager = null;
31
+ this.pageTrackingManager = null;
32
+ this.ephemeralSessionId = null;
33
+ this.eventCountSinceLastHeartbeat = 0;
21
34
  this.config = {
22
35
  apiUrl: 'https://api.grainql.com',
23
36
  authStrategy: 'NONE',
@@ -32,9 +45,30 @@ class GrainAnalytics {
32
45
  configCacheKey: 'grain_config',
33
46
  configRefreshInterval: 300000, // 5 minutes
34
47
  enableConfigCache: true,
48
+ // Privacy defaults
49
+ consentMode: 'opt-out',
50
+ waitForConsent: false,
51
+ enableCookies: false,
52
+ anonymizeIP: false,
53
+ disableAutoProperties: false,
54
+ // Automatic Tracking defaults
55
+ enableHeartbeat: true,
56
+ heartbeatActiveInterval: 120000, // 2 minutes
57
+ heartbeatInactiveInterval: 300000, // 5 minutes
58
+ enableAutoPageView: true,
59
+ stripQueryParams: true,
35
60
  ...config,
36
61
  tenantId: config.tenantId,
37
62
  };
63
+ // Initialize consent manager
64
+ this.consentManager = new consent_1.ConsentManager(this.config.tenantId, this.config.consentMode);
65
+ // Check if cookies are enabled
66
+ if (this.config.enableCookies) {
67
+ this.cookiesEnabled = (0, cookies_1.areCookiesEnabled)();
68
+ if (!this.cookiesEnabled && this.config.debug) {
69
+ console.warn('[Grain Analytics] Cookies are not available, falling back to localStorage');
70
+ }
71
+ }
38
72
  // Set global userId if provided in config
39
73
  if (config.userId) {
40
74
  this.globalUserId = config.userId;
@@ -44,6 +78,18 @@ class GrainAnalytics {
44
78
  this.setupBeforeUnload();
45
79
  this.startFlushTimer();
46
80
  this.initializeConfigCache();
81
+ // Initialize ephemeral session ID (memory-only, not persisted)
82
+ this.ephemeralSessionId = this.generateUUID();
83
+ // Initialize automatic tracking (browser only)
84
+ if (typeof window !== 'undefined') {
85
+ this.initializeAutomaticTracking();
86
+ }
87
+ // Set up consent change listener to flush waiting events and handle consent upgrade
88
+ this.consentManager.addListener((state) => {
89
+ if (state.granted) {
90
+ this.handleConsentGranted();
91
+ }
92
+ });
47
93
  }
48
94
  validateConfig() {
49
95
  if (!this.config.tenantId) {
@@ -77,22 +123,38 @@ class GrainAnalytics {
77
123
  return this.generateUUID();
78
124
  }
79
125
  /**
80
- * Initialize persistent anonymous user ID from localStorage or create new one
126
+ * Initialize persistent anonymous user ID from cookies or localStorage
127
+ * Priority: Cookie → localStorage → generate new
81
128
  */
82
129
  initializePersistentAnonymousUserId() {
83
130
  if (typeof window === 'undefined')
84
131
  return;
85
132
  const storageKey = `grain_anonymous_user_id_${this.config.tenantId}`;
133
+ const cookieName = '_grain_uid';
86
134
  try {
135
+ // Try to load from cookie first if enabled
136
+ if (this.cookiesEnabled) {
137
+ const cookieValue = (0, cookies_1.getCookie)(cookieName);
138
+ if (cookieValue) {
139
+ this.persistentAnonymousUserId = cookieValue;
140
+ this.log('Loaded persistent anonymous user ID from cookie:', this.persistentAnonymousUserId);
141
+ return;
142
+ }
143
+ }
144
+ // Fallback to localStorage
87
145
  const stored = localStorage.getItem(storageKey);
88
146
  if (stored) {
89
147
  this.persistentAnonymousUserId = stored;
90
- this.log('Loaded persistent anonymous user ID:', this.persistentAnonymousUserId);
148
+ this.log('Loaded persistent anonymous user ID from localStorage:', this.persistentAnonymousUserId);
149
+ // Migrate to cookie if enabled
150
+ if (this.cookiesEnabled) {
151
+ this.savePersistentAnonymousUserId(stored);
152
+ }
91
153
  }
92
154
  else {
93
155
  // Generate new UUIDv4 anonymous user ID
94
156
  this.persistentAnonymousUserId = this.generateAnonymousUserId();
95
- localStorage.setItem(storageKey, this.persistentAnonymousUserId);
157
+ this.savePersistentAnonymousUserId(this.persistentAnonymousUserId);
96
158
  this.log('Generated new persistent anonymous user ID:', this.persistentAnonymousUserId);
97
159
  }
98
160
  }
@@ -102,10 +164,36 @@ class GrainAnalytics {
102
164
  this.persistentAnonymousUserId = this.generateAnonymousUserId();
103
165
  }
104
166
  }
167
+ /**
168
+ * Save persistent anonymous user ID to cookie and/or localStorage
169
+ */
170
+ savePersistentAnonymousUserId(userId) {
171
+ if (typeof window === 'undefined')
172
+ return;
173
+ const storageKey = `grain_anonymous_user_id_${this.config.tenantId}`;
174
+ const cookieName = '_grain_uid';
175
+ try {
176
+ // Save to cookie if enabled
177
+ if (this.cookiesEnabled) {
178
+ const cookieOptions = {
179
+ maxAge: 365 * 24 * 60 * 60, // 365 days
180
+ sameSite: 'lax',
181
+ secure: window.location.protocol === 'https:',
182
+ ...this.config.cookieOptions,
183
+ };
184
+ (0, cookies_1.setCookie)(cookieName, userId, cookieOptions);
185
+ }
186
+ // Always save to localStorage as fallback
187
+ localStorage.setItem(storageKey, userId);
188
+ }
189
+ catch (error) {
190
+ this.log('Failed to save persistent anonymous user ID:', error);
191
+ }
192
+ }
105
193
  /**
106
194
  * Get the effective user ID (global userId or persistent anonymous ID)
107
195
  */
108
- getEffectiveUserId() {
196
+ getEffectiveUserIdInternal() {
109
197
  if (this.globalUserId) {
110
198
  return this.globalUserId;
111
199
  }
@@ -253,7 +341,7 @@ class GrainAnalytics {
253
341
  formatEvent(event) {
254
342
  return {
255
343
  eventName: event.eventName,
256
- userId: event.userId || this.getEffectiveUserId(),
344
+ userId: event.userId || this.getEffectiveUserIdInternal(),
257
345
  properties: event.properties || {},
258
346
  };
259
347
  }
@@ -433,6 +521,120 @@ class GrainAnalytics {
433
521
  }
434
522
  });
435
523
  }
524
+ /**
525
+ * Initialize automatic tracking (heartbeat and page views)
526
+ */
527
+ initializeAutomaticTracking() {
528
+ if (this.config.enableHeartbeat) {
529
+ try {
530
+ this.activityDetector = new activity_1.ActivityDetector();
531
+ this.heartbeatManager = new heartbeat_1.HeartbeatManager(this, this.activityDetector, {
532
+ activeInterval: this.config.heartbeatActiveInterval,
533
+ inactiveInterval: this.config.heartbeatInactiveInterval,
534
+ debug: this.config.debug,
535
+ });
536
+ this.log('Heartbeat tracking initialized');
537
+ }
538
+ catch (error) {
539
+ this.log('Failed to initialize heartbeat tracking:', error);
540
+ }
541
+ }
542
+ if (this.config.enableAutoPageView) {
543
+ try {
544
+ this.pageTrackingManager = new page_tracking_1.PageTrackingManager(this, {
545
+ stripQueryParams: this.config.stripQueryParams,
546
+ debug: this.config.debug,
547
+ });
548
+ this.log('Auto page view tracking initialized');
549
+ }
550
+ catch (error) {
551
+ this.log('Failed to initialize page view tracking:', error);
552
+ }
553
+ }
554
+ }
555
+ /**
556
+ * Handle consent granted - upgrade ephemeral session to persistent user
557
+ */
558
+ handleConsentGranted() {
559
+ this.flushWaitingForConsentQueue();
560
+ // Track consent granted event with mapping
561
+ if (this.ephemeralSessionId) {
562
+ this.trackSystemEvent('_grain_consent_granted', {
563
+ previous_session_id: this.ephemeralSessionId,
564
+ new_user_id: this.getEffectiveUserId(),
565
+ timestamp: Date.now(),
566
+ });
567
+ }
568
+ }
569
+ /**
570
+ * Track system events that bypass consent checks (for necessary/functional tracking)
571
+ */
572
+ trackSystemEvent(eventName, properties) {
573
+ if (this.isDestroyed)
574
+ return;
575
+ const hasConsent = this.consentManager.hasConsent('analytics');
576
+ // Create event with appropriate user ID
577
+ const event = {
578
+ eventName,
579
+ userId: hasConsent ? this.getEffectiveUserId() : this.getEphemeralSessionId(),
580
+ properties: {
581
+ ...properties,
582
+ _minimal: !hasConsent, // Flag to indicate minimal tracking
583
+ _consent_status: hasConsent ? 'granted' : 'pending',
584
+ },
585
+ };
586
+ // Bypass consent check for necessary system events
587
+ this.eventQueue.push(event);
588
+ this.eventCountSinceLastHeartbeat++;
589
+ this.log(`Queued system event: ${eventName}`, properties);
590
+ // Consider flushing
591
+ if (this.eventQueue.length >= this.config.batchSize) {
592
+ this.flush().catch((error) => {
593
+ const formattedError = this.formatError(error, 'flush system event');
594
+ this.logError(formattedError);
595
+ });
596
+ }
597
+ }
598
+ /**
599
+ * Get ephemeral session ID (memory-only, not persisted)
600
+ */
601
+ getEphemeralSessionId() {
602
+ if (!this.ephemeralSessionId) {
603
+ this.ephemeralSessionId = this.generateUUID();
604
+ }
605
+ return this.ephemeralSessionId;
606
+ }
607
+ /**
608
+ * Get the current page path from page tracker
609
+ */
610
+ getCurrentPage() {
611
+ return this.pageTrackingManager?.getCurrentPage() || null;
612
+ }
613
+ /**
614
+ * Get event count since last heartbeat
615
+ */
616
+ getEventCountSinceLastHeartbeat() {
617
+ return this.eventCountSinceLastHeartbeat;
618
+ }
619
+ /**
620
+ * Reset event count since last heartbeat
621
+ */
622
+ resetEventCountSinceLastHeartbeat() {
623
+ this.eventCountSinceLastHeartbeat = 0;
624
+ }
625
+ /**
626
+ * Get the effective user ID (public method)
627
+ */
628
+ getEffectiveUserId() {
629
+ return this.getEffectiveUserIdInternal();
630
+ }
631
+ /**
632
+ * Get the session ID (ephemeral or persistent based on consent)
633
+ */
634
+ getSessionId() {
635
+ const hasConsent = this.consentManager.hasConsent('analytics');
636
+ return hasConsent ? this.getEffectiveUserId() : this.getEphemeralSessionId();
637
+ }
436
638
  async track(eventOrName, propertiesOrOptions, options) {
437
639
  try {
438
640
  if (this.isDestroyed) {
@@ -454,8 +656,30 @@ class GrainAnalytics {
454
656
  event = eventOrName;
455
657
  opts = propertiesOrOptions || {};
456
658
  }
659
+ // Filter properties if whitelist is enabled
660
+ if (this.config.allowedProperties && event.properties) {
661
+ const filtered = {};
662
+ for (const key of this.config.allowedProperties) {
663
+ if (key in event.properties) {
664
+ filtered[key] = event.properties[key];
665
+ }
666
+ }
667
+ event.properties = filtered;
668
+ }
457
669
  const formattedEvent = this.formatEvent(event);
670
+ // Check consent before tracking
671
+ if (this.consentManager.shouldWaitForConsent() && this.config.waitForConsent) {
672
+ // Queue event until consent is granted
673
+ this.waitingForConsentQueue.push(formattedEvent);
674
+ this.log(`Event waiting for consent: ${event.eventName}`, event.properties);
675
+ return;
676
+ }
677
+ if (!this.consentManager.hasConsent('analytics')) {
678
+ this.log(`Event blocked by consent: ${event.eventName}`);
679
+ return;
680
+ }
458
681
  this.eventQueue.push(formattedEvent);
682
+ this.eventCountSinceLastHeartbeat++;
459
683
  this.log(`Queued event: ${event.eventName}`, event.properties);
460
684
  // Check if we should flush immediately
461
685
  if (opts.flush || this.eventQueue.length >= this.config.batchSize) {
@@ -467,6 +691,22 @@ class GrainAnalytics {
467
691
  this.logError(formattedError);
468
692
  }
469
693
  }
694
+ /**
695
+ * Flush events that were waiting for consent
696
+ */
697
+ flushWaitingForConsentQueue() {
698
+ if (this.waitingForConsentQueue.length === 0)
699
+ return;
700
+ this.log(`Flushing ${this.waitingForConsentQueue.length} events waiting for consent`);
701
+ // Move waiting events to main queue
702
+ this.eventQueue.push(...this.waitingForConsentQueue);
703
+ this.waitingForConsentQueue = [];
704
+ // Flush immediately
705
+ this.flush().catch((error) => {
706
+ const formattedError = this.formatError(error, 'flush waiting for consent queue');
707
+ this.logError(formattedError);
708
+ });
709
+ }
470
710
  /**
471
711
  * Identify a user (sets userId for subsequent events)
472
712
  */
@@ -513,7 +753,7 @@ class GrainAnalytics {
513
753
  * Get current effective user ID (global userId or persistent anonymous ID)
514
754
  */
515
755
  getEffectiveUserIdPublic() {
516
- return this.getEffectiveUserId();
756
+ return this.getEffectiveUserIdInternal();
517
757
  }
518
758
  /**
519
759
  * Login with auth token or userId on the fly
@@ -563,7 +803,7 @@ class GrainAnalytics {
563
803
  this.log(`Login: Setting auth strategy to ${options.authStrategy}`);
564
804
  this.config.authStrategy = options.authStrategy;
565
805
  }
566
- this.log(`Login successful. Effective user ID: ${this.getEffectiveUserId()}`);
806
+ this.log(`Login successful. Effective user ID: ${this.getEffectiveUserIdInternal()}`);
567
807
  }
568
808
  catch (error) {
569
809
  const formattedError = this.formatError(error, 'login');
@@ -608,7 +848,7 @@ class GrainAnalytics {
608
848
  }
609
849
  }
610
850
  }
611
- this.log(`Logout successful. Effective user ID: ${this.getEffectiveUserId()}`);
851
+ this.log(`Logout successful. Effective user ID: ${this.getEffectiveUserIdInternal()}`);
612
852
  }
613
853
  catch (error) {
614
854
  const formattedError = this.formatError(error, 'logout');
@@ -626,7 +866,7 @@ class GrainAnalytics {
626
866
  this.logError(formattedError);
627
867
  return;
628
868
  }
629
- const userId = options?.userId || this.getEffectiveUserId();
869
+ const userId = options?.userId || this.getEffectiveUserIdInternal();
630
870
  // Validate property count (max 4 properties)
631
871
  const propertyKeys = Object.keys(properties);
632
872
  if (propertyKeys.length > 4) {
@@ -906,7 +1146,7 @@ class GrainAnalytics {
906
1146
  this.logError(formattedError);
907
1147
  return null;
908
1148
  }
909
- const userId = options.userId || this.getEffectiveUserId();
1149
+ const userId = options.userId || this.getEffectiveUserIdInternal();
910
1150
  const immediateKeys = options.immediateKeys || [];
911
1151
  const properties = options.properties || {};
912
1152
  const request = {
@@ -1108,7 +1348,7 @@ class GrainAnalytics {
1108
1348
  async preloadConfig(immediateKeys = [], properties) {
1109
1349
  try {
1110
1350
  // Use effective userId (will be generated if not set)
1111
- const effectiveUserId = this.getEffectiveUserId();
1351
+ const effectiveUserId = this.getEffectiveUserIdInternal();
1112
1352
  this.log(`Preloading config for user: ${effectiveUserId}`);
1113
1353
  const response = await this.fetchConfig({ immediateKeys, properties });
1114
1354
  if (response) {
@@ -1130,6 +1370,63 @@ class GrainAnalytics {
1130
1370
  }
1131
1371
  return chunks;
1132
1372
  }
1373
+ // Privacy & Consent Methods
1374
+ /**
1375
+ * Grant consent for tracking
1376
+ * @param categories - Optional array of consent categories (e.g., ['analytics', 'functional'])
1377
+ */
1378
+ grantConsent(categories) {
1379
+ try {
1380
+ this.consentManager.grantConsent(categories);
1381
+ this.log('Consent granted', categories);
1382
+ }
1383
+ catch (error) {
1384
+ const formattedError = this.formatError(error, 'grantConsent');
1385
+ this.logError(formattedError);
1386
+ }
1387
+ }
1388
+ /**
1389
+ * Revoke consent for tracking (opt-out)
1390
+ * @param categories - Optional array of categories to revoke (if not provided, revokes all)
1391
+ */
1392
+ revokeConsent(categories) {
1393
+ try {
1394
+ this.consentManager.revokeConsent(categories);
1395
+ this.log('Consent revoked', categories);
1396
+ // Clear queued events when consent is revoked
1397
+ this.eventQueue = [];
1398
+ this.waitingForConsentQueue = [];
1399
+ }
1400
+ catch (error) {
1401
+ const formattedError = this.formatError(error, 'revokeConsent');
1402
+ this.logError(formattedError);
1403
+ }
1404
+ }
1405
+ /**
1406
+ * Get current consent state
1407
+ */
1408
+ getConsentState() {
1409
+ return this.consentManager.getConsentState();
1410
+ }
1411
+ /**
1412
+ * Check if user has granted consent
1413
+ * @param category - Optional category to check (if not provided, checks general consent)
1414
+ */
1415
+ hasConsent(category) {
1416
+ return this.consentManager.hasConsent(category);
1417
+ }
1418
+ /**
1419
+ * Add listener for consent state changes
1420
+ */
1421
+ onConsentChange(listener) {
1422
+ this.consentManager.addListener(listener);
1423
+ }
1424
+ /**
1425
+ * Remove consent change listener
1426
+ */
1427
+ offConsentChange(listener) {
1428
+ this.consentManager.removeListener(listener);
1429
+ }
1133
1430
  /**
1134
1431
  * Destroy the client and clean up resources
1135
1432
  */
@@ -1143,6 +1440,19 @@ class GrainAnalytics {
1143
1440
  this.stopConfigRefreshTimer();
1144
1441
  // Clear config change listeners
1145
1442
  this.configChangeListeners = [];
1443
+ // Destroy automatic tracking managers
1444
+ if (this.heartbeatManager) {
1445
+ this.heartbeatManager.destroy();
1446
+ this.heartbeatManager = null;
1447
+ }
1448
+ if (this.pageTrackingManager) {
1449
+ this.pageTrackingManager.destroy();
1450
+ this.pageTrackingManager = null;
1451
+ }
1452
+ if (this.activityDetector) {
1453
+ this.activityDetector.destroy();
1454
+ this.activityDetector = null;
1455
+ }
1146
1456
  // Send any remaining events (in chunks if necessary)
1147
1457
  if (this.eventQueue.length > 0) {
1148
1458
  const eventsToSend = [...this.eventQueue];