analytics-client
Advanced tools
Comparing version 0.4.0-roman-experiments-fd969ee053e500586da4050c94ba48bf6ab03da1 to 0.4.0
@@ -7,6 +7,10 @@ # Change Log | ||
## 0.4.0 - 2020-03-17 | ||
## 0.4.0 - 2020-03-18 | ||
* Add experiments implementation [Roman Mazur] | ||
## 0.3.1 - 2020-03-18 | ||
* Bump balena linter [Roman Mazur] | ||
## 0.3.0 - 2020-03-17 | ||
@@ -13,0 +17,0 @@ |
export { AnalyticsUrlParams } from './src/url-params'; | ||
export { Client, Config, createClient } from './src/client'; | ||
export { Experiment, LocalExperiment } from './src/experiment'; |
@@ -7,1 +7,3 @@ "use strict"; | ||
exports.createClient = client_1.createClient; | ||
var experiment_1 = require("./src/experiment"); | ||
exports.LocalExperiment = experiment_1.LocalExperiment; |
@@ -10,10 +10,8 @@ import { Client } from './client'; | ||
private readonly data; | ||
private defaultVariation?; | ||
private coveredPercent; | ||
constructor(name: string, analytics?: Client | undefined); | ||
private static checkPercent; | ||
private checkDuplicates; | ||
private static dataString; | ||
define(variation: Variation, fraction: number): LocalExperiment<Variation>; | ||
defineDefault(variation: Variation): LocalExperiment<Variation>; | ||
engage(userId: string): Variation; | ||
define(variation: Variation, targetPercent: number): LocalExperiment<Variation>; | ||
engage(deviceId: string): Variation; | ||
} |
@@ -5,2 +5,7 @@ "use strict"; | ||
var LOCAL_STORAGE_EXPERIMENTS_PREFIX = '__analytics_experiments_'; | ||
var checkPercent = function (f) { | ||
if (isNaN(f) || f < 0 || f > 100) { | ||
throw new Error('Variation target percent must be defined as a percent value between 0 and 100'); | ||
} | ||
}; | ||
var LocalExperiment = (function () { | ||
@@ -11,29 +16,23 @@ function LocalExperiment(name, analytics) { | ||
this.data = []; | ||
this.coveredPercent = 0; | ||
} | ||
LocalExperiment.checkPercent = function (f) { | ||
if (isNaN(f) || f < 0 || f > 100) { | ||
throw new Error('Variation fraction must be defines as a percent value between 0 and 100'); | ||
} | ||
}; | ||
LocalExperiment.prototype.checkDuplicates = function (variation) { | ||
var present = this.data.find(function (d) { return d.variation === variation; }); | ||
if (present != null) { | ||
throw new Error("Variation [" + present.variation + " " + present.fraction + "%] already exists in experiment " + this.name + "."); | ||
throw new Error("Variation [" + present.variation + " " + present.targetPercent + "%] already exists in experiment " + this.name + "."); | ||
} | ||
}; | ||
LocalExperiment.dataString = function (data) { | ||
return data.map(function (d) { return "variation " + d.variation + ": " + d.fraction + "%"; }).join(', '); | ||
return data | ||
.map(function (d) { return "variation " + d.variation + ": " + d.targetPercent + "%"; }) | ||
.join(', '); | ||
}; | ||
LocalExperiment.prototype.define = function (variation, fraction) { | ||
LocalExperiment.checkPercent(fraction); | ||
LocalExperiment.prototype.define = function (variation, targetPercent) { | ||
checkPercent(targetPercent); | ||
this.checkDuplicates(variation); | ||
var previousMargin = 0; | ||
if (this.data.length > 0) { | ||
previousMargin = this.data[this.data.length - 1].margin; | ||
} | ||
var margin = previousMargin + fraction; | ||
var varData = { variation: variation, margin: margin, fraction: fraction }; | ||
if (margin > 100) { | ||
var varData = { variation: variation, targetPercent: targetPercent }; | ||
this.coveredPercent += targetPercent; | ||
if (this.coveredPercent > 100) { | ||
var allData = LocalExperiment.dataString(this.data.concat(varData)); | ||
throw new Error("Incorrect fraction in experiment " + this.name + ". Sum of fractions is greater than 100%: " + allData); | ||
throw new Error("Incorrect target percent in experiment " + this.name + ". Sum of fractions is greater than 100%: " + allData); | ||
} | ||
@@ -43,19 +42,13 @@ this.data.push(varData); | ||
}; | ||
LocalExperiment.prototype.defineDefault = function (variation) { | ||
this.defaultVariation = variation; | ||
return this; | ||
}; | ||
LocalExperiment.prototype.engage = function (userId) { | ||
LocalExperiment.prototype.engage = function (deviceId) { | ||
if (this.data.length === 0) { | ||
throw new Error("Variations are not defined for experiment " + this.name); | ||
} | ||
if (this.data[this.data.length - 1].margin < 100 && | ||
this.defaultVariation == null) { | ||
throw new Error("Experiments is not fully defined. Current data: " + LocalExperiment.dataString(this.data)); | ||
if (this.coveredPercent < 100) { | ||
throw new Error("Experiment " + this.name + " is not fully defined. Current data: " + LocalExperiment.dataString(this.data)); | ||
} | ||
if (window.localStorage == null) { | ||
console.log(userId); | ||
return this.defaultVariation || this.data[0].variation; | ||
if ((window === null || window === void 0 ? void 0 : window.localStorage) == null) { | ||
return this.data[0].variation; | ||
} | ||
var key = "" + LOCAL_STORAGE_EXPERIMENTS_PREFIX + this.name + "_" + userId; | ||
var key = "" + LOCAL_STORAGE_EXPERIMENTS_PREFIX + this.name + "_" + deviceId; | ||
var value = window.localStorage.getItem(key); | ||
@@ -67,5 +60,7 @@ if (value != null) { | ||
var result = null; | ||
var margin = 0; | ||
for (var _i = 0, _a = this.data; _i < _a.length; _i++) { | ||
var varData = _a[_i]; | ||
if (dieRoll < varData.margin) { | ||
margin += varData.targetPercent; | ||
if (dieRoll < margin) { | ||
result = varData.variation; | ||
@@ -76,3 +71,3 @@ break; | ||
if (result == null) { | ||
result = this.defaultVariation; | ||
throw new Error("Variations implementation problem: " + LocalExperiment.dataString(this.data)); | ||
} | ||
@@ -79,0 +74,0 @@ window.localStorage.setItem(key, result); |
@@ -31,3 +31,3 @@ "use strict"; | ||
try { | ||
exp.engage('test-user'); | ||
exp.engage('test-device'); | ||
} | ||
@@ -40,8 +40,8 @@ catch (e) { | ||
var twoVariants = new experiment_1.LocalExperiment('test'); | ||
twoVariants.define('var1', 50).defineDefault('var2'); | ||
twoVariants.define('var1', 50).define('var2', 50); | ||
test('engage user idempotence', function () { | ||
var variation = twoVariants.engage('test-user-id'); | ||
var variation = twoVariants.engage('test-device-id'); | ||
expect(variation).toBeTruthy(); | ||
expect(twoVariants.engage('test-user-id')).toStrictEqual(variation); | ||
expect(twoVariants.engage('test-user-id')).toStrictEqual(variation); | ||
expect(twoVariants.engage('test-device-id')).toStrictEqual(variation); | ||
expect(twoVariants.engage('test-device-id')).toStrictEqual(variation); | ||
}); | ||
@@ -52,3 +52,3 @@ test('engage user variance', function () { | ||
for (var i = 0; i < 100; i++) { | ||
if (twoVariants.engage("test-user-id-" + i) === 'var1') { | ||
if (twoVariants.engage("test-device-id-" + i) === 'var1') { | ||
var1Counter++; | ||
@@ -76,3 +76,3 @@ } | ||
it('sets user property', function () { | ||
exp.engage('test-user-1'); | ||
exp.engage('test-device-1'); | ||
expect(identifyCallsCount).toStrictEqual(1); | ||
@@ -79,0 +79,0 @@ }); |
@@ -21,1 +21,6 @@ const client = analyticsClient.createClient({ | ||
client.linkDevices('test-user-1', urlHandler.allDeviceIds()); | ||
const exp = new analyticsClient.LocalExperiment('test-exp') | ||
.define('v1', 50) | ||
.define('v2', 50); | ||
console.log('Variation:', exp.engage(client.deviceId())); |
export { AnalyticsUrlParams } from './src/url-params'; | ||
export { Client, Config, createClient } from './src/client'; | ||
export { Experiment, LocalExperiment } from './src/experiment'; |
{ | ||
"name": "analytics-client", | ||
"version": "0.4.0-roman-experiments-fd969ee053e500586da4050c94ba48bf6ab03da1", | ||
"version": "0.4.0", | ||
"description": "Convenient builders to compose analytics tools", | ||
@@ -14,4 +14,4 @@ "repository": { | ||
"test": "jest", | ||
"prettify": "resin-lint --typescript --fix src/ test/ index.ts", | ||
"lint": "resin-lint --typescript src/ test/ index.ts && tsc --noEmit", | ||
"prettify": "balena-lint --typescript --fix src/ test/ index.ts", | ||
"lint": "balena-lint --typescript src/ test/ index.ts && tsc --noEmit", | ||
"build": "npm run lint && npm run test && tsc && webpack", | ||
@@ -26,2 +26,3 @@ "prepublish": "npm run build" | ||
"devDependencies": { | ||
"@balena/lint": "^4.0.1", | ||
"@types/jest": "^24.0.18", | ||
@@ -35,3 +36,2 @@ "@types/js-cookie": "^2.2.5", | ||
"mixpanel-browser": "^2.29.1", | ||
"resin-lint": "^3.3.1", | ||
"ts-jest": "^24.1.0", | ||
@@ -45,6 +45,6 @@ "ts-loader": "^6.2.0", | ||
"*.ts": [ | ||
"resin-lint --typescript --fix" | ||
"balena-lint --typescript --fix" | ||
], | ||
"test/**/*.ts": [ | ||
"resin-lint --typescript --no-prettier --tests" | ||
"balena-lint --typescript --no-prettier --tests" | ||
] | ||
@@ -51,0 +51,0 @@ }, |
@@ -26,2 +26,23 @@ Analytics client | ||
UI experiments definition. | ||
```typescript | ||
import { createClient, LocalExperiment } from 'analytics-client'; | ||
const client = createClient({projectName: 'my-project'}); | ||
type Variation = 'modal' | 'sidebar-left' | 'sidebar-right'; | ||
const experiment = new LocalExperiment<Variation>('WelcomeUI', client) | ||
.define('modal', 50) | ||
.define('sidebar-left', 25) | ||
.define('sidebar-right', 25); | ||
switch (experiment.engage(client.deviceId())) { | ||
case 'modal': | ||
showModal(); | ||
break; | ||
// ... | ||
} | ||
``` | ||
## Using without npm packages | ||
@@ -28,0 +49,0 @@ |
@@ -9,3 +9,2 @@ import { Identify } from 'amplitude-js'; | ||
name: string; | ||
engage(userId: string): Variation; | ||
@@ -16,6 +15,13 @@ } | ||
variation: Variation; | ||
fraction: number; | ||
margin: number; | ||
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. */ | ||
@@ -25,3 +31,3 @@ export class LocalExperiment<Variation extends string> | ||
private readonly data: Array<VariationData<Variation>> = []; | ||
private defaultVariation?: Variation; | ||
private coveredPercent: number = 0; | ||
@@ -33,10 +39,2 @@ constructor( | ||
private static checkPercent(f: number) { | ||
if (isNaN(f) || f < 0 || f > 100) { | ||
throw new Error( | ||
'Variation fraction must be defines as a percent value between 0 and 100', | ||
); | ||
} | ||
} | ||
private checkDuplicates(variation: Variation) { | ||
@@ -46,3 +44,3 @@ const present = this.data.find(d => d.variation === variation); | ||
throw new Error( | ||
`Variation [${present.variation} ${present.fraction}%] already exists in experiment ${this.name}.`, | ||
`Variation [${present.variation} ${present.targetPercent}%] already exists in experiment ${this.name}.`, | ||
); | ||
@@ -53,20 +51,20 @@ } | ||
private static dataString(data: Array<VariationData<any>>): string { | ||
return data.map(d => `variation ${d.variation}: ${d.fraction}%`).join(', '); | ||
return data | ||
.map(d => `variation ${d.variation}: ${d.targetPercent}%`) | ||
.join(', '); | ||
} | ||
define(variation: Variation, fraction: number): LocalExperiment<Variation> { | ||
LocalExperiment.checkPercent(fraction); | ||
define( | ||
variation: Variation, | ||
targetPercent: number, | ||
): LocalExperiment<Variation> { | ||
checkPercent(targetPercent); | ||
this.checkDuplicates(variation); | ||
const varData = { variation, targetPercent }; | ||
let previousMargin = 0; | ||
if (this.data.length > 0) { | ||
previousMargin = this.data[this.data.length - 1].margin; | ||
} | ||
const margin = previousMargin + fraction; | ||
const varData = { variation, margin, fraction }; | ||
if (margin > 100) { | ||
this.coveredPercent += targetPercent; | ||
if (this.coveredPercent > 100) { | ||
const allData = LocalExperiment.dataString(this.data.concat(varData)); | ||
throw new Error( | ||
`Incorrect fraction in experiment ${this.name}. Sum of fractions is greater than 100%: ${allData}`, | ||
`Incorrect target percent in experiment ${this.name}. Sum of fractions is greater than 100%: ${allData}`, | ||
); | ||
@@ -79,17 +77,11 @@ } | ||
defineDefault(variation: Variation): LocalExperiment<Variation> { | ||
this.defaultVariation = variation; | ||
return this; | ||
} | ||
engage(userId: string): Variation { | ||
engage(deviceId: string): Variation { | ||
if (this.data.length === 0) { | ||
throw new Error(`Variations are not defined for experiment ${this.name}`); | ||
} | ||
if ( | ||
this.data[this.data.length - 1].margin < 100 && | ||
this.defaultVariation == null | ||
) { | ||
if (this.coveredPercent < 100) { | ||
throw new Error( | ||
`Experiments is not fully defined. Current data: ${LocalExperiment.dataString( | ||
`Experiment ${ | ||
this.name | ||
} is not fully defined. Current data: ${LocalExperiment.dataString( | ||
this.data, | ||
@@ -100,9 +92,8 @@ )}`, | ||
if (window.localStorage == null) { | ||
// No storage support. | ||
console.log(userId); | ||
return this.defaultVariation || this.data[0].variation; | ||
if (window?.localStorage == null) { | ||
// No storage support. Return a consistent result. | ||
return this.data[0].variation; | ||
} | ||
const key = `${LOCAL_STORAGE_EXPERIMENTS_PREFIX}${this.name}_${userId}`; | ||
const key = `${LOCAL_STORAGE_EXPERIMENTS_PREFIX}${this.name}_${deviceId}`; | ||
const value = window.localStorage.getItem(key); | ||
@@ -115,4 +106,6 @@ if (value != null) { | ||
let result: Variation | null = null; | ||
let margin = 0; | ||
for (const varData of this.data) { | ||
if (dieRoll < varData.margin) { | ||
margin += varData.targetPercent; | ||
if (dieRoll < margin) { | ||
result = varData.variation; | ||
@@ -123,4 +116,9 @@ break; | ||
if (result == null) { | ||
result = this.defaultVariation!; | ||
throw new Error( | ||
`Variations implementation problem: ${LocalExperiment.dataString( | ||
this.data, | ||
)}`, | ||
); | ||
} | ||
window.localStorage.setItem(key, result); | ||
@@ -127,0 +125,0 @@ if (this.analytics != null) { |
@@ -30,3 +30,3 @@ import { createClient } from '../src/client'; | ||
try { | ||
exp.engage('test-user'); | ||
exp.engage('test-device'); | ||
} catch (e) { | ||
@@ -39,10 +39,10 @@ expect(e.message).toContain('fully'); | ||
const twoVariants = new LocalExperiment<'var1' | 'var2'>('test'); | ||
twoVariants.define('var1', 50).defineDefault('var2'); | ||
twoVariants.define('var1', 50).define('var2', 50); | ||
test('engage user idempotence', () => { | ||
const variation = twoVariants.engage('test-user-id'); | ||
const variation = twoVariants.engage('test-device-id'); | ||
expect(variation).toBeTruthy(); | ||
expect(twoVariants.engage('test-user-id')).toStrictEqual(variation); | ||
expect(twoVariants.engage('test-user-id')).toStrictEqual(variation); | ||
expect(twoVariants.engage('test-device-id')).toStrictEqual(variation); | ||
expect(twoVariants.engage('test-device-id')).toStrictEqual(variation); | ||
}); | ||
@@ -54,3 +54,3 @@ | ||
for (let i = 0; i < 100; i++) { | ||
if (twoVariants.engage(`test-user-id-${i}`) === 'var1') { | ||
if (twoVariants.engage(`test-device-id-${i}`) === 'var1') { | ||
var1Counter++; | ||
@@ -83,3 +83,3 @@ } else { | ||
it('sets user property', () => { | ||
exp.engage('test-user-1'); | ||
exp.engage('test-device-1'); | ||
expect(identifyCallsCount).toStrictEqual(1); | ||
@@ -86,0 +86,0 @@ }); |
Sorry, the diff of this file is too big to display
221159
1367
57