@grainql/analytics-web 1.2.0 → 1.4.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/index.mjs CHANGED
@@ -7,6 +7,7 @@ export class GrainAnalytics {
7
7
  this.eventQueue = [];
8
8
  this.flushTimer = null;
9
9
  this.isDestroyed = false;
10
+ this.globalUserId = null;
10
11
  this.config = {
11
12
  apiUrl: 'https://api.grainql.com',
12
13
  authStrategy: 'NONE',
@@ -19,6 +20,10 @@ export class GrainAnalytics {
19
20
  ...config,
20
21
  tenantId: config.tenantId,
21
22
  };
23
+ // Set global userId if provided in config
24
+ if (config.userId) {
25
+ this.globalUserId = config.userId;
26
+ }
22
27
  this.validateConfig();
23
28
  this.setupBeforeUnload();
24
29
  this.startFlushTimer();
@@ -42,7 +47,7 @@ export class GrainAnalytics {
42
47
  formatEvent(event) {
43
48
  return {
44
49
  eventName: event.eventName,
45
- userId: event.userId || 'anonymous',
50
+ userId: event.userId || this.globalUserId || 'anonymous',
46
51
  properties: event.properties || {},
47
52
  };
48
53
  }
@@ -95,12 +100,12 @@ export class GrainAnalytics {
95
100
  for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
96
101
  try {
97
102
  const headers = await this.getAuthHeaders();
98
- const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}`;
103
+ const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/multi`;
99
104
  this.log(`Sending ${events.length} events to ${url} (attempt ${attempt + 1})`);
100
105
  const response = await fetch(url, {
101
106
  method: 'POST',
102
107
  headers,
103
- body: JSON.stringify(events),
108
+ body: JSON.stringify({ events }),
104
109
  });
105
110
  if (!response.ok) {
106
111
  let errorMessage = `HTTP ${response.status}`;
@@ -146,7 +151,7 @@ export class GrainAnalytics {
146
151
  return;
147
152
  try {
148
153
  const headers = await this.getAuthHeaders();
149
- const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}`;
154
+ const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/multi`;
150
155
  const body = JSON.stringify({ events });
151
156
  // Try beacon API first (more reliable for page unload)
152
157
  if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) {
@@ -246,9 +251,159 @@ export class GrainAnalytics {
246
251
  * Identify a user (sets userId for subsequent events)
247
252
  */
248
253
  identify(userId) {
249
- // Store userId for future events - this would typically be handled
250
- // by the application layer, but we can provide a helper
251
254
  this.log(`Identified user: ${userId}`);
255
+ this.globalUserId = userId;
256
+ }
257
+ /**
258
+ * Set global user ID for all subsequent events
259
+ */
260
+ setUserId(userId) {
261
+ this.log(`Set global user ID: ${userId}`);
262
+ this.globalUserId = userId;
263
+ }
264
+ /**
265
+ * Get current global user ID
266
+ */
267
+ getUserId() {
268
+ return this.globalUserId;
269
+ }
270
+ /**
271
+ * Set user properties
272
+ */
273
+ async setProperty(properties, options) {
274
+ if (this.isDestroyed) {
275
+ throw new Error('Grain Analytics: Client has been destroyed');
276
+ }
277
+ const userId = options?.userId || this.globalUserId || 'anonymous';
278
+ // Validate property count (max 4 properties)
279
+ const propertyKeys = Object.keys(properties);
280
+ if (propertyKeys.length > 4) {
281
+ throw new Error('Grain Analytics: Maximum 4 properties allowed per request');
282
+ }
283
+ if (propertyKeys.length === 0) {
284
+ throw new Error('Grain Analytics: At least one property is required');
285
+ }
286
+ // Serialize all values to strings
287
+ const serializedProperties = {};
288
+ for (const [key, value] of Object.entries(properties)) {
289
+ if (value === null || value === undefined) {
290
+ serializedProperties[key] = '';
291
+ }
292
+ else if (typeof value === 'string') {
293
+ serializedProperties[key] = value;
294
+ }
295
+ else {
296
+ serializedProperties[key] = JSON.stringify(value);
297
+ }
298
+ }
299
+ const payload = {
300
+ userId,
301
+ ...serializedProperties,
302
+ };
303
+ await this.sendProperties(payload);
304
+ }
305
+ /**
306
+ * Send properties to the API
307
+ */
308
+ async sendProperties(payload) {
309
+ let lastError;
310
+ for (let attempt = 0; attempt <= this.config.retryAttempts; attempt++) {
311
+ try {
312
+ const headers = await this.getAuthHeaders();
313
+ const url = `${this.config.apiUrl}/v1/events/${encodeURIComponent(this.config.tenantId)}/properties`;
314
+ this.log(`Setting properties for user ${payload.userId} (attempt ${attempt + 1})`);
315
+ const response = await fetch(url, {
316
+ method: 'POST',
317
+ headers,
318
+ body: JSON.stringify(payload),
319
+ });
320
+ if (!response.ok) {
321
+ let errorMessage = `HTTP ${response.status}`;
322
+ try {
323
+ const errorBody = await response.json();
324
+ if (errorBody?.message) {
325
+ errorMessage = errorBody.message;
326
+ }
327
+ }
328
+ catch {
329
+ const errorText = await response.text();
330
+ if (errorText) {
331
+ errorMessage = errorText;
332
+ }
333
+ }
334
+ const error = new Error(`Failed to set properties: ${errorMessage}`);
335
+ error.status = response.status;
336
+ throw error;
337
+ }
338
+ this.log(`Successfully set properties for user ${payload.userId}`);
339
+ return; // Success, exit retry loop
340
+ }
341
+ catch (error) {
342
+ lastError = error;
343
+ if (attempt === this.config.retryAttempts) {
344
+ // Last attempt, don't retry
345
+ break;
346
+ }
347
+ if (!this.isRetriableError(error)) {
348
+ // Non-retriable error, don't retry
349
+ break;
350
+ }
351
+ const delayMs = this.config.retryDelay * Math.pow(2, attempt); // Exponential backoff
352
+ this.log(`Retrying in ${delayMs}ms after error:`, error);
353
+ await this.delay(delayMs);
354
+ }
355
+ }
356
+ console.error('[Grain Analytics] Failed to set properties after all retries:', lastError);
357
+ throw lastError;
358
+ }
359
+ // Template event methods
360
+ /**
361
+ * Track user login event
362
+ */
363
+ async trackLogin(properties, options) {
364
+ return this.track('login', properties, options);
365
+ }
366
+ /**
367
+ * Track user signup event
368
+ */
369
+ async trackSignup(properties, options) {
370
+ return this.track('signup', properties, options);
371
+ }
372
+ /**
373
+ * Track checkout event
374
+ */
375
+ async trackCheckout(properties, options) {
376
+ return this.track('checkout', properties, options);
377
+ }
378
+ /**
379
+ * Track page view event
380
+ */
381
+ async trackPageView(properties, options) {
382
+ return this.track('page_view', properties, options);
383
+ }
384
+ /**
385
+ * Track purchase event
386
+ */
387
+ async trackPurchase(properties, options) {
388
+ return this.track('purchase', properties, options);
389
+ }
390
+ /**
391
+ * Track search event
392
+ */
393
+ async trackSearch(properties, options) {
394
+ return this.track('search', properties, options);
395
+ }
396
+ /**
397
+ * Track add to cart event
398
+ */
399
+ async trackAddToCart(properties, options) {
400
+ return this.track('add_to_cart', properties, options);
401
+ }
402
+ /**
403
+ * Track remove from cart event
404
+ */
405
+ async trackRemoveFromCart(properties, options) {
406
+ return this.track('remove_from_cart', properties, options);
252
407
  }
253
408
  /**
254
409
  * Manually flush all queued events
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grainql/analytics-web",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Lightweight TypeScript SDK for sending analytics events to Grain's REST API",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -22,10 +22,10 @@
22
22
  "build:cjs": "tsc --module commonjs --target es2020 --outDir dist/cjs && mv dist/cjs/index.js dist/index.js",
23
23
  "build:iife": "node scripts/build-iife.js",
24
24
  "clean": "rm -rf dist",
25
- "test": "echo 'Tests disabled'",
26
- "test:watch": "echo 'Tests disabled'",
27
- "test:coverage": "echo 'Tests disabled'",
28
- "test:ci": "echo 'Tests disabled'",
25
+ "test": "jest",
26
+ "test:watch": "jest --watch",
27
+ "test:coverage": "jest --coverage",
28
+ "test:ci": "jest --ci --coverage",
29
29
  "prepublishOnly": "npm run clean && npm run build",
30
30
  "size": "npm run build && node scripts/bundle-analysis.js",
31
31
  "size:limit": "size-limit"