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