@croct/rule-engine-experiments
Version:
A rule engine extension for A/B and multivariate testing.
241 lines (240 loc) • 9.88 kB
JavaScript
import { __read, __spread, __values } from "tslib";
import { formatCause } from '@croct/plug/sdk/validation';
import { And, Contains, Variable } from '@croct/plug-rule-engine/predicate';
import { propertiesSchema } from './schemas';
var ExperimentsExtension = /** @class */ (function () {
function ExperimentsExtension(experiments, tracker, browserStorage, tagStorage, logger) {
this.experiments = experiments;
this.tracker = tracker;
this.browserStorage = browserStorage;
this.tabStorage = tagStorage;
this.logger = logger;
}
ExperimentsExtension.prototype.getVariables = function () {
var e_1, _a;
var _this = this;
var variables = {};
var _loop_1 = function (testId) {
variables[testId] = function () { return Promise.resolve(_this.assignGroup(testId)); };
};
try {
for (var _b = __values(Object.keys(this.experiments)), _c = _b.next(); !_c.done; _c = _b.next()) {
var testId = _c.value;
_loop_1(testId);
}
}
catch (e_1_1) { e_1 = { error: e_1_1 }; }
finally {
try {
if (_c && !_c.done && (_a = _b.return)) _a.call(_b);
}
finally { if (e_1) throw e_1.error; }
}
return variables;
};
ExperimentsExtension.prototype.getPredicate = function (_a) {
var name = _a.name, experiment = _a.properties.experiment;
if (experiment === undefined) {
return null;
}
try {
propertiesSchema.validate(experiment);
}
catch (error) {
this.logger.error("Invalid experiment properties specified for rule \"" + name + "\": " + formatCause(error));
return null;
}
var _b = experiment, testId = _b.testId, groupId = _b.groupId;
return this.getGroupCondition(testId, groupId);
};
ExperimentsExtension.prototype.getGroupCondition = function (testId, groupId) {
var groupCondition = new Contains(testId, groupId);
var audience = this.experiments[testId].audience;
if (audience === undefined) {
return groupCondition;
}
return new And(groupCondition, new Variable(audience));
};
ExperimentsExtension.prototype.assignGroup = function (testId) {
var experiment = this.experiments[testId];
switch (experiment.type) {
case 'ab': {
var group = this.assignAbGroup(testId, experiment);
if (group !== null) {
return [group];
}
return [];
}
case 'multivariate': {
var groups = this.assignMultivariateGroups(testId, experiment);
return __spread([groups.join('|')], groups);
}
}
};
ExperimentsExtension.prototype.assignAbGroup = function (testId, experiment) {
var previousGroupId = null;
var serializedGroupId = this.tabStorage.getItem(testId);
if (serializedGroupId !== null) {
previousGroupId = deserializeGroup(serializedGroupId);
if (previousGroupId !== null) {
return previousGroupId === '' ? null : previousGroupId;
}
}
serializedGroupId = this.browserStorage.getItem(testId);
if (serializedGroupId !== null) {
previousGroupId = deserializeGroup(serializedGroupId);
if (previousGroupId !== null) {
this.tabStorage.setItem(testId, JSON.stringify(previousGroupId));
if (previousGroupId !== '') {
this.trackAssignedAbGroup(testId, previousGroupId);
}
return previousGroupId === '' ? null : previousGroupId;
}
}
this.logger.debug("No group previously assigned to A/B test \"" + testId + "\"");
var groupId = this.selectAbGroup(experiment);
serializedGroupId = JSON.stringify(groupId !== null && groupId !== void 0 ? groupId : '');
this.tabStorage.setItem(testId, serializedGroupId);
this.browserStorage.setItem(testId, serializedGroupId);
if (groupId !== null) {
this.trackAssignedAbGroup(testId, groupId);
}
this.logger.debug(groupId === null
? "Traffic ineligible for A/B test \"" + testId + "\"."
: "Group \"" + groupId + "\" assigned to A/B test \"" + testId + "\".");
return groupId;
};
ExperimentsExtension.prototype.trackAssignedAbGroup = function (testId, groupId) {
var _this = this;
var event = {
testId: testId,
groupId: groupId,
};
this.tracker.track('testGroupAssigned', event).catch(function () {
_this.logger.error("Failed to track group assignment \"" + event.groupId + "\" for test \"" + event.testId + "\".");
});
};
ExperimentsExtension.prototype.selectAbGroup = function (_a) {
var e_2, _b;
var traffic = _a.traffic, groups = _a.groups;
if (traffic !== undefined && Math.random() >= traffic) {
return null;
}
if (Array.isArray(groups)) {
// Evenly split groups
return groups[Math.floor(Math.random() * groups.length)];
}
// Weighted groups
var sum = 0;
var choices = [];
try {
for (var _c = __values(Object.entries(groups)), _d = _c.next(); !_d.done; _d = _c.next()) {
var _e = __read(_d.value, 2), name_1 = _e[0], weight = _e[1].weight;
sum += weight * 100;
choices.push({ group: name_1, weight: weight * 100 });
}
}
catch (e_2_1) { e_2 = { error: e_2_1 }; }
finally {
try {
if (_d && !_d.done && (_b = _c.return)) _b.call(_c);
}
finally { if (e_2) throw e_2.error; }
}
var random = Math.floor(Math.random() * sum);
for (var index = 0; index < choices.length - 1; index++) {
random -= choices[index].weight;
if (random < 0) {
return choices[index].group;
}
}
return choices[choices.length - 1].group;
};
ExperimentsExtension.prototype.assignMultivariateGroups = function (testId, experiment) {
var previousGroupIds = null;
var serializedGroupIds = this.tabStorage.getItem(testId);
if (serializedGroupIds !== null) {
previousGroupIds = deserializeGroups(serializedGroupIds);
if (previousGroupIds !== null) {
return previousGroupIds;
}
}
serializedGroupIds = this.browserStorage.getItem(testId);
if (serializedGroupIds !== null) {
previousGroupIds = deserializeGroups(serializedGroupIds);
if (previousGroupIds !== null) {
this.tabStorage.setItem(testId, JSON.stringify(previousGroupIds));
if (previousGroupIds.length > 0) {
this.trackAssignedMultivariateGroup(testId, previousGroupIds);
}
return previousGroupIds;
}
}
this.logger.debug("No group previously assigned to multivariate test \"" + testId + "\"");
var groupIds = this.selectMultivariateGroups(experiment);
serializedGroupIds = JSON.stringify(groupIds);
this.tabStorage.setItem(testId, serializedGroupIds);
this.browserStorage.setItem(testId, serializedGroupIds);
if (groupIds.length > 0) {
this.trackAssignedMultivariateGroup(testId, groupIds);
}
this.logger.debug(groupIds.length === 0
? "Traffic ineligible for multivariate test \"" + testId + "\"."
: "Groups [\"" + groupIds.join('", "') + "\"] assigned to multivariate test \"" + testId + "\".");
return groupIds;
};
ExperimentsExtension.prototype.trackAssignedMultivariateGroup = function (testId, groupIds) {
var _this = this;
var event = {
testId: testId,
groupId: groupIds.join('|'),
};
this.tracker.track('testGroupAssigned', event).catch(function () {
_this.logger.error("Failed to track group assignment \"" + event.groupId + "\" for test \"" + event.testId + "\".");
});
};
ExperimentsExtension.prototype.selectMultivariateGroups = function (_a) {
var traffic = _a.traffic, groups = _a.groups;
if (traffic !== undefined && Math.random() >= traffic) {
return [];
}
var combinationCount = groups.reduce(function (count, group) { return count * group.length; }, 1);
var combinationIndex = Math.floor(Math.random() * combinationCount);
var combination = [];
for (var index = groups.length - 1; index >= 0; index--) {
var currentGroupLength = groups[index].length;
var currentVariation = groups[index][combinationIndex % currentGroupLength];
combination.unshift(currentVariation);
combinationIndex = Math.floor(combinationIndex / currentGroupLength);
}
return combination;
};
return ExperimentsExtension;
}());
export default ExperimentsExtension;
function deserializeGroup(json) {
var value;
try {
value = JSON.parse(json);
}
catch (_a) {
return null;
}
if (typeof value === 'string') {
return value;
}
return null;
}
function deserializeGroups(json) {
var value;
try {
value = JSON.parse(json);
}
catch (_a) {
return null;
}
if (!Array.isArray(value)) {
return null;
}
return value.filter(function (element) { return typeof element === 'string'; });
}