import { Identify } from '@amplitude/analytics-browser'; import { Client } from './client'; const LOCAL_STORAGE_EXPERIMENTS_PREFIX = '__analytics_experiments_'; /** Experiment interface allows getting a variation for a specified user. */ export interface Experiment { name: string; engage(userId: string): Variation; } interface VariationData { variation: Variation; targetPercent: number; } const checkPercent = (f: number) => { if (isNaN(f) || f < 0 || f > 100) { throw new Error( 'Variation target percent must be defined as a percent value between 0 and 100', ); } }; /** LocalExperiment allows describing an Experiment implemented with local storage. */ export class LocalExperiment implements Experiment { private readonly data: VariationData[] = []; private coveredPercent: number = 0; constructor( public readonly name: string, private readonly analytics?: Client, ) {} private checkDuplicates(variation: Variation) { const present = this.data.find((d) => d.variation === variation); if (present != null) { throw new Error( `Variation [${present.variation} ${present.targetPercent}%] already exists in experiment ${this.name}.`, ); } } private static dataString(data: VariationData[]): string { return data .map((d) => `variation ${d.variation}: ${d.targetPercent}%`) .join(', '); } private identify(result: Variation) { if (this.analytics != null) { const identifyObj = new Identify(); identifyObj.preInsert('Experiments', `${this.name}_${result}`); this.analytics.identify(identifyObj); } } define( variation: Variation, targetPercent: number, ): LocalExperiment { checkPercent(targetPercent); this.checkDuplicates(variation); const varData = { variation, targetPercent }; this.coveredPercent += targetPercent; if (this.coveredPercent > 100) { const allData = LocalExperiment.dataString(this.data.concat(varData)); throw new Error( `Incorrect target percent in experiment ${this.name}. Sum of fractions is greater than 100%: ${allData}`, ); } this.data.push(varData); return this; } engage(deviceId: string): Variation { if (this.data.length === 0) { throw new Error(`Variations are not defined for experiment ${this.name}`); } if (this.coveredPercent < 100) { throw new Error( `Experiment ${ this.name } is not fully defined. Current data: ${LocalExperiment.dataString( this.data, )}`, ); } if (window?.localStorage == null) { // No storage support. Return a consistent result. return this.data[0].variation; } const key = `${LOCAL_STORAGE_EXPERIMENTS_PREFIX}${this.name}_${deviceId}`; const value = window.localStorage.getItem(key); if (value != null) { this.identify(value as Variation); return value as Variation; } const dieRoll = Math.random() * 100; let result: Variation | null = null; let margin = 0; for (const varData of this.data) { margin += varData.targetPercent; if (dieRoll < margin) { result = varData.variation; break; } } if (result == null) { throw new Error( `Variations implementation problem: ${LocalExperiment.dataString( this.data, )}`, ); } window.localStorage.setItem(key, result); this.identify(result); return result; } }