@mostly-good-metrics/javascript 0.4.4 → 0.5.1
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/README.md +50 -4
- package/dist/cjs/client.js +91 -21
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/index.js +7 -3
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/storage.js +185 -1
- package/dist/cjs/storage.js.map +1 -1
- package/dist/cjs/types.js +2 -1
- package/dist/cjs/types.js.map +1 -1
- package/dist/cjs/utils.js +29 -0
- package/dist/cjs/utils.js.map +1 -1
- package/dist/esm/client.js +92 -22
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/index.js +6 -3
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/storage.js +185 -1
- package/dist/esm/storage.js.map +1 -1
- package/dist/esm/types.js +2 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/utils.js +28 -0
- package/dist/esm/utils.js.map +1 -1
- package/dist/types/client.d.ts +28 -5
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/index.d.ts +7 -4
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/storage.d.ts +56 -1
- package/dist/types/storage.d.ts.map +1 -1
- package/dist/types/types.d.ts +49 -15
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/utils.d.ts +5 -0
- package/dist/types/utils.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client.test.ts +172 -1
- package/src/client.ts +113 -21
- package/src/index.ts +7 -2
- package/src/storage.test.ts +126 -0
- package/src/storage.ts +204 -1
- package/src/types.ts +60 -15
- package/src/utils.test.ts +21 -0
- package/src/utils.ts +29 -0
package/src/client.test.ts
CHANGED
|
@@ -258,7 +258,7 @@ describe('MostlyGoodMetrics', () => {
|
|
|
258
258
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
259
259
|
|
|
260
260
|
const events = await storage.fetchEvents(1);
|
|
261
|
-
expect(events[0].
|
|
261
|
+
expect(events[0].user_id).toBe('user_456');
|
|
262
262
|
});
|
|
263
263
|
|
|
264
264
|
it('should not set empty userId', () => {
|
|
@@ -266,6 +266,97 @@ describe('MostlyGoodMetrics', () => {
|
|
|
266
266
|
MostlyGoodMetrics.identify('');
|
|
267
267
|
expect(MostlyGoodMetrics.shared?.userId).toBe('user_123');
|
|
268
268
|
});
|
|
269
|
+
|
|
270
|
+
it('should send $identify event with email', async () => {
|
|
271
|
+
MostlyGoodMetrics.resetIdentity(); // Clear any previous identify state
|
|
272
|
+
MostlyGoodMetrics.identify('user_123', { email: 'test@example.com' });
|
|
273
|
+
|
|
274
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
275
|
+
|
|
276
|
+
const events = await storage.fetchEvents(10);
|
|
277
|
+
const identifyEvent = events.find((e) => e.name === '$identify');
|
|
278
|
+
expect(identifyEvent).toBeDefined();
|
|
279
|
+
expect(identifyEvent?.properties?.email).toBe('test@example.com');
|
|
280
|
+
expect(identifyEvent?.properties?.name).toBeUndefined();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('should send $identify event with name', async () => {
|
|
284
|
+
MostlyGoodMetrics.resetIdentity(); // Clear any previous identify state
|
|
285
|
+
MostlyGoodMetrics.identify('user_123', { name: 'John Doe' });
|
|
286
|
+
|
|
287
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
288
|
+
|
|
289
|
+
const events = await storage.fetchEvents(10);
|
|
290
|
+
const identifyEvent = events.find((e) => e.name === '$identify');
|
|
291
|
+
expect(identifyEvent).toBeDefined();
|
|
292
|
+
expect(identifyEvent?.properties?.name).toBe('John Doe');
|
|
293
|
+
expect(identifyEvent?.properties?.email).toBeUndefined();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('should send $identify event with both email and name', async () => {
|
|
297
|
+
MostlyGoodMetrics.resetIdentity(); // Clear any previous identify state
|
|
298
|
+
MostlyGoodMetrics.identify('user_123', { email: 'test@example.com', name: 'John Doe' });
|
|
299
|
+
|
|
300
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
301
|
+
|
|
302
|
+
const events = await storage.fetchEvents(10);
|
|
303
|
+
const identifyEvent = events.find((e) => e.name === '$identify');
|
|
304
|
+
expect(identifyEvent).toBeDefined();
|
|
305
|
+
expect(identifyEvent?.properties?.email).toBe('test@example.com');
|
|
306
|
+
expect(identifyEvent?.properties?.name).toBe('John Doe');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should not send $identify event without profile data', async () => {
|
|
310
|
+
MostlyGoodMetrics.resetIdentity(); // Clear any previous identify state
|
|
311
|
+
MostlyGoodMetrics.identify('user_123');
|
|
312
|
+
|
|
313
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
314
|
+
|
|
315
|
+
const events = await storage.fetchEvents(10);
|
|
316
|
+
const identifyEvent = events.find((e) => e.name === '$identify');
|
|
317
|
+
expect(identifyEvent).toBeUndefined();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('should debounce $identify event with same profile data', async () => {
|
|
321
|
+
MostlyGoodMetrics.resetIdentity(); // Clear any previous identify state
|
|
322
|
+
|
|
323
|
+
MostlyGoodMetrics.identify('user_123', { email: 'debounce-test@example.com' });
|
|
324
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
325
|
+
|
|
326
|
+
// Call again with same data - should not send another event
|
|
327
|
+
MostlyGoodMetrics.identify('user_123', { email: 'debounce-test@example.com' });
|
|
328
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
329
|
+
|
|
330
|
+
const events = await storage.fetchEvents(10);
|
|
331
|
+
const identifyEvents = events.filter((e) => e.name === '$identify');
|
|
332
|
+
expect(identifyEvents.length).toBe(1);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('should send new $identify event when profile data changes', async () => {
|
|
336
|
+
MostlyGoodMetrics.resetIdentity(); // Clear any previous identify state
|
|
337
|
+
|
|
338
|
+
MostlyGoodMetrics.identify('user_123', { email: 'change-test@example.com' });
|
|
339
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
340
|
+
|
|
341
|
+
// Call with different email - should send new event
|
|
342
|
+
MostlyGoodMetrics.identify('user_123', { email: 'changed@example.com' });
|
|
343
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
344
|
+
|
|
345
|
+
const events = await storage.fetchEvents(10);
|
|
346
|
+
const identifyEvents = events.filter((e) => e.name === '$identify');
|
|
347
|
+
expect(identifyEvents.length).toBe(2);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('should not send $identify event with empty profile', async () => {
|
|
351
|
+
MostlyGoodMetrics.resetIdentity(); // Clear any previous identify state
|
|
352
|
+
MostlyGoodMetrics.identify('user_123', {});
|
|
353
|
+
|
|
354
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
355
|
+
|
|
356
|
+
const events = await storage.fetchEvents(10);
|
|
357
|
+
const identifyEvent = events.find((e) => e.name === '$identify');
|
|
358
|
+
expect(identifyEvent).toBeUndefined();
|
|
359
|
+
});
|
|
269
360
|
});
|
|
270
361
|
|
|
271
362
|
describe('resetIdentity', () => {
|
|
@@ -283,6 +374,86 @@ describe('MostlyGoodMetrics', () => {
|
|
|
283
374
|
MostlyGoodMetrics.resetIdentity();
|
|
284
375
|
expect(MostlyGoodMetrics.shared?.userId).toBeNull();
|
|
285
376
|
});
|
|
377
|
+
|
|
378
|
+
it('should keep anonymousId unchanged', () => {
|
|
379
|
+
const originalAnonymousId = MostlyGoodMetrics.shared?.anonymousId;
|
|
380
|
+
MostlyGoodMetrics.resetIdentity();
|
|
381
|
+
const newAnonymousId = MostlyGoodMetrics.shared?.anonymousId;
|
|
382
|
+
expect(newAnonymousId).toBe(originalAnonymousId);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
describe('anonymousId', () => {
|
|
387
|
+
it('should auto-generate anonymousId on init', () => {
|
|
388
|
+
MostlyGoodMetrics.configure({
|
|
389
|
+
apiKey: 'test-key',
|
|
390
|
+
storage,
|
|
391
|
+
networkClient,
|
|
392
|
+
trackAppLifecycleEvents: false,
|
|
393
|
+
});
|
|
394
|
+
expect(MostlyGoodMetrics.shared?.anonymousId).toBeDefined();
|
|
395
|
+
expect(MostlyGoodMetrics.shared?.anonymousId.length).toBeGreaterThan(0);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('should include anonymousId as user_id in events when not identified', async () => {
|
|
399
|
+
MostlyGoodMetrics.configure({
|
|
400
|
+
apiKey: 'test-key',
|
|
401
|
+
storage,
|
|
402
|
+
networkClient,
|
|
403
|
+
trackAppLifecycleEvents: false,
|
|
404
|
+
});
|
|
405
|
+
MostlyGoodMetrics.track('test_event');
|
|
406
|
+
|
|
407
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
408
|
+
|
|
409
|
+
const events = await storage.fetchEvents(1);
|
|
410
|
+
expect(events[0].user_id).toBe(MostlyGoodMetrics.shared?.anonymousId);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('should allow wrapper SDK to override anonymousId', () => {
|
|
414
|
+
const customAnonymousId = 'react-native-device-id-12345';
|
|
415
|
+
MostlyGoodMetrics.configure({
|
|
416
|
+
apiKey: 'test-key',
|
|
417
|
+
storage,
|
|
418
|
+
networkClient,
|
|
419
|
+
trackAppLifecycleEvents: false,
|
|
420
|
+
anonymousId: customAnonymousId,
|
|
421
|
+
});
|
|
422
|
+
expect(MostlyGoodMetrics.shared?.anonymousId).toBe(customAnonymousId);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('should include custom anonymousId as user_id in events', async () => {
|
|
426
|
+
const customAnonymousId = 'react-native-device-id-12345';
|
|
427
|
+
MostlyGoodMetrics.configure({
|
|
428
|
+
apiKey: 'test-key',
|
|
429
|
+
storage,
|
|
430
|
+
networkClient,
|
|
431
|
+
trackAppLifecycleEvents: false,
|
|
432
|
+
anonymousId: customAnonymousId,
|
|
433
|
+
});
|
|
434
|
+
MostlyGoodMetrics.track('test_event');
|
|
435
|
+
|
|
436
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
437
|
+
|
|
438
|
+
const events = await storage.fetchEvents(1);
|
|
439
|
+
expect(events[0].user_id).toBe(customAnonymousId);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('should use identified userId over anonymousId', async () => {
|
|
443
|
+
MostlyGoodMetrics.configure({
|
|
444
|
+
apiKey: 'test-key',
|
|
445
|
+
storage,
|
|
446
|
+
networkClient,
|
|
447
|
+
trackAppLifecycleEvents: false,
|
|
448
|
+
});
|
|
449
|
+
MostlyGoodMetrics.identify('identified_user_123');
|
|
450
|
+
MostlyGoodMetrics.track('test_event');
|
|
451
|
+
|
|
452
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
453
|
+
|
|
454
|
+
const events = await storage.fetchEvents(1);
|
|
455
|
+
expect(events[0].user_id).toBe('identified_user_123');
|
|
456
|
+
});
|
|
286
457
|
});
|
|
287
458
|
|
|
288
459
|
describe('flush', () => {
|
package/src/client.ts
CHANGED
|
@@ -12,10 +12,12 @@ import {
|
|
|
12
12
|
ResolvedConfiguration,
|
|
13
13
|
SystemEvents,
|
|
14
14
|
SystemProperties,
|
|
15
|
+
UserProfile,
|
|
15
16
|
} from './types';
|
|
16
17
|
import {
|
|
17
18
|
delay,
|
|
18
19
|
detectDeviceType,
|
|
20
|
+
generateAnonymousId,
|
|
19
21
|
generateUUID,
|
|
20
22
|
getDeviceModel,
|
|
21
23
|
getISOTimestamp,
|
|
@@ -42,6 +44,7 @@ export class MostlyGoodMetrics {
|
|
|
42
44
|
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
43
45
|
private isFlushingInternal = false;
|
|
44
46
|
private sessionIdValue: string;
|
|
47
|
+
private anonymousIdValue: string;
|
|
45
48
|
private lifecycleSetup = false;
|
|
46
49
|
|
|
47
50
|
/**
|
|
@@ -51,6 +54,13 @@ export class MostlyGoodMetrics {
|
|
|
51
54
|
this.config = resolveConfiguration(config);
|
|
52
55
|
this.sessionIdValue = generateUUID();
|
|
53
56
|
|
|
57
|
+
// Configure cookie settings before initializing anonymous ID
|
|
58
|
+
persistence.configureCookies(config.cookieDomain, config.disableCookies);
|
|
59
|
+
this.anonymousIdValue = persistence.initializeAnonymousId(
|
|
60
|
+
config.anonymousId,
|
|
61
|
+
generateAnonymousId
|
|
62
|
+
);
|
|
63
|
+
|
|
54
64
|
// Set up logging
|
|
55
65
|
setDebugLogging(this.config.enableDebugLogging);
|
|
56
66
|
|
|
@@ -113,9 +123,9 @@ export class MostlyGoodMetrics {
|
|
|
113
123
|
}
|
|
114
124
|
}
|
|
115
125
|
|
|
116
|
-
//
|
|
126
|
+
// =====================================================
|
|
117
127
|
// Static convenience methods (delegate to shared instance)
|
|
118
|
-
//
|
|
128
|
+
// =====================================================
|
|
119
129
|
|
|
120
130
|
/**
|
|
121
131
|
* Track an event with the given name and optional properties.
|
|
@@ -125,10 +135,12 @@ export class MostlyGoodMetrics {
|
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
/**
|
|
128
|
-
* Identify the current user.
|
|
138
|
+
* Identify the current user with optional profile data.
|
|
139
|
+
* @param userId The user's unique identifier
|
|
140
|
+
* @param profile Optional profile data (email, name)
|
|
129
141
|
*/
|
|
130
|
-
static identify(userId: string): void {
|
|
131
|
-
MostlyGoodMetrics.instance?.identify(userId);
|
|
142
|
+
static identify(userId: string, profile?: UserProfile): void {
|
|
143
|
+
MostlyGoodMetrics.instance?.identify(userId, profile);
|
|
132
144
|
}
|
|
133
145
|
|
|
134
146
|
/**
|
|
@@ -201,9 +213,9 @@ export class MostlyGoodMetrics {
|
|
|
201
213
|
return MostlyGoodMetrics.instance?.getSuperProperties() ?? {};
|
|
202
214
|
}
|
|
203
215
|
|
|
204
|
-
//
|
|
216
|
+
// =====================================================
|
|
205
217
|
// Instance properties
|
|
206
|
-
//
|
|
218
|
+
// =====================================================
|
|
207
219
|
|
|
208
220
|
/**
|
|
209
221
|
* Get the current user ID.
|
|
@@ -219,6 +231,13 @@ export class MostlyGoodMetrics {
|
|
|
219
231
|
return this.sessionIdValue;
|
|
220
232
|
}
|
|
221
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Get the anonymous ID (auto-generated UUID, persisted across sessions).
|
|
236
|
+
*/
|
|
237
|
+
get anonymousId(): string {
|
|
238
|
+
return this.anonymousIdValue;
|
|
239
|
+
}
|
|
240
|
+
|
|
222
241
|
/**
|
|
223
242
|
* Check if a flush operation is in progress.
|
|
224
243
|
*/
|
|
@@ -233,9 +252,9 @@ export class MostlyGoodMetrics {
|
|
|
233
252
|
return { ...this.config };
|
|
234
253
|
}
|
|
235
254
|
|
|
236
|
-
//
|
|
255
|
+
// =====================================================
|
|
237
256
|
// Instance methods
|
|
238
|
-
//
|
|
257
|
+
// =====================================================
|
|
239
258
|
|
|
240
259
|
/**
|
|
241
260
|
* Track an event with the given name and optional properties.
|
|
@@ -265,11 +284,13 @@ export class MostlyGoodMetrics {
|
|
|
265
284
|
name,
|
|
266
285
|
client_event_id: generateUUID(),
|
|
267
286
|
timestamp: getISOTimestamp(),
|
|
268
|
-
|
|
269
|
-
|
|
287
|
+
|
|
288
|
+
user_id: this.userId ?? this.anonymousIdValue,
|
|
289
|
+
|
|
290
|
+
session_id: this.sessionIdValue,
|
|
270
291
|
platform: this.config.platform,
|
|
271
|
-
|
|
272
|
-
|
|
292
|
+
app_version: this.config.appVersion || undefined,
|
|
293
|
+
os_version: this.config.osVersion || getOSVersion() || undefined,
|
|
273
294
|
environment: this.config.environment,
|
|
274
295
|
locale: getLocale(),
|
|
275
296
|
timezone: getTimezone(),
|
|
@@ -288,9 +309,14 @@ export class MostlyGoodMetrics {
|
|
|
288
309
|
}
|
|
289
310
|
|
|
290
311
|
/**
|
|
291
|
-
* Identify the current user.
|
|
312
|
+
* Identify the current user with optional profile data.
|
|
313
|
+
* Profile data is sent to the backend via the $identify event.
|
|
314
|
+
* Debouncing: only sends $identify if payload changed or >24h since last send.
|
|
315
|
+
*
|
|
316
|
+
* @param userId The user's unique identifier
|
|
317
|
+
* @param profile Optional profile data (email, name)
|
|
292
318
|
*/
|
|
293
|
-
identify(userId: string): void {
|
|
319
|
+
identify(userId: string, profile?: UserProfile): void {
|
|
294
320
|
if (!userId) {
|
|
295
321
|
logger.warn('identify called with empty userId');
|
|
296
322
|
return;
|
|
@@ -298,14 +324,78 @@ export class MostlyGoodMetrics {
|
|
|
298
324
|
|
|
299
325
|
logger.debug(`Identifying user: ${userId}`);
|
|
300
326
|
persistence.setUserId(userId);
|
|
327
|
+
|
|
328
|
+
// If profile data is provided, check if we should send $identify event
|
|
329
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional truthy check for non-empty strings
|
|
330
|
+
if (profile && (profile.email || profile.name)) {
|
|
331
|
+
this.sendIdentifyEventIfNeeded(userId, profile);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Send $identify event if debounce conditions are met.
|
|
337
|
+
* Only sends if: hash changed OR more than 24 hours since last send.
|
|
338
|
+
*/
|
|
339
|
+
private sendIdentifyEventIfNeeded(userId: string, profile: UserProfile): void {
|
|
340
|
+
// Compute hash of the identify payload
|
|
341
|
+
const payloadString = JSON.stringify({ userId, email: profile.email, name: profile.name });
|
|
342
|
+
const currentHash = this.simpleHash(payloadString);
|
|
343
|
+
|
|
344
|
+
const storedHash = persistence.getIdentifyHash();
|
|
345
|
+
const lastSentAt = persistence.getIdentifyLastSentAt();
|
|
346
|
+
const now = Date.now();
|
|
347
|
+
const twentyFourHoursMs = 24 * 60 * 60 * 1000;
|
|
348
|
+
|
|
349
|
+
const hashChanged = storedHash !== currentHash;
|
|
350
|
+
const expiredTime = !lastSentAt || now - lastSentAt > twentyFourHoursMs;
|
|
351
|
+
|
|
352
|
+
if (hashChanged || expiredTime) {
|
|
353
|
+
logger.debug(
|
|
354
|
+
`Sending $identify event (hashChanged=${hashChanged}, expiredTime=${expiredTime})`
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// Build properties object with only defined values
|
|
358
|
+
const properties: EventProperties = {};
|
|
359
|
+
if (profile.email) {
|
|
360
|
+
properties.email = profile.email;
|
|
361
|
+
}
|
|
362
|
+
if (profile.name) {
|
|
363
|
+
properties.name = profile.name;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Track the $identify event
|
|
367
|
+
this.track(SystemEvents.IDENTIFY, properties);
|
|
368
|
+
|
|
369
|
+
// Update stored hash and timestamp
|
|
370
|
+
persistence.setIdentifyHash(currentHash);
|
|
371
|
+
persistence.setIdentifyLastSentAt(now);
|
|
372
|
+
} else {
|
|
373
|
+
logger.debug('Skipping $identify event (debounced)');
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Simple hash function for debouncing.
|
|
379
|
+
* Uses a basic string hash - not cryptographic, just for comparison.
|
|
380
|
+
*/
|
|
381
|
+
private simpleHash(str: string): string {
|
|
382
|
+
let hash = 0;
|
|
383
|
+
for (let i = 0; i < str.length; i++) {
|
|
384
|
+
const char = str.charCodeAt(i);
|
|
385
|
+
hash = (hash << 5) - hash + char;
|
|
386
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
387
|
+
}
|
|
388
|
+
return hash.toString(16);
|
|
301
389
|
}
|
|
302
390
|
|
|
303
391
|
/**
|
|
304
392
|
* Reset user identity.
|
|
393
|
+
* Clears the user ID and identify debounce state.
|
|
305
394
|
*/
|
|
306
395
|
resetIdentity(): void {
|
|
307
396
|
logger.debug('Resetting user identity');
|
|
308
397
|
persistence.setUserId(null);
|
|
398
|
+
persistence.clearIdentifyState();
|
|
309
399
|
}
|
|
310
400
|
|
|
311
401
|
/**
|
|
@@ -398,9 +488,9 @@ export class MostlyGoodMetrics {
|
|
|
398
488
|
logger.debug('MostlyGoodMetrics instance destroyed');
|
|
399
489
|
}
|
|
400
490
|
|
|
401
|
-
//
|
|
491
|
+
// =====================================================
|
|
402
492
|
// Private methods
|
|
403
|
-
//
|
|
493
|
+
// =====================================================
|
|
404
494
|
|
|
405
495
|
private async checkBatchSize(): Promise<void> {
|
|
406
496
|
const count = await this.storage.eventCount();
|
|
@@ -466,10 +556,12 @@ export class MostlyGoodMetrics {
|
|
|
466
556
|
private buildPayload(events: MGMEvent[]): MGMEventsPayload {
|
|
467
557
|
const context: MGMEventContext = {
|
|
468
558
|
platform: this.config.platform,
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
559
|
+
app_version: this.config.appVersion || undefined,
|
|
560
|
+
os_version: this.config.osVersion || getOSVersion() || undefined,
|
|
561
|
+
|
|
562
|
+
user_id: this.userId ?? this.anonymousIdValue,
|
|
563
|
+
|
|
564
|
+
session_id: this.sessionIdValue,
|
|
473
565
|
environment: this.config.environment,
|
|
474
566
|
locale: getLocale(),
|
|
475
567
|
timezone: getTimezone(),
|
package/src/index.ts
CHANGED
|
@@ -19,8 +19,11 @@
|
|
|
19
19
|
* page: '/checkout',
|
|
20
20
|
* });
|
|
21
21
|
*
|
|
22
|
-
* // Identify users
|
|
23
|
-
* MostlyGoodMetrics.identify('user_123'
|
|
22
|
+
* // Identify users with profile data
|
|
23
|
+
* MostlyGoodMetrics.identify('user_123', {
|
|
24
|
+
* email: 'user@example.com',
|
|
25
|
+
* name: 'Jane Doe',
|
|
26
|
+
* });
|
|
24
27
|
* ```
|
|
25
28
|
*/
|
|
26
29
|
|
|
@@ -43,6 +46,7 @@ export type {
|
|
|
43
46
|
SendResult,
|
|
44
47
|
IEventStorage,
|
|
45
48
|
INetworkClient,
|
|
49
|
+
UserProfile,
|
|
46
50
|
} from './types';
|
|
47
51
|
|
|
48
52
|
// Error class
|
|
@@ -65,6 +69,7 @@ export { FetchNetworkClient, createDefaultNetworkClient } from './network';
|
|
|
65
69
|
|
|
66
70
|
// Utilities (for advanced usage)
|
|
67
71
|
export {
|
|
72
|
+
generateAnonymousId,
|
|
68
73
|
generateUUID,
|
|
69
74
|
getISOTimestamp,
|
|
70
75
|
isValidEventName,
|
package/src/storage.test.ts
CHANGED
|
@@ -322,3 +322,129 @@ describe('PersistenceManager super properties', () => {
|
|
|
322
322
|
expect(props).toEqual({});
|
|
323
323
|
});
|
|
324
324
|
});
|
|
325
|
+
|
|
326
|
+
describe('PersistenceManager anonymous ID (localStorage)', () => {
|
|
327
|
+
beforeEach(() => {
|
|
328
|
+
jest.clearAllMocks();
|
|
329
|
+
(localStorage.getItem as jest.Mock).mockReturnValue(null);
|
|
330
|
+
// Disable cookies for these tests to test localStorage behavior
|
|
331
|
+
persistence.configureCookies(undefined, true);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const generateMockUUID = () => 'mock-uuid-12345';
|
|
335
|
+
|
|
336
|
+
it('should generate anonymous ID if none exists', () => {
|
|
337
|
+
const id = persistence.initializeAnonymousId(undefined, generateMockUUID);
|
|
338
|
+
expect(id).toBe('mock-uuid-12345');
|
|
339
|
+
expect(localStorage.setItem).toHaveBeenCalledWith(
|
|
340
|
+
'mostlygoodmetrics_anonymous_id',
|
|
341
|
+
'mock-uuid-12345'
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should return existing anonymous ID from localStorage', () => {
|
|
346
|
+
(localStorage.getItem as jest.Mock).mockReturnValueOnce('existing-anonymous-id');
|
|
347
|
+
const id = persistence.initializeAnonymousId(undefined, generateMockUUID);
|
|
348
|
+
expect(id).toBe('existing-anonymous-id');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('should use override ID from wrapper SDK', () => {
|
|
352
|
+
const overrideId = 'react-native-device-id';
|
|
353
|
+
const id = persistence.initializeAnonymousId(overrideId, generateMockUUID);
|
|
354
|
+
expect(id).toBe(overrideId);
|
|
355
|
+
expect(localStorage.setItem).toHaveBeenCalledWith('mostlygoodmetrics_anonymous_id', overrideId);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('should prefer override ID over existing persisted ID', () => {
|
|
359
|
+
(localStorage.getItem as jest.Mock).mockReturnValueOnce('existing-anonymous-id');
|
|
360
|
+
const overrideId = 'react-native-device-id';
|
|
361
|
+
const id = persistence.initializeAnonymousId(overrideId, generateMockUUID);
|
|
362
|
+
expect(id).toBe(overrideId);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('should reset anonymous ID with new UUID', () => {
|
|
366
|
+
let callCount = 0;
|
|
367
|
+
const generateNewUUID = () => {
|
|
368
|
+
callCount++;
|
|
369
|
+
return `new-uuid-${callCount}`;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const id1 = persistence.resetAnonymousId(generateNewUUID);
|
|
373
|
+
expect(id1).toBe('new-uuid-1');
|
|
374
|
+
|
|
375
|
+
const id2 = persistence.resetAnonymousId(generateNewUUID);
|
|
376
|
+
expect(id2).toBe('new-uuid-2');
|
|
377
|
+
expect(id2).not.toBe(id1);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('should persist anonymous ID to localStorage', () => {
|
|
381
|
+
persistence.setAnonymousId('test-anonymous-id');
|
|
382
|
+
expect(localStorage.setItem).toHaveBeenCalledWith(
|
|
383
|
+
'mostlygoodmetrics_anonymous_id',
|
|
384
|
+
'test-anonymous-id'
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('should get anonymous ID from localStorage', () => {
|
|
389
|
+
(localStorage.getItem as jest.Mock).mockReturnValueOnce('stored-anonymous-id');
|
|
390
|
+
const id = persistence.getAnonymousId();
|
|
391
|
+
expect(id).toBe('stored-anonymous-id');
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
describe('PersistenceManager anonymous ID (cookies)', () => {
|
|
396
|
+
beforeEach(() => {
|
|
397
|
+
jest.clearAllMocks();
|
|
398
|
+
(localStorage.getItem as jest.Mock).mockReturnValue(null);
|
|
399
|
+
// Clear document.cookie
|
|
400
|
+
document.cookie = 'mostlygoodmetrics_anonymous_id=; path=/; max-age=0';
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const generateMockUUID = () => 'mock-uuid-12345';
|
|
404
|
+
|
|
405
|
+
it('should use cookies when enabled', () => {
|
|
406
|
+
persistence.configureCookies(undefined, false);
|
|
407
|
+
persistence.setAnonymousId('cookie-test-id');
|
|
408
|
+
expect(document.cookie).toContain('mostlygoodmetrics_anonymous_id=cookie-test-id');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('should read from cookies before localStorage', () => {
|
|
412
|
+
persistence.configureCookies(undefined, false);
|
|
413
|
+
document.cookie = 'mostlygoodmetrics_anonymous_id=cookie-id; path=/';
|
|
414
|
+
(localStorage.getItem as jest.Mock).mockReturnValue('localStorage-id');
|
|
415
|
+
|
|
416
|
+
const id = persistence.getAnonymousId();
|
|
417
|
+
expect(id).toBe('cookie-id');
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('should fall back to localStorage when cookie is not set', () => {
|
|
421
|
+
persistence.configureCookies(undefined, false);
|
|
422
|
+
(localStorage.getItem as jest.Mock).mockReturnValue('localStorage-id');
|
|
423
|
+
|
|
424
|
+
const id = persistence.getAnonymousId();
|
|
425
|
+
expect(id).toBe('localStorage-id');
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('should set cookie with custom domain for cross-subdomain support', () => {
|
|
429
|
+
persistence.configureCookies('.example.com', false);
|
|
430
|
+
persistence.setAnonymousId('cross-domain-id');
|
|
431
|
+
// Note: jsdom rejects cookies for non-matching domains, so we verify localStorage fallback
|
|
432
|
+
// In real browsers, the cookie would be set with domain=.example.com
|
|
433
|
+
expect(localStorage.setItem).toHaveBeenCalledWith(
|
|
434
|
+
'mostlygoodmetrics_anonymous_id',
|
|
435
|
+
'cross-domain-id'
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('should not use cookies when disabled', () => {
|
|
440
|
+
persistence.configureCookies(undefined, true);
|
|
441
|
+
persistence.setAnonymousId('no-cookie-id');
|
|
442
|
+
// Cookie should not contain our ID (cleared in beforeEach)
|
|
443
|
+
expect(document.cookie).not.toContain('mostlygoodmetrics_anonymous_id=no-cookie-id');
|
|
444
|
+
// But localStorage should have it
|
|
445
|
+
expect(localStorage.setItem).toHaveBeenCalledWith(
|
|
446
|
+
'mostlygoodmetrics_anonymous_id',
|
|
447
|
+
'no-cookie-id'
|
|
448
|
+
);
|
|
449
|
+
});
|
|
450
|
+
});
|