UNPKG

13.7 kBJavaScriptView Raw
1const murmurhash = require('murmurhash');
2const cookie = require('cookie');
3const url = require('url');
4const uuidv4 = require('uuid/v4');
5
6const pkg = require('../package.json');
7const sync = require('./sync');
8const expressionEvaluator = require('./expressionEvaluator');
9
10const Logger = require('./logger');
11
12const HASH_SEED = 1;
13const MAX_HASH_VALUE = Math.pow(2, 32);
14const MAX_TRAFFIC_VALUE = 10000;
15const COOKIE_NAME = 'ABBY_EXPERIMENTS';
16const COOKIE_SESSION = 'ABBY_SESSION';
17const COOKIE_MAX_AGE = 60 * 60 * 24 * 7 * 52; // 1year in seconds
18const COOKIE_SESSION_MAX_AGE = 60 * 45;// 45minutes in seconds;
19const CONTROL_VARIANT = 'control';
20const SEPARATOR = '|';
21
22// region typedef
23
24/**
25 * @typedef {Object} Synchroniser
26 * @param {string} lastSync
27 * @param {Array.<Experiment>} data
28 */
29
30/**
31 * @typedef {Object} Experiment
32 * @property {Number} id
33 * @property {String} name
34 * @property {Boolean} active
35 * @property {Array<Tag>} tags
36 * @property {String} testType
37 * @property {Object} targeting
38 * @property {Object} audence
39 * @property {Array.<Variant>} variants
40 */
41
42/**
43 * @typedef {Object} Variant
44 * @property {String} name
45 * @property {String} code
46 * @property {Object.<String,String>} properties
47 *
48 */
49
50/**
51 * @typedef {Object} Tag
52 * @property {String} name
53 * @property {String} code
54 */
55
56/**
57 * @typedef {Object} Configs
58 * @property {String} API_ENDPOINT
59 * @property {String?} tags
60 * @property {Object?} Logger
61 */
62
63/**
64 * @typedef {Object} Logger
65 * @function {Void} log
66 * @function {Void} debug
67 * @function {Void} error
68 */
69
70/**
71* @typedef {Object} UserExperiment
72* @property {string} experimentCode
73* @property {Object} experiment
74* @property {String} experiment.code
75* @property {Array.<string>} experiment.tags
76* @property {string} variantCode
77* @property {Object} variant
78* @property {String} variant.code
79* @property {Object?} variant.properties
80* @property {boolean} variant.original
81* @property {string?} hash
82* @property {boolean} paused
83*
84*/
85
86/**
87 * @typedef {Object} UserExperimentFilter
88 * @property {Array.<String>?} tags
89 */
90// endregion
91
92
93/**
94 * will return the current hash or generated a new hash if has is not present
95 *
96 * @param {string} experimentCode
97 * @param {UserExperiment?} experiment
98 * @returns {string}
99 */
100function experimentHash(experimentCode, experiment) {
101 return experiment && isFinite(parseInt(experiment.hash))
102 ? experiment.hash
103 : generateHash(`${uuidv4()}_${experimentCode}`);
104}
105
106/**
107* Helper function to generate bucket value in half-closed interval [0, MAX_TRAFFIC_VALUE)
108* @param {string} experimentID String value for experiment ID
109* @return {number} the generated hash value
110*/
111function generateHash(experimentID) {
112 let hashValue = murmurhash.v3(experimentID, HASH_SEED),
113 ratio = hashValue / MAX_HASH_VALUE;
114 return parseInt(ratio * MAX_TRAFFIC_VALUE, 10);
115}
116
117/**
118 * Helper function to generate bucket value in half-closed interval [0, MAX_TRAFFIC_VALUE)
119 * @param {Object} Experiment Object value for experiment
120 * @param {number} hash number value of generated hash
121 * @return {string} the id of available variant
122 */
123function getVariantCode(experiment, hash, currentExperiment) {
124 var percentage = Math.floor((hash * 100) / MAX_TRAFFIC_VALUE),
125 variants = experiment.variants.filter(variant => {
126 let { start, end } = variant.range;
127 return percentage >= start && percentage <= end;
128 });
129
130 if (variants.length === 0) {
131 return CONTROL_VARIANT;
132 } else if (!currentExperiment || currentExperiment.variantCode === CONTROL_VARIANT) {
133 return variants[0].code;
134 } else {
135 return currentExperiment.variantCode;
136 }
137}
138
139/**
140 * will return empty object if variation is not found
141 *
142 * @param {Experiment} experiment
143 * @param {string} variantCode
144 * @returns {Variation}
145 */
146function getVariant(experiment, variantCode) {
147 let variants = experiment.variants;
148 if (Array.isArray(variants)) {
149 for (let i = 0, ii = variants.length; i < ii; i++) {
150 if (variants[i].code === variantCode) {
151 return variants[i];
152 }
153 }
154 }
155 return null;
156}
157
158/**
159 * will add only the active non-paused experiments to the request
160 * @param {http.ClientRequest} req
161 * @param {Array.<UserExperiment>} experiments
162 */
163function assignToRequest(req, experiments) {
164 req.experiments = {};
165 for (let experiment of experiments) {
166 if(experiment.paused) {
167 continue;
168 }
169 req.experiments[experiment.experimentCode] = experiment;
170 }
171}
172
173
174/**
175 * returns ths ABBY_EXPERIMENTS cookie value which expires after 1 YEAR
176 * @param {Object} http.ServerResponse object value for http response
177 * @param {Array<UserExperiment>} experiments array list of cookie data
178 * @return {Void}
179 */
180function experimentsCookieValue(experiments) {
181 let path = '/',
182 maxAge = COOKIE_MAX_AGE,
183 expires = new Date(Date.now() + maxAge);
184
185 let value = experiments
186 .map(item => `${item.experimentCode}${SEPARATOR}${item.variantCode}${SEPARATOR}${item.hash}`)
187 .join(',');
188
189 return cookie.serialize(COOKIE_NAME, value,
190 {
191 path,
192 maxAge,
193 expires
194 });
195}
196
197/**
198 * sets X-Abby-Version header
199 * @param {http.ServerResponse} res
200 * @param {Object} pkg
201 * @param {Synchroniser} synchroniser
202 */
203function assignVersion(res, pk, synchroniser) {
204 if (typeof pkg.version === 'string') {
205 res.setHeader('X-Abby-Version', pkg.version);
206 }
207 if (typeof synchroniser.lastSync === 'string') {
208 res.setHeader('X-Abby-Sync', synchroniser.lastSync);
209 }
210}
211
212/**
213 * returns the ABBY_SESSION cookie value
214 * @param {http.ServerResponse} object value for http response
215 * @param {boolean} newSession
216 * @param {string} sessionId
217 * @return {Void}
218 */
219function sessionCookieValue(newSession, sessionId) {
220 let path = '/',
221 maxAge = COOKIE_SESSION_MAX_AGE,
222 expires = new Date(Date.now() + (maxAge * 1000));
223 return cookie.serialize(COOKIE_SESSION, newSession ? uuidv4() : sessionId, {
224 path,
225 maxAge,
226 expires
227 });
228}
229
230/**
231 * @param {http.ClientRequest} req value for http response
232 * @return {Void}
233 */
234function getCookieData(req) {
235 return req.headers.cookie ? cookie.parse(req.headers.cookie) : {};
236}
237/**
238 *
239 * @param {string} data
240 * @returns {Object.<string, UserExperiment>}
241 */
242function getCurrentExperiments(data) {
243
244 if (typeof data !== 'string') {
245 return {};
246 }
247 let experiments = {};
248 data.split(',').forEach(value => {
249 let [experimentCode, variantCode, hash] = value.split(SEPARATOR);
250 experiments[experimentCode] = { experimentCode, variantCode, hash };
251 });
252 return experiments;
253}
254
255/**
256 *
257 * @param {http.ClientRequest} req
258 * @returns {Object.<string, string>}
259 */
260function getForcedExperimentsFromQuery(req) {
261 const { query: { abby_experiment: query } } = url.parse(req.url, true);
262
263 if (typeof query !== 'string') {
264 return {};
265 }
266
267 let experiments = {};
268 query.split(',').forEach(value => {
269 let [experimentCode, variantCode] = value.split(SEPARATOR);
270 experiments[experimentCode] = variantCode;
271 });
272 return experiments;
273}
274
275/**
276 *
277 * @param {string} sessionId
278 */
279function validateSession(sessionId) {
280 return typeof sessionId !== 'string' || sessionId.trim().length === 0;
281}
282
283
284/**
285 *
286 * @param {Experiment} experiment
287 * @param {Variant} variant
288 * @param {string} hash
289 * @returns {UserExperiment}
290 *
291 */
292function buildUserExperiment(experiment, variant, hash) {
293 return {
294 experimentCode: experiment.code,
295 experiment: {
296 code: experiment.code,
297 tags: experiment.tags.map(tag => tag.name),
298 audience: experiment.audience,
299 targeting: experiment.targeting
300 },
301 variantCode: variant.code,
302 variant: {
303 code: variant.code,
304 properties: Object.assign({}, variant.properties),
305 original: variant.original
306 },
307 hash,
308 paused: !experiment.active
309 };
310}
311
312/**
313 *
314 * @param {Array.<UserExperiment>} experiments
315 * @param {UserExperimentFilter} filter
316 */
317function filterUserExperiments(experiments, filter = {}) {
318 let result = experiments;
319 if (filter.tags) {
320 result = result.filter(({ experiment }) =>
321 filter.tags.every(tag => experiment.tags.find(value => value === tag))
322 );
323 }
324 return result;
325}
326
327/**
328 *
329 * returns all targetedExperiments which do not have original variant
330 * original variant will not be used when implementing experiments
331 * because original variants are the original view of the website/channel
332 *
333 * @param {Array.<UserExperiment>} userExperiments
334 * @param {Object} props
335 * @param {UserExperimentFilter?} filter
336 */
337function getTargetedExperiments(userExperiments, props, filter = {}) {
338 return filterUserExperiments(userExperiments, filter)
339 .filter(({ experiment, variant }) => !variant.original && expressionEvaluator.evaluate(experiment.targeting, props));
340}
341
342/**
343 * sets the cookie on the response and preserves any
344 * existing cookies that do not start with ABBY_
345 * adds all experiments also the paused/non-active user experiments
346 *
347 * @param {http.ServerResponse} res
348 * @param {Array.<String>} values
349 */
350function assignCookies(res, ...values) {
351 let cookies = [],
352 cookieHeader = res.getHeader('Set-Cookie');
353
354 if (typeof cookieHeader !== 'undefined' && cookieHeader !== null) {
355 if (Array.isArray(cookieHeader)) {
356 cookies = cookie.concat(cookieHeader);
357 } else {
358 cookies.push(cookieHeader);
359 }
360 }
361 // remove all current abby cookies
362 cookies = cookies.filter(cookie => !cookie.startsWith('ABBY_'));
363 // push the new values
364 cookies = cookies.concat(values);
365 res.setHeader('Set-Cookie', cookies);
366}
367
368/**
369 *
370 * @param {Experiment} experiment
371 * @param {Array<String>?} rebalance optional list of experimentcodes to be rebalance
372 * @returns {Boolean}
373 */
374function rebalanceExperiment(experiment, rebalance = []) {
375 if (Array.isArray(rebalance)) {
376 return rebalance.find(code => code === experiment.code);
377 }
378 return false;
379}
380
381function abby(logger, synchroniser, configs) {
382
383 /**
384 *
385 * when the user already has a session cookie set than only change the forced experiments
386 * otherwise validate the current experiments and migrate to different variant if needed
387 *
388 * @param {http.ClientRequest} req
389 * @param {http.ServerResponse} res
390 * @param {Object?} props
391 * @param {Object?} config
392 * @param {Array<String>} config.rebalance
393 */
394 function handle(req, res, props = {}, config = {}) {
395 let cookies = getCookieData(req),
396 sessionCookie = cookies[COOKIE_SESSION],
397 newSession = validateSession(sessionCookie),
398 currentExperiments = getCurrentExperiments(cookies[COOKIE_NAME]),
399 forcedExperiments = getForcedExperimentsFromQuery(req);
400
401
402 let /**Object.<string, UserExperiment>*/experimentMap = {};
403 for (let /**Experiment*/ experiment of /**Array.<Experiment>*/synchroniser.data) {
404 let experimentCode = experiment.code,
405 hash,
406 variantCode,
407 /**
408 * @type {UserExperiment}
409 */
410 currentExperiment = currentExperiments[experimentCode],
411 forced = forcedExperiments[experimentCode],
412 rebalance = rebalanceExperiment(experiment, config.rebalance);
413
414 if (forced || rebalance) {
415 hash = currentExperiment ? currentExperiment.hash : experimentHash(experimentCode, currentExperiment);
416 if (rebalance) {
417 variantCode = getVariantCode(experiment, hash, null);
418 } else {
419 variantCode = forced;
420 }
421 } else if (newSession) {
422 if (!expressionEvaluator.evaluate(experiment.audience, props)) {
423 continue;
424 }
425 hash = experimentHash(experimentCode, currentExperiment);
426 variantCode = getVariantCode(experiment, hash, currentExperiment);
427 } else if (currentExperiment) {
428 hash = currentExperiment.hash;
429 variantCode = currentExperiment.variantCode;
430 }
431
432 let variant = getVariant(experiment, variantCode);
433 if (variant === null) {
434 continue;
435 }
436
437 experimentMap[experimentCode] = buildUserExperiment(experiment, variant, hash);
438 }
439 let userExperiments = Object.values(experimentMap);
440 assignToRequest(req, userExperiments);
441 assignVersion(res, pkg, synchroniser);
442 // order is important otherwise experiments might not get assigned
443 assignCookies(res, experimentsCookieValue(userExperiments), sessionCookieValue(newSession, sessionCookie));
444 }
445 handle.ready = () => synchroniser.load(configs);
446 handle.getTargetedExperiments = getTargetedExperiments;
447 return handle;
448}
449
450module.exports = function (/**Configs*/configs, /**Synchroniser?*/synchroniser = sync) {
451 let { tags, apiEndpoint, logger } = Object.assign({}, configs);
452 let logger_ = Logger.instance(logger ? logger : console);
453
454 if (!tags) {
455 logger.error(new Error('You should pass <tags>'));
456 return;
457 }
458 if (!apiEndpoint) {
459 logger.error(new Error('You should pass <apiEndpoint>'));
460 return;
461 }
462 return abby(logger_, synchroniser, configs);
463};