@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/LICENSE +21 -0
- package/README.md +198 -68
- package/dist/cjs/client.js +181 -83
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/index.js +4 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/storage.js +52 -1
- package/dist/cjs/storage.js.map +1 -1
- package/dist/cjs/types.js +3 -0
- package/dist/cjs/types.js.map +1 -1
- package/dist/cjs/utils.js +1 -0
- package/dist/cjs/utils.js.map +1 -1
- package/dist/esm/client.js +182 -84
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/storage.js +49 -1
- package/dist/esm/storage.js.map +1 -1
- package/dist/esm/types.js +3 -0
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/utils.js +1 -0
- package/dist/esm/utils.js.map +1 -1
- package/dist/types/client.d.ts +73 -27
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/storage.d.ts +23 -1
- package/dist/types/storage.d.ts.map +1 -1
- package/dist/types/types.d.ts +30 -1
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/utils.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client.test.ts +584 -1
- package/src/client.ts +202 -87
- package/src/index.ts +9 -1
- package/src/storage.ts +58 -1
- package/src/types.ts +39 -1
- package/src/utils.ts +1 -0
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
|
|
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
|
-
//
|
|
99
|
-
void this.
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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,
|
|
373
|
-
//
|
|
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.
|
|
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
|
|
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
|
|
533
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
591
|
+
return fallback;
|
|
565
592
|
}
|
|
566
593
|
|
|
567
594
|
/**
|
|
568
|
-
* Returns a promise that resolves when experiments have been loaded
|
|
569
|
-
*
|
|
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
|
|
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
|
-
*
|
|
795
|
-
* Called automatically during initialization and after identify().
|
|
832
|
+
* Initialize experiments on startup.
|
|
796
833
|
*
|
|
797
|
-
* Flow:
|
|
798
|
-
* 1.
|
|
799
|
-
*
|
|
800
|
-
*
|
|
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
|
|
843
|
+
private async initializeExperiments(): Promise<void> {
|
|
803
844
|
const currentUserId = this.userId ?? this.anonymousIdValue;
|
|
804
845
|
|
|
805
|
-
//
|
|
806
|
-
|
|
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
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
865
|
+
return;
|
|
818
866
|
}
|
|
819
867
|
|
|
820
|
-
//
|
|
821
|
-
|
|
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
|
-
//
|
|
916
|
+
// Atomically swap in the server-assigned variants
|
|
843
917
|
this.assignedVariants = data.assigned_variants ?? {};
|
|
844
918
|
|
|
845
|
-
//
|
|
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
|
-
*
|
|
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
|
|
871
|
-
if (
|
|
872
|
-
return
|
|
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
|
-
|
|
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
|
|
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(
|
|
891
|
-
|
|
892
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|