@mostly-good-metrics/javascript 0.6.1 → 0.8.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
@@ -1,11 +1,12 @@
1
1
  import { logger, setDebugLogging } from './logger';
2
2
  import { createDefaultNetworkClient } from './network';
3
- import { createDefaultStorage, persistence } from './storage';
3
+ import { createDefaultExperimentStorage, createDefaultStorage, persistence } from './storage';
4
4
  import {
5
+ CachedExperimentVariants,
5
6
  EventProperties,
6
- Experiment,
7
7
  ExperimentsResponse,
8
8
  IEventStorage,
9
+ IExperimentStorage,
9
10
  INetworkClient,
10
11
  MGMConfiguration,
11
12
  MGMEvent,
@@ -32,6 +33,11 @@ import {
32
33
  } from './utils';
33
34
 
34
35
  const FLUSH_DELAY_MS = 100; // Delay between batch sends
36
+ const EXPERIMENTS_CACHE_KEY = 'mgm_experiment_variants';
37
+ const EXPERIMENT_EXPOSURES_KEY = 'mgm_experiment_exposures';
38
+ const EXPERIMENTS_REFETCH_INTERVAL_MS = 60 * 60 * 1000; // Background revalidation at most hourly
39
+ const EXPERIMENTS_FETCH_TIMEOUT_MS = 60 * 1000; // Abort hung experiments fetches so they always settle
40
+ const READY_DEFAULT_TIMEOUT_MS = 5000; // Default ready() timeout, unified across all MGM SDKs
35
41
 
36
42
  /**
37
43
  * Main client for MostlyGoodMetrics.
@@ -50,10 +56,12 @@ export class MostlyGoodMetrics {
50
56
  private lifecycleSetup = false;
51
57
 
52
58
  // A/B testing state
53
- private experiments: Map<string, Experiment> = new Map();
59
+ private assignedVariants: Record<string, string> = {}; // Server-assigned variants
60
+ private experimentStorage: IExperimentStorage;
54
61
  private experimentsLoaded = false;
55
62
  private experimentsReadyResolve: (() => void) | null = null;
56
63
  private experimentsReadyPromise: Promise<void>;
64
+ private trackedExposures = new Set<string>(); // (user, experiment, variant) exposure dedup
57
65
 
58
66
  /**
59
67
  * Private constructor - use `configure` to create an instance.
@@ -78,6 +86,9 @@ export class MostlyGoodMetrics {
78
86
  // Initialize network client
79
87
  this.networkClient = this.config.networkClient ?? createDefaultNetworkClient();
80
88
 
89
+ // Initialize experiment storage (pluggable for React Native AsyncStorage etc.)
90
+ this.experimentStorage = this.config.experimentStorage ?? createDefaultExperimentStorage();
91
+
81
92
  // Initialize experiments ready promise
82
93
  this.experimentsReadyPromise = new Promise((resolve) => {
83
94
  this.experimentsReadyResolve = resolve;
@@ -93,8 +104,11 @@ export class MostlyGoodMetrics {
93
104
  this.setupLifecycleTracking();
94
105
  }
95
106
 
96
- // Fetch experiments in background
97
- void this.fetchExperiments();
107
+ // Hydrate the experiments cache and revalidate in the background
108
+ void this.initializeExperiments().catch((e) => {
109
+ logger.warn('Failed to initialize experiments', e);
110
+ this.markExperimentsReady();
111
+ });
98
112
  }
99
113
 
100
114
  /**
@@ -231,18 +245,23 @@ export class MostlyGoodMetrics {
231
245
 
232
246
  /**
233
247
  * Get the variant for an experiment.
234
- * Returns the assigned variant ('a', 'b', etc.) or null if experiment not found.
248
+ * Returns the assigned variant ('a', 'b', etc.), or `fallback` (default null)
249
+ * if the experiment is unknown or experiments have not loaded yet.
235
250
  */
236
- static getVariant(experimentName: string): string | null {
237
- return MostlyGoodMetrics.instance?.getVariant(experimentName) ?? null;
251
+ static getVariant(experimentName: string, fallback: string | null = null): string | null {
252
+ const instance = MostlyGoodMetrics.instance;
253
+ return instance ? instance.getVariant(experimentName, fallback) : fallback;
238
254
  }
239
255
 
240
256
  /**
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.
257
+ * Returns a promise that resolves when experiments have been loaded, or
258
+ * after `timeoutMs` (default 5000) elapses - whichever comes first.
259
+ * Never rejects. Useful for waiting before calling getVariant() to ensure
260
+ * server-side experiments are available without blocking app startup on a
261
+ * hanging network.
243
262
  */
244
- static ready(): Promise<void> {
245
- return MostlyGoodMetrics.instance?.ready() ?? Promise.resolve();
263
+ static ready(timeoutMs: number = READY_DEFAULT_TIMEOUT_MS): Promise<void> {
264
+ return MostlyGoodMetrics.instance?.ready(timeoutMs) ?? Promise.resolve();
246
265
  }
247
266
 
248
267
  // =====================================================
@@ -345,6 +364,9 @@ export class MostlyGoodMetrics {
345
364
  * Profile data is sent to the backend via the $identify event.
346
365
  * Debouncing: only sends $identify if payload changed or >24h since last send.
347
366
  *
367
+ * This also invalidates the experiment variants cache and refetches experiments
368
+ * with the new user ID to handle the "identified wins" aliasing strategy.
369
+ *
348
370
  * @param userId The user's unique identifier
349
371
  * @param profile Optional profile data (email, name)
350
372
  */
@@ -354,6 +376,7 @@ export class MostlyGoodMetrics {
354
376
  return;
355
377
  }
356
378
 
379
+ const previousUserId = this.userId ?? this.anonymousIdValue;
357
380
  logger.debug(`Identifying user: ${userId}`);
358
381
  persistence.setUserId(userId);
359
382
 
@@ -362,6 +385,17 @@ export class MostlyGoodMetrics {
362
385
  if (profile && (profile.email || profile.name)) {
363
386
  this.sendIdentifyEventIfNeeded(userId, profile);
364
387
  }
388
+
389
+ // If user ID changed, refetch experiments immediately for the new user.
390
+ // Current in-memory variants keep being served until the response arrives,
391
+ // then they are swapped atomically - never cleared to null mid-session.
392
+ // The refetch includes anonymous_id so the server can honor the
393
+ // "identified wins" aliasing strategy.
394
+ if (previousUserId !== userId) {
395
+ logger.debug('User identity changed, refetching experiments');
396
+ this.resetExperimentsReadyPromise();
397
+ void this.fetchExperiments();
398
+ }
365
399
  }
366
400
 
367
401
  /**
@@ -513,59 +547,67 @@ export class MostlyGoodMetrics {
513
547
 
514
548
  /**
515
549
  * Get the variant for an experiment.
516
- * Returns the assigned variant ('a', 'b', etc.) or null if experiment not found.
550
+ * Returns the assigned variant ('a', 'b', etc.), or `fallback` (default null)
551
+ * if the experiment is unknown or experiments have not loaded yet.
517
552
  *
518
- * Assignment is deterministic based on userId + experimentName, so the same
519
- * user always gets the same variant for the same experiment.
553
+ * Variants are assigned server-side and cached locally (forever, per user,
554
+ * with stale-while-revalidate background refreshes). The server ensures the
555
+ * same user always gets the same variant for the same experiment.
520
556
  *
521
- * The variant is automatically stored as a super property (experiment_{name}: 'variant')
522
- * so it's attached to all subsequent events.
557
+ * On a hit, the variant is stored as a super property
558
+ * ($experiment_{name}: 'variant') so it's attached to all subsequent events,
559
+ * and a $experiment_exposure event is tracked once per
560
+ * (user, experiment, variant).
523
561
  */
524
- getVariant(experimentName: string): string | null {
562
+ getVariant(experimentName: string, fallback: string | null = null): string | null {
525
563
  if (!experimentName) {
526
564
  logger.warn('getVariant called with empty experimentName');
527
- return null;
565
+ return fallback;
528
566
  }
529
567
 
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
- }
568
+ // Check if we have a server-assigned variant for this experiment
569
+ const variant = this.assignedVariants[experimentName];
543
570
 
544
- if (!experiment.variants || experiment.variants.length === 0) {
545
- logger.warn(`Experiment ${experimentName} has no variants`);
546
- return null;
547
- }
571
+ if (variant) {
572
+ // Store as super property so it's attached to all events
573
+ const propertyName = `$experiment_${this.toSnakeCase(experimentName)}`;
574
+ this.setSuperProperty(propertyName, variant);
548
575
 
549
- // Get the user ID for deterministic assignment
550
- const userId = this.userId ?? this.anonymousIdValue;
576
+ // Track exposure once per (user, experiment, variant)
577
+ this.trackExposureIfNeeded(experimentName, variant);
551
578
 
552
- // Compute deterministic variant assignment
553
- const variant = this.computeVariant(userId, experimentName, experiment.variants);
579
+ logger.debug(`Using server-assigned variant '${variant}' for experiment '${experimentName}'`);
580
+ return variant;
581
+ }
554
582
 
555
- // Store as super property so it's attached to all events
556
- const propertyName = `experiment_${this.toSnakeCase(experimentName)}`;
557
- this.setSuperProperty(propertyName, variant);
583
+ // No assigned variant - check if experiments have been loaded
584
+ if (!this.experimentsLoaded) {
585
+ logger.debug(`Experiments not loaded yet, cannot get variant for: ${experimentName}`);
586
+ return fallback;
587
+ }
558
588
 
559
- logger.debug(`Assigned variant '${variant}' for experiment '${experimentName}'`);
560
- return variant;
589
+ // Experiments loaded but no assignment for this experiment
590
+ logger.debug(`No variant assigned for experiment: ${experimentName}`);
591
+ return fallback;
561
592
  }
562
593
 
563
594
  /**
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.
595
+ * Returns a promise that resolves when experiments have been loaded, or
596
+ * after `timeoutMs` (default 5000) elapses - whichever comes first.
597
+ * Never rejects.
598
+ *
599
+ * If the timeout fires first, getVariant() keeps returning fallbacks until
600
+ * the in-flight fetch settles; a late response is still applied atomically,
601
+ * so variants become available to subsequent calls.
566
602
  */
567
- ready(): Promise<void> {
568
- return this.experimentsReadyPromise;
603
+ ready(timeoutMs: number = READY_DEFAULT_TIMEOUT_MS): Promise<void> {
604
+ return new Promise((resolve) => {
605
+ const timer = setTimeout(resolve, timeoutMs);
606
+ void this.experimentsReadyPromise.then(() => {
607
+ clearTimeout(timer);
608
+ resolve();
609
+ });
610
+ });
569
611
  }
570
612
 
571
613
  /**
@@ -787,11 +829,70 @@ export class MostlyGoodMetrics {
787
829
  // =====================================================
788
830
 
789
831
  /**
790
- * Fetch experiments from the server.
791
- * Called automatically during initialization.
832
+ * Initialize experiments on startup.
833
+ *
834
+ * Flow (stale-while-revalidate, no TTL):
835
+ * 1. Hydrate exposure dedup flags and the variants cache from the
836
+ * (possibly async) experiment storage adapter.
837
+ * 2. If cached variants exist for the current user, serve them immediately
838
+ * (ready() resolves) and revalidate in the background - but only if the
839
+ * last fetch was more than an hour ago.
840
+ * 3. If there is no usable cache, fetch from the server (ready() resolves
841
+ * when the fetch settles, even on failure).
842
+ */
843
+ private async initializeExperiments(): Promise<void> {
844
+ const currentUserId = this.userId ?? this.anonymousIdValue;
845
+
846
+ // Hydrate exposure flags first so exposure dedup survives restarts
847
+ await this.hydrateExposures();
848
+
849
+ const cached = await this.loadExperimentsCache();
850
+ if (cached && cached.userId === currentUserId) {
851
+ const cacheAge = Date.now() - cached.fetchedAt;
852
+ logger.debug(
853
+ `Using cached experiment variants (age: ${Math.round(cacheAge / 1000 / 60)}min)`
854
+ );
855
+
856
+ // Serve cached variants immediately - they never expire
857
+ this.assignedVariants = cached.variants;
858
+ this.markExperimentsReady();
859
+
860
+ // Revalidate in the background, throttled to at most once per hour
861
+ if (cacheAge >= EXPERIMENTS_REFETCH_INTERVAL_MS) {
862
+ logger.debug('Cached experiment variants are stale, revalidating in background');
863
+ void this.fetchExperiments();
864
+ }
865
+ return;
866
+ }
867
+
868
+ // No usable cache - fetch from the server
869
+ await this.fetchExperiments();
870
+ }
871
+
872
+ /**
873
+ * Fetch experiments from the server and atomically swap in the response.
874
+ * Called on initialization (when no fresh cache exists) and after identify().
875
+ * On failure, previously loaded variants keep being served.
876
+ *
877
+ * The request is aborted after EXPERIMENTS_FETCH_TIMEOUT_MS so it is
878
+ * guaranteed to settle. A response that arrives after a ready() timeout is
879
+ * still applied atomically - late variants are better than none.
792
880
  */
793
881
  private async fetchExperiments(): Promise<void> {
794
- const url = `${this.config.baseURL}/v1/experiments`;
882
+ const currentUserId = this.userId ?? this.anonymousIdValue;
883
+
884
+ // Build URL with user_id for server-side variant assignment.
885
+ // When the user is identified, also pass the stored anonymous_id so the
886
+ // server can alias pre-identify assignments ("identified wins", MGM-28).
887
+ let url = `${this.config.baseURL}/v1/experiments?user_id=${encodeURIComponent(currentUserId)}`;
888
+ if (currentUserId !== this.anonymousIdValue) {
889
+ url += `&anonymous_id=${encodeURIComponent(this.anonymousIdValue)}`;
890
+ }
891
+
892
+ // Abort the request if it hangs so this promise is guaranteed to settle
893
+ // (and markExperimentsReady() is guaranteed to run).
894
+ const abortController = new AbortController();
895
+ const abortTimer = setTimeout(() => abortController.abort(), EXPERIMENTS_FETCH_TIMEOUT_MS);
795
896
 
796
897
  try {
797
898
  logger.debug('Fetching experiments...');
@@ -802,26 +903,27 @@ export class MostlyGoodMetrics {
802
903
  Authorization: `Bearer ${this.config.apiKey}`,
803
904
  'Content-Type': 'application/json',
804
905
  },
906
+ signal: abortController.signal,
805
907
  });
806
908
 
807
909
  if (!response.ok) {
808
910
  logger.warn(`Failed to fetch experiments: ${response.status}`);
809
- this.markExperimentsReady();
810
911
  return;
811
912
  }
812
913
 
813
914
  const data = (await response.json()) as ExperimentsResponse;
814
915
 
815
- // Cache experiments by ID
816
- this.experiments.clear();
817
- for (const experiment of data.experiments || []) {
818
- this.experiments.set(experiment.id, experiment);
819
- }
916
+ // Atomically swap in the server-assigned variants
917
+ this.assignedVariants = data.assigned_variants ?? {};
820
918
 
821
- logger.debug(`Loaded ${this.experiments.size} experiments`);
919
+ // Persist to the experiment storage adapter (with last-fetch timestamp)
920
+ await this.saveExperimentsCache(currentUserId, this.assignedVariants);
921
+
922
+ logger.debug(`Loaded ${Object.keys(this.assignedVariants).length} assigned variants`);
822
923
  } catch (e) {
823
924
  logger.warn('Failed to fetch experiments', e);
824
925
  } finally {
926
+ clearTimeout(abortTimer);
825
927
  this.markExperimentsReady();
826
928
  }
827
929
  }
@@ -838,19 +940,108 @@ export class MostlyGoodMetrics {
838
940
  }
839
941
 
840
942
  /**
841
- * Compute a deterministic variant assignment based on userId and experimentName.
842
- * Same user + same experiment = same variant (consistent assignment).
943
+ * Arm a new ready() promise for an in-flight refetch (e.g. after identify).
944
+ * No-op if the current promise is still pending - it will be resolved by
945
+ * whichever fetch settles first.
843
946
  */
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);
947
+ private resetExperimentsReadyPromise(): void {
948
+ if (this.experimentsReadyResolve !== null) {
949
+ return;
950
+ }
951
+ this.experimentsReadyPromise = new Promise((resolve) => {
952
+ this.experimentsReadyResolve = resolve;
953
+ });
954
+ }
955
+
956
+ /**
957
+ * Track a $experiment_exposure event the first time a (user, experiment,
958
+ * variant) combination is returned by getVariant(). Dedup flags are
959
+ * persisted via the experiment storage adapter so exposures are not
960
+ * re-tracked across restarts.
961
+ */
962
+ private trackExposureIfNeeded(experimentName: string, variant: string): void {
963
+ const currentUserId = this.userId ?? this.anonymousIdValue;
964
+ const exposureKey = `${currentUserId}::${experimentName}::${variant}`;
965
+
966
+ if (this.trackedExposures.has(exposureKey)) {
967
+ return;
968
+ }
969
+ this.trackedExposures.add(exposureKey);
970
+
971
+ this.track(SystemEvents.EXPERIMENT_EXPOSURE, {
972
+ [SystemProperties.EXPERIMENT_NAME]: experimentName,
973
+ [SystemProperties.VARIANT]: variant,
974
+ });
975
+
976
+ void this.persistExposures();
977
+ }
848
978
 
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;
979
+ /**
980
+ * Hydrate persisted exposure dedup flags from the experiment storage adapter.
981
+ */
982
+ private async hydrateExposures(): Promise<void> {
983
+ try {
984
+ const raw = await this.experimentStorage.getItem(EXPERIMENT_EXPOSURES_KEY);
985
+ if (raw) {
986
+ const parsed = JSON.parse(raw) as unknown;
987
+ if (Array.isArray(parsed)) {
988
+ this.trackedExposures = new Set(parsed.filter((k): k is string => typeof k === 'string'));
989
+ }
990
+ }
991
+ } catch (e) {
992
+ logger.debug('Failed to load experiment exposures', e);
993
+ }
994
+ }
852
995
 
853
- return variants[variantIndex];
996
+ /**
997
+ * Persist exposure dedup flags via the experiment storage adapter.
998
+ */
999
+ private async persistExposures(): Promise<void> {
1000
+ try {
1001
+ await this.experimentStorage.setItem(
1002
+ EXPERIMENT_EXPOSURES_KEY,
1003
+ JSON.stringify([...this.trackedExposures])
1004
+ );
1005
+ } catch (e) {
1006
+ logger.debug('Failed to persist experiment exposures', e);
1007
+ }
1008
+ }
1009
+
1010
+ /**
1011
+ * Load experiment variants from the experiment storage adapter.
1012
+ */
1013
+ private async loadExperimentsCache(): Promise<CachedExperimentVariants | null> {
1014
+ try {
1015
+ const cached = await this.experimentStorage.getItem(EXPERIMENTS_CACHE_KEY);
1016
+ if (!cached) {
1017
+ return null;
1018
+ }
1019
+ return JSON.parse(cached) as CachedExperimentVariants;
1020
+ } catch (e) {
1021
+ logger.debug('Failed to load experiments cache', e);
1022
+ return null;
1023
+ }
1024
+ }
1025
+
1026
+ /**
1027
+ * Save experiment variants via the experiment storage adapter.
1028
+ * `fetchedAt` doubles as the last-fetch timestamp used to throttle
1029
+ * background revalidation - the cache itself never expires.
1030
+ */
1031
+ private async saveExperimentsCache(
1032
+ userId: string,
1033
+ variants: Record<string, string>
1034
+ ): Promise<void> {
1035
+ try {
1036
+ const cached: CachedExperimentVariants = {
1037
+ userId,
1038
+ variants,
1039
+ fetchedAt: Date.now(),
1040
+ };
1041
+ await this.experimentStorage.setItem(EXPERIMENTS_CACHE_KEY, JSON.stringify(cached));
1042
+ } catch (e) {
1043
+ logger.debug('Failed to save experiments cache', e);
1044
+ }
854
1045
  }
855
1046
 
856
1047
  /**
package/src/index.ts CHANGED
@@ -45,6 +45,7 @@ export type {
45
45
  MGMErrorType,
46
46
  SendResult,
47
47
  IEventStorage,
48
+ IExperimentStorage,
48
49
  INetworkClient,
49
50
  UserProfile,
50
51
  Experiment,
@@ -64,7 +65,14 @@ export {
64
65
  } from './types';
65
66
 
66
67
  // Storage implementations (for custom storage adapters)
67
- export { InMemoryEventStorage, LocalStorageEventStorage, createDefaultStorage } from './storage';
68
+ export {
69
+ InMemoryEventStorage,
70
+ LocalStorageEventStorage,
71
+ createDefaultStorage,
72
+ InMemoryExperimentStorage,
73
+ LocalStorageExperimentStorage,
74
+ createDefaultExperimentStorage,
75
+ } from './storage';
68
76
 
69
77
  // Network client (for custom network implementations)
70
78
  export { FetchNetworkClient, createDefaultNetworkClient } from './network';
package/src/storage.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import { logger } from './logger';
2
- import { Constraints, EventProperties, IEventStorage, MGMError, MGMEvent } from './types';
2
+ import {
3
+ Constraints,
4
+ EventProperties,
5
+ IEventStorage,
6
+ IExperimentStorage,
7
+ MGMError,
8
+ MGMEvent,
9
+ } from './types';
3
10
 
4
11
  const STORAGE_KEY = 'mostlygoodmetrics_events';
5
12
  const USER_ID_KEY = 'mostlygoodmetrics_user_id';
@@ -227,6 +234,56 @@ export function createDefaultStorage(maxEvents: number): IEventStorage {
227
234
  return new InMemoryEventStorage(maxEvents);
228
235
  }
229
236
 
237
+ /**
238
+ * localStorage-backed experiment storage adapter (default in browsers).
239
+ */
240
+ export class LocalStorageExperimentStorage implements IExperimentStorage {
241
+ getItem(key: string): string | null {
242
+ try {
243
+ return localStorage.getItem(key);
244
+ } catch (e) {
245
+ logger.debug(`Failed to read '${key}' from localStorage`, e);
246
+ return null;
247
+ }
248
+ }
249
+
250
+ setItem(key: string, value: string): void {
251
+ try {
252
+ localStorage.setItem(key, value);
253
+ } catch (e) {
254
+ logger.debug(`Failed to write '${key}' to localStorage`, e);
255
+ }
256
+ }
257
+ }
258
+
259
+ /**
260
+ * In-memory experiment storage adapter.
261
+ * Used as a fallback when localStorage is not available, or for testing.
262
+ */
263
+ export class InMemoryExperimentStorage implements IExperimentStorage {
264
+ private values = new Map<string, string>();
265
+
266
+ getItem(key: string): string | null {
267
+ return this.values.get(key) ?? null;
268
+ }
269
+
270
+ setItem(key: string, value: string): void {
271
+ this.values.set(key, value);
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Create the appropriate experiment storage implementation for the environment.
277
+ * React Native apps should inject an AsyncStorage-backed adapter via the
278
+ * `experimentStorage` configuration option instead.
279
+ */
280
+ export function createDefaultExperimentStorage(): IExperimentStorage {
281
+ if (isLocalStorageAvailable()) {
282
+ return new LocalStorageExperimentStorage();
283
+ }
284
+ return new InMemoryExperimentStorage();
285
+ }
286
+
230
287
  /**
231
288
  * Persistence helpers for user ID and app version.
232
289
  * Uses cookies first (for cross-subdomain support), then localStorage as fallback.
package/src/types.ts CHANGED
@@ -124,6 +124,15 @@ export interface MGMConfiguration {
124
124
  */
125
125
  networkClient?: INetworkClient;
126
126
 
127
+ /**
128
+ * Custom key-value storage adapter used for the experiments variant cache
129
+ * and exposure-event deduplication flags.
130
+ * The adapter may be asynchronous (e.g. React Native's AsyncStorage).
131
+ * If not provided, uses localStorage in browsers or in-memory storage
132
+ * in non-browser environments.
133
+ */
134
+ experimentStorage?: IExperimentStorage;
135
+
127
136
  /**
128
137
  * Callback invoked when a network error occurs.
129
138
  * Useful for reporting errors to external services like Sentry.
@@ -138,11 +147,18 @@ export interface MGMConfiguration {
138
147
  export interface ResolvedConfiguration extends Required<
139
148
  Omit<
140
149
  MGMConfiguration,
141
- 'storage' | 'networkClient' | 'onError' | 'anonymousId' | 'cookieDomain' | 'disableCookies'
150
+ | 'storage'
151
+ | 'networkClient'
152
+ | 'experimentStorage'
153
+ | 'onError'
154
+ | 'anonymousId'
155
+ | 'cookieDomain'
156
+ | 'disableCookies'
142
157
  >
143
158
  > {
144
159
  storage?: IEventStorage;
145
160
  networkClient?: INetworkClient;
161
+ experimentStorage?: IExperimentStorage;
146
162
  onError?: (error: MGMError) => void;
147
163
  }
148
164
 
@@ -335,6 +351,25 @@ export interface IEventStorage {
335
351
  clear(): Promise<void>;
336
352
  }
337
353
 
354
+ /**
355
+ * Minimal key-value storage interface used for the experiments cache and
356
+ * exposure-event deduplication flags.
357
+ *
358
+ * Implementations may be synchronous (e.g. localStorage) or asynchronous
359
+ * (e.g. React Native's AsyncStorage) - the SDK awaits both forms.
360
+ */
361
+ export interface IExperimentStorage {
362
+ /**
363
+ * Get a value by key. Returns null (or resolves to null) if not present.
364
+ */
365
+ getItem(key: string): string | null | Promise<string | null>;
366
+
367
+ /**
368
+ * Set a value for a key.
369
+ */
370
+ setItem(key: string, value: string): void | Promise<void>;
371
+ }
372
+
338
373
  /**
339
374
  * Interface for network client implementations.
340
375
  */
@@ -390,6 +425,7 @@ export const SystemEvents = {
390
425
  APP_OPENED: '$app_opened',
391
426
  APP_BACKGROUNDED: '$app_backgrounded',
392
427
  IDENTIFY: '$identify',
428
+ EXPERIMENT_EXPOSURE: '$experiment_exposure',
393
429
  } as const;
394
430
 
395
431
  /**
@@ -423,10 +459,35 @@ export interface Experiment {
423
459
  }
424
460
 
425
461
  /**
426
- * Response from the experiments API endpoint.
462
+ * Response from the experiments API endpoint when user_id is provided.
463
+ * Contains only the assigned variants for the user.
427
464
  */
428
465
  export interface ExperimentsResponse {
429
- experiments: Experiment[];
466
+ /**
467
+ * Server-assigned variants for the user.
468
+ * Maps experiment ID to the assigned variant (e.g., {"button-color": "a"}).
469
+ */
470
+ assigned_variants: Record<string, string>;
471
+ }
472
+
473
+ /**
474
+ * Cached experiment variants stored in localStorage.
475
+ */
476
+ export interface CachedExperimentVariants {
477
+ /**
478
+ * The user ID these variants are assigned to.
479
+ */
480
+ userId: string;
481
+
482
+ /**
483
+ * Map of experiment ID to assigned variant.
484
+ */
485
+ variants: Record<string, string>;
486
+
487
+ /**
488
+ * Timestamp when the cache was created (ms since epoch).
489
+ */
490
+ fetchedAt: number;
430
491
  }
431
492
 
432
493
  /**
@@ -438,6 +499,8 @@ export const SystemProperties = {
438
499
  VERSION: '$version',
439
500
  PREVIOUS_VERSION: '$previous_version',
440
501
  SDK: '$sdk',
502
+ EXPERIMENT_NAME: '$experiment_name',
503
+ VARIANT: '$variant',
441
504
  } as const;
442
505
 
443
506
  /**
package/src/utils.ts CHANGED
@@ -212,6 +212,7 @@ export function resolveConfiguration(config: MGMConfiguration): ResolvedConfigur
212
212
  sdkVersion: config.sdkVersion ?? '',
213
213
  storage: config.storage,
214
214
  networkClient: config.networkClient,
215
+ experimentStorage: config.experimentStorage,
215
216
  onError: config.onError,
216
217
  };
217
218
  }