@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.
- package/dist/activity.d.ts +59 -0
- package/dist/activity.d.ts.map +1 -0
- package/dist/cjs/activity.d.ts +59 -0
- package/dist/cjs/activity.d.ts.map +1 -0
- package/dist/cjs/activity.js +131 -0
- package/dist/cjs/activity.js.map +1 -0
- package/dist/cjs/consent.d.ts +68 -0
- package/dist/cjs/consent.d.ts.map +1 -0
- package/dist/cjs/consent.js +191 -0
- package/dist/cjs/consent.js.map +1 -0
- package/dist/cjs/cookies.d.ts +28 -0
- package/dist/cjs/cookies.d.ts.map +1 -0
- package/dist/cjs/cookies.js +95 -0
- package/dist/cjs/cookies.js.map +1 -0
- package/dist/cjs/heartbeat.d.ts +42 -0
- package/dist/cjs/heartbeat.d.ts.map +1 -0
- package/dist/cjs/heartbeat.js +92 -0
- package/dist/cjs/heartbeat.js.map +1 -0
- package/dist/cjs/index.d.ts +100 -3
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/page-tracking.d.ts +60 -0
- package/dist/cjs/page-tracking.d.ts.map +1 -0
- package/dist/cjs/page-tracking.js +180 -0
- package/dist/cjs/page-tracking.js.map +1 -0
- package/dist/cjs/react/components/ConsentBanner.d.ts +16 -0
- package/dist/cjs/react/components/ConsentBanner.d.ts.map +1 -0
- package/dist/cjs/react/components/ConsentBanner.js +112 -0
- package/dist/cjs/react/components/ConsentBanner.js.map +1 -0
- package/dist/cjs/react/components/CookieNotice.d.ts +12 -0
- package/dist/cjs/react/components/CookieNotice.d.ts.map +1 -0
- package/dist/cjs/react/components/CookieNotice.js +62 -0
- package/dist/cjs/react/components/CookieNotice.js.map +1 -0
- package/dist/cjs/react/components/PrivacyPreferenceCenter.d.ts +12 -0
- package/dist/cjs/react/components/PrivacyPreferenceCenter.d.ts.map +1 -0
- package/dist/cjs/react/components/PrivacyPreferenceCenter.js +120 -0
- package/dist/cjs/react/components/PrivacyPreferenceCenter.js.map +1 -0
- package/dist/cjs/react/hooks/useConsent.d.ts +13 -0
- package/dist/cjs/react/hooks/useConsent.d.ts.map +1 -0
- package/dist/cjs/react/hooks/useConsent.js +84 -0
- package/dist/cjs/react/hooks/useConsent.js.map +1 -0
- package/dist/cjs/react/hooks/useDataDeletion.d.ts +17 -0
- package/dist/cjs/react/hooks/useDataDeletion.d.ts.map +1 -0
- package/dist/cjs/react/hooks/useDataDeletion.js +117 -0
- package/dist/cjs/react/hooks/useDataDeletion.js.map +1 -0
- package/dist/cjs/react/hooks/usePrivacyPreferences.d.ts +15 -0
- package/dist/cjs/react/hooks/usePrivacyPreferences.d.ts.map +1 -0
- package/dist/cjs/react/hooks/usePrivacyPreferences.js +82 -0
- package/dist/cjs/react/hooks/usePrivacyPreferences.js.map +1 -0
- package/dist/cjs/react/index.d.ts +11 -0
- package/dist/cjs/react/index.d.ts.map +1 -1
- package/dist/cjs/react/index.js +15 -1
- package/dist/cjs/react/index.js.map +1 -1
- package/dist/consent.d.ts +68 -0
- package/dist/consent.d.ts.map +1 -0
- package/dist/cookies.d.ts +28 -0
- package/dist/cookies.d.ts.map +1 -0
- package/dist/esm/activity.d.ts +59 -0
- package/dist/esm/activity.d.ts.map +1 -0
- package/dist/esm/activity.js +127 -0
- package/dist/esm/activity.js.map +1 -0
- package/dist/esm/consent.d.ts +68 -0
- package/dist/esm/consent.d.ts.map +1 -0
- package/dist/esm/consent.js +187 -0
- package/dist/esm/consent.js.map +1 -0
- package/dist/esm/cookies.d.ts +28 -0
- package/dist/esm/cookies.d.ts.map +1 -0
- package/dist/esm/cookies.js +89 -0
- package/dist/esm/cookies.js.map +1 -0
- package/dist/esm/heartbeat.d.ts +42 -0
- package/dist/esm/heartbeat.d.ts.map +1 -0
- package/dist/esm/heartbeat.js +88 -0
- package/dist/esm/heartbeat.js.map +1 -0
- package/dist/esm/index.d.ts +100 -3
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/page-tracking.d.ts +60 -0
- package/dist/esm/page-tracking.d.ts.map +1 -0
- package/dist/esm/page-tracking.js +176 -0
- package/dist/esm/page-tracking.js.map +1 -0
- package/dist/esm/react/components/ConsentBanner.d.ts +16 -0
- package/dist/esm/react/components/ConsentBanner.d.ts.map +1 -0
- package/dist/esm/react/components/ConsentBanner.js +76 -0
- package/dist/esm/react/components/ConsentBanner.js.map +1 -0
- package/dist/esm/react/components/CookieNotice.d.ts +12 -0
- package/dist/esm/react/components/CookieNotice.d.ts.map +1 -0
- package/dist/esm/react/components/CookieNotice.js +26 -0
- package/dist/esm/react/components/CookieNotice.js.map +1 -0
- package/dist/esm/react/components/PrivacyPreferenceCenter.d.ts +12 -0
- package/dist/esm/react/components/PrivacyPreferenceCenter.d.ts.map +1 -0
- package/dist/esm/react/components/PrivacyPreferenceCenter.js +84 -0
- package/dist/esm/react/components/PrivacyPreferenceCenter.js.map +1 -0
- package/dist/esm/react/hooks/useConsent.d.ts +13 -0
- package/dist/esm/react/hooks/useConsent.d.ts.map +1 -0
- package/dist/esm/react/hooks/useConsent.js +48 -0
- package/dist/esm/react/hooks/useConsent.js.map +1 -0
- package/dist/esm/react/hooks/useDataDeletion.d.ts +17 -0
- package/dist/esm/react/hooks/useDataDeletion.d.ts.map +1 -0
- package/dist/esm/react/hooks/useDataDeletion.js +81 -0
- package/dist/esm/react/hooks/useDataDeletion.js.map +1 -0
- package/dist/esm/react/hooks/usePrivacyPreferences.d.ts +15 -0
- package/dist/esm/react/hooks/usePrivacyPreferences.d.ts.map +1 -0
- package/dist/esm/react/hooks/usePrivacyPreferences.js +46 -0
- package/dist/esm/react/hooks/usePrivacyPreferences.js.map +1 -0
- package/dist/esm/react/index.d.ts +11 -0
- package/dist/esm/react/index.d.ts.map +1 -1
- package/dist/esm/react/index.js +8 -0
- package/dist/esm/react/index.js.map +1 -1
- package/dist/heartbeat.d.ts +42 -0
- package/dist/heartbeat.d.ts.map +1 -0
- package/dist/index.d.ts +100 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.global.dev.js +903 -12
- package/dist/index.global.dev.js.map +3 -3
- package/dist/index.global.js +2 -2
- package/dist/index.global.js.map +4 -4
- package/dist/index.js +321 -11
- package/dist/index.mjs +321 -11
- package/dist/page-tracking.d.ts +60 -0
- package/dist/page-tracking.d.ts.map +1 -0
- package/dist/react/activity.d.ts +59 -0
- package/dist/react/activity.d.ts.map +1 -0
- package/dist/react/activity.js +130 -0
- package/dist/react/activity.mjs +126 -0
- package/dist/react/consent.d.ts +68 -0
- package/dist/react/consent.d.ts.map +1 -0
- package/dist/react/consent.js +190 -0
- package/dist/react/consent.mjs +186 -0
- package/dist/react/cookies.d.ts +28 -0
- package/dist/react/cookies.d.ts.map +1 -0
- package/dist/react/cookies.js +94 -0
- package/dist/react/cookies.mjs +88 -0
- package/dist/react/heartbeat.d.ts +42 -0
- package/dist/react/heartbeat.d.ts.map +1 -0
- package/dist/react/heartbeat.js +91 -0
- package/dist/react/heartbeat.mjs +87 -0
- package/dist/react/index.d.ts +100 -3
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +321 -11
- package/dist/react/index.mjs +321 -11
- package/dist/react/page-tracking.d.ts +60 -0
- package/dist/react/page-tracking.d.ts.map +1 -0
- package/dist/react/page-tracking.js +179 -0
- package/dist/react/page-tracking.mjs +175 -0
- package/dist/react/react/components/ConsentBanner.d.ts +16 -0
- package/dist/react/react/components/ConsentBanner.d.ts.map +1 -0
- package/dist/react/react/components/ConsentBanner.js +78 -0
- package/dist/react/react/components/ConsentBanner.mjs +75 -0
- package/dist/react/react/components/CookieNotice.d.ts +12 -0
- package/dist/react/react/components/CookieNotice.d.ts.map +1 -0
- package/dist/react/react/components/CookieNotice.js +28 -0
- package/dist/react/react/components/CookieNotice.mjs +25 -0
- package/dist/react/react/components/PrivacyPreferenceCenter.d.ts +12 -0
- package/dist/react/react/components/PrivacyPreferenceCenter.d.ts.map +1 -0
- package/dist/react/react/components/PrivacyPreferenceCenter.js +86 -0
- package/dist/react/react/components/PrivacyPreferenceCenter.mjs +83 -0
- package/dist/react/react/hooks/useConsent.d.ts +13 -0
- package/dist/react/react/hooks/useConsent.d.ts.map +1 -0
- package/dist/react/react/hooks/useConsent.js +50 -0
- package/dist/react/react/hooks/useConsent.mjs +47 -0
- package/dist/react/react/hooks/useDataDeletion.d.ts +17 -0
- package/dist/react/react/hooks/useDataDeletion.d.ts.map +1 -0
- package/dist/react/react/hooks/useDataDeletion.js +83 -0
- package/dist/react/react/hooks/useDataDeletion.mjs +80 -0
- package/dist/react/react/hooks/usePrivacyPreferences.d.ts +15 -0
- package/dist/react/react/hooks/usePrivacyPreferences.d.ts.map +1 -0
- package/dist/react/react/hooks/usePrivacyPreferences.js +48 -0
- package/dist/react/react/hooks/usePrivacyPreferences.mjs +45 -0
- package/dist/react/react/index.d.ts +11 -0
- package/dist/react/react/index.d.ts.map +1 -1
- package/dist/react/react/index.js +15 -1
- package/dist/react/react/index.mjs +8 -0
- package/package.json +1 -1
package/dist/index.global.dev.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* Grain Analytics Web SDK v2.
|
|
1
|
+
/* Grain Analytics Web SDK v2.1.0 | MIT License | Development Build */
|
|
2
2
|
"use strict";
|
|
3
3
|
var Grain = (() => {
|
|
4
4
|
var __defProp = Object.defineProperty;
|
|
@@ -26,9 +26,611 @@ var Grain = (() => {
|
|
|
26
26
|
createGrainAnalytics: () => createGrainAnalytics,
|
|
27
27
|
default: () => src_default
|
|
28
28
|
});
|
|
29
|
+
|
|
30
|
+
// src/consent.ts
|
|
31
|
+
var DEFAULT_CONSENT_CATEGORIES = ["necessary", "analytics", "functional"];
|
|
32
|
+
var CONSENT_VERSION = "1.0.0";
|
|
33
|
+
var ConsentManager = class {
|
|
34
|
+
constructor(tenantId, consentMode = "opt-out") {
|
|
35
|
+
this.consentState = null;
|
|
36
|
+
this.listeners = [];
|
|
37
|
+
this.consentMode = consentMode;
|
|
38
|
+
this.storageKey = `grain_consent_${tenantId}`;
|
|
39
|
+
this.loadConsentState();
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Load consent state from localStorage
|
|
43
|
+
*/
|
|
44
|
+
loadConsentState() {
|
|
45
|
+
if (typeof window === "undefined")
|
|
46
|
+
return;
|
|
47
|
+
try {
|
|
48
|
+
const stored = localStorage.getItem(this.storageKey);
|
|
49
|
+
if (stored) {
|
|
50
|
+
const parsed = JSON.parse(stored);
|
|
51
|
+
this.consentState = {
|
|
52
|
+
...parsed,
|
|
53
|
+
timestamp: new Date(parsed.timestamp)
|
|
54
|
+
};
|
|
55
|
+
} else if (this.consentMode === "opt-out" || this.consentMode === "disabled") {
|
|
56
|
+
this.consentState = {
|
|
57
|
+
granted: true,
|
|
58
|
+
categories: DEFAULT_CONSENT_CATEGORIES,
|
|
59
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
60
|
+
version: CONSENT_VERSION
|
|
61
|
+
};
|
|
62
|
+
this.saveConsentState();
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error("[Grain Consent] Failed to load consent state:", error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Save consent state to localStorage
|
|
70
|
+
*/
|
|
71
|
+
saveConsentState() {
|
|
72
|
+
if (typeof window === "undefined" || !this.consentState)
|
|
73
|
+
return;
|
|
74
|
+
try {
|
|
75
|
+
localStorage.setItem(this.storageKey, JSON.stringify(this.consentState));
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error("[Grain Consent] Failed to save consent state:", error);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Grant consent with optional categories
|
|
82
|
+
*/
|
|
83
|
+
grantConsent(categories) {
|
|
84
|
+
const grantedCategories = categories || DEFAULT_CONSENT_CATEGORIES;
|
|
85
|
+
this.consentState = {
|
|
86
|
+
granted: true,
|
|
87
|
+
categories: grantedCategories,
|
|
88
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
89
|
+
version: CONSENT_VERSION
|
|
90
|
+
};
|
|
91
|
+
this.saveConsentState();
|
|
92
|
+
this.notifyListeners();
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Revoke consent (opt-out)
|
|
96
|
+
*/
|
|
97
|
+
revokeConsent(categories) {
|
|
98
|
+
if (!this.consentState) {
|
|
99
|
+
this.consentState = {
|
|
100
|
+
granted: false,
|
|
101
|
+
categories: [],
|
|
102
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
103
|
+
version: CONSENT_VERSION
|
|
104
|
+
};
|
|
105
|
+
} else if (categories) {
|
|
106
|
+
this.consentState = {
|
|
107
|
+
...this.consentState,
|
|
108
|
+
categories: this.consentState.categories.filter((cat) => !categories.includes(cat)),
|
|
109
|
+
granted: this.consentState.categories.length > 0,
|
|
110
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
111
|
+
};
|
|
112
|
+
} else {
|
|
113
|
+
this.consentState = {
|
|
114
|
+
granted: false,
|
|
115
|
+
categories: [],
|
|
116
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
117
|
+
version: CONSENT_VERSION
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
this.saveConsentState();
|
|
121
|
+
this.notifyListeners();
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Get current consent state
|
|
125
|
+
*/
|
|
126
|
+
getConsentState() {
|
|
127
|
+
return this.consentState ? { ...this.consentState } : null;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Check if user has granted consent
|
|
131
|
+
*/
|
|
132
|
+
hasConsent(category) {
|
|
133
|
+
if (this.consentMode === "disabled") {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (this.consentMode === "opt-in" && !this.consentState) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
if (!this.consentState?.granted) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
if (category) {
|
|
143
|
+
return this.consentState.categories.includes(category);
|
|
144
|
+
}
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Check if we should wait for consent before tracking
|
|
149
|
+
*/
|
|
150
|
+
shouldWaitForConsent() {
|
|
151
|
+
return this.consentMode === "opt-in" && !this.consentState?.granted;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Add consent change listener
|
|
155
|
+
*/
|
|
156
|
+
addListener(listener) {
|
|
157
|
+
this.listeners.push(listener);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Remove consent change listener
|
|
161
|
+
*/
|
|
162
|
+
removeListener(listener) {
|
|
163
|
+
const index = this.listeners.indexOf(listener);
|
|
164
|
+
if (index > -1) {
|
|
165
|
+
this.listeners.splice(index, 1);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Notify all listeners of consent state change
|
|
170
|
+
*/
|
|
171
|
+
notifyListeners() {
|
|
172
|
+
if (!this.consentState)
|
|
173
|
+
return;
|
|
174
|
+
this.listeners.forEach((listener) => {
|
|
175
|
+
try {
|
|
176
|
+
listener(this.consentState);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
console.error("[Grain Consent] Listener error:", error);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Clear all consent data
|
|
184
|
+
*/
|
|
185
|
+
clearConsent() {
|
|
186
|
+
if (typeof window === "undefined")
|
|
187
|
+
return;
|
|
188
|
+
try {
|
|
189
|
+
localStorage.removeItem(this.storageKey);
|
|
190
|
+
this.consentState = null;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.error("[Grain Consent] Failed to clear consent:", error);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// src/cookies.ts
|
|
198
|
+
function setCookie(name, value, config) {
|
|
199
|
+
if (typeof document === "undefined")
|
|
200
|
+
return;
|
|
201
|
+
const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
|
|
202
|
+
if (config?.maxAge !== void 0) {
|
|
203
|
+
parts.push(`max-age=${config.maxAge}`);
|
|
204
|
+
}
|
|
205
|
+
if (config?.domain) {
|
|
206
|
+
parts.push(`domain=${config.domain}`);
|
|
207
|
+
}
|
|
208
|
+
if (config?.path) {
|
|
209
|
+
parts.push(`path=${config.path}`);
|
|
210
|
+
} else {
|
|
211
|
+
parts.push("path=/");
|
|
212
|
+
}
|
|
213
|
+
if (config?.sameSite) {
|
|
214
|
+
parts.push(`samesite=${config.sameSite}`);
|
|
215
|
+
}
|
|
216
|
+
if (config?.secure) {
|
|
217
|
+
parts.push("secure");
|
|
218
|
+
}
|
|
219
|
+
document.cookie = parts.join("; ");
|
|
220
|
+
}
|
|
221
|
+
function getCookie(name) {
|
|
222
|
+
if (typeof document === "undefined")
|
|
223
|
+
return null;
|
|
224
|
+
const nameEQ = encodeURIComponent(name) + "=";
|
|
225
|
+
const cookies = document.cookie.split(";");
|
|
226
|
+
for (let i = 0; i < cookies.length; i++) {
|
|
227
|
+
let cookie = cookies[i];
|
|
228
|
+
while (cookie.charAt(0) === " ") {
|
|
229
|
+
cookie = cookie.substring(1);
|
|
230
|
+
}
|
|
231
|
+
if (cookie.indexOf(nameEQ) === 0) {
|
|
232
|
+
return decodeURIComponent(cookie.substring(nameEQ.length));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
function deleteCookie(name, config) {
|
|
238
|
+
if (typeof document === "undefined")
|
|
239
|
+
return;
|
|
240
|
+
const parts = [
|
|
241
|
+
`${encodeURIComponent(name)}=`,
|
|
242
|
+
"max-age=0"
|
|
243
|
+
];
|
|
244
|
+
if (config?.domain) {
|
|
245
|
+
parts.push(`domain=${config.domain}`);
|
|
246
|
+
}
|
|
247
|
+
if (config?.path) {
|
|
248
|
+
parts.push(`path=${config.path}`);
|
|
249
|
+
} else {
|
|
250
|
+
parts.push("path=/");
|
|
251
|
+
}
|
|
252
|
+
document.cookie = parts.join("; ");
|
|
253
|
+
}
|
|
254
|
+
function areCookiesEnabled() {
|
|
255
|
+
if (typeof document === "undefined")
|
|
256
|
+
return false;
|
|
257
|
+
try {
|
|
258
|
+
const testCookie = "_grain_cookie_test";
|
|
259
|
+
setCookie(testCookie, "test", { maxAge: 1 });
|
|
260
|
+
const result = getCookie(testCookie) === "test";
|
|
261
|
+
deleteCookie(testCookie);
|
|
262
|
+
return result;
|
|
263
|
+
} catch {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// src/activity.ts
|
|
269
|
+
var ActivityDetector = class {
|
|
270
|
+
constructor() {
|
|
271
|
+
this.activityThreshold = 3e4;
|
|
272
|
+
// 30 seconds
|
|
273
|
+
this.listeners = [];
|
|
274
|
+
this.isDestroyed = false;
|
|
275
|
+
// Events that indicate user activity
|
|
276
|
+
this.activityEvents = [
|
|
277
|
+
"mousemove",
|
|
278
|
+
"mousedown",
|
|
279
|
+
"keydown",
|
|
280
|
+
"scroll",
|
|
281
|
+
"touchstart",
|
|
282
|
+
"click"
|
|
283
|
+
];
|
|
284
|
+
this.lastActivityTime = Date.now();
|
|
285
|
+
this.boundActivityHandler = this.debounce(this.handleActivity.bind(this), 500);
|
|
286
|
+
this.setupListeners();
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Setup event listeners for activity detection
|
|
290
|
+
*/
|
|
291
|
+
setupListeners() {
|
|
292
|
+
if (typeof window === "undefined")
|
|
293
|
+
return;
|
|
294
|
+
for (const event of this.activityEvents) {
|
|
295
|
+
window.addEventListener(event, this.boundActivityHandler, { passive: true });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Handle activity event
|
|
300
|
+
*/
|
|
301
|
+
handleActivity() {
|
|
302
|
+
if (this.isDestroyed)
|
|
303
|
+
return;
|
|
304
|
+
this.lastActivityTime = Date.now();
|
|
305
|
+
this.notifyListeners();
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Debounce function to limit how often activity handler is called
|
|
309
|
+
*/
|
|
310
|
+
debounce(func, wait) {
|
|
311
|
+
let timeout = null;
|
|
312
|
+
return () => {
|
|
313
|
+
if (timeout !== null) {
|
|
314
|
+
clearTimeout(timeout);
|
|
315
|
+
}
|
|
316
|
+
timeout = window.setTimeout(() => {
|
|
317
|
+
func();
|
|
318
|
+
timeout = null;
|
|
319
|
+
}, wait);
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Check if user is currently active
|
|
324
|
+
* @param threshold Time in ms to consider user inactive (default: 30s)
|
|
325
|
+
*/
|
|
326
|
+
isActive(threshold) {
|
|
327
|
+
const thresholdToUse = threshold ?? this.activityThreshold;
|
|
328
|
+
const now = Date.now();
|
|
329
|
+
return now - this.lastActivityTime < thresholdToUse;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Get time since last activity in milliseconds
|
|
333
|
+
*/
|
|
334
|
+
getTimeSinceLastActivity() {
|
|
335
|
+
return Date.now() - this.lastActivityTime;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Get last activity timestamp
|
|
339
|
+
*/
|
|
340
|
+
getLastActivityTime() {
|
|
341
|
+
return this.lastActivityTime;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Set activity threshold
|
|
345
|
+
*/
|
|
346
|
+
setActivityThreshold(threshold) {
|
|
347
|
+
this.activityThreshold = threshold;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Add listener for activity changes
|
|
351
|
+
*/
|
|
352
|
+
addListener(listener) {
|
|
353
|
+
this.listeners.push(listener);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Remove listener
|
|
357
|
+
*/
|
|
358
|
+
removeListener(listener) {
|
|
359
|
+
const index = this.listeners.indexOf(listener);
|
|
360
|
+
if (index > -1) {
|
|
361
|
+
this.listeners.splice(index, 1);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Notify all listeners
|
|
366
|
+
*/
|
|
367
|
+
notifyListeners() {
|
|
368
|
+
for (const listener of this.listeners) {
|
|
369
|
+
try {
|
|
370
|
+
listener();
|
|
371
|
+
} catch (error) {
|
|
372
|
+
console.error("[Activity Detector] Listener error:", error);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Cleanup and remove listeners
|
|
378
|
+
*/
|
|
379
|
+
destroy() {
|
|
380
|
+
if (this.isDestroyed)
|
|
381
|
+
return;
|
|
382
|
+
if (typeof window !== "undefined") {
|
|
383
|
+
for (const event of this.activityEvents) {
|
|
384
|
+
window.removeEventListener(event, this.boundActivityHandler);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
this.listeners = [];
|
|
388
|
+
this.isDestroyed = true;
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// src/heartbeat.ts
|
|
393
|
+
var HeartbeatManager = class {
|
|
394
|
+
constructor(tracker, activityDetector, config) {
|
|
395
|
+
this.heartbeatTimer = null;
|
|
396
|
+
this.isDestroyed = false;
|
|
397
|
+
this.tracker = tracker;
|
|
398
|
+
this.activityDetector = activityDetector;
|
|
399
|
+
this.config = config;
|
|
400
|
+
this.lastHeartbeatTime = Date.now();
|
|
401
|
+
this.currentInterval = config.activeInterval;
|
|
402
|
+
this.scheduleNextHeartbeat();
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Schedule the next heartbeat based on current activity
|
|
406
|
+
*/
|
|
407
|
+
scheduleNextHeartbeat() {
|
|
408
|
+
if (this.isDestroyed)
|
|
409
|
+
return;
|
|
410
|
+
if (this.heartbeatTimer !== null) {
|
|
411
|
+
clearTimeout(this.heartbeatTimer);
|
|
412
|
+
}
|
|
413
|
+
const isActive = this.activityDetector.isActive(6e4);
|
|
414
|
+
this.currentInterval = isActive ? this.config.activeInterval : this.config.inactiveInterval;
|
|
415
|
+
this.heartbeatTimer = window.setTimeout(() => {
|
|
416
|
+
this.sendHeartbeat();
|
|
417
|
+
this.scheduleNextHeartbeat();
|
|
418
|
+
}, this.currentInterval);
|
|
419
|
+
if (this.config.debug) {
|
|
420
|
+
console.log(
|
|
421
|
+
`[Heartbeat] Scheduled next heartbeat in ${this.currentInterval / 1e3}s (${isActive ? "active" : "inactive"})`
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Send heartbeat event
|
|
427
|
+
*/
|
|
428
|
+
sendHeartbeat() {
|
|
429
|
+
if (this.isDestroyed)
|
|
430
|
+
return;
|
|
431
|
+
const now = Date.now();
|
|
432
|
+
const isActive = this.activityDetector.isActive(6e4);
|
|
433
|
+
const hasConsent = this.tracker.hasConsent("analytics");
|
|
434
|
+
const properties = {
|
|
435
|
+
type: "heartbeat",
|
|
436
|
+
status: isActive ? "active" : "inactive",
|
|
437
|
+
timestamp: now
|
|
438
|
+
};
|
|
439
|
+
if (hasConsent) {
|
|
440
|
+
const page = this.tracker.getCurrentPage();
|
|
441
|
+
if (page) {
|
|
442
|
+
properties.page = page;
|
|
443
|
+
}
|
|
444
|
+
properties.duration = now - this.lastHeartbeatTime;
|
|
445
|
+
properties.event_count = this.tracker.getEventCountSinceLastHeartbeat();
|
|
446
|
+
this.tracker.resetEventCountSinceLastHeartbeat();
|
|
447
|
+
}
|
|
448
|
+
this.tracker.trackSystemEvent("_grain_heartbeat", properties);
|
|
449
|
+
this.lastHeartbeatTime = now;
|
|
450
|
+
if (this.config.debug) {
|
|
451
|
+
console.log("[Heartbeat] Sent heartbeat:", properties);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Destroy the heartbeat manager
|
|
456
|
+
*/
|
|
457
|
+
destroy() {
|
|
458
|
+
if (this.isDestroyed)
|
|
459
|
+
return;
|
|
460
|
+
if (this.heartbeatTimer !== null) {
|
|
461
|
+
clearTimeout(this.heartbeatTimer);
|
|
462
|
+
this.heartbeatTimer = null;
|
|
463
|
+
}
|
|
464
|
+
this.isDestroyed = true;
|
|
465
|
+
if (this.config.debug) {
|
|
466
|
+
console.log("[Heartbeat] Destroyed");
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
// src/page-tracking.ts
|
|
472
|
+
var PageTrackingManager = class {
|
|
473
|
+
constructor(tracker, config) {
|
|
474
|
+
this.isDestroyed = false;
|
|
475
|
+
this.currentPath = null;
|
|
476
|
+
this.originalPushState = null;
|
|
477
|
+
this.originalReplaceState = null;
|
|
478
|
+
/**
|
|
479
|
+
* Handle popstate event (back/forward navigation)
|
|
480
|
+
*/
|
|
481
|
+
this.handlePopState = () => {
|
|
482
|
+
if (this.isDestroyed)
|
|
483
|
+
return;
|
|
484
|
+
this.trackCurrentPage();
|
|
485
|
+
};
|
|
486
|
+
/**
|
|
487
|
+
* Handle hash change event
|
|
488
|
+
*/
|
|
489
|
+
this.handleHashChange = () => {
|
|
490
|
+
if (this.isDestroyed)
|
|
491
|
+
return;
|
|
492
|
+
this.trackCurrentPage();
|
|
493
|
+
};
|
|
494
|
+
this.tracker = tracker;
|
|
495
|
+
this.config = config;
|
|
496
|
+
this.trackCurrentPage();
|
|
497
|
+
this.setupHistoryListeners();
|
|
498
|
+
this.setupHashChangeListener();
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Setup History API listeners (pushState, replaceState, popstate)
|
|
502
|
+
*/
|
|
503
|
+
setupHistoryListeners() {
|
|
504
|
+
if (typeof window === "undefined" || typeof history === "undefined")
|
|
505
|
+
return;
|
|
506
|
+
this.originalPushState = history.pushState;
|
|
507
|
+
history.pushState = (state, title, url) => {
|
|
508
|
+
this.originalPushState?.call(history, state, title, url);
|
|
509
|
+
this.trackCurrentPage();
|
|
510
|
+
};
|
|
511
|
+
this.originalReplaceState = history.replaceState;
|
|
512
|
+
history.replaceState = (state, title, url) => {
|
|
513
|
+
this.originalReplaceState?.call(history, state, title, url);
|
|
514
|
+
this.trackCurrentPage();
|
|
515
|
+
};
|
|
516
|
+
window.addEventListener("popstate", this.handlePopState);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Setup hash change listener
|
|
520
|
+
*/
|
|
521
|
+
setupHashChangeListener() {
|
|
522
|
+
if (typeof window === "undefined")
|
|
523
|
+
return;
|
|
524
|
+
window.addEventListener("hashchange", this.handleHashChange);
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Track the current page
|
|
528
|
+
*/
|
|
529
|
+
trackCurrentPage() {
|
|
530
|
+
if (this.isDestroyed || typeof window === "undefined")
|
|
531
|
+
return;
|
|
532
|
+
const page = this.extractPath(window.location.href);
|
|
533
|
+
if (page === this.currentPath) {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
this.currentPath = page;
|
|
537
|
+
const hasConsent = this.tracker.hasConsent("analytics");
|
|
538
|
+
const properties = {
|
|
539
|
+
page,
|
|
540
|
+
timestamp: Date.now()
|
|
541
|
+
};
|
|
542
|
+
if (hasConsent) {
|
|
543
|
+
properties.referrer = document.referrer || "";
|
|
544
|
+
properties.title = document.title || "";
|
|
545
|
+
properties.full_url = window.location.href;
|
|
546
|
+
}
|
|
547
|
+
this.tracker.trackSystemEvent("page_view", properties);
|
|
548
|
+
if (this.config.debug) {
|
|
549
|
+
console.log("[Page Tracking] Tracked page view:", properties);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Extract path from URL, optionally stripping query parameters
|
|
554
|
+
*/
|
|
555
|
+
extractPath(url) {
|
|
556
|
+
try {
|
|
557
|
+
const urlObj = new URL(url);
|
|
558
|
+
let path = urlObj.pathname + urlObj.hash;
|
|
559
|
+
if (!this.config.stripQueryParams && urlObj.search) {
|
|
560
|
+
path += urlObj.search;
|
|
561
|
+
}
|
|
562
|
+
return path;
|
|
563
|
+
} catch (error) {
|
|
564
|
+
if (this.config.debug) {
|
|
565
|
+
console.warn("[Page Tracking] Failed to parse URL:", url, error);
|
|
566
|
+
}
|
|
567
|
+
return url;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Get the current page path
|
|
572
|
+
*/
|
|
573
|
+
getCurrentPage() {
|
|
574
|
+
return this.currentPath;
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Manually track a page view (for custom navigation)
|
|
578
|
+
*/
|
|
579
|
+
trackPage(page, properties) {
|
|
580
|
+
if (this.isDestroyed)
|
|
581
|
+
return;
|
|
582
|
+
const hasConsent = this.tracker.hasConsent("analytics");
|
|
583
|
+
const baseProperties = {
|
|
584
|
+
page,
|
|
585
|
+
timestamp: Date.now(),
|
|
586
|
+
...properties
|
|
587
|
+
};
|
|
588
|
+
if (hasConsent && typeof document !== "undefined") {
|
|
589
|
+
if (!baseProperties.referrer) {
|
|
590
|
+
baseProperties.referrer = document.referrer || "";
|
|
591
|
+
}
|
|
592
|
+
if (!baseProperties.title) {
|
|
593
|
+
baseProperties.title = document.title || "";
|
|
594
|
+
}
|
|
595
|
+
if (!baseProperties.full_url && typeof window !== "undefined") {
|
|
596
|
+
baseProperties.full_url = window.location.href;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
this.tracker.trackSystemEvent("page_view", baseProperties);
|
|
600
|
+
if (this.config.debug) {
|
|
601
|
+
console.log("[Page Tracking] Manually tracked page:", baseProperties);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Destroy the page tracker
|
|
606
|
+
*/
|
|
607
|
+
destroy() {
|
|
608
|
+
if (this.isDestroyed)
|
|
609
|
+
return;
|
|
610
|
+
if (typeof history !== "undefined") {
|
|
611
|
+
if (this.originalPushState) {
|
|
612
|
+
history.pushState = this.originalPushState;
|
|
613
|
+
}
|
|
614
|
+
if (this.originalReplaceState) {
|
|
615
|
+
history.replaceState = this.originalReplaceState;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (typeof window !== "undefined") {
|
|
619
|
+
window.removeEventListener("popstate", this.handlePopState);
|
|
620
|
+
window.removeEventListener("hashchange", this.handleHashChange);
|
|
621
|
+
}
|
|
622
|
+
this.isDestroyed = true;
|
|
623
|
+
if (this.config.debug) {
|
|
624
|
+
console.log("[Page Tracking] Destroyed");
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// src/index.ts
|
|
29
630
|
var GrainAnalytics = class {
|
|
30
631
|
constructor(config) {
|
|
31
632
|
this.eventQueue = [];
|
|
633
|
+
this.waitingForConsentQueue = [];
|
|
32
634
|
this.flushTimer = null;
|
|
33
635
|
this.isDestroyed = false;
|
|
34
636
|
this.globalUserId = null;
|
|
@@ -38,6 +640,13 @@ var Grain = (() => {
|
|
|
38
640
|
this.configRefreshTimer = null;
|
|
39
641
|
this.configChangeListeners = [];
|
|
40
642
|
this.configFetchPromise = null;
|
|
643
|
+
this.cookiesEnabled = false;
|
|
644
|
+
// Automatic Tracking properties
|
|
645
|
+
this.activityDetector = null;
|
|
646
|
+
this.heartbeatManager = null;
|
|
647
|
+
this.pageTrackingManager = null;
|
|
648
|
+
this.ephemeralSessionId = null;
|
|
649
|
+
this.eventCountSinceLastHeartbeat = 0;
|
|
41
650
|
this.config = {
|
|
42
651
|
apiUrl: "https://api.grainql.com",
|
|
43
652
|
authStrategy: "NONE",
|
|
@@ -56,9 +665,30 @@ var Grain = (() => {
|
|
|
56
665
|
configRefreshInterval: 3e5,
|
|
57
666
|
// 5 minutes
|
|
58
667
|
enableConfigCache: true,
|
|
668
|
+
// Privacy defaults
|
|
669
|
+
consentMode: "opt-out",
|
|
670
|
+
waitForConsent: false,
|
|
671
|
+
enableCookies: false,
|
|
672
|
+
anonymizeIP: false,
|
|
673
|
+
disableAutoProperties: false,
|
|
674
|
+
// Automatic Tracking defaults
|
|
675
|
+
enableHeartbeat: true,
|
|
676
|
+
heartbeatActiveInterval: 12e4,
|
|
677
|
+
// 2 minutes
|
|
678
|
+
heartbeatInactiveInterval: 3e5,
|
|
679
|
+
// 5 minutes
|
|
680
|
+
enableAutoPageView: true,
|
|
681
|
+
stripQueryParams: true,
|
|
59
682
|
...config,
|
|
60
683
|
tenantId: config.tenantId
|
|
61
684
|
};
|
|
685
|
+
this.consentManager = new ConsentManager(this.config.tenantId, this.config.consentMode);
|
|
686
|
+
if (this.config.enableCookies) {
|
|
687
|
+
this.cookiesEnabled = areCookiesEnabled();
|
|
688
|
+
if (!this.cookiesEnabled && this.config.debug) {
|
|
689
|
+
console.warn("[Grain Analytics] Cookies are not available, falling back to localStorage");
|
|
690
|
+
}
|
|
691
|
+
}
|
|
62
692
|
if (config.userId) {
|
|
63
693
|
this.globalUserId = config.userId;
|
|
64
694
|
}
|
|
@@ -67,6 +697,15 @@ var Grain = (() => {
|
|
|
67
697
|
this.setupBeforeUnload();
|
|
68
698
|
this.startFlushTimer();
|
|
69
699
|
this.initializeConfigCache();
|
|
700
|
+
this.ephemeralSessionId = this.generateUUID();
|
|
701
|
+
if (typeof window !== "undefined") {
|
|
702
|
+
this.initializeAutomaticTracking();
|
|
703
|
+
}
|
|
704
|
+
this.consentManager.addListener((state) => {
|
|
705
|
+
if (state.granted) {
|
|
706
|
+
this.handleConsentGranted();
|
|
707
|
+
}
|
|
708
|
+
});
|
|
70
709
|
}
|
|
71
710
|
validateConfig() {
|
|
72
711
|
if (!this.config.tenantId) {
|
|
@@ -99,20 +738,33 @@ var Grain = (() => {
|
|
|
99
738
|
return this.generateUUID();
|
|
100
739
|
}
|
|
101
740
|
/**
|
|
102
|
-
* Initialize persistent anonymous user ID from
|
|
741
|
+
* Initialize persistent anonymous user ID from cookies or localStorage
|
|
742
|
+
* Priority: Cookie → localStorage → generate new
|
|
103
743
|
*/
|
|
104
744
|
initializePersistentAnonymousUserId() {
|
|
105
745
|
if (typeof window === "undefined")
|
|
106
746
|
return;
|
|
107
747
|
const storageKey = `grain_anonymous_user_id_${this.config.tenantId}`;
|
|
748
|
+
const cookieName = "_grain_uid";
|
|
108
749
|
try {
|
|
750
|
+
if (this.cookiesEnabled) {
|
|
751
|
+
const cookieValue = getCookie(cookieName);
|
|
752
|
+
if (cookieValue) {
|
|
753
|
+
this.persistentAnonymousUserId = cookieValue;
|
|
754
|
+
this.log("Loaded persistent anonymous user ID from cookie:", this.persistentAnonymousUserId);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
109
758
|
const stored = localStorage.getItem(storageKey);
|
|
110
759
|
if (stored) {
|
|
111
760
|
this.persistentAnonymousUserId = stored;
|
|
112
|
-
this.log("Loaded persistent anonymous user ID:", this.persistentAnonymousUserId);
|
|
761
|
+
this.log("Loaded persistent anonymous user ID from localStorage:", this.persistentAnonymousUserId);
|
|
762
|
+
if (this.cookiesEnabled) {
|
|
763
|
+
this.savePersistentAnonymousUserId(stored);
|
|
764
|
+
}
|
|
113
765
|
} else {
|
|
114
766
|
this.persistentAnonymousUserId = this.generateAnonymousUserId();
|
|
115
|
-
|
|
767
|
+
this.savePersistentAnonymousUserId(this.persistentAnonymousUserId);
|
|
116
768
|
this.log("Generated new persistent anonymous user ID:", this.persistentAnonymousUserId);
|
|
117
769
|
}
|
|
118
770
|
} catch (error) {
|
|
@@ -120,10 +772,34 @@ var Grain = (() => {
|
|
|
120
772
|
this.persistentAnonymousUserId = this.generateAnonymousUserId();
|
|
121
773
|
}
|
|
122
774
|
}
|
|
775
|
+
/**
|
|
776
|
+
* Save persistent anonymous user ID to cookie and/or localStorage
|
|
777
|
+
*/
|
|
778
|
+
savePersistentAnonymousUserId(userId) {
|
|
779
|
+
if (typeof window === "undefined")
|
|
780
|
+
return;
|
|
781
|
+
const storageKey = `grain_anonymous_user_id_${this.config.tenantId}`;
|
|
782
|
+
const cookieName = "_grain_uid";
|
|
783
|
+
try {
|
|
784
|
+
if (this.cookiesEnabled) {
|
|
785
|
+
const cookieOptions = {
|
|
786
|
+
maxAge: 365 * 24 * 60 * 60,
|
|
787
|
+
// 365 days
|
|
788
|
+
sameSite: "lax",
|
|
789
|
+
secure: window.location.protocol === "https:",
|
|
790
|
+
...this.config.cookieOptions
|
|
791
|
+
};
|
|
792
|
+
setCookie(cookieName, userId, cookieOptions);
|
|
793
|
+
}
|
|
794
|
+
localStorage.setItem(storageKey, userId);
|
|
795
|
+
} catch (error) {
|
|
796
|
+
this.log("Failed to save persistent anonymous user ID:", error);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
123
799
|
/**
|
|
124
800
|
* Get the effective user ID (global userId or persistent anonymous ID)
|
|
125
801
|
*/
|
|
126
|
-
|
|
802
|
+
getEffectiveUserIdInternal() {
|
|
127
803
|
if (this.globalUserId) {
|
|
128
804
|
return this.globalUserId;
|
|
129
805
|
}
|
|
@@ -256,7 +932,7 @@ var Grain = (() => {
|
|
|
256
932
|
formatEvent(event) {
|
|
257
933
|
return {
|
|
258
934
|
eventName: event.eventName,
|
|
259
|
-
userId: event.userId || this.
|
|
935
|
+
userId: event.userId || this.getEffectiveUserIdInternal(),
|
|
260
936
|
properties: event.properties || {}
|
|
261
937
|
};
|
|
262
938
|
}
|
|
@@ -419,6 +1095,122 @@ var Grain = (() => {
|
|
|
419
1095
|
}
|
|
420
1096
|
});
|
|
421
1097
|
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Initialize automatic tracking (heartbeat and page views)
|
|
1100
|
+
*/
|
|
1101
|
+
initializeAutomaticTracking() {
|
|
1102
|
+
if (this.config.enableHeartbeat) {
|
|
1103
|
+
try {
|
|
1104
|
+
this.activityDetector = new ActivityDetector();
|
|
1105
|
+
this.heartbeatManager = new HeartbeatManager(
|
|
1106
|
+
this,
|
|
1107
|
+
this.activityDetector,
|
|
1108
|
+
{
|
|
1109
|
+
activeInterval: this.config.heartbeatActiveInterval,
|
|
1110
|
+
inactiveInterval: this.config.heartbeatInactiveInterval,
|
|
1111
|
+
debug: this.config.debug
|
|
1112
|
+
}
|
|
1113
|
+
);
|
|
1114
|
+
this.log("Heartbeat tracking initialized");
|
|
1115
|
+
} catch (error) {
|
|
1116
|
+
this.log("Failed to initialize heartbeat tracking:", error);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
if (this.config.enableAutoPageView) {
|
|
1120
|
+
try {
|
|
1121
|
+
this.pageTrackingManager = new PageTrackingManager(
|
|
1122
|
+
this,
|
|
1123
|
+
{
|
|
1124
|
+
stripQueryParams: this.config.stripQueryParams,
|
|
1125
|
+
debug: this.config.debug
|
|
1126
|
+
}
|
|
1127
|
+
);
|
|
1128
|
+
this.log("Auto page view tracking initialized");
|
|
1129
|
+
} catch (error) {
|
|
1130
|
+
this.log("Failed to initialize page view tracking:", error);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Handle consent granted - upgrade ephemeral session to persistent user
|
|
1136
|
+
*/
|
|
1137
|
+
handleConsentGranted() {
|
|
1138
|
+
this.flushWaitingForConsentQueue();
|
|
1139
|
+
if (this.ephemeralSessionId) {
|
|
1140
|
+
this.trackSystemEvent("_grain_consent_granted", {
|
|
1141
|
+
previous_session_id: this.ephemeralSessionId,
|
|
1142
|
+
new_user_id: this.getEffectiveUserId(),
|
|
1143
|
+
timestamp: Date.now()
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Track system events that bypass consent checks (for necessary/functional tracking)
|
|
1149
|
+
*/
|
|
1150
|
+
trackSystemEvent(eventName, properties) {
|
|
1151
|
+
if (this.isDestroyed)
|
|
1152
|
+
return;
|
|
1153
|
+
const hasConsent = this.consentManager.hasConsent("analytics");
|
|
1154
|
+
const event = {
|
|
1155
|
+
eventName,
|
|
1156
|
+
userId: hasConsent ? this.getEffectiveUserId() : this.getEphemeralSessionId(),
|
|
1157
|
+
properties: {
|
|
1158
|
+
...properties,
|
|
1159
|
+
_minimal: !hasConsent,
|
|
1160
|
+
// Flag to indicate minimal tracking
|
|
1161
|
+
_consent_status: hasConsent ? "granted" : "pending"
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
this.eventQueue.push(event);
|
|
1165
|
+
this.eventCountSinceLastHeartbeat++;
|
|
1166
|
+
this.log(`Queued system event: ${eventName}`, properties);
|
|
1167
|
+
if (this.eventQueue.length >= this.config.batchSize) {
|
|
1168
|
+
this.flush().catch((error) => {
|
|
1169
|
+
const formattedError = this.formatError(error, "flush system event");
|
|
1170
|
+
this.logError(formattedError);
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Get ephemeral session ID (memory-only, not persisted)
|
|
1176
|
+
*/
|
|
1177
|
+
getEphemeralSessionId() {
|
|
1178
|
+
if (!this.ephemeralSessionId) {
|
|
1179
|
+
this.ephemeralSessionId = this.generateUUID();
|
|
1180
|
+
}
|
|
1181
|
+
return this.ephemeralSessionId;
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Get the current page path from page tracker
|
|
1185
|
+
*/
|
|
1186
|
+
getCurrentPage() {
|
|
1187
|
+
return this.pageTrackingManager?.getCurrentPage() || null;
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Get event count since last heartbeat
|
|
1191
|
+
*/
|
|
1192
|
+
getEventCountSinceLastHeartbeat() {
|
|
1193
|
+
return this.eventCountSinceLastHeartbeat;
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* Reset event count since last heartbeat
|
|
1197
|
+
*/
|
|
1198
|
+
resetEventCountSinceLastHeartbeat() {
|
|
1199
|
+
this.eventCountSinceLastHeartbeat = 0;
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Get the effective user ID (public method)
|
|
1203
|
+
*/
|
|
1204
|
+
getEffectiveUserId() {
|
|
1205
|
+
return this.getEffectiveUserIdInternal();
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Get the session ID (ephemeral or persistent based on consent)
|
|
1209
|
+
*/
|
|
1210
|
+
getSessionId() {
|
|
1211
|
+
const hasConsent = this.consentManager.hasConsent("analytics");
|
|
1212
|
+
return hasConsent ? this.getEffectiveUserId() : this.getEphemeralSessionId();
|
|
1213
|
+
}
|
|
422
1214
|
async track(eventOrName, propertiesOrOptions, options) {
|
|
423
1215
|
try {
|
|
424
1216
|
if (this.isDestroyed) {
|
|
@@ -439,8 +1231,27 @@ var Grain = (() => {
|
|
|
439
1231
|
event = eventOrName;
|
|
440
1232
|
opts = propertiesOrOptions || {};
|
|
441
1233
|
}
|
|
1234
|
+
if (this.config.allowedProperties && event.properties) {
|
|
1235
|
+
const filtered = {};
|
|
1236
|
+
for (const key of this.config.allowedProperties) {
|
|
1237
|
+
if (key in event.properties) {
|
|
1238
|
+
filtered[key] = event.properties[key];
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
event.properties = filtered;
|
|
1242
|
+
}
|
|
442
1243
|
const formattedEvent = this.formatEvent(event);
|
|
1244
|
+
if (this.consentManager.shouldWaitForConsent() && this.config.waitForConsent) {
|
|
1245
|
+
this.waitingForConsentQueue.push(formattedEvent);
|
|
1246
|
+
this.log(`Event waiting for consent: ${event.eventName}`, event.properties);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
if (!this.consentManager.hasConsent("analytics")) {
|
|
1250
|
+
this.log(`Event blocked by consent: ${event.eventName}`);
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
443
1253
|
this.eventQueue.push(formattedEvent);
|
|
1254
|
+
this.eventCountSinceLastHeartbeat++;
|
|
444
1255
|
this.log(`Queued event: ${event.eventName}`, event.properties);
|
|
445
1256
|
if (opts.flush || this.eventQueue.length >= this.config.batchSize) {
|
|
446
1257
|
await this.flush();
|
|
@@ -450,6 +1261,20 @@ var Grain = (() => {
|
|
|
450
1261
|
this.logError(formattedError);
|
|
451
1262
|
}
|
|
452
1263
|
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Flush events that were waiting for consent
|
|
1266
|
+
*/
|
|
1267
|
+
flushWaitingForConsentQueue() {
|
|
1268
|
+
if (this.waitingForConsentQueue.length === 0)
|
|
1269
|
+
return;
|
|
1270
|
+
this.log(`Flushing ${this.waitingForConsentQueue.length} events waiting for consent`);
|
|
1271
|
+
this.eventQueue.push(...this.waitingForConsentQueue);
|
|
1272
|
+
this.waitingForConsentQueue = [];
|
|
1273
|
+
this.flush().catch((error) => {
|
|
1274
|
+
const formattedError = this.formatError(error, "flush waiting for consent queue");
|
|
1275
|
+
this.logError(formattedError);
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
453
1278
|
/**
|
|
454
1279
|
* Identify a user (sets userId for subsequent events)
|
|
455
1280
|
*/
|
|
@@ -490,7 +1315,7 @@ var Grain = (() => {
|
|
|
490
1315
|
* Get current effective user ID (global userId or persistent anonymous ID)
|
|
491
1316
|
*/
|
|
492
1317
|
getEffectiveUserIdPublic() {
|
|
493
|
-
return this.
|
|
1318
|
+
return this.getEffectiveUserIdInternal();
|
|
494
1319
|
}
|
|
495
1320
|
/**
|
|
496
1321
|
* Login with auth token or userId on the fly
|
|
@@ -534,7 +1359,7 @@ var Grain = (() => {
|
|
|
534
1359
|
this.log(`Login: Setting auth strategy to ${options.authStrategy}`);
|
|
535
1360
|
this.config.authStrategy = options.authStrategy;
|
|
536
1361
|
}
|
|
537
|
-
this.log(`Login successful. Effective user ID: ${this.
|
|
1362
|
+
this.log(`Login successful. Effective user ID: ${this.getEffectiveUserIdInternal()}`);
|
|
538
1363
|
} catch (error) {
|
|
539
1364
|
const formattedError = this.formatError(error, "login");
|
|
540
1365
|
this.logError(formattedError);
|
|
@@ -573,7 +1398,7 @@ var Grain = (() => {
|
|
|
573
1398
|
}
|
|
574
1399
|
}
|
|
575
1400
|
}
|
|
576
|
-
this.log(`Logout successful. Effective user ID: ${this.
|
|
1401
|
+
this.log(`Logout successful. Effective user ID: ${this.getEffectiveUserIdInternal()}`);
|
|
577
1402
|
} catch (error) {
|
|
578
1403
|
const formattedError = this.formatError(error, "logout");
|
|
579
1404
|
this.logError(formattedError);
|
|
@@ -590,7 +1415,7 @@ var Grain = (() => {
|
|
|
590
1415
|
this.logError(formattedError);
|
|
591
1416
|
return;
|
|
592
1417
|
}
|
|
593
|
-
const userId = options?.userId || this.
|
|
1418
|
+
const userId = options?.userId || this.getEffectiveUserIdInternal();
|
|
594
1419
|
const propertyKeys = Object.keys(properties);
|
|
595
1420
|
if (propertyKeys.length > 4) {
|
|
596
1421
|
const error = new Error("Grain Analytics: Maximum 4 properties allowed per request");
|
|
@@ -846,7 +1671,7 @@ var Grain = (() => {
|
|
|
846
1671
|
this.logError(formattedError);
|
|
847
1672
|
return null;
|
|
848
1673
|
}
|
|
849
|
-
const userId = options.userId || this.
|
|
1674
|
+
const userId = options.userId || this.getEffectiveUserIdInternal();
|
|
850
1675
|
const immediateKeys = options.immediateKeys || [];
|
|
851
1676
|
const properties = options.properties || {};
|
|
852
1677
|
const request = {
|
|
@@ -1027,7 +1852,7 @@ var Grain = (() => {
|
|
|
1027
1852
|
*/
|
|
1028
1853
|
async preloadConfig(immediateKeys = [], properties) {
|
|
1029
1854
|
try {
|
|
1030
|
-
const effectiveUserId = this.
|
|
1855
|
+
const effectiveUserId = this.getEffectiveUserIdInternal();
|
|
1031
1856
|
this.log(`Preloading config for user: ${effectiveUserId}`);
|
|
1032
1857
|
const response = await this.fetchConfig({ immediateKeys, properties });
|
|
1033
1858
|
if (response) {
|
|
@@ -1048,6 +1873,60 @@ var Grain = (() => {
|
|
|
1048
1873
|
}
|
|
1049
1874
|
return chunks;
|
|
1050
1875
|
}
|
|
1876
|
+
// Privacy & Consent Methods
|
|
1877
|
+
/**
|
|
1878
|
+
* Grant consent for tracking
|
|
1879
|
+
* @param categories - Optional array of consent categories (e.g., ['analytics', 'functional'])
|
|
1880
|
+
*/
|
|
1881
|
+
grantConsent(categories) {
|
|
1882
|
+
try {
|
|
1883
|
+
this.consentManager.grantConsent(categories);
|
|
1884
|
+
this.log("Consent granted", categories);
|
|
1885
|
+
} catch (error) {
|
|
1886
|
+
const formattedError = this.formatError(error, "grantConsent");
|
|
1887
|
+
this.logError(formattedError);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
/**
|
|
1891
|
+
* Revoke consent for tracking (opt-out)
|
|
1892
|
+
* @param categories - Optional array of categories to revoke (if not provided, revokes all)
|
|
1893
|
+
*/
|
|
1894
|
+
revokeConsent(categories) {
|
|
1895
|
+
try {
|
|
1896
|
+
this.consentManager.revokeConsent(categories);
|
|
1897
|
+
this.log("Consent revoked", categories);
|
|
1898
|
+
this.eventQueue = [];
|
|
1899
|
+
this.waitingForConsentQueue = [];
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
const formattedError = this.formatError(error, "revokeConsent");
|
|
1902
|
+
this.logError(formattedError);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Get current consent state
|
|
1907
|
+
*/
|
|
1908
|
+
getConsentState() {
|
|
1909
|
+
return this.consentManager.getConsentState();
|
|
1910
|
+
}
|
|
1911
|
+
/**
|
|
1912
|
+
* Check if user has granted consent
|
|
1913
|
+
* @param category - Optional category to check (if not provided, checks general consent)
|
|
1914
|
+
*/
|
|
1915
|
+
hasConsent(category) {
|
|
1916
|
+
return this.consentManager.hasConsent(category);
|
|
1917
|
+
}
|
|
1918
|
+
/**
|
|
1919
|
+
* Add listener for consent state changes
|
|
1920
|
+
*/
|
|
1921
|
+
onConsentChange(listener) {
|
|
1922
|
+
this.consentManager.addListener(listener);
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* Remove consent change listener
|
|
1926
|
+
*/
|
|
1927
|
+
offConsentChange(listener) {
|
|
1928
|
+
this.consentManager.removeListener(listener);
|
|
1929
|
+
}
|
|
1051
1930
|
/**
|
|
1052
1931
|
* Destroy the client and clean up resources
|
|
1053
1932
|
*/
|
|
@@ -1059,6 +1938,18 @@ var Grain = (() => {
|
|
|
1059
1938
|
}
|
|
1060
1939
|
this.stopConfigRefreshTimer();
|
|
1061
1940
|
this.configChangeListeners = [];
|
|
1941
|
+
if (this.heartbeatManager) {
|
|
1942
|
+
this.heartbeatManager.destroy();
|
|
1943
|
+
this.heartbeatManager = null;
|
|
1944
|
+
}
|
|
1945
|
+
if (this.pageTrackingManager) {
|
|
1946
|
+
this.pageTrackingManager.destroy();
|
|
1947
|
+
this.pageTrackingManager = null;
|
|
1948
|
+
}
|
|
1949
|
+
if (this.activityDetector) {
|
|
1950
|
+
this.activityDetector.destroy();
|
|
1951
|
+
this.activityDetector = null;
|
|
1952
|
+
}
|
|
1062
1953
|
if (this.eventQueue.length > 0) {
|
|
1063
1954
|
const eventsToSend = [...this.eventQueue];
|
|
1064
1955
|
this.eventQueue = [];
|