@mostly-good-metrics/javascript 0.5.1 → 0.6.1
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 +144 -0
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/index.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 +144 -0
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/index.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 +49 -0
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/types.d.ts +21 -2
- 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 +255 -0
- package/src/client.ts +172 -0
- package/src/index.ts +2 -0
- package/src/types.ts +25 -3
- package/src/utils.test.ts +3 -1
- package/src/utils.ts +2 -2
package/src/client.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { createDefaultNetworkClient } from './network';
|
|
|
3
3
|
import { createDefaultStorage, persistence } from './storage';
|
|
4
4
|
import {
|
|
5
5
|
EventProperties,
|
|
6
|
+
Experiment,
|
|
7
|
+
ExperimentsResponse,
|
|
6
8
|
IEventStorage,
|
|
7
9
|
INetworkClient,
|
|
8
10
|
MGMConfiguration,
|
|
@@ -47,6 +49,12 @@ export class MostlyGoodMetrics {
|
|
|
47
49
|
private anonymousIdValue: string;
|
|
48
50
|
private lifecycleSetup = false;
|
|
49
51
|
|
|
52
|
+
// A/B testing state
|
|
53
|
+
private experiments: Map<string, Experiment> = new Map();
|
|
54
|
+
private experimentsLoaded = false;
|
|
55
|
+
private experimentsReadyResolve: (() => void) | null = null;
|
|
56
|
+
private experimentsReadyPromise: Promise<void>;
|
|
57
|
+
|
|
50
58
|
/**
|
|
51
59
|
* Private constructor - use `configure` to create an instance.
|
|
52
60
|
*/
|
|
@@ -70,6 +78,11 @@ export class MostlyGoodMetrics {
|
|
|
70
78
|
// Initialize network client
|
|
71
79
|
this.networkClient = this.config.networkClient ?? createDefaultNetworkClient();
|
|
72
80
|
|
|
81
|
+
// Initialize experiments ready promise
|
|
82
|
+
this.experimentsReadyPromise = new Promise((resolve) => {
|
|
83
|
+
this.experimentsReadyResolve = resolve;
|
|
84
|
+
});
|
|
85
|
+
|
|
73
86
|
logger.info(`MostlyGoodMetrics initialized with environment: ${this.config.environment}`);
|
|
74
87
|
|
|
75
88
|
// Start auto-flush timer
|
|
@@ -79,6 +92,9 @@ export class MostlyGoodMetrics {
|
|
|
79
92
|
if (this.config.trackAppLifecycleEvents) {
|
|
80
93
|
this.setupLifecycleTracking();
|
|
81
94
|
}
|
|
95
|
+
|
|
96
|
+
// Fetch experiments in background
|
|
97
|
+
void this.fetchExperiments();
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
/**
|
|
@@ -213,6 +229,22 @@ export class MostlyGoodMetrics {
|
|
|
213
229
|
return MostlyGoodMetrics.instance?.getSuperProperties() ?? {};
|
|
214
230
|
}
|
|
215
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Get the variant for an experiment.
|
|
234
|
+
* Returns the assigned variant ('a', 'b', etc.) or null if experiment not found.
|
|
235
|
+
*/
|
|
236
|
+
static getVariant(experimentName: string): string | null {
|
|
237
|
+
return MostlyGoodMetrics.instance?.getVariant(experimentName) ?? null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Returns a promise that resolves when experiments have been loaded.
|
|
242
|
+
* Useful for waiting before calling getVariant() to ensure server-side experiments are available.
|
|
243
|
+
*/
|
|
244
|
+
static ready(): Promise<void> {
|
|
245
|
+
return MostlyGoodMetrics.instance?.ready() ?? Promise.resolve();
|
|
246
|
+
}
|
|
247
|
+
|
|
216
248
|
// =====================================================
|
|
217
249
|
// Instance properties
|
|
218
250
|
// =====================================================
|
|
@@ -479,6 +511,63 @@ export class MostlyGoodMetrics {
|
|
|
479
511
|
return persistence.getSuperProperties();
|
|
480
512
|
}
|
|
481
513
|
|
|
514
|
+
/**
|
|
515
|
+
* Get the variant for an experiment.
|
|
516
|
+
* Returns the assigned variant ('a', 'b', etc.) or null if experiment not found.
|
|
517
|
+
*
|
|
518
|
+
* Assignment is deterministic based on userId + experimentName, so the same
|
|
519
|
+
* user always gets the same variant for the same experiment.
|
|
520
|
+
*
|
|
521
|
+
* The variant is automatically stored as a super property (experiment_{name}: 'variant')
|
|
522
|
+
* so it's attached to all subsequent events.
|
|
523
|
+
*/
|
|
524
|
+
getVariant(experimentName: string): string | null {
|
|
525
|
+
if (!experimentName) {
|
|
526
|
+
logger.warn('getVariant called with empty experimentName');
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Check if we have this experiment cached
|
|
531
|
+
const experiment = this.experiments.get(experimentName);
|
|
532
|
+
|
|
533
|
+
if (!experiment) {
|
|
534
|
+
if (this.experimentsLoaded) {
|
|
535
|
+
// Experiments loaded but this one doesn't exist
|
|
536
|
+
logger.debug(`Experiment not found: ${experimentName}`);
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
// Experiments not loaded yet - can't assign variant without knowing variants
|
|
540
|
+
logger.debug(`Experiments not loaded yet, cannot assign variant for: ${experimentName}`);
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (!experiment.variants || experiment.variants.length === 0) {
|
|
545
|
+
logger.warn(`Experiment ${experimentName} has no variants`);
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Get the user ID for deterministic assignment
|
|
550
|
+
const userId = this.userId ?? this.anonymousIdValue;
|
|
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;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Returns a promise that resolves when experiments have been loaded.
|
|
565
|
+
* Useful for waiting before calling getVariant() to ensure server-side experiments are available.
|
|
566
|
+
*/
|
|
567
|
+
ready(): Promise<void> {
|
|
568
|
+
return this.experimentsReadyPromise;
|
|
569
|
+
}
|
|
570
|
+
|
|
482
571
|
/**
|
|
483
572
|
* Clean up resources (stop timers, etc.).
|
|
484
573
|
*/
|
|
@@ -692,4 +781,87 @@ export class MostlyGoodMetrics {
|
|
|
692
781
|
// the regular flush mechanism for most events
|
|
693
782
|
logger.debug('Page unloading, attempting beacon flush');
|
|
694
783
|
}
|
|
784
|
+
|
|
785
|
+
// =====================================================
|
|
786
|
+
// A/B Testing methods
|
|
787
|
+
// =====================================================
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Fetch experiments from the server.
|
|
791
|
+
* Called automatically during initialization.
|
|
792
|
+
*/
|
|
793
|
+
private async fetchExperiments(): Promise<void> {
|
|
794
|
+
const url = `${this.config.baseURL}/v1/experiments`;
|
|
795
|
+
|
|
796
|
+
try {
|
|
797
|
+
logger.debug('Fetching experiments...');
|
|
798
|
+
|
|
799
|
+
const response = await fetch(url, {
|
|
800
|
+
method: 'GET',
|
|
801
|
+
headers: {
|
|
802
|
+
Authorization: `Bearer ${this.config.apiKey}`,
|
|
803
|
+
'Content-Type': 'application/json',
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
if (!response.ok) {
|
|
808
|
+
logger.warn(`Failed to fetch experiments: ${response.status}`);
|
|
809
|
+
this.markExperimentsReady();
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const data = (await response.json()) as ExperimentsResponse;
|
|
814
|
+
|
|
815
|
+
// Cache experiments by ID
|
|
816
|
+
this.experiments.clear();
|
|
817
|
+
for (const experiment of data.experiments || []) {
|
|
818
|
+
this.experiments.set(experiment.id, experiment);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
logger.debug(`Loaded ${this.experiments.size} experiments`);
|
|
822
|
+
} catch (e) {
|
|
823
|
+
logger.warn('Failed to fetch experiments', e);
|
|
824
|
+
} finally {
|
|
825
|
+
this.markExperimentsReady();
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Mark experiments as loaded and resolve the ready promise.
|
|
831
|
+
*/
|
|
832
|
+
private markExperimentsReady(): void {
|
|
833
|
+
this.experimentsLoaded = true;
|
|
834
|
+
if (this.experimentsReadyResolve) {
|
|
835
|
+
this.experimentsReadyResolve();
|
|
836
|
+
this.experimentsReadyResolve = null;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Compute a deterministic variant assignment based on userId and experimentName.
|
|
842
|
+
* Same user + same experiment = same variant (consistent assignment).
|
|
843
|
+
*/
|
|
844
|
+
private computeVariant(userId: string, experimentName: string, variants: string[]): string {
|
|
845
|
+
// Create a deterministic hash from userId + experimentName
|
|
846
|
+
const hashInput = `${userId}:${experimentName}`;
|
|
847
|
+
const hash = this.simpleHash(hashInput);
|
|
848
|
+
|
|
849
|
+
// Convert hash to a positive number and mod by variant count
|
|
850
|
+
const hashNum = Math.abs(parseInt(hash, 16));
|
|
851
|
+
const variantIndex = hashNum % variants.length;
|
|
852
|
+
|
|
853
|
+
return variants[variantIndex];
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Convert a string to snake_case.
|
|
858
|
+
* Used for experiment property names (e.g., "button-color" -> "button_color").
|
|
859
|
+
*/
|
|
860
|
+
private toSnakeCase(str: string): string {
|
|
861
|
+
return str
|
|
862
|
+
.replace(/([A-Z])/g, '_$1')
|
|
863
|
+
.replace(/[-\s]+/g, '_')
|
|
864
|
+
.toLowerCase()
|
|
865
|
+
.replace(/^_/, '');
|
|
866
|
+
}
|
|
695
867
|
}
|
package/src/index.ts
CHANGED
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;
|
|
@@ -407,6 +407,28 @@ export interface UserProfile {
|
|
|
407
407
|
name?: string;
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
+
/**
|
|
411
|
+
* An experiment configuration from the server.
|
|
412
|
+
*/
|
|
413
|
+
export interface Experiment {
|
|
414
|
+
/**
|
|
415
|
+
* The experiment identifier (e.g., "button-color").
|
|
416
|
+
*/
|
|
417
|
+
id: string;
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The variants for this experiment (e.g., ["a", "b"]).
|
|
421
|
+
*/
|
|
422
|
+
variants: string[];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Response from the experiments API endpoint.
|
|
427
|
+
*/
|
|
428
|
+
export interface ExperimentsResponse {
|
|
429
|
+
experiments: Experiment[];
|
|
430
|
+
}
|
|
431
|
+
|
|
410
432
|
/**
|
|
411
433
|
* System property keys (prefixed with $).
|
|
412
434
|
*/
|
|
@@ -448,6 +470,6 @@ export const Constraints = {
|
|
|
448
470
|
|
|
449
471
|
/**
|
|
450
472
|
* Regular expression for validating event names.
|
|
451
|
-
* Must start with a letter (or $ for system events) followed by alphanumeric
|
|
473
|
+
* Must start with a letter (or $ for system events) followed by alphanumeric, underscores, or spaces.
|
|
452
474
|
*/
|
|
453
|
-
export const EVENT_NAME_REGEX = /^\$?[a-zA-Z][a-zA-Z0-9_]*$/;
|
|
475
|
+
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
|
}
|