@mostly-good-metrics/javascript 0.7.0 → 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
5
  CachedExperimentVariants,
6
6
  EventProperties,
7
7
  ExperimentsResponse,
8
8
  IEventStorage,
9
+ IExperimentStorage,
9
10
  INetworkClient,
10
11
  MGMConfiguration,
11
12
  MGMEvent,
@@ -33,7 +34,10 @@ import {
33
34
 
34
35
  const FLUSH_DELAY_MS = 100; // Delay between batch sends
35
36
  const EXPERIMENTS_CACHE_KEY = 'mgm_experiment_variants';
36
- const EXPERIMENTS_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
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
37
41
 
38
42
  /**
39
43
  * Main client for MostlyGoodMetrics.
@@ -53,9 +57,11 @@ export class MostlyGoodMetrics {
53
57
 
54
58
  // A/B testing state
55
59
  private assignedVariants: Record<string, string> = {}; // Server-assigned variants
60
+ private experimentStorage: IExperimentStorage;
56
61
  private experimentsLoaded = false;
57
62
  private experimentsReadyResolve: (() => void) | null = null;
58
63
  private experimentsReadyPromise: Promise<void>;
64
+ private trackedExposures = new Set<string>(); // (user, experiment, variant) exposure dedup
59
65
 
60
66
  /**
61
67
  * Private constructor - use `configure` to create an instance.
@@ -80,6 +86,9 @@ export class MostlyGoodMetrics {
80
86
  // Initialize network client
81
87
  this.networkClient = this.config.networkClient ?? createDefaultNetworkClient();
82
88
 
89
+ // Initialize experiment storage (pluggable for React Native AsyncStorage etc.)
90
+ this.experimentStorage = this.config.experimentStorage ?? createDefaultExperimentStorage();
91
+
83
92
  // Initialize experiments ready promise
84
93
  this.experimentsReadyPromise = new Promise((resolve) => {
85
94
  this.experimentsReadyResolve = resolve;
@@ -95,8 +104,11 @@ export class MostlyGoodMetrics {
95
104
  this.setupLifecycleTracking();
96
105
  }
97
106
 
98
- // Fetch experiments in background
99
- 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
+ });
100
112
  }
101
113
 
102
114
  /**
@@ -233,18 +245,23 @@ export class MostlyGoodMetrics {
233
245
 
234
246
  /**
235
247
  * Get the variant for an experiment.
236
- * 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.
237
250
  */
238
- static getVariant(experimentName: string): string | null {
239
- 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;
240
254
  }
241
255
 
242
256
  /**
243
- * Returns a promise that resolves when experiments have been loaded.
244
- * 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.
245
262
  */
246
- static ready(): Promise<void> {
247
- 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();
248
265
  }
249
266
 
250
267
  // =====================================================
@@ -369,11 +386,14 @@ export class MostlyGoodMetrics {
369
386
  this.sendIdentifyEventIfNeeded(userId, profile);
370
387
  }
371
388
 
372
- // If user ID changed, invalidate experiment cache and refetch
373
- // This handles the "identified wins" aliasing strategy on the server
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.
374
394
  if (previousUserId !== userId) {
375
395
  logger.debug('User identity changed, refetching experiments');
376
- this.invalidateExperimentsCache();
396
+ this.resetExperimentsReadyPromise();
377
397
  void this.fetchExperiments();
378
398
  }
379
399
  }
