@mostly-good-metrics/javascript 0.5.1 → 0.6.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/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,
@@ -47,6 +49,12 @@ export class MostlyGoodMetrics {
47
49
  private anonymousIdValue: string;
48
50
  private lifecycleSetup = false;
49
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
+
50
58
  /**
51
59
  * Private constructor - use `configure` to create an instance.
52
60
  */
@@ -70,6 +78,11 @@ export class MostlyGoodMetrics {
70
78
  // Initialize network client
71
79
  this.networkClient = this.config.networkClient ?? createDefaultNetworkClient();
72
80
 
81
+ // Initialize experiments ready promise
82
+ this.experimentsReadyPromise = new Promise((resolve) => {
83
+ this.experimentsReadyResolve = resolve;
84
+ });
85
+
73
86
  logger.info(`MostlyGoodMetrics initialized with environment: ${this.config.environment}`);
74
87
 
75
88
  // Start auto-flush timer
@@ -79,6 +92,9 @@ export class MostlyGoodMetrics {
79
92
  if (this.config.trackAppLifecycleEvents) {
80
93
  this.setupLifecycleTracking();
81
94
  }
95
+
96
+ // Fetch experiments in background
97
+ void this.fetchExperiments();
82
98
  }
83
99
 
84
100
  /**
@@ -213,6 +229,22 @@ export class MostlyGoodMetrics {
213
229
  return MostlyGoodMetrics.instance?.getSuperProperties() ?? {};
214
230
  }
215
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
+
216
248
  // =====================================================
217
249
  // Instance properties
218
250
  // =====================================================
@@ -479,6 +511,63 @@ export class MostlyGoodMetrics {
479
511
  return persistence.getSuperProperties();
480
512
  }
481
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
+
482
571
  /**
483
572
  * Clean up resources (stop timers, etc.).
484
573
  */
@@ -692,4 +781,87 @@ export class MostlyGoodMetrics {
692
781
  // the regular flush mechanism for most events
693
782
  logger.debug('Page unloading, attempting beacon flush');
694
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
+ }
695
867
  }
package/src/index.ts CHANGED
@@ -47,6 +47,8 @@ export type {
47
47
  IEventStorage,
48
48
  INetworkClient,
49
49
  UserProfile,
50
+ Experiment,
51
+ ExperimentsResponse,
50
52
  } from './types';
51
53
 
52
54
  // Error class
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;
@@ -407,6 +407,28 @@ export interface UserProfile {
407
407
  name?: string;
408
408
  }
409
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
+
410
432
  /**
411
433
  * System property keys (prefixed with $).
412
434
  */
@@ -448,6 +470,6 @@ export const Constraints = {
448
470
 
449
471
  /**
450
472
  * Regular expression for validating event names.
451
- * Must start with a letter (or $ for system events) followed by alphanumeric and underscores.
473
+ * Must start with a letter (or $ for system events) followed by alphanumeric, underscores, or spaces.
452
474
  */
453
- export const EVENT_NAME_REGEX = /^\$?[a-zA-Z][a-zA-Z0-9_]*$/;
475
+ 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
  }