@grainql/analytics-web 1.6.0 → 1.7.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/cjs/index.d.ts +53 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.d.ts +53 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/index.d.ts +53 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.global.dev.js +412 -147
- package/dist/index.global.dev.js.map +2 -2
- package/dist/index.global.js +2 -2
- package/dist/index.global.js.map +3 -3
- package/dist/index.js +462 -161
- package/dist/index.mjs +462 -161
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ class GrainAnalytics {
|
|
|
12
12
|
this.flushTimer = null;
|
|
13
13
|
this.isDestroyed = false;
|
|
14
14
|
this.globalUserId = null;
|
|
15
|
+
this.persistentAnonymousUserId = null;
|
|
15
16
|
// Remote Config properties
|
|
16
17
|
this.configCache = null;
|
|
17
18
|
this.configRefreshTimer = null;
|
|
@@ -39,6 +40,7 @@ class GrainAnalytics {
|
|
|
39
40
|
this.globalUserId = config.userId;
|
|
40
41
|
}
|
|
41
42
|
this.validateConfig();
|
|
43
|
+
this.initializePersistentAnonymousUserId();
|
|
42
44
|
this.setupBeforeUnload();
|
|
43
45
|
this.startFlushTimer();
|
|
44
46
|
this.initializeConfigCache();
|
|
@@ -54,15 +56,188 @@ class GrainAnalytics {
|
|
|
54
56
|
throw new Error('Grain Analytics: authProvider is required for JWT auth strategy');
|
|
55
57
|
}
|
|
56
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Generate a UUID v4 string
|
|
61
|
+
*/
|
|
62
|
+
generateUUID() {
|
|
63
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
64
|
+
return crypto.randomUUID();
|
|
65
|
+
}
|
|
66
|
+
// Fallback for environments without crypto.randomUUID
|
|
67
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
|
68
|
+
const r = Math.random() * 16 | 0;
|
|
69
|
+
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
70
|
+
return v.toString(16);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Format UUID for anonymous user ID (remove dashes and prefix with 'temp:')
|
|
75
|
+
*/
|
|
76
|
+
formatAnonymousUserId(uuid) {
|
|
77
|
+
return `temp:${uuid.replace(/-/g, '')}`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Initialize persistent anonymous user ID from localStorage or create new one
|
|
81
|
+
*/
|
|
82
|
+
initializePersistentAnonymousUserId() {
|
|
83
|
+
if (typeof window === 'undefined')
|
|
84
|
+
return;
|
|
85
|
+
const storageKey = `grain_anonymous_user_id_${this.config.tenantId}`;
|
|
86
|
+
try {
|
|
87
|
+
const stored = localStorage.getItem(storageKey);
|
|
88
|
+
if (stored) {
|
|
89
|
+
this.persistentAnonymousUserId = stored;
|
|
90
|
+
this.log('Loaded persistent anonymous user ID:', this.persistentAnonymousUserId);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// Generate new anonymous user ID
|
|
94
|
+
const uuid = this.generateUUID();
|
|
95
|
+
this.persistentAnonymousUserId = this.formatAnonymousUserId(uuid);
|
|
96
|
+
localStorage.setItem(storageKey, this.persistentAnonymousUserId);
|
|
97
|
+
this.log('Generated new persistent anonymous user ID:', this.persistentAnonymousUserId);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
this.log('Failed to initialize persistent anonymous user ID:', error);
|
|
102
|
+
// Fallback: generate temporary ID without persistence
|
|
103
|
+
const uuid = this.generateUUID();
|
|
104
|
+
this.persistentAnonymousUserId = this.formatAnonymousUserId(uuid);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Get the effective user ID (global userId or persistent anonymous ID)
|
|
109
|
+
*/
|
|
110
|
+
getEffectiveUserId() {
|
|
111
|
+
return this.globalUserId || this.persistentAnonymousUserId || 'anonymous';
|
|
112
|
+
}
|
|
57
113
|
log(...args) {
|
|
58
114
|
if (this.config.debug) {
|
|
59
115
|
console.log('[Grain Analytics]', ...args);
|
|
60
116
|
}
|
|
61
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Create error digest from events
|
|
120
|
+
*/
|
|
121
|
+
createErrorDigest(events) {
|
|
122
|
+
const eventNames = [...new Set(events.map(e => e.eventName))];
|
|
123
|
+
const userIds = [...new Set(events.map(e => e.userId))];
|
|
124
|
+
let totalProperties = 0;
|
|
125
|
+
let totalSize = 0;
|
|
126
|
+
events.forEach(event => {
|
|
127
|
+
const properties = event.properties || {};
|
|
128
|
+
totalProperties += Object.keys(properties).length;
|
|
129
|
+
totalSize += JSON.stringify(event).length;
|
|
130
|
+
});
|
|
131
|
+
return {
|
|
132
|
+
eventCount: events.length,
|
|
133
|
+
totalProperties,
|
|
134
|
+
totalSize,
|
|
135
|
+
eventNames,
|
|
136
|
+
userIds,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Format error with beautiful structure
|
|
141
|
+
*/
|
|
142
|
+
formatError(error, context, events) {
|
|
143
|
+
const digest = events ? this.createErrorDigest(events) : {
|
|
144
|
+
eventCount: 0,
|
|
145
|
+
totalProperties: 0,
|
|
146
|
+
totalSize: 0,
|
|
147
|
+
eventNames: [],
|
|
148
|
+
userIds: [],
|
|
149
|
+
};
|
|
150
|
+
let code = 'UNKNOWN_ERROR';
|
|
151
|
+
let message = 'An unknown error occurred';
|
|
152
|
+
if (error instanceof Error) {
|
|
153
|
+
message = error.message;
|
|
154
|
+
// Determine error code based on error type and message
|
|
155
|
+
if (message.includes('fetch failed') || message.includes('network error')) {
|
|
156
|
+
code = 'NETWORK_ERROR';
|
|
157
|
+
}
|
|
158
|
+
else if (message.includes('timeout')) {
|
|
159
|
+
code = 'TIMEOUT_ERROR';
|
|
160
|
+
}
|
|
161
|
+
else if (message.includes('HTTP 4')) {
|
|
162
|
+
code = 'CLIENT_ERROR';
|
|
163
|
+
}
|
|
164
|
+
else if (message.includes('HTTP 5')) {
|
|
165
|
+
code = 'SERVER_ERROR';
|
|
166
|
+
}
|
|
167
|
+
else if (message.includes('JSON')) {
|
|
168
|
+
code = 'PARSE_ERROR';
|
|
169
|
+
}
|
|
170
|
+
else if (message.includes('auth') || message.includes('unauthorized')) {
|
|
171
|
+
code = 'AUTH_ERROR';
|
|
172
|
+
}
|
|
173
|
+
else if (message.includes('rate limit') || message.includes('429')) {
|
|
174
|
+
code = 'RATE_LIMIT_ERROR';
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
code = 'GENERAL_ERROR';
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
else if (typeof error === 'string') {
|
|
181
|
+
message = error;
|
|
182
|
+
code = 'STRING_ERROR';
|
|
183
|
+
}
|
|
184
|
+
else if (error && typeof error === 'object' && 'status' in error) {
|
|
185
|
+
const status = error.status;
|
|
186
|
+
code = `HTTP_${status}`;
|
|
187
|
+
message = `HTTP ${status} error`;
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
code,
|
|
191
|
+
message,
|
|
192
|
+
digest,
|
|
193
|
+
timestamp: new Date().toISOString(),
|
|
194
|
+
context,
|
|
195
|
+
originalError: error,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Log formatted error gracefully
|
|
200
|
+
*/
|
|
201
|
+
logError(formattedError) {
|
|
202
|
+
const { code, message, digest, timestamp, context } = formattedError;
|
|
203
|
+
const errorOutput = {
|
|
204
|
+
'🚨 Grain Analytics Error': {
|
|
205
|
+
'Error Code': code,
|
|
206
|
+
'Message': message,
|
|
207
|
+
'Context': context,
|
|
208
|
+
'Timestamp': timestamp,
|
|
209
|
+
'Event Digest': {
|
|
210
|
+
'Events': digest.eventCount,
|
|
211
|
+
'Properties': digest.totalProperties,
|
|
212
|
+
'Size (bytes)': digest.totalSize,
|
|
213
|
+
'Event Names': digest.eventNames.length > 0 ? digest.eventNames.join(', ') : 'None',
|
|
214
|
+
'User IDs': digest.userIds.length > 0 ? digest.userIds.slice(0, 3).join(', ') + (digest.userIds.length > 3 ? '...' : '') : 'None',
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
console.error('🚨 Grain Analytics Error:', errorOutput);
|
|
219
|
+
// Also log in a more compact format for debugging
|
|
220
|
+
if (this.config.debug) {
|
|
221
|
+
console.error(`[Grain Analytics] ${code}: ${message} (${context}) - Events: ${digest.eventCount}, Props: ${digest.totalProperties}, Size: ${digest.totalSize}B`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Safely execute a function with error handling
|
|
226
|
+
*/
|
|
227
|
+
async safeExecute(fn, context, events) {
|
|
228
|
+
try {
|
|
229
|
+
return await fn();
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
const formattedError = this.formatError(error, context, events);
|
|
233
|
+
this.logError(formattedError);
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
62
237
|
formatEvent(event) {
|
|
63
238
|
return {
|
|
64
239
|
eventName: event.eventName,
|
|
65
|
-
userId: event.userId || this.
|
|
240
|
+
userId: event.userId || this.getEffectiveUserId(),
|
|
66
241
|
properties: event.properties || {},
|
|
67
242
|
};
|
|
68
243
|
}
|
|
@@ -146,20 +321,22 @@ class GrainAnalytics {
|
|
|
146
321
|
catch (error) {
|
|
147
322
|
lastError = error;
|
|
148
323
|
if (attempt === this.config.retryAttempts) {
|
|
149
|
-
// Last attempt, don't retry
|
|
150
|
-
|
|
324
|
+
// Last attempt, don't retry - log error gracefully
|
|
325
|
+
const formattedError = this.formatError(error, `sendEvents (attempt ${attempt + 1}/${this.config.retryAttempts + 1})`, events);
|
|
326
|
+
this.logError(formattedError);
|
|
327
|
+
return; // Don't throw, just return gracefully
|
|
151
328
|
}
|
|
152
329
|
if (!this.isRetriableError(error)) {
|
|
153
|
-
// Non-retriable error, don't retry
|
|
154
|
-
|
|
330
|
+
// Non-retriable error, don't retry - log error gracefully
|
|
331
|
+
const formattedError = this.formatError(error, `sendEvents (non-retriable error)`, events);
|
|
332
|
+
this.logError(formattedError);
|
|
333
|
+
return; // Don't throw, just return gracefully
|
|
155
334
|
}
|
|
156
335
|
const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff
|
|
157
336
|
this.log(`Retrying in ${delayMs}ms after error:`, error);
|
|
158
337
|
await this.delay(delayMs);
|
|
159
338
|
}
|
|
160
339
|
}
|
|
161
|
-
console.error('[Grain Analytics] Failed to send events after all retries:', lastError);
|
|
162
|
-
throw lastError;
|
|
163
340
|
}
|
|
164
341
|
async sendEventsWithBeacon(events) {
|
|
165
342
|
if (events.length === 0)
|
|
@@ -187,7 +364,9 @@ class GrainAnalytics {
|
|
|
187
364
|
this.log(`Successfully sent ${events.length} events via fetch (keepalive)`);
|
|
188
365
|
}
|
|
189
366
|
catch (error) {
|
|
190
|
-
|
|
367
|
+
// Log error gracefully for beacon failures (page unload scenarios)
|
|
368
|
+
const formattedError = this.formatError(error, 'sendEventsWithBeacon', events);
|
|
369
|
+
this.logError(formattedError);
|
|
191
370
|
}
|
|
192
371
|
}
|
|
193
372
|
startFlushTimer() {
|
|
@@ -197,7 +376,8 @@ class GrainAnalytics {
|
|
|
197
376
|
this.flushTimer = window.setInterval(() => {
|
|
198
377
|
if (this.eventQueue.length > 0) {
|
|
199
378
|
this.flush().catch((error) => {
|
|
200
|
-
|
|
379
|
+
const formattedError = this.formatError(error, 'auto-flush');
|
|
380
|
+
this.logError(formattedError);
|
|
201
381
|
});
|
|
202
382
|
}
|
|
203
383
|
}, this.config.flushInterval);
|
|
@@ -238,28 +418,37 @@ class GrainAnalytics {
|
|
|
238
418
|
});
|
|
239
419
|
}
|
|
240
420
|
async track(eventOrName, propertiesOrOptions, options) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
421
|
+
try {
|
|
422
|
+
if (this.isDestroyed) {
|
|
423
|
+
const error = new Error('Grain Analytics: Client has been destroyed');
|
|
424
|
+
const formattedError = this.formatError(error, 'track (client destroyed)');
|
|
425
|
+
this.logError(formattedError);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
let event;
|
|
429
|
+
let opts = {};
|
|
430
|
+
if (typeof eventOrName === 'string') {
|
|
431
|
+
event = {
|
|
432
|
+
eventName: eventOrName,
|
|
433
|
+
properties: propertiesOrOptions,
|
|
434
|
+
};
|
|
435
|
+
opts = options || {};
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
event = eventOrName;
|
|
439
|
+
opts = propertiesOrOptions || {};
|
|
440
|
+
}
|
|
441
|
+
const formattedEvent = this.formatEvent(event);
|
|
442
|
+
this.eventQueue.push(formattedEvent);
|
|
443
|
+
this.log(`Queued event: ${event.eventName}`, event.properties);
|
|
444
|
+
// Check if we should flush immediately
|
|
445
|
+
if (opts.flush || this.eventQueue.length >= this.config.batchSize) {
|
|
446
|
+
await this.flush();
|
|
447
|
+
}
|
|
256
448
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
// Check if we should flush immediately
|
|
261
|
-
if (opts.flush || this.eventQueue.length >= this.config.batchSize) {
|
|
262
|
-
await this.flush();
|
|
449
|
+
catch (error) {
|
|
450
|
+
const formattedError = this.formatError(error, 'track');
|
|
451
|
+
this.logError(formattedError);
|
|
263
452
|
}
|
|
264
453
|
}
|
|
265
454
|
/**
|
|
@@ -268,6 +457,8 @@ class GrainAnalytics {
|
|
|
268
457
|
identify(userId) {
|
|
269
458
|
this.log(`Identified user: ${userId}`);
|
|
270
459
|
this.globalUserId = userId;
|
|
460
|
+
// Clear persistent anonymous user ID since we now have a real user ID
|
|
461
|
+
this.persistentAnonymousUserId = null;
|
|
271
462
|
}
|
|
272
463
|
/**
|
|
273
464
|
* Set global user ID for all subsequent events
|
|
@@ -275,6 +466,10 @@ class GrainAnalytics {
|
|
|
275
466
|
setUserId(userId) {
|
|
276
467
|
this.log(`Set global user ID: ${userId}`);
|
|
277
468
|
this.globalUserId = userId;
|
|
469
|
+
// Clear persistent anonymous user ID if setting a real user ID
|
|
470
|
+
if (userId) {
|
|
471
|
+
this.persistentAnonymousUserId = null;
|
|
472
|
+
}
|
|
278
473
|
}
|
|
279
474
|
/**
|
|
280
475
|
* Get current global user ID
|
|
@@ -282,40 +477,61 @@ class GrainAnalytics {
|
|
|
282
477
|
getUserId() {
|
|
283
478
|
return this.globalUserId;
|
|
284
479
|
}
|
|
480
|
+
/**
|
|
481
|
+
* Get current effective user ID (global userId or persistent anonymous ID)
|
|
482
|
+
*/
|
|
483
|
+
getEffectiveUserIdPublic() {
|
|
484
|
+
return this.getEffectiveUserId();
|
|
485
|
+
}
|
|
285
486
|
/**
|
|
286
487
|
* Set user properties
|
|
287
488
|
*/
|
|
288
489
|
async setProperty(properties, options) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (propertyKeys.length > 4) {
|
|
296
|
-
throw new Error('Grain Analytics: Maximum 4 properties allowed per request');
|
|
297
|
-
}
|
|
298
|
-
if (propertyKeys.length === 0) {
|
|
299
|
-
throw new Error('Grain Analytics: At least one property is required');
|
|
300
|
-
}
|
|
301
|
-
// Serialize all values to strings
|
|
302
|
-
const serializedProperties = {};
|
|
303
|
-
for (const [key, value] of Object.entries(properties)) {
|
|
304
|
-
if (value === null || value === undefined) {
|
|
305
|
-
serializedProperties[key] = '';
|
|
490
|
+
try {
|
|
491
|
+
if (this.isDestroyed) {
|
|
492
|
+
const error = new Error('Grain Analytics: Client has been destroyed');
|
|
493
|
+
const formattedError = this.formatError(error, 'setProperty (client destroyed)');
|
|
494
|
+
this.logError(formattedError);
|
|
495
|
+
return;
|
|
306
496
|
}
|
|
307
|
-
|
|
308
|
-
|
|
497
|
+
const userId = options?.userId || this.getEffectiveUserId();
|
|
498
|
+
// Validate property count (max 4 properties)
|
|
499
|
+
const propertyKeys = Object.keys(properties);
|
|
500
|
+
if (propertyKeys.length > 4) {
|
|
501
|
+
const error = new Error('Grain Analytics: Maximum 4 properties allowed per request');
|
|
502
|
+
const formattedError = this.formatError(error, 'setProperty (validation)');
|
|
503
|
+
this.logError(formattedError);
|
|
504
|
+
return;
|
|
309
505
|
}
|
|
310
|
-
|
|
311
|
-
|
|
506
|
+
if (propertyKeys.length === 0) {
|
|
507
|
+
const error = new Error('Grain Analytics: At least one property is required');
|
|
508
|
+
const formattedError = this.formatError(error, 'setProperty (validation)');
|
|
509
|
+
this.logError(formattedError);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
// Serialize all values to strings
|
|
513
|
+
const serializedProperties = {};
|
|
514
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
515
|
+
if (value === null || value === undefined) {
|
|
516
|
+
serializedProperties[key] = '';
|
|
517
|
+
}
|
|
518
|
+
else if (typeof value === 'string') {
|
|
519
|
+
serializedProperties[key] = value;
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
serializedProperties[key] = JSON.stringify(value);
|
|
523
|
+
}
|
|
312
524
|
}
|
|
525
|
+
const payload = {
|
|
526
|
+
userId,
|
|
527
|
+
...serializedProperties,
|
|
528
|
+
};
|
|
529
|
+
await this.sendProperties(payload);
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
const formattedError = this.formatError(error, 'setProperty');
|
|
533
|
+
this.logError(formattedError);
|
|
313
534
|
}
|
|
314
|
-
const payload = {
|
|
315
|
-
userId,
|
|
316
|
-
...serializedProperties,
|
|
317
|
-
};
|
|
318
|
-
await this.sendProperties(payload);
|
|
319
535
|
}
|
|
320
536
|
/**
|
|
321
537
|
* Send properties to the API
|
|
@@ -356,83 +572,139 @@ class GrainAnalytics {
|
|
|
356
572
|
catch (error) {
|
|
357
573
|
lastError = error;
|
|
358
574
|
if (attempt === this.config.retryAttempts) {
|
|
359
|
-
// Last attempt, don't retry
|
|
360
|
-
|
|
575
|
+
// Last attempt, don't retry - log error gracefully
|
|
576
|
+
const formattedError = this.formatError(error, `sendProperties (attempt ${attempt + 1}/${this.config.retryAttempts + 1})`);
|
|
577
|
+
this.logError(formattedError);
|
|
578
|
+
return; // Don't throw, just return gracefully
|
|
361
579
|
}
|
|
362
580
|
if (!this.isRetriableError(error)) {
|
|
363
|
-
// Non-retriable error, don't retry
|
|
364
|
-
|
|
581
|
+
// Non-retriable error, don't retry - log error gracefully
|
|
582
|
+
const formattedError = this.formatError(error, 'sendProperties (non-retriable error)');
|
|
583
|
+
this.logError(formattedError);
|
|
584
|
+
return; // Don't throw, just return gracefully
|
|
365
585
|
}
|
|
366
586
|
const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff
|
|
367
587
|
this.log(`Retrying in ${delayMs}ms after error:`, error);
|
|
368
588
|
await this.delay(delayMs);
|
|
369
589
|
}
|
|
370
590
|
}
|
|
371
|
-
console.error('[Grain Analytics] Failed to set properties after all retries:', lastError);
|
|
372
|
-
throw lastError;
|
|
373
591
|
}
|
|
374
592
|
// Template event methods
|
|
375
593
|
/**
|
|
376
594
|
* Track user login event
|
|
377
595
|
*/
|
|
378
596
|
async trackLogin(properties, options) {
|
|
379
|
-
|
|
597
|
+
try {
|
|
598
|
+
return await this.track('login', properties, options);
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
const formattedError = this.formatError(error, 'trackLogin');
|
|
602
|
+
this.logError(formattedError);
|
|
603
|
+
}
|
|
380
604
|
}
|
|
381
605
|
/**
|
|
382
606
|
* Track user signup event
|
|
383
607
|
*/
|
|
384
608
|
async trackSignup(properties, options) {
|
|
385
|
-
|
|
609
|
+
try {
|
|
610
|
+
return await this.track('signup', properties, options);
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
const formattedError = this.formatError(error, 'trackSignup');
|
|
614
|
+
this.logError(formattedError);
|
|
615
|
+
}
|
|
386
616
|
}
|
|
387
617
|
/**
|
|
388
618
|
* Track checkout event
|
|
389
619
|
*/
|
|
390
620
|
async trackCheckout(properties, options) {
|
|
391
|
-
|
|
621
|
+
try {
|
|
622
|
+
return await this.track('checkout', properties, options);
|
|
623
|
+
}
|
|
624
|
+
catch (error) {
|
|
625
|
+
const formattedError = this.formatError(error, 'trackCheckout');
|
|
626
|
+
this.logError(formattedError);
|
|
627
|
+
}
|
|
392
628
|
}
|
|
393
629
|
/**
|
|
394
630
|
* Track page view event
|
|
395
631
|
*/
|
|
396
632
|
async trackPageView(properties, options) {
|
|
397
|
-
|
|
633
|
+
try {
|
|
634
|
+
return await this.track('page_view', properties, options);
|
|
635
|
+
}
|
|
636
|
+
catch (error) {
|
|
637
|
+
const formattedError = this.formatError(error, 'trackPageView');
|
|
638
|
+
this.logError(formattedError);
|
|
639
|
+
}
|
|
398
640
|
}
|
|
399
641
|
/**
|
|
400
642
|
* Track purchase event
|
|
401
643
|
*/
|
|
402
644
|
async trackPurchase(properties, options) {
|
|
403
|
-
|
|
645
|
+
try {
|
|
646
|
+
return await this.track('purchase', properties, options);
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
const formattedError = this.formatError(error, 'trackPurchase');
|
|
650
|
+
this.logError(formattedError);
|
|
651
|
+
}
|
|
404
652
|
}
|
|
405
653
|
/**
|
|
406
654
|
* Track search event
|
|
407
655
|
*/
|
|
408
656
|
async trackSearch(properties, options) {
|
|
409
|
-
|
|
657
|
+
try {
|
|
658
|
+
return await this.track('search', properties, options);
|
|
659
|
+
}
|
|
660
|
+
catch (error) {
|
|
661
|
+
const formattedError = this.formatError(error, 'trackSearch');
|
|
662
|
+
this.logError(formattedError);
|
|
663
|
+
}
|
|
410
664
|
}
|
|
411
665
|
/**
|
|
412
666
|
* Track add to cart event
|
|
413
667
|
*/
|
|
414
668
|
async trackAddToCart(properties, options) {
|
|
415
|
-
|
|
669
|
+
try {
|
|
670
|
+
return await this.track('add_to_cart', properties, options);
|
|
671
|
+
}
|
|
672
|
+
catch (error) {
|
|
673
|
+
const formattedError = this.formatError(error, 'trackAddToCart');
|
|
674
|
+
this.logError(formattedError);
|
|
675
|
+
}
|
|
416
676
|
}
|
|
417
677
|
/**
|
|
418
678
|
* Track remove from cart event
|
|
419
679
|
*/
|
|
420
680
|
async trackRemoveFromCart(properties, options) {
|
|
421
|
-
|
|
681
|
+
try {
|
|
682
|
+
return await this.track('remove_from_cart', properties, options);
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
const formattedError = this.formatError(error, 'trackRemoveFromCart');
|
|
686
|
+
this.logError(formattedError);
|
|
687
|
+
}
|
|
422
688
|
}
|
|
423
689
|
/**
|
|
424
690
|
* Manually flush all queued events
|
|
425
691
|
*/
|
|
426
692
|
async flush() {
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
693
|
+
try {
|
|
694
|
+
if (this.eventQueue.length === 0)
|
|
695
|
+
return;
|
|
696
|
+
const eventsToSend = [...this.eventQueue];
|
|
697
|
+
this.eventQueue = [];
|
|
698
|
+
// Split events into chunks to respect maxEventsPerRequest limit
|
|
699
|
+
const chunks = this.chunkEvents(eventsToSend, this.config.maxEventsPerRequest);
|
|
700
|
+
// Send all chunks sequentially to maintain order
|
|
701
|
+
for (const chunk of chunks) {
|
|
702
|
+
await this.sendEvents(chunk);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
catch (error) {
|
|
706
|
+
const formattedError = this.formatError(error, 'flush');
|
|
707
|
+
this.logError(formattedError);
|
|
436
708
|
}
|
|
437
709
|
}
|
|
438
710
|
// Remote Config Methods
|
|
@@ -495,89 +767,109 @@ class GrainAnalytics {
|
|
|
495
767
|
* Fetch configurations from API
|
|
496
768
|
*/
|
|
497
769
|
async fetchConfig(options = {}) {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
userId
|
|
506
|
-
immediateKeys
|
|
507
|
-
properties
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
770
|
+
try {
|
|
771
|
+
if (this.isDestroyed) {
|
|
772
|
+
const error = new Error('Grain Analytics: Client has been destroyed');
|
|
773
|
+
const formattedError = this.formatError(error, 'fetchConfig (client destroyed)');
|
|
774
|
+
this.logError(formattedError);
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
const userId = options.userId || this.getEffectiveUserId();
|
|
778
|
+
const immediateKeys = options.immediateKeys || [];
|
|
779
|
+
const properties = options.properties || {};
|
|
780
|
+
const request = {
|
|
781
|
+
userId,
|
|
782
|
+
immediateKeys,
|
|
783
|
+
properties,
|
|
784
|
+
};
|
|
785
|
+
let lastError;
|
|
786
|
+
for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
|
|
787
|
+
try {
|
|
788
|
+
const headers = await this.getAuthHeaders();
|
|
789
|
+
const url = `${this.config.apiUrl}/v1/client/${encodeURIComponent(this.config.tenantId)}/config/configurations`;
|
|
790
|
+
this.log(`Fetching configurations for user ${userId} (attempt ${attempt + 1})`);
|
|
791
|
+
const response = await fetch(url, {
|
|
792
|
+
method: 'POST',
|
|
793
|
+
headers,
|
|
794
|
+
body: JSON.stringify(request),
|
|
795
|
+
});
|
|
796
|
+
if (!response.ok) {
|
|
797
|
+
let errorMessage = `HTTP ${response.status}`;
|
|
798
|
+
try {
|
|
799
|
+
const errorBody = await response.json();
|
|
800
|
+
if (errorBody?.message) {
|
|
801
|
+
errorMessage = errorBody.message;
|
|
802
|
+
}
|
|
526
803
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
804
|
+
catch {
|
|
805
|
+
const errorText = await response.text();
|
|
806
|
+
if (errorText) {
|
|
807
|
+
errorMessage = errorText;
|
|
808
|
+
}
|
|
532
809
|
}
|
|
810
|
+
const error = new Error(`Failed to fetch configurations: ${errorMessage}`);
|
|
811
|
+
error.status = response.status;
|
|
812
|
+
throw error;
|
|
533
813
|
}
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
this.updateConfigCache(configResponse, userId);
|
|
542
|
-
}
|
|
543
|
-
this.log(`Successfully fetched configurations for user ${userId}:`, configResponse);
|
|
544
|
-
return configResponse;
|
|
545
|
-
}
|
|
546
|
-
catch (error) {
|
|
547
|
-
lastError = error;
|
|
548
|
-
if (attempt === this.config.retryAttempts) {
|
|
549
|
-
break;
|
|
814
|
+
const configResponse = await response.json();
|
|
815
|
+
// Update cache if successful
|
|
816
|
+
if (configResponse.configurations) {
|
|
817
|
+
this.updateConfigCache(configResponse, userId);
|
|
818
|
+
}
|
|
819
|
+
this.log(`Successfully fetched configurations for user ${userId}:`, configResponse);
|
|
820
|
+
return configResponse;
|
|
550
821
|
}
|
|
551
|
-
|
|
552
|
-
|
|
822
|
+
catch (error) {
|
|
823
|
+
lastError = error;
|
|
824
|
+
if (attempt === this.config.retryAttempts) {
|
|
825
|
+
// Last attempt, don't retry - log error gracefully
|
|
826
|
+
const formattedError = this.formatError(error, `fetchConfig (attempt ${attempt + 1}/${this.config.retryAttempts + 1})`);
|
|
827
|
+
this.logError(formattedError);
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
if (!this.isRetriableError(error)) {
|
|
831
|
+
// Non-retriable error, don't retry - log error gracefully
|
|
832
|
+
const formattedError = this.formatError(error, 'fetchConfig (non-retriable error)');
|
|
833
|
+
this.logError(formattedError);
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
const delayMs = this.config.retryDelay * Math.pow(2, attempt);
|
|
837
|
+
this.log(`Retrying config fetch in ${delayMs}ms after error:`, error);
|
|
838
|
+
await this.delay(delayMs);
|
|
553
839
|
}
|
|
554
|
-
const delayMs = this.config.retryDelay * Math.pow(2, attempt);
|
|
555
|
-
this.log(`Retrying config fetch in ${delayMs}ms after error:`, error);
|
|
556
|
-
await this.delay(delayMs);
|
|
557
840
|
}
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
catch (error) {
|
|
844
|
+
const formattedError = this.formatError(error, 'fetchConfig');
|
|
845
|
+
this.logError(formattedError);
|
|
846
|
+
return null;
|
|
558
847
|
}
|
|
559
|
-
console.error('[Grain Analytics] Failed to fetch configurations after all retries:', lastError);
|
|
560
|
-
throw lastError;
|
|
561
848
|
}
|
|
562
849
|
/**
|
|
563
850
|
* Get configuration asynchronously (cache-first with fallback to API)
|
|
564
851
|
*/
|
|
565
852
|
async getConfigAsync(key, options = {}) {
|
|
566
|
-
// Return immediately if we have it in cache and not forcing refresh
|
|
567
|
-
if (!options.forceRefresh && this.configCache?.configurations?.[key]) {
|
|
568
|
-
return this.configCache.configurations[key];
|
|
569
|
-
}
|
|
570
|
-
// Return default if available and not forcing refresh
|
|
571
|
-
if (!options.forceRefresh && this.config.defaultConfigurations?.[key]) {
|
|
572
|
-
return this.config.defaultConfigurations[key];
|
|
573
|
-
}
|
|
574
|
-
// Fetch from API
|
|
575
853
|
try {
|
|
854
|
+
// Return immediately if we have it in cache and not forcing refresh
|
|
855
|
+
if (!options.forceRefresh && this.configCache?.configurations?.[key]) {
|
|
856
|
+
return this.configCache.configurations[key];
|
|
857
|
+
}
|
|
858
|
+
// Return default if available and not forcing refresh
|
|
859
|
+
if (!options.forceRefresh && this.config.defaultConfigurations?.[key]) {
|
|
860
|
+
return this.config.defaultConfigurations[key];
|
|
861
|
+
}
|
|
862
|
+
// Fetch from API
|
|
576
863
|
const response = await this.fetchConfig(options);
|
|
577
|
-
|
|
864
|
+
if (response) {
|
|
865
|
+
return response.configurations[key];
|
|
866
|
+
}
|
|
867
|
+
// Return default as fallback
|
|
868
|
+
return this.config.defaultConfigurations?.[key];
|
|
578
869
|
}
|
|
579
870
|
catch (error) {
|
|
580
|
-
this.
|
|
871
|
+
const formattedError = this.formatError(error, 'getConfigAsync');
|
|
872
|
+
this.logError(formattedError);
|
|
581
873
|
// Return default as fallback
|
|
582
874
|
return this.config.defaultConfigurations?.[key];
|
|
583
875
|
}
|
|
@@ -586,17 +878,22 @@ class GrainAnalytics {
|
|
|
586
878
|
* Get all configurations asynchronously (cache-first with fallback to API)
|
|
587
879
|
*/
|
|
588
880
|
async getAllConfigsAsync(options = {}) {
|
|
589
|
-
// Return cache if available and not forcing refresh
|
|
590
|
-
if (!options.forceRefresh && this.configCache?.configurations) {
|
|
591
|
-
return { ...this.config.defaultConfigurations, ...this.configCache.configurations };
|
|
592
|
-
}
|
|
593
|
-
// Fetch from API
|
|
594
881
|
try {
|
|
882
|
+
// Return cache if available and not forcing refresh
|
|
883
|
+
if (!options.forceRefresh && this.configCache?.configurations) {
|
|
884
|
+
return { ...this.config.defaultConfigurations, ...this.configCache.configurations };
|
|
885
|
+
}
|
|
886
|
+
// Fetch from API
|
|
595
887
|
const response = await this.fetchConfig(options);
|
|
596
|
-
|
|
888
|
+
if (response) {
|
|
889
|
+
return { ...this.config.defaultConfigurations, ...response.configurations };
|
|
890
|
+
}
|
|
891
|
+
// Return defaults as fallback
|
|
892
|
+
return { ...this.config.defaultConfigurations };
|
|
597
893
|
}
|
|
598
894
|
catch (error) {
|
|
599
|
-
this.
|
|
895
|
+
const formattedError = this.formatError(error, 'getAllConfigsAsync');
|
|
896
|
+
this.logError(formattedError);
|
|
600
897
|
// Return defaults as fallback
|
|
601
898
|
return { ...this.config.defaultConfigurations };
|
|
602
899
|
}
|
|
@@ -657,7 +954,8 @@ class GrainAnalytics {
|
|
|
657
954
|
this.configRefreshTimer = window.setInterval(() => {
|
|
658
955
|
if (!this.isDestroyed && this.globalUserId) {
|
|
659
956
|
this.fetchConfig().catch((error) => {
|
|
660
|
-
|
|
957
|
+
const formattedError = this.formatError(error, 'auto-config refresh');
|
|
958
|
+
this.logError(formattedError);
|
|
661
959
|
});
|
|
662
960
|
}
|
|
663
961
|
}, this.config.configRefreshInterval);
|
|
@@ -675,16 +973,19 @@ class GrainAnalytics {
|
|
|
675
973
|
* Preload configurations for immediate access
|
|
676
974
|
*/
|
|
677
975
|
async preloadConfig(immediateKeys = [], properties) {
|
|
678
|
-
if (!this.globalUserId) {
|
|
679
|
-
this.log('Cannot preload config: no user ID set');
|
|
680
|
-
return;
|
|
681
|
-
}
|
|
682
976
|
try {
|
|
683
|
-
|
|
684
|
-
|
|
977
|
+
if (!this.globalUserId) {
|
|
978
|
+
this.log('Cannot preload config: no user ID set');
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const response = await this.fetchConfig({ immediateKeys, properties });
|
|
982
|
+
if (response) {
|
|
983
|
+
this.startConfigRefreshTimer();
|
|
984
|
+
}
|
|
685
985
|
}
|
|
686
986
|
catch (error) {
|
|
687
|
-
this.
|
|
987
|
+
const formattedError = this.formatError(error, 'preloadConfig');
|
|
988
|
+
this.logError(formattedError);
|
|
688
989
|
}
|
|
689
990
|
}
|
|
690
991
|
/**
|