@@ -527,18 +547,22 @@ export class MostlyGoodMetrics {
527
547
 
528
548
  /**
529
549
  * Get the variant for an experiment.
530
- * 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.
531
552
  *
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.
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.
534
556
  *
535
- * The variant is automatically stored as a super property ($experiment_{name}: 'variant')
536
- * 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).
537
561
  */
538
- getVariant(experimentName: string): string | null {
562
+ getVariant(experimentName: string, fallback: string | null = null): string | null {
539
563
  if (!experimentName) {
540
564
  logger.warn('getVariant called with empty experimentName');
541
- return null;
565
+ return fallback;
542
566
  }
543
567
 
544
568
  // Check if we have a server-assigned variant for this experiment
@@ -549,6 +573,9 @@ export class MostlyGoodMetrics {
549
573
  const propertyName = `$experiment_${this.toSnakeCase(experimentName)}`;
550
574
  this.setSuperProperty(propertyName, variant);
551
575
 
576
+ // Track exposure once per (user, experiment, variant)
577
+ this.trackExposureIfNeeded(experimentName, variant);
578
+
552
579
  logger.debug(`Using server-assigned variant '${variant}' for experiment '${experimentName}'`);
553
580
  return variant;
554
581
  }
@@ -556,20 +583,31 @@ export class MostlyGoodMetrics {
556
583
  // No assigned variant - check if experiments have been loaded
557
584
  if (!this.experimentsLoaded) {
558
585
  logger.debug(`Experiments not loaded yet, cannot get variant for: ${experimentName}`);
559
- return null;
586
+ return fallback;
560
587
  }
561
588
 
562
589
  // Experiments loaded but no assignment for this experiment
563
590
  logger.debug(`No variant assigned for experiment: ${experimentName}`);
564
- return null;
591
+ return fallback;
565
592
  }
566
593
 
567
594
  /**
568
- * Returns a promise that resolves when experiments have been loaded.
569
- * 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.
570
602
  */
571
- ready(): Promise<void> {
572
- 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
+ });
573
611
  }
574
612
 
575
613
  /**
@@ -791,34 +829,70 @@ export class MostlyGoodMetrics {
791
829
  // =====================================================
792
830
 
793
831
  /**
794
- * Fetch experiments from the server.
795
- * Called automatically during initialization and after identify().
832
+ * Initialize experiments on startup.
796
833
  *
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
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).
801
842
  */
802
- private async fetchExperiments(): Promise<void> {
843
+ private async initializeExperiments(): Promise<void> {
803
844
  const currentUserId = this.userId ?? this.anonymousIdValue;
804
845
 
805
- // Check localStorage cache first
806
- const cached = this.loadExperimentsCache();
846
+ // Hydrate exposure flags first so exposure dedup survives restarts
847
+ await this.hydrateExposures();
848
+
849
+ const cached = await this.loadExperimentsCache();
807
850
  if (cached && cached.userId === currentUserId) {
808
851
  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;
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();
816
864
  }
817
- logger.debug('Cached experiment variants expired, fetching fresh');
865
+ return;
818
866
  }
819
867
 
820
- // Build URL with user_id for server-side variant assignment
821
- const url = `${this.config.baseURL}/v1/experiments?user_id=${encodeURIComponent(currentUserId)}`;
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.
880
+ */
881
+ private async fetchExperiments(): Promise<void> {
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);
822
896
 
823
897
  try {
824
898
  logger.debug('Fetching experiments...');
@@ -829,26 +903,27 @@ export class MostlyGoodMetrics {
829
903
  Authorization: `Bearer ${this.config.apiKey}`,
830
904
  'Content-Type': 'application/json',
831
905
  },
906
+ signal: abortController.signal,
832
907
  });
833
908
 
834
909
  if (!response.ok) {
835
910
  logger.warn(`Failed to fetch experiments: ${response.status}`);
836
- this.markExperimentsReady();
837
911
  return;
838
912
  }
839
913
 
840
914
  const data = (await response.json()) as ExperimentsResponse;
841
915
 
842
- // Store server-assigned variants
916
+ // Atomically swap in the server-assigned variants
843
917
  this.assignedVariants = data.assigned_variants ?? {};
844
918
 
845
- // Cache in localStorage
846
- this.saveExperimentsCache(currentUserId, this.assignedVariants);
919
+ // Persist to the experiment storage adapter (with last-fetch timestamp)
920
+ await this.saveExperimentsCache(currentUserId, this.assignedVariants);
847
921
 
848
922
  logger.debug(`Loaded ${Object.keys(this.assignedVariants).length} assigned variants`);
849
923
  } catch (e) {
850
924
  logger.warn('Failed to fetch experiments', e);
851
925
  } finally {
926
+ clearTimeout(abortTimer);
852
927
  this.markExperimentsReady();
853
928
  }
854
929
  }
@@ -865,15 +940,79 @@ export class MostlyGoodMetrics {
865
940
  }
866
941
 
867
942
  /**
868
- * Load experiment variants from localStorage cache.
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.
869
946
  */
870
- private loadExperimentsCache(): CachedExperimentVariants | null {
871
- if (typeof localStorage === 'undefined') {
872
- return null;
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
+ }
978
+
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);
873
993
  }
994
+ }
874
995
 
996
+ /**
997
+ * Persist exposure dedup flags via the experiment storage adapter.
998
+ */
999
+ private async persistExposures(): Promise<void> {
875
1000
  try {
876
- const cached = localStorage.getItem(EXPERIMENTS_CACHE_KEY);
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);
877
1016
  if (!cached) {
878
1017
  return null;
879
1018
  }
@@ -885,50 +1024,26 @@ export class MostlyGoodMetrics {
885
1024
  }
886
1025
 
887
1026
  /**
888
- * Save experiment variants to localStorage cache.
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.
889
1030
  */
890
- private saveExperimentsCache(userId: string, variants: Record<string, string>): void {
891
- if (typeof localStorage === 'undefined') {
892
- return;
893
- }
894
-
1031
+ private async saveExperimentsCache(
1032
+ userId: string,
1033
+ variants: Record<string, string>
1034
+ ): Promise<void> {
895
1035
  try {
896
1036
  const cached: CachedExperimentVariants = {
897
1037
  userId,
898
1038
  variants,
899
1039
  fetchedAt: Date.now(),
900
1040
  };
901
- localStorage.setItem(EXPERIMENTS_CACHE_KEY, JSON.stringify(cached));
1041
+ await this.experimentStorage.setItem(EXPERIMENTS_CACHE_KEY, JSON.stringify(cached));
902
1042
  } catch (e) {
903
1043
  logger.debug('Failed to save experiments cache', e);
904
1044
  }
905
1045
  }
906
1046
 
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;
915
-
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
- }
930
- }
931
-
932
1047
  /**
933
1048
  * Convert a string to snake_case.
934
1049
  * Used for experiment property names (e.g., "button-color" -> "button_color").
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
  /**
@@ -463,6 +499,8 @@ export const SystemProperties = {
463
499
  VERSION: '$version',
464
500
  PREVIOUS_VERSION: '$previous_version',
465
501
  SDK: '$sdk',
502
+ EXPERIMENT_NAME: '$experiment_name',
503
+ VARIANT: '$variant',
466
504
  } as const;
467
505
 
468
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
  }