UNPKG

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