@mostly-good-metrics/javascript 0.6.0 → 0.7.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/dist/cjs/client.js +116 -45
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/types.js +2 -2
- package/dist/cjs/types.js.map +1 -1
- package/dist/cjs/utils.js +2 -2
- package/dist/cjs/utils.js.map +1 -1
- package/dist/esm/client.js +116 -45
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/types.js +2 -2
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/utils.js +2 -2
- package/dist/esm/utils.js.map +1 -1
- package/dist/types/client.d.ts +24 -8
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/types.d.ts +26 -4
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/utils.d.ts +1 -1
- package/package.json +1 -1
- package/src/client.test.ts +156 -6
- package/src/client.ts +124 -48
- package/src/types.ts +30 -5
- package/src/utils.test.ts +3 -1
- package/src/utils.ts +2 -2
package/src/client.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { logger, setDebugLogging } from './logger';
|
|
|
2
2
|
import { createDefaultNetworkClient } from './network';
|
|
3
3
|
import { createDefaultStorage, persistence } from './storage';
|
|
4
4
|
import {
|
|
5
|
+
CachedExperimentVariants,
|
|
5
6
|
EventProperties,
|
|
6
|
-
Experiment,
|
|
7
7
|
ExperimentsResponse,
|
|
8
8
|
IEventStorage,
|
|
9
9
|
INetworkClient,
|
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
} from './utils';
|
|
33
33
|
|
|
34
34
|
const FLUSH_DELAY_MS = 100; // Delay between batch sends
|
|
35
|
+
const EXPERIMENTS_CACHE_KEY = 'mgm_experiment_variants';
|
|
36
|
+
const EXPERIMENTS_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* Main client for MostlyGoodMetrics.
|
|
@@ -50,7 +52,7 @@ export class MostlyGoodMetrics {
|
|
|
50
52
|
private lifecycleSetup = false;
|
|
51
53
|
|
|
52
54
|
// A/B testing state
|
|
53
|
-
private
|
|
55
|
+
private assignedVariants: Record<string, string> = {}; // Server-assigned variants
|
|
54
56
|
private experimentsLoaded = false;
|
|
55
57
|
private experimentsReadyResolve: (() => void) | null = null;
|
|
56
58
|
private experimentsReadyPromise: Promise<void>;
|
|
@@ -345,6 +347,9 @@ export class MostlyGoodMetrics {
|
|
|
345
347
|
* Profile data is sent to the backend via the $identify event.
|
|
346
348
|
* Debouncing: only sends $identify if payload changed or >24h since last send.
|
|
347
349
|
*
|
|
350
|
+
* This also invalidates the experiment variants cache and refetches experiments
|
|
351
|
+
* with the new user ID to handle the "identified wins" aliasing strategy.
|
|
352
|
+
*
|
|
348
353
|
* @param userId The user's unique identifier
|
|
349
354
|
* @param profile Optional profile data (email, name)
|
|
350
355
|
*/
|
|
@@ -354,6 +359,7 @@ export class MostlyGoodMetrics {
|
|
|
354
359
|
return;
|
|
355
360
|
}
|
|
356
361
|
|
|
362
|
+
const previousUserId = this.userId ?? this.anonymousIdValue;
|
|
357
363
|
logger.debug(`Identifying user: ${userId}`);
|
|
358
364
|
persistence.setUserId(userId);
|
|
359
365
|
|
|
@@ -362,6 +368,14 @@ export class MostlyGoodMetrics {
|
|
|
362
368
|
if (profile && (profile.email || profile.name)) {
|
|
363
369
|
this.sendIdentifyEventIfNeeded(userId, profile);
|
|
364
370
|
}
|
|
371
|
+
|
|
372
|
+
// If user ID changed, invalidate experiment cache and refetch
|
|
373
|
+
// This handles the "identified wins" aliasing strategy on the server
|
|
374
|
+
if (previousUserId !== userId) {
|
|
375
|
+
logger.debug('User identity changed, refetching experiments');
|
|
376
|
+
this.invalidateExperimentsCache();
|
|
377
|
+
void this.fetchExperiments();
|
|
378
|
+
}
|
|
365
379
|
}
|
|
366
380
|
|
|
367
381
|
/**
|
|
@@ -515,10 +529,10 @@ export class MostlyGoodMetrics {
|
|
|
515
529
|
* Get the variant for an experiment.
|
|
516
530
|
* Returns the assigned variant ('a', 'b', etc.) or null if experiment not found.
|
|
517
531
|
*
|
|
518
|
-
*
|
|
519
|
-
* user always gets the same variant for the same experiment.
|
|
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.
|
|
520
534
|
*
|
|
521
|
-
* The variant is automatically stored as a super property (experiment_{name}: 'variant')
|
|
535
|
+
* The variant is automatically stored as a super property ($experiment_{name}: 'variant')
|
|
522
536
|
* so it's attached to all subsequent events.
|
|
523
537
|
*/
|
|
524
538
|
getVariant(experimentName: string): string | null {
|
|
@@ -527,37 +541,27 @@ export class MostlyGoodMetrics {
|
|
|
527
541
|
return null;
|
|
528
542
|
}
|
|
529
543
|
|
|
530
|
-
// Check if we have this experiment
|
|
531
|
-
const
|
|
544
|
+
// Check if we have a server-assigned variant for this experiment
|
|
545
|
+
const variant = this.assignedVariants[experimentName];
|
|
532
546
|
|
|
533
|
-
if (
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
logger.debug(`Experiments not loaded yet, cannot assign variant for: ${experimentName}`);
|
|
541
|
-
return null;
|
|
547
|
+
if (variant) {
|
|
548
|
+
// Store as super property so it's attached to all events
|
|
549
|
+
const propertyName = `$experiment_${this.toSnakeCase(experimentName)}`;
|
|
550
|
+
this.setSuperProperty(propertyName, variant);
|
|
551
|
+
|
|
552
|
+
logger.debug(`Using server-assigned variant '${variant}' for experiment '${experimentName}'`);
|
|
553
|
+
return variant;
|
|
542
554
|
}
|
|
543
555
|
|
|
544
|
-
|
|
545
|
-
|
|
556
|
+
// No assigned variant - check if experiments have been loaded
|
|
557
|
+
if (!this.experimentsLoaded) {
|
|
558
|
+
logger.debug(`Experiments not loaded yet, cannot get variant for: ${experimentName}`);
|
|
546
559
|
return null;
|
|
547
560
|
}
|
|
548
561
|
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
// Compute deterministic variant assignment
|
|
553
|
-
const variant = this.computeVariant(userId, experimentName, experiment.variants);
|
|
554
|
-
|
|
555
|
-
// Store as super property so it's attached to all events
|
|
556
|
-
const propertyName = `experiment_${this.toSnakeCase(experimentName)}`;
|
|
557
|
-
this.setSuperProperty(propertyName, variant);
|
|
558
|
-
|
|
559
|
-
logger.debug(`Assigned variant '${variant}' for experiment '${experimentName}'`);
|
|
560
|
-
return variant;
|
|
562
|
+
// Experiments loaded but no assignment for this experiment
|
|
563
|
+
logger.debug(`No variant assigned for experiment: ${experimentName}`);
|
|
564
|
+
return null;
|
|
561
565
|
}
|
|
562
566
|
|
|
563
567
|
/**
|
|
@@ -788,10 +792,33 @@ export class MostlyGoodMetrics {
|
|
|
788
792
|
|
|
789
793
|
/**
|
|
790
794
|
* Fetch experiments from the server.
|
|
791
|
-
* Called automatically during initialization.
|
|
795
|
+
* Called automatically during initialization and after identify().
|
|
796
|
+
*
|
|
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
|
|
792
801
|
*/
|
|
793
802
|
private async fetchExperiments(): Promise<void> {
|
|
794
|
-
const
|
|
803
|
+
const currentUserId = this.userId ?? this.anonymousIdValue;
|
|
804
|
+
|
|
805
|
+
// Check localStorage cache first
|
|
806
|
+
const cached = this.loadExperimentsCache();
|
|
807
|
+
if (cached && cached.userId === currentUserId) {
|
|
808
|
+
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;
|
|
816
|
+
}
|
|
817
|
+
logger.debug('Cached experiment variants expired, fetching fresh');
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Build URL with user_id for server-side variant assignment
|
|
821
|
+
const url = `${this.config.baseURL}/v1/experiments?user_id=${encodeURIComponent(currentUserId)}`;
|
|
795
822
|
|
|
796
823
|
try {
|
|
797
824
|
logger.debug('Fetching experiments...');
|
|
@@ -812,13 +839,13 @@ export class MostlyGoodMetrics {
|
|
|
812
839
|
|
|
813
840
|
const data = (await response.json()) as ExperimentsResponse;
|
|
814
841
|
|
|
815
|
-
//
|
|
816
|
-
this.
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
842
|
+
// Store server-assigned variants
|
|
843
|
+
this.assignedVariants = data.assigned_variants ?? {};
|
|
844
|
+
|
|
845
|
+
// Cache in localStorage
|
|
846
|
+
this.saveExperimentsCache(currentUserId, this.assignedVariants);
|
|
820
847
|
|
|
821
|
-
logger.debug(`Loaded ${this.
|
|
848
|
+
logger.debug(`Loaded ${Object.keys(this.assignedVariants).length} assigned variants`);
|
|
822
849
|
} catch (e) {
|
|
823
850
|
logger.warn('Failed to fetch experiments', e);
|
|
824
851
|
} finally {
|
|
@@ -838,19 +865,68 @@ export class MostlyGoodMetrics {
|
|
|
838
865
|
}
|
|
839
866
|
|
|
840
867
|
/**
|
|
841
|
-
*
|
|
842
|
-
|
|
868
|
+
* Load experiment variants from localStorage cache.
|
|
869
|
+
*/
|
|
870
|
+
private loadExperimentsCache(): CachedExperimentVariants | null {
|
|
871
|
+
if (typeof localStorage === 'undefined') {
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
try {
|
|
876
|
+
const cached = localStorage.getItem(EXPERIMENTS_CACHE_KEY);
|
|
877
|
+
if (!cached) {
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
return JSON.parse(cached) as CachedExperimentVariants;
|
|
881
|
+
} catch (e) {
|
|
882
|
+
logger.debug('Failed to load experiments cache', e);
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Save experiment variants to localStorage cache.
|
|
843
889
|
*/
|
|
844
|
-
private
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
890
|
+
private saveExperimentsCache(userId: string, variants: Record<string, string>): void {
|
|
891
|
+
if (typeof localStorage === 'undefined') {
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
848
894
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
895
|
+
try {
|
|
896
|
+
const cached: CachedExperimentVariants = {
|
|
897
|
+
userId,
|
|
898
|
+
variants,
|
|
899
|
+
fetchedAt: Date.now(),
|
|
900
|
+
};
|
|
901
|
+
localStorage.setItem(EXPERIMENTS_CACHE_KEY, JSON.stringify(cached));
|
|
902
|
+
} catch (e) {
|
|
903
|
+
logger.debug('Failed to save experiments cache', e);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
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;
|
|
852
915
|
|
|
853
|
-
|
|
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
|
+
}
|
|
854
930
|
}
|
|
855
931
|
|
|
856
932
|
/**
|
package/src/types.ts
CHANGED
|
@@ -168,7 +168,7 @@ export type EventPropertyValue =
|
|
|
168
168
|
*/
|
|
169
169
|
export interface MGMEvent {
|
|
170
170
|
/**
|
|
171
|
-
* The name of the event. Must match pattern: ^$?[a-zA-Z][a-zA-Z0-9_]*$
|
|
171
|
+
* The name of the event. Must match pattern: ^$?[a-zA-Z][a-zA-Z0-9_]*( [a-zA-Z0-9_]+)*$
|
|
172
172
|
* Max 255 characters.
|
|
173
173
|
*/
|
|
174
174
|
name: string;
|
|
@@ -423,10 +423,35 @@ export interface Experiment {
|
|
|
423
423
|
}
|
|
424
424
|
|
|
425
425
|
/**
|
|
426
|
-
* Response from the experiments API endpoint.
|
|
426
|
+
* Response from the experiments API endpoint when user_id is provided.
|
|
427
|
+
* Contains only the assigned variants for the user.
|
|
427
428
|
*/
|
|
428
429
|
export interface ExperimentsResponse {
|
|
429
|
-
|
|
430
|
+
/**
|
|
431
|
+
* Server-assigned variants for the user.
|
|
432
|
+
* Maps experiment ID to the assigned variant (e.g., {"button-color": "a"}).
|
|
433
|
+
*/
|
|
434
|
+
assigned_variants: Record<string, string>;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Cached experiment variants stored in localStorage.
|
|
439
|
+
*/
|
|
440
|
+
export interface CachedExperimentVariants {
|
|
441
|
+
/**
|
|
442
|
+
* The user ID these variants are assigned to.
|
|
443
|
+
*/
|
|
444
|
+
userId: string;
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Map of experiment ID to assigned variant.
|
|
448
|
+
*/
|
|
449
|
+
variants: Record<string, string>;
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Timestamp when the cache was created (ms since epoch).
|
|
453
|
+
*/
|
|
454
|
+
fetchedAt: number;
|
|
430
455
|
}
|
|
431
456
|
|
|
432
457
|
/**
|
|
@@ -470,6 +495,6 @@ export const Constraints = {
|
|
|
470
495
|
|
|
471
496
|
/**
|
|
472
497
|
* Regular expression for validating event names.
|
|
473
|
-
* Must start with a letter (or $ for system events) followed by alphanumeric
|
|
498
|
+
* Must start with a letter (or $ for system events) followed by alphanumeric, underscores, or spaces.
|
|
474
499
|
*/
|
|
475
|
-
export const EVENT_NAME_REGEX = /^\$?[a-zA-Z][a-zA-Z0-9_]*$/;
|
|
500
|
+
export const EVENT_NAME_REGEX = /^\$?[a-zA-Z][a-zA-Z0-9_]*( [a-zA-Z0-9_]+)*$/;
|
package/src/utils.test.ts
CHANGED
|
@@ -72,6 +72,8 @@ describe('isValidEventName', () => {
|
|
|
72
72
|
expect(isValidEventName('event123')).toBe(true);
|
|
73
73
|
expect(isValidEventName('a')).toBe(true);
|
|
74
74
|
expect(isValidEventName('ABC_123_xyz')).toBe(true);
|
|
75
|
+
expect(isValidEventName('Button Clicked')).toBe(true);
|
|
76
|
+
expect(isValidEventName('User Signed Up')).toBe(true);
|
|
75
77
|
});
|
|
76
78
|
|
|
77
79
|
it('should accept system event names (starting with $)', () => {
|
|
@@ -86,7 +88,6 @@ describe('isValidEventName', () => {
|
|
|
86
88
|
expect(isValidEventName('_event')).toBe(false); // starts with underscore
|
|
87
89
|
expect(isValidEventName('event-name')).toBe(false); // contains hyphen
|
|
88
90
|
expect(isValidEventName('event.name')).toBe(false); // contains dot
|
|
89
|
-
expect(isValidEventName('event name')).toBe(false); // contains space
|
|
90
91
|
expect(isValidEventName('event@name')).toBe(false); // contains @
|
|
91
92
|
});
|
|
92
93
|
|
|
@@ -103,6 +104,7 @@ describe('validateEventName', () => {
|
|
|
103
104
|
it('should not throw for valid event names', () => {
|
|
104
105
|
expect(() => validateEventName('valid_event')).not.toThrow();
|
|
105
106
|
expect(() => validateEventName('$system_event')).not.toThrow();
|
|
107
|
+
expect(() => validateEventName('Button Clicked')).not.toThrow();
|
|
106
108
|
});
|
|
107
109
|
|
|
108
110
|
it('should throw MGMError for empty event names', () => {
|
package/src/utils.ts
CHANGED
|
@@ -67,7 +67,7 @@ export function getISOTimestamp(): string {
|
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
69
|
* Validate an event name.
|
|
70
|
-
* Must match pattern: ^$?[a-zA-Z][a-zA-Z0-9_]*$
|
|
70
|
+
* Must match pattern: ^$?[a-zA-Z][a-zA-Z0-9_]*( [a-zA-Z0-9_]+)*$
|
|
71
71
|
* Max 255 characters.
|
|
72
72
|
*/
|
|
73
73
|
export function isValidEventName(name: string): boolean {
|
|
@@ -95,7 +95,7 @@ export function validateEventName(name: string): void {
|
|
|
95
95
|
if (!EVENT_NAME_REGEX.test(name)) {
|
|
96
96
|
throw new MGMError(
|
|
97
97
|
'INVALID_EVENT_NAME',
|
|
98
|
-
'Event name must start with a letter (or $ for system events) and contain only alphanumeric characters and
|
|
98
|
+
'Event name must start with a letter (or $ for system events) and contain only alphanumeric characters, underscores, and spaces'
|
|
99
99
|
);
|
|
100
100
|
}
|
|
101
101
|
}
|