experiments
Advanced tools
Comparing version 0.1.0 to 0.2.0
{ | ||
"name": "experiments", | ||
"version": "0.1.0", | ||
"version": "0.2.0", | ||
"author": "Matt Chadburn <matt.chadburn@ft.com>", | ||
"description": "Simple split testing", | ||
"contributors": [ | ||
"Matt Chadburn", | ||
"Guardian News & Media Ltd" | ||
"Matt Chadburn" | ||
], | ||
"repository": { | ||
"type": "git", | ||
"url": "git@github.com:commuterjoy/experiments.git" | ||
}, | ||
"engines": { | ||
"node": "*" | ||
}, | ||
"keywords": [ | ||
"ab testing", | ||
"split testing" | ||
], | ||
"dependencies": { | ||
"seedrandom": "2.3.4" | ||
}, | ||
"devDependencies": { | ||
@@ -47,3 +41,3 @@ "browserify": "*", | ||
}, | ||
"license": "Apache-2.0" | ||
"license": "MIT" | ||
} |
@@ -9,7 +9,8 @@ A JavaScript AB testing framework, ported from [http://www.github.com/guardian/frontend](guardian/frontend). | ||
- Basic segmentation of audience. | ||
- Fixed duration tests - that automatically close and delete themselves. | ||
- Deterministic segmentation - allowing allocation of users in to tests based on some external key (Eg, user name) | ||
- Fixed duration tests - that automatically close and delete their footprint. | ||
- Isolation of each test audience - so a user can not accidently be in several tests at once. | ||
- Agnostic of where the data is logged - most companies have their own customer data repoisitories. | ||
- Minimal payload - ~1kb (minified + gzip) with no additional cookie overhead created. | ||
### Experiment profiles | ||
@@ -16,0 +17,0 @@ |
284
src/ab.js
@@ -1,150 +0,258 @@ | ||
/*global module*/ | ||
/*global module,require */ | ||
require('seedrandom'); | ||
/** | ||
* Represents a single Ab test | ||
* @type {Function} | ||
* @param {Object} profile | ||
* @param {Object} opts (optional) | ||
*/ | ||
var Ab = function (profile, opts) { | ||
"use strict"; | ||
this.opts = opts || {}; | ||
this.profile = profile; | ||
this.storagePrefix += profile.id; | ||
// All users need to be allocated to a test percentile | ||
this.allocateId(); | ||
"use strict"; | ||
if (!this.idValidator.test(this.profile.id)) { | ||
throw new Error('Invalid test profile id'); | ||
} | ||
// if a variant is supplied then force the user in to that test | ||
if (!!this.opts.variant) { | ||
this.addParticipation(this.profile.id, this.opts.variant); | ||
} | ||
this.opts = opts || {}; | ||
this.seed = this.opts.seed; | ||
this.profile = profile; | ||
this.storagePrefix += profile.id; | ||
// All users need to be allocated to a test percentile | ||
this.allocateId(); | ||
if (!this.idValidator.test(this.profile.id)) { | ||
throw new Error('Invalid test profile id'); | ||
} | ||
// if a variant is supplied then force the user in to that test | ||
if (!!this.opts.variant) { | ||
this.addParticipation(this.profile.id, this.opts.variant); | ||
} | ||
return this; | ||
}; | ||
/** | ||
* Represents the minimum end of the range of a user's ID. Ie. Allocation | ||
* of user ID's should be evenly distributed between min and max to ensure | ||
* the segmention is fair. | ||
* @type {Number} | ||
*/ | ||
Ab.prototype.min = 0; | ||
Ab.prototype.max = 1000; | ||
/** | ||
* Represents the upper end of the range of a user's ID. | ||
* @type {Number} | ||
*/ | ||
Ab.prototype.max = 1000000; | ||
/** | ||
* The localStorage key of the user id. | ||
* @type {String} | ||
*/ | ||
Ab.prototype.uidKey = 'ab__uid'; | ||
/** | ||
* If set, the allocation of the uidKey is generated with a seeded random | ||
* number. This makes the uid deterministic, while making the allocation evenly | ||
* distributed from 0 to 1 across all test subjects. A good idea is to seed the | ||
* number with a persistant string or integer held externally, for example a | ||
* logged in user account. This ensures the user gets allocated in to the same | ||
* test bucket and variant across devices, sessions etc. even if the | ||
* localStorage data is disgarded. | ||
* | ||
* Ref: https://github.com/davidbau/seedrandom | ||
* | ||
* @type {String} | ||
*/ | ||
Ab.prototype.seed = undefined; | ||
/** | ||
* The localstorage key prefix for each AB test. | ||
* @type {String} | ||
*/ | ||
Ab.prototype.storagePrefix = 'ab__'; | ||
/** | ||
* The test profile. | ||
* @type {Object} | ||
*/ | ||
Ab.prototype.profile = {}; | ||
/** | ||
* Each test can be marked as complete (AKA. a conversion), so this property | ||
* represents the state of the test for ecah user. | ||
* @type {Boolean} | ||
*/ | ||
Ab.prototype.isComplete = false; | ||
/** | ||
* The valid pattern for each AB test's ID. | ||
* @type {RegExp} | ||
*/ | ||
Ab.prototype.idValidator = /^[a-z0-9-]{1,10}$/; | ||
/** | ||
* Gets the state of the user for this experiment | ||
* @return {Object} An object representing that state | ||
*/ | ||
Ab.prototype.getParticipation = function () { | ||
"use strict"; | ||
var db = localStorage.getItem(this.storagePrefix); | ||
return (db) ? JSON.parse(db) : {}; | ||
"use strict"; | ||
var db = localStorage.getItem(this.storagePrefix); | ||
return (db) ? JSON.parse(db) : {}; | ||
}; | ||
/** | ||
* Remove a user's participation from an experiment | ||
* @return {Boolean} | ||
*/ | ||
Ab.prototype.removeParticipation = function () { | ||
"use strict"; | ||
return localStorage.removeItem(this.storagePrefix); | ||
"use strict"; | ||
return localStorage.removeItem(this.storagePrefix); | ||
}; | ||
/** | ||
* Allow a user to join an experiment | ||
*/ | ||
Ab.prototype.addParticipation = function(test, variantId) { | ||
"use strict"; | ||
localStorage.setItem(this.storagePrefix, JSON.stringify({ | ||
"id": test, | ||
"variant": variantId | ||
})); | ||
"use strict"; | ||
localStorage.setItem(this.storagePrefix, JSON.stringify({ | ||
"id": test, | ||
"variant": variantId | ||
})); | ||
}; | ||
/** | ||
* Tests whether the experiment has expired | ||
* @return {Boolean} | ||
*/ | ||
Ab.prototype.hasExpired = function () { | ||
"use strict"; | ||
return (new Date() > this.profile.expiry); | ||
"use strict"; | ||
return (new Date() > this.profile.expiry); | ||
}; | ||
/** | ||
* Remove a user's participation from an experiment | ||
* @return {Object} | ||
*/ | ||
Ab.prototype.clean = function () { | ||
"use strict"; | ||
this.removeParticipation(); | ||
"use strict"; | ||
this.removeParticipation(); | ||
return this; | ||
}; | ||
/** | ||
* Puts the user in a test variant | ||
* @return {Object} | ||
*/ | ||
Ab.prototype.segment = function () { | ||
"use strict"; | ||
"use strict"; | ||
var smallestTestId = this.max * this.profile.audienceOffset, | ||
largestTestId = smallestTestId + this.max * this.profile.audience; | ||
var smallestTestId = this.max * this.profile.audienceOffset, | ||
largestTestId = smallestTestId + this.max * this.profile.audience; | ||
// deterministically allocate the user in to a test variant | ||
var allocateVariant = function (id, profile) { | ||
return profile.variants[id % profile.variants.length].id; | ||
}; | ||
// check if not already a member of this experiment | ||
if (this.getParticipation().id === this.profile.id) { | ||
return false; | ||
} | ||
// deterministically allocate the user in to a test variant | ||
var allocateVariant = function (id, profile) { | ||
return profile.variants[id % profile.variants.length].id; | ||
}; | ||
// check if not already a member of this experiment | ||
if (this.getParticipation().id === this.profile.id) { | ||
return this; | ||
} | ||
// check the test has not passed it's expiry date | ||
if (this.hasExpired()) { | ||
return false; | ||
} | ||
// check the test has not passed it's expiry date | ||
if (this.hasExpired()) { | ||
return this; | ||
} | ||
// check the test can be exectuted in this context | ||
if (!this.profile.canRun.call(this)) { | ||
return false; | ||
} | ||
// check the test can be exectuted in this context | ||
if (!this.profile.canRun.call(this)) { | ||
return this; | ||
} | ||
if (smallestTestId <= this.getId() && largestTestId > this.getId()) { | ||
var variant = allocateVariant(this.getId(), this.profile); | ||
this.addParticipation(this.profile.id, variant); | ||
return variant; | ||
} else { | ||
this.addParticipation(this.profile.id, 'not-in-test'); | ||
} | ||
if (smallestTestId <= this.getId() && largestTestId > this.getId()) { | ||
var variant = allocateVariant(this.getId(), this.profile); | ||
this.addParticipation(this.profile.id, variant); | ||
} else { | ||
this.addParticipation(this.profile.id, 'not-in-test'); | ||
} | ||
return this; | ||
}; | ||
/** | ||
* Has the user been allocated a permanent AB test ID | ||
* @return {Boolean} | ||
*/ | ||
Ab.prototype.hasId = function () { | ||
"use strict"; | ||
return !!localStorage.getItem(this.uidKey); | ||
"use strict"; | ||
return !!localStorage.getItem(this.uidKey); | ||
}; | ||
/** | ||
* Get the user's AB test ID | ||
* @return {Boolean} | ||
*/ | ||
Ab.prototype.getId = function () { | ||
"use strict"; | ||
return parseInt(localStorage.getItem(this.uidKey)); | ||
"use strict"; | ||
return parseInt(localStorage.getItem(this.uidKey)); | ||
}; | ||
/** | ||
* Set the user's AB test ID | ||
* @return {String} | ||
*/ | ||
Ab.prototype.setId = function (n) { | ||
"use strict"; | ||
localStorage.setItem(this.uidKey, n); | ||
return n; | ||
"use strict"; | ||
localStorage.setItem(this.uidKey, n); | ||
return n; | ||
}; | ||
/** | ||
* Allocate the user a permanent test ID | ||
* @return {Object} | ||
*/ | ||
Ab.prototype.allocateId = function () { | ||
"use strict"; | ||
// TODO for signed in people we should create a key off their user ids, I.e. deterministic | ||
var generateRandomInteger = function(min, max) { | ||
return Math.floor(Math.random() * (max - min + 1) + min); | ||
}; | ||
"use strict"; | ||
switch (this.hasId()) { | ||
case true: | ||
return this.getId(); | ||
default: | ||
return this.setId(generateRandomInteger(this.min, this.max)); | ||
} | ||
var generateRandomInteger = function(min, max, seed) { | ||
var rng = (seed) ? new Math.seedrandom(seed) : Math.random; | ||
return Math.floor(rng() * (max - min + 1) + min); | ||
}; | ||
switch (this.hasId()) { | ||
case true: | ||
return this.getId(); | ||
default: | ||
return this.setId(generateRandomInteger(this.min, this.max, this.seed)); | ||
} | ||
}; | ||
/** | ||
* Run the AB test | ||
* @return {Object} | ||
*/ | ||
Ab.prototype.run = function () { | ||
"use strict"; | ||
var belongsTo = this.getParticipation().variant; | ||
this.profile.variants.forEach(function (v) { | ||
if (v.id === belongsTo) { | ||
v.test.call(); | ||
} | ||
}); | ||
"use strict"; | ||
var belongsTo = this.getParticipation().variant; | ||
this.profile.variants.forEach(function (v) { | ||
if (v.id === belongsTo) { | ||
v.test.call(); | ||
} | ||
}); | ||
return this; | ||
}; | ||
// a conversion | ||
/** | ||
* Mark the AB test as complete, IE. a successful conversion | ||
* @return {Object} | ||
*/ | ||
Ab.prototype.complete = function () { | ||
"use strict"; | ||
this.isComplete = true; | ||
"use strict"; | ||
this.isComplete = true; | ||
return this; | ||
}; | ||
module.exports = Ab; |
@@ -9,2 +9,3 @@ /*global require,describe,beforeEach,it,expect,spyOn*/ | ||
// mock test | ||
var test = { | ||
@@ -53,4 +54,10 @@ id: 'foo', | ||
var a = new Ab(test); | ||
expect(a.getId()).toEqual(852); | ||
expect(a.getId()).toEqual(851230); | ||
}); | ||
it("segmentation should be optionally deterministic", function() { | ||
var a = new Ab(test, { seed: 'abc' }).complete(); | ||
expect(a.getId()).toEqual(731943); | ||
}); | ||
@@ -64,4 +71,3 @@ it('should not reassign the user to audience segment if one already exists', function() { | ||
it('should allocate the user to a test variant', function() { | ||
var a = new Ab(test); | ||
expect(a.segment()).toEqual('A'); | ||
new Ab(test).segment(); | ||
expect(localStorage.getItem('ab__foo')).toEqual('{"id":"foo","variant":"A"}'); | ||
@@ -72,4 +78,3 @@ }); | ||
var t = Object.create(test, { audienceOffset: { value: 0.3 } }); | ||
var a = new Ab(t); | ||
a.segment(); | ||
new Ab(t).segment(); | ||
expect(localStorage.getItem('ab__foo')).toEqual('{"id":"foo","variant":"not-in-test"}'); | ||
@@ -81,4 +86,3 @@ }); | ||
localStorage.setItem('ab__foo', t); | ||
var a = new Ab(test); | ||
a.segment(); | ||
new Ab(test).segment(); | ||
expect(localStorage.getItem('ab__foo')).toEqual(t); | ||
@@ -92,4 +96,3 @@ }); | ||
}}); | ||
var a = new Ab(t); | ||
a.segment(); | ||
new Ab(t).segment(); | ||
expect(localStorage.getItem('ab__foo')).toBeNull(); | ||
@@ -99,4 +102,3 @@ }); | ||
it("Mark the test as complete", function() { | ||
var a = new Ab(test); | ||
a.complete(); | ||
var a = new Ab(test).complete(); | ||
expect(a.isComplete).toBeTruthy(); | ||
@@ -107,4 +109,3 @@ }); | ||
var t = Object.create(test, { audienceOffset: { value: 0.3 } }); | ||
var a = new Ab(t, { variant: 'B' }); | ||
a.segment(); | ||
new Ab(t, { variant: 'B' }).segment(); | ||
expect(localStorage.getItem('ab__foo')).toEqual('{"id":"foo","variant":"B"}'); | ||
@@ -116,4 +117,3 @@ }); | ||
var t = Object.create(test, { expiry: { value: dateInThePast }}); | ||
var a = new Ab(t); | ||
a.segment(); | ||
new Ab(t).segment(); | ||
expect(localStorage.getItem('ab__foo')).toBeNull(); | ||
@@ -123,4 +123,3 @@ }); | ||
it('should remove participation from a test', function () { | ||
var a = new Ab(test); | ||
a.segment(); | ||
var a = new Ab(test).segment(); | ||
expect(localStorage.getItem('ab__foo')).toEqual('{"id":"foo","variant":"A"}'); | ||
@@ -130,3 +129,3 @@ a.clean(); | ||
}); | ||
}); | ||
@@ -139,5 +138,3 @@ | ||
spyOn(variant, 'test'); | ||
var a = new Ab(test); | ||
a.segment(); | ||
a.run(); | ||
new Ab(test).segment().run(); | ||
expect(variant.test.calls.count()).toBe(1); | ||
@@ -151,4 +148,3 @@ }); | ||
it('should return the variant of a test that current user is participating in', function () { | ||
var ab = new Ab(test); | ||
ab.segment(); | ||
var ab = new Ab(test).segment(); | ||
expect(ab.getParticipation().variant).toBe('A'); | ||
@@ -155,0 +151,0 @@ }); |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Dynamic require
Supply chain riskDynamic require can indicate the package is performing dangerous or unsafe dynamic code execution.
Found 1 instance in 1 package
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
Found 1 instance in 1 package
Mixed license
License(Experimental) Package contains multiple licenses.
Found 1 instance in 1 package
No repository
Supply chain riskPackage does not have a linked source code repository. Without this field, a package will have no reference to the location of the source code use to generate the package.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Unidentified License
License(Experimental) Something that seems like a license was found, but its contents could not be matched with a known license.
Found 1 instance in 1 package
88799
24
100
1495
97
1
3
5
1
+ Addedseedrandom@2.3.4
+ Addedseedrandom@2.3.4(transitive)