@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/LICENSE +21 -0
- package/README.md +198 -68
- package/dist/cjs/client.js +239 -70
- 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 +240 -71
- 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 +82 -20
- 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 +54 -3
- 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 +740 -7
- package/src/client.ts +260 -69
- package/src/index.ts +9 -1
- package/src/storage.ts +58 -1
- package/src/types.ts +66 -3
- 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
|
+
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
|
|
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
|
-
//
|
|
97
|
-
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
|
+
});
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
519
|
-
*
|
|
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
|
-
*
|
|
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
|
|
565
|
+
return fallback;
|
|
528
566
|
}
|
|
529
567
|
|
|
530
|
-
// Check if we have this experiment
|
|
531
|
-
const
|
|
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 (
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
550
|
-
|
|
576
|
+
// Track exposure once per (user, experiment, variant)
|
|
577
|
+
this.trackExposureIfNeeded(experimentName, variant);
|
|
551
578
|
|
|
552
|
-
|
|
553
|
-
|
|
579
|
+
logger.debug(`Using server-assigned variant '${variant}' for experiment '${experimentName}'`);
|
|
580
|
+
return variant;
|
|
581
|
+
}
|
|
554
582
|
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
560
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
791
|
-
*
|
|
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
|
|
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
|
-
//
|
|
816
|
-
this.
|
|
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
|
-
|
|
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
|
-
*
|
|
842
|
-
*
|
|
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
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
/**
|
|
@@ -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
|
-
|
|
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
|
}
|