@mostly-good-metrics/javascript 0.5.0 → 0.6.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/src/client.ts CHANGED
@@ -3,6 +3,8 @@ import { createDefaultNetworkClient } from './network';
3
3
  import { createDefaultStorage, persistence } from './storage';
4
4
  import {
5
5
  EventProperties,
6
+ Experiment,
7
+ ExperimentsResponse,
6
8
  IEventStorage,
7
9
  INetworkClient,
8
10
  MGMConfiguration,
@@ -12,6 +14,7 @@ import {
12
14
  ResolvedConfiguration,
13
15
  SystemEvents,
14
16
  SystemProperties,
17
+ UserProfile,
15
18
  } from './types';
16
19
  import {
17
20
  delay,
@@ -46,6 +49,12 @@ export class MostlyGoodMetrics {
46
49
  private anonymousIdValue: string;
47
50
  private lifecycleSetup = false;
48
51
 
52
+ // A/B testing state
53
+ private experiments: Map<string, Experiment> = new Map();
54
+ private experimentsLoaded = false;
55
+ private experimentsReadyResolve: (() => void) | null = null;
56
+ private experimentsReadyPromise: Promise<void>;
57
+
49
58
  /**
50
59
  * Private constructor - use `configure` to create an instance.
51
60
  */
@@ -69,6 +78,11 @@ export class MostlyGoodMetrics {
69
78
  // Initialize network client
70
79
  this.networkClient = this.config.networkClient ?? createDefaultNetworkClient();
71
80
 
81
+ // Initialize experiments ready promise
82
+ this.experimentsReadyPromise = new Promise((resolve) => {
83
+ this.experimentsReadyResolve = resolve;
84
+ });
85
+
72
86
  logger.info(`MostlyGoodMetrics initialized with environment: ${this.config.environment}`);
73
87
 
74
88
  // Start auto-flush timer
@@ -78,6 +92,9 @@ export class MostlyGoodMetrics {
78
92
  if (this.config.trackAppLifecycleEvents) {
79
93
  this.setupLifecycleTracking();
80
94
  }
95
+
96
+ // Fetch experiments in background
97
+ void this.fetchExperiments();
81
98
  }
82
99
 
83
100
  /**
@@ -134,10 +151,12 @@ export class MostlyGoodMetrics {
134
151
  }
135
152
 
136
153
  /**
137
- * Identify the current user.
154
+ * Identify the current user with optional profile data.
155
+ * @param userId The user's unique identifier
156
+ * @param profile Optional profile data (email, name)
138
157
  */
139
- static identify(userId: string): void {
140
- MostlyGoodMetrics.instance?.identify(userId);
158
+ static identify(userId: string, profile?: UserProfile): void {
159
+ MostlyGoodMetrics.instance?.identify(userId, profile);
141
160
  }
142
161
 
143
162
  /**
@@ -210,6 +229,22 @@ export class MostlyGoodMetrics {
210
229
  return MostlyGoodMetrics.instance?.getSuperProperties() ?? {};
211
230
  }
212
231
 
232
+ /**
233
+ * Get the variant for an experiment.
234
+ * Returns the assigned variant ('a', 'b', etc.) or null if experiment not found.
235
+ */
236
+ static getVariant(experimentName: string): string | null {
237
+ return MostlyGoodMetrics.instance?.getVariant(experimentName) ?? null;
238
+ }
239
+
240
+ /**
241
+ * Returns a promise that resolves when experiments have been loaded.
242
+ * Useful for waiting before calling getVariant() to ensure server-side experiments are available.
243
+ */
244
+ static ready(): Promise<void> {
245
+ return MostlyGoodMetrics.instance?.ready() ?? Promise.resolve();
246
+ }
247
+
213
248
  // =====================================================
214
249
  // Instance properties
215
250
  // =====================================================
@@ -306,9 +341,14 @@ export class MostlyGoodMetrics {
306
341
  }
307
342
 
308
343
  /**
309
- * Identify the current user.
344
+ * Identify the current user with optional profile data.
345
+ * Profile data is sent to the backend via the $identify event.
346
+ * Debouncing: only sends $identify if payload changed or >24h since last send.
347
+ *
348
+ * @param userId The user's unique identifier
349
+ * @param profile Optional profile data (email, name)
310
350
  */
311
- identify(userId: string): void {
351
+ identify(userId: string, profile?: UserProfile): void {
312
352
  if (!userId) {
313
353
  logger.warn('identify called with empty userId');
314
354
  return;
@@ -316,14 +356,78 @@ export class MostlyGoodMetrics {
316
356
 
317
357
  logger.debug(`Identifying user: ${userId}`);
318
358
  persistence.setUserId(userId);
359
+
360
+ // If profile data is provided, check if we should send $identify event
361
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional truthy check for non-empty strings
362
+ if (profile && (profile.email || profile.name)) {
363
+ this.sendIdentifyEventIfNeeded(userId, profile);
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Send $identify event if debounce conditions are met.
369
+ * Only sends if: hash changed OR more than 24 hours since last send.
370
+ */
371
+ private sendIdentifyEventIfNeeded(userId: string, profile: UserProfile): void {
372
+ // Compute hash of the identify payload
373
+ const payloadString = JSON.stringify({ userId, email: profile.email, name: profile.name });
374
+ const currentHash = this.simpleHash(payloadString);
375
+
376
+ const storedHash = persistence.getIdentifyHash();
377
+ const lastSentAt = persistence.getIdentifyLastSentAt();
378
+ const now = Date.now();
379
+ const twentyFourHoursMs = 24 * 60 * 60 * 1000;
380
+
381
+ const hashChanged = storedHash !== currentHash;
382
+ const expiredTime = !lastSentAt || now - lastSentAt > twentyFourHoursMs;
383
+
384
+ if (hashChanged || expiredTime) {
385
+ logger.debug(
386
+ `Sending $identify event (hashChanged=${hashChanged}, expiredTime=${expiredTime})`
387
+ );
388
+
389
+ // Build properties object with only defined values
390
+ const properties: EventProperties = {};
391
+ if (profile.email) {
392
+ properties.email = profile.email;
393
+ }
394
+ if (profile.name) {
395
+ properties.name = profile.name;
396
+ }
397
+
398
+ // Track the $identify event
399
+ this.track(SystemEvents.IDENTIFY, properties);
400
+
401
+ // Update stored hash and timestamp
402
+ persistence.setIdentifyHash(currentHash);
403
+ persistence.setIdentifyLastSentAt(now);
404
+ } else {
405
+ logger.debug('Skipping $identify event (debounced)');
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Simple hash function for debouncing.
411
+ * Uses a basic string hash - not cryptographic, just for comparison.
412
+ */
413
+ private simpleHash(str: string): string {
414
+ let hash = 0;
415
+ for (let i = 0; i < str.length; i++) {
416
+ const char = str.charCodeAt(i);
417
+ hash = (hash << 5) - hash + char;
418
+ hash = hash & hash; // Convert to 32-bit integer
419
+ }
420
+ return hash.toString(16);
319
421
  }
320
422
 
321
423
  /**
322
424
  * Reset user identity.
425
+ * Clears the user ID and identify debounce state.
323
426
  */
324
427
  resetIdentity(): void {
325
428
  logger.debug('Resetting user identity');
326
429
  persistence.setUserId(null);
430
+ persistence.clearIdentifyState();
327
431
  }
328
432
 
329
433
  /**
@@ -407,6 +511,63 @@ export class MostlyGoodMetrics {
407
511
  return persistence.getSuperProperties();
408
512
  }
409
513
 
514
+ /**
515
+ * Get the variant for an experiment.
516
+ * Returns the assigned variant ('a', 'b', etc.) or null if experiment not found.
517
+ *
518
+ * Assignment is deterministic based on userId + experimentName, so the same
519
+ * user always gets the same variant for the same experiment.
520
+ *
521
+ * The variant is automatically stored as a super property (experiment_{name}: 'variant')
522
+ * so it's attached to all subsequent events.
523
+ */
524
+ getVariant(experimentName: string): string | null {
525
+ if (!experimentName) {
526
+ logger.warn('getVariant called with empty experimentName');
527
+ return null;
528
+ }
529
+
530
+ // Check if we have this experiment cached
531
+ const experiment = this.experiments.get(experimentName);
532
+
533
+ if (!experiment) {
534
+ if (this.experimentsLoaded) {
535
+ // Experiments loaded but this one doesn't exist
536
+ logger.debug(`Experiment not found: ${experimentName}`);
537
+ return null;
538
+ }
539
+ // Experiments not loaded yet - can't assign variant without knowing variants
540
+ logger.debug(`Experiments not loaded yet, cannot assign variant for: ${experimentName}`);
541
+ return null;
542
+ }
543
+
544
+ if (!experiment.variants || experiment.variants.length === 0) {
545
+ logger.warn(`Experiment ${experimentName} has no variants`);
546
+ return null;
547
+ }
548
+
549
+ // Get the user ID for deterministic assignment
550
+ const userId = this.userId ?? this.anonymousIdValue;
551
+
552
+ // Compute deterministic variant assignment
553
+ const variant = this.computeVariant(userId, experimentName, experiment.variants);
554
+
555
+ // Store as super property so it's attached to all events
556
+ const propertyName = `experiment_${this.toSnakeCase(experimentName)}`;
557
+ this.setSuperProperty(propertyName, variant);
558
+
559
+ logger.debug(`Assigned variant '${variant}' for experiment '${experimentName}'`);
560
+ return variant;
561
+ }
562
+
563
+ /**
564
+ * Returns a promise that resolves when experiments have been loaded.
565
+ * Useful for waiting before calling getVariant() to ensure server-side experiments are available.
566
+ */
567
+ ready(): Promise<void> {
568
+ return this.experimentsReadyPromise;
569
+ }
570
+
410
571
  /**
411
572
  * Clean up resources (stop timers, etc.).
412
573
  */
@@ -620,4 +781,87 @@ export class MostlyGoodMetrics {
620
781
  // the regular flush mechanism for most events
621
782
  logger.debug('Page unloading, attempting beacon flush');
622
783
  }
784
+
785
+ // =====================================================
786
+ // A/B Testing methods
787
+ // =====================================================
788
+
789
+ /**
790
+ * Fetch experiments from the server.
791
+ * Called automatically during initialization.
792
+ */
793
+ private async fetchExperiments(): Promise<void> {
794
+ const url = `${this.config.baseURL}/v1/experiments`;
795
+
796
+ try {
797
+ logger.debug('Fetching experiments...');
798
+
799
+ const response = await fetch(url, {
800
+ method: 'GET',
801
+ headers: {
802
+ Authorization: `Bearer ${this.config.apiKey}`,
803
+ 'Content-Type': 'application/json',
804
+ },
805
+ });
806
+
807
+ if (!response.ok) {
808
+ logger.warn(`Failed to fetch experiments: ${response.status}`);
809
+ this.markExperimentsReady();
810
+ return;
811
+ }
812
+
813
+ const data = (await response.json()) as ExperimentsResponse;
814
+
815
+ // Cache experiments by ID
816
+ this.experiments.clear();
817
+ for (const experiment of data.experiments || []) {
818
+ this.experiments.set(experiment.id, experiment);
819
+ }
820
+
821
+ logger.debug(`Loaded ${this.experiments.size} experiments`);
822
+ } catch (e) {
823
+ logger.warn('Failed to fetch experiments', e);
824
+ } finally {
825
+ this.markExperimentsReady();
826
+ }
827
+ }
828
+
829
+ /**
830
+ * Mark experiments as loaded and resolve the ready promise.
831
+ */
832
+ private markExperimentsReady(): void {
833
+ this.experimentsLoaded = true;
834
+ if (this.experimentsReadyResolve) {
835
+ this.experimentsReadyResolve();
836
+ this.experimentsReadyResolve = null;
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Compute a deterministic variant assignment based on userId and experimentName.
842
+ * Same user + same experiment = same variant (consistent assignment).
843
+ */
844
+ private computeVariant(userId: string, experimentName: string, variants: string[]): string {
845
+ // Create a deterministic hash from userId + experimentName
846
+ const hashInput = `${userId}:${experimentName}`;
847
+ const hash = this.simpleHash(hashInput);
848
+
849
+ // Convert hash to a positive number and mod by variant count
850
+ const hashNum = Math.abs(parseInt(hash, 16));
851
+ const variantIndex = hashNum % variants.length;
852
+
853
+ return variants[variantIndex];
854
+ }
855
+
856
+ /**
857
+ * Convert a string to snake_case.
858
+ * Used for experiment property names (e.g., "button-color" -> "button_color").
859
+ */
860
+ private toSnakeCase(str: string): string {
861
+ return str
862
+ .replace(/([A-Z])/g, '_$1')
863
+ .replace(/[-\s]+/g, '_')
864
+ .toLowerCase()
865
+ .replace(/^_/, '');
866
+ }
623
867
  }
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,9 @@ export type {
43
46
  SendResult,
44
47
  IEventStorage,
45
48
  INetworkClient,
49
+ UserProfile,
50
+ Experiment,
51
+ ExperimentsResponse,
46
52
  } from './types';
47
53
 
48
54
  // Error class
package/src/storage.ts CHANGED
@@ -6,6 +6,8 @@ const USER_ID_KEY = 'mostlygoodmetrics_user_id';
6
6
  const ANONYMOUS_ID_KEY = 'mostlygoodmetrics_anonymous_id';
7
7
  const APP_VERSION_KEY = 'mostlygoodmetrics_app_version';
8
8
  const SUPER_PROPERTIES_KEY = 'mostlygoodmetrics_super_properties';
9
+ const IDENTIFY_HASH_KEY = 'mostlygoodmetrics_identify_hash';
10
+ const IDENTIFY_TIMESTAMP_KEY = 'mostlygoodmetrics_identify_timestamp';
9
11
 
10
12
  /**
11
13
  * Check if we're running in a browser environment with localStorage available.
@@ -461,6 +463,55 @@ class PersistenceManager {
461
463
  }
462
464
  }
463
465
  }
466
+
467
+ /**
468
+ * Get the stored identify hash (for debouncing).
469
+ */
470
+ getIdentifyHash(): string | null {
471
+ if (isLocalStorageAvailable()) {
472
+ return localStorage.getItem(IDENTIFY_HASH_KEY);
473
+ }
474
+ return null;
475
+ }
476
+
477
+ /**
478
+ * Set the identify hash.
479
+ */
480
+ setIdentifyHash(hash: string): void {
481
+ if (isLocalStorageAvailable()) {
482
+ localStorage.setItem(IDENTIFY_HASH_KEY, hash);
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Get the timestamp of the last identify event sent.
488
+ */
489
+ getIdentifyLastSentAt(): number | null {
490
+ if (isLocalStorageAvailable()) {
491
+ const timestamp = localStorage.getItem(IDENTIFY_TIMESTAMP_KEY);
492
+ return timestamp ? parseInt(timestamp, 10) : null;
493
+ }
494
+ return null;
495
+ }
496
+
497
+ /**
498
+ * Set the timestamp of the last identify event sent.
499
+ */
500
+ setIdentifyLastSentAt(timestamp: number): void {
501
+ if (isLocalStorageAvailable()) {
502
+ localStorage.setItem(IDENTIFY_TIMESTAMP_KEY, timestamp.toString());
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Clear identify debounce state (used when resetting identity).
508
+ */
509
+ clearIdentifyState(): void {
510
+ if (isLocalStorageAvailable()) {
511
+ localStorage.removeItem(IDENTIFY_HASH_KEY);
512
+ localStorage.removeItem(IDENTIFY_TIMESTAMP_KEY);
513
+ }
514
+ }
464
515
  }
465
516
 
466
517
  export const persistence = new PersistenceManager();
package/src/types.ts CHANGED
@@ -389,8 +389,46 @@ export const SystemEvents = {
389
389
  APP_UPDATED: '$app_updated',
390
390
  APP_OPENED: '$app_opened',
391
391
  APP_BACKGROUNDED: '$app_backgrounded',
392
+ IDENTIFY: '$identify',
392
393
  } as const;
393
394
 
395
+ /**
396
+ * User profile data for the identify() call.
397
+ */
398
+ export interface UserProfile {
399
+ /**
400
+ * The user's email address.
401
+ */
402
+ email?: string;
403
+
404
+ /**
405
+ * The user's display name.
406
+ */
407
+ name?: string;
408
+ }
409
+
410
+ /**
411
+ * An experiment configuration from the server.
412
+ */
413
+ export interface Experiment {
414
+ /**
415
+ * The experiment identifier (e.g., "button-color").
416
+ */
417
+ id: string;
418
+
419
+ /**
420
+ * The variants for this experiment (e.g., ["a", "b"]).
421
+ */
422
+ variants: string[];
423
+ }
424
+
425
+ /**
426
+ * Response from the experiments API endpoint.
427
+ */
428
+ export interface ExperimentsResponse {
429
+ experiments: Experiment[];
430
+ }
431
+
394
432
  /**
395
433
  * System property keys (prefixed with $).
396
434
  */