@mostly-good-metrics/javascript 0.6.0 → 0.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/src/client.ts CHANGED
@@ -2,8 +2,8 @@ import { logger, setDebugLogging } from './logger';
2
2
  import { createDefaultNetworkClient } from './network';
3
3
  import { createDefaultStorage, persistence } from './storage';
4
4
  import {
5
+ CachedExperimentVariants,
5
6
  EventProperties,
6
- Experiment,
7
7
  ExperimentsResponse,
8
8
  IEventStorage,
9
9
  INetworkClient,
@@ -32,6 +32,8 @@ import {
32
32
  } from './utils';
33
33
 
34
34
  const FLUSH_DELAY_MS = 100; // Delay between batch sends
35
+ const EXPERIMENTS_CACHE_KEY = 'mgm_experiment_variants';
36
+ const EXPERIMENTS_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
35
37
 
36
38
  /**
37
39
  * Main client for MostlyGoodMetrics.
@@ -50,7 +52,7 @@ export class MostlyGoodMetrics {
50
52
  private lifecycleSetup = false;
51
53
 
52
54
  // A/B testing state
53
- private experiments: Map<string, Experiment> = new Map();
55
+ private assignedVariants: Record<string, string> = {}; // Server-assigned variants
54
56
  private experimentsLoaded = false;
55
57
  private experimentsReadyResolve: (() => void) | null = null;
56
58
  private experimentsReadyPromise: Promise<void>;
@@ -345,6 +347,9 @@ export class MostlyGoodMetrics {
345
347
  * Profile data is sent to the backend via the $identify event.
346
348
  * Debouncing: only sends $identify if payload changed or >24h since last send.
347
349
  *
350
+ * This also invalidates the experiment variants cache and refetches experiments
351
+ * with the new user ID to handle the "identified wins" aliasing strategy.
352
+ *
348
353
  * @param userId The user's unique identifier
349
354
  * @param profile Optional profile data (email, name)
350
355
  */
@@ -354,6 +359,7 @@ export class MostlyGoodMetrics {
354
359
  return;
355
360
  }
356
361
 
362
+ const previousUserId = this.userId ?? this.anonymousIdValue;
357
363
  logger.debug(`Identifying user: ${userId}`);
358
364
  persistence.setUserId(userId);
359
365
 
@@ -362,6 +368,14 @@ export class MostlyGoodMetrics {
362
368
  if (profile && (profile.email || profile.name)) {
363
369
  this.sendIdentifyEventIfNeeded(userId, profile);
364
370
  }
371
+
372
+ // If user ID changed, invalidate experiment cache and refetch
373
+ // This handles the "identified wins" aliasing strategy on the server
374
+ if (previousUserId !== userId) {
375
+ logger.debug('User identity changed, refetching experiments');
376
+ this.invalidateExperimentsCache();
377
+ void this.fetchExperiments();
378
+ }
365
379
  }
366
380
 
367
381
  /**
@@ -515,10 +529,10 @@ export class MostlyGoodMetrics {
515
529
  * Get the variant for an experiment.
516
530
  * Returns the assigned variant ('a', 'b', etc.) or null if experiment not found.
517
531
  *
518
- * Assignment is deterministic based on userId + experimentName, so the same
519
- * user always gets the same variant for the same experiment.
532
+ * Variants are assigned server-side and cached locally. The server ensures
533
+ * the same user always gets the same variant for the same experiment.
520
534
  *
521
- * The variant is automatically stored as a super property (experiment_{name}: 'variant')
535
+ * The variant is automatically stored as a super property ($experiment_{name}: 'variant')
522
536
  * so it's attached to all subsequent events.
523
537
  */
524
538
  getVariant(experimentName: string): string | null {
@@ -527,37 +541,27 @@ export class MostlyGoodMetrics {
527
541
  return null;
528
542
  }
529
543
 
530
- // Check if we have this experiment cached
531
- const experiment = this.experiments.get(experimentName);
544
+ // Check if we have a server-assigned variant for this experiment
545
+ const variant = this.assignedVariants[experimentName];
532
546
 
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;
547
+ if (variant) {
548
+ // Store as super property so it's attached to all events
549
+ const propertyName = `$experiment_${this.toSnakeCase(experimentName)}`;
550
+ this.setSuperProperty(propertyName, variant);
551
+
552
+ logger.debug(`Using server-assigned variant '${variant}' for experiment '${experimentName}'`);
553
+ return variant;
542
554
  }
543
555
 
544
- if (!experiment.variants || experiment.variants.length === 0) {
545
- logger.warn(`Experiment ${experimentName} has no variants`);
556
+ // No assigned variant - check if experiments have been loaded
557
+ if (!this.experimentsLoaded) {
558
+ logger.debug(`Experiments not loaded yet, cannot get variant for: ${experimentName}`);
546
559
  return null;
547
560
  }
548
561
 
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;
562
+ // Experiments loaded but no assignment for this experiment
563
+ logger.debug(`No variant assigned for experiment: ${experimentName}`);
564
+ return null;
561
565
  }
562
566
 
563
567
  /**
@@ -788,10 +792,33 @@ export class MostlyGoodMetrics {
788
792
 
789
793
  /**
790
794
  * Fetch experiments from the server.
791
- * Called automatically during initialization.
795
+ * Called automatically during initialization and after identify().
796
+ *
797
+ * Flow:
798
+ * 1. Check localStorage cache - if valid and same user, use cached variants
799
+ * 2. Otherwise, fetch from server with user_id
800
+ * 3. Store response in memory and localStorage cache
792
801
  */
793
802
  private async fetchExperiments(): Promise<void> {
794
- const url = `${this.config.baseURL}/v1/experiments`;
803
+ const currentUserId = this.userId ?? this.anonymousIdValue;
804
+
805
+ // Check localStorage cache first
806
+ const cached = this.loadExperimentsCache();
807
+ if (cached && cached.userId === currentUserId) {
808
+ const cacheAge = Date.now() - cached.fetchedAt;
809
+ if (cacheAge < EXPERIMENTS_CACHE_TTL_MS) {
810
+ logger.debug(
811
+ `Using cached experiment variants (age: ${Math.round(cacheAge / 1000 / 60)}min)`
812
+ );
813
+ this.assignedVariants = cached.variants;
814
+ this.markExperimentsReady();
815
+ return;
816
+ }
817
+ logger.debug('Cached experiment variants expired, fetching fresh');
818
+ }
819
+
820
+ // Build URL with user_id for server-side variant assignment
821
+ const url = `${this.config.baseURL}/v1/experiments?user_id=${encodeURIComponent(currentUserId)}`;
795
822
 
796
823
  try {
797
824
  logger.debug('Fetching experiments...');
@@ -812,13 +839,13 @@ export class MostlyGoodMetrics {
812
839
 
813
840
  const data = (await response.json()) as ExperimentsResponse;
814
841
 
815
- // Cache experiments by ID
816
- this.experiments.clear();
817
- for (const experiment of data.experiments || []) {
818
- this.experiments.set(experiment.id, experiment);
819
- }
842
+ // Store server-assigned variants
843
+ this.assignedVariants = data.assigned_variants ?? {};
844
+
845
+ // Cache in localStorage
846
+ this.saveExperimentsCache(currentUserId, this.assignedVariants);
820
847
 
821
- logger.debug(`Loaded ${this.experiments.size} experiments`);
848
+ logger.debug(`Loaded ${Object.keys(this.assignedVariants).length} assigned variants`);
822
849
  } catch (e) {
823
850
  logger.warn('Failed to fetch experiments', e);
824
851
  } finally {
@@ -838,19 +865,68 @@ export class MostlyGoodMetrics {
838
865
  }
839
866
 
840
867
  /**
841
- * Compute a deterministic variant assignment based on userId and experimentName.
842
- * Same user + same experiment = same variant (consistent assignment).
868
+ * Load experiment variants from localStorage cache.
869
+ */
870
+ private loadExperimentsCache(): CachedExperimentVariants | null {
871
+ if (typeof localStorage === 'undefined') {
872
+ return null;
873
+ }
874
+
875
+ try {
876
+ const cached = localStorage.getItem(EXPERIMENTS_CACHE_KEY);
877
+ if (!cached) {
878
+ return null;
879
+ }
880
+ return JSON.parse(cached) as CachedExperimentVariants;
881
+ } catch (e) {
882
+ logger.debug('Failed to load experiments cache', e);
883
+ return null;
884
+ }
885
+ }
886
+
887
+ /**
888
+ * Save experiment variants to localStorage cache.
843
889
  */
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);
890
+ private saveExperimentsCache(userId: string, variants: Record<string, string>): void {
891
+ if (typeof localStorage === 'undefined') {
892
+ return;
893
+ }
848
894
 
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;
895
+ try {
896
+ const cached: CachedExperimentVariants = {
897
+ userId,
898
+ variants,
899
+ fetchedAt: Date.now(),
900
+ };
901
+ localStorage.setItem(EXPERIMENTS_CACHE_KEY, JSON.stringify(cached));
902
+ } catch (e) {
903
+ logger.debug('Failed to save experiments cache', e);
904
+ }
905
+ }
906
+
907
+ /**
908
+ * Invalidate (clear) the localStorage experiments cache.
909
+ * Called when user identity changes.
910
+ */
911
+ private invalidateExperimentsCache(): void {
912
+ // Clear in-memory state
913
+ this.assignedVariants = {};
914
+ this.experimentsLoaded = false;
852
915
 
853
- return variants[variantIndex];
916
+ // Reset the ready promise for the new fetch
917
+ this.experimentsReadyPromise = new Promise((resolve) => {
918
+ this.experimentsReadyResolve = resolve;
919
+ });
920
+
921
+ // Clear localStorage cache
922
+ if (typeof localStorage !== 'undefined') {
923
+ try {
924
+ localStorage.removeItem(EXPERIMENTS_CACHE_KEY);
925
+ logger.debug('Invalidated experiments cache');
926
+ } catch (e) {
927
+ logger.debug('Failed to invalidate experiments cache', e);
928
+ }
929
+ }
854
930
  }
855
931
 
856
932
  /**
package/src/types.ts CHANGED
@@ -168,7 +168,7 @@ export type EventPropertyValue =
168
168
  */
169
169
  export interface MGMEvent {
170
170
  /**
171
- * The name of the event. Must match pattern: ^$?[a-zA-Z][a-zA-Z0-9_]*$
171
+ * The name of the event. Must match pattern: ^$?[a-zA-Z][a-zA-Z0-9_]*( [a-zA-Z0-9_]+)*$
172
172
  * Max 255 characters.
173
173
  */
174
174
  name: string;
@@ -423,10 +423,35 @@ export interface Experiment {
423
423
  }
424
424
 
425
425
  /**
426
- * Response from the experiments API endpoint.
426
+ * Response from the experiments API endpoint when user_id is provided.
427
+ * Contains only the assigned variants for the user.
427
428
  */
428
429
  export interface ExperimentsResponse {
429
- experiments: Experiment[];
430
+ /**
431
+ * Server-assigned variants for the user.
432
+ * Maps experiment ID to the assigned variant (e.g., {"button-color": "a"}).
433
+ */
434
+ assigned_variants: Record<string, string>;
435
+ }
436
+
437
+ /**
438
+ * Cached experiment variants stored in localStorage.
439
+ */
440
+ export interface CachedExperimentVariants {
441
+ /**
442
+ * The user ID these variants are assigned to.
443
+ */
444
+ userId: string;
445
+
446
+ /**
447
+ * Map of experiment ID to assigned variant.
448
+ */
449
+ variants: Record<string, string>;
450
+
451
+ /**
452
+ * Timestamp when the cache was created (ms since epoch).
453
+ */
454
+ fetchedAt: number;
430
455
  }
431
456
 
432
457
  /**
@@ -470,6 +495,6 @@ export const Constraints = {
470
495
 
471
496
  /**
472
497
  * Regular expression for validating event names.
473
- * Must start with a letter (or $ for system events) followed by alphanumeric and underscores.
498
+ * Must start with a letter (or $ for system events) followed by alphanumeric, underscores, or spaces.
474
499
  */
475
- export const EVENT_NAME_REGEX = /^\$?[a-zA-Z][a-zA-Z0-9_]*$/;
500
+ export const EVENT_NAME_REGEX = /^\$?[a-zA-Z][a-zA-Z0-9_]*( [a-zA-Z0-9_]+)*$/;
package/src/utils.test.ts CHANGED
@@ -72,6 +72,8 @@ describe('isValidEventName', () => {
72
72
  expect(isValidEventName('event123')).toBe(true);
73
73
  expect(isValidEventName('a')).toBe(true);
74
74
  expect(isValidEventName('ABC_123_xyz')).toBe(true);
75
+ expect(isValidEventName('Button Clicked')).toBe(true);
76
+ expect(isValidEventName('User Signed Up')).toBe(true);
75
77
  });
76
78
 
77
79
  it('should accept system event names (starting with $)', () => {
@@ -86,7 +88,6 @@ describe('isValidEventName', () => {
86
88
  expect(isValidEventName('_event')).toBe(false); // starts with underscore
87
89
  expect(isValidEventName('event-name')).toBe(false); // contains hyphen
88
90
  expect(isValidEventName('event.name')).toBe(false); // contains dot
89
- expect(isValidEventName('event name')).toBe(false); // contains space
90
91
  expect(isValidEventName('event@name')).toBe(false); // contains @
91
92
  });
92
93
 
@@ -103,6 +104,7 @@ describe('validateEventName', () => {
103
104
  it('should not throw for valid event names', () => {
104
105
  expect(() => validateEventName('valid_event')).not.toThrow();
105
106
  expect(() => validateEventName('$system_event')).not.toThrow();
107
+ expect(() => validateEventName('Button Clicked')).not.toThrow();
106
108
  });
107
109
 
108
110
  it('should throw MGMError for empty event names', () => {
package/src/utils.ts CHANGED
@@ -67,7 +67,7 @@ export function getISOTimestamp(): string {
67
67
 
68
68
  /**
69
69
  * Validate an event name.
70
- * Must match pattern: ^$?[a-zA-Z][a-zA-Z0-9_]*$
70
+ * Must match pattern: ^$?[a-zA-Z][a-zA-Z0-9_]*( [a-zA-Z0-9_]+)*$
71
71
  * Max 255 characters.
72
72
  */
73
73
  export function isValidEventName(name: string): boolean {
@@ -95,7 +95,7 @@ export function validateEventName(name: string): void {
95
95
  if (!EVENT_NAME_REGEX.test(name)) {
96
96
  throw new MGMError(
97
97
  'INVALID_EVENT_NAME',
98
- 'Event name must start with a letter (or $ for system events) and contain only alphanumeric characters and underscores'
98
+ 'Event name must start with a letter (or $ for system events) and contain only alphanumeric characters, underscores, and spaces'
99
99
  );
100
100
  }
101
101
  }