scientist
Version:
Carefully refactor critical paths in production
209 lines (182 loc) • 6.19 kB
JavaScript
var EventEmitter, Experiment, Observation, Promise, Result, _, expects,
extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
hasProp = {}.hasOwnProperty,
slice = [].slice;
_ = require('underscore');
EventEmitter = require('events').EventEmitter;
Promise = require('bluebird');
Observation = require('./observation');
Result = require('./result');
expects = function(type, wrapped) {
return function(arg) {
if (typeof arg !== type) {
throw TypeError("Expected " + type + ", got " + arg);
}
return wrapped.call(this, arg);
};
};
Experiment = (function(superClass) {
extend(Experiment, superClass);
function Experiment(name) {
Experiment.__super__.constructor.call(this);
this.name = name;
this._behaviors = {};
this._options = {
context: {},
async: false,
skipper: _.constant(false),
mapper: _.identity,
ignorers: [],
comparator: _.isEqual,
cleaner: _.identity
};
}
Experiment.prototype.use = function(block) {
return this["try"]('control', block);
};
Experiment.prototype["try"] = function() {
var arg1, block, i, name;
arg1 = 2 <= arguments.length ? slice.call(arguments, 0, i = arguments.length - 1) : (i = 0, []), block = arguments[i++];
name = arg1[0];
if (name == null) {
name = 'candidate';
}
if (name in this._behaviors) {
throw Error("Duplicate behavior: " + name);
}
if (!_.isFunction(block)) {
throw TypeError("Invalid block: expected function, got " + block);
}
return this._behaviors[name] = block;
};
Experiment.prototype.run = function(sampler) {
var candidates, control, hasNoBehaviors, observations, shouldNotSample, shouldSkip, skipReason;
if (!('control' in this._behaviors)) {
throw Error("Expected control behavior to be defined");
}
hasNoBehaviors = _.size(this._behaviors) < 2;
shouldNotSample = this._try("Sampler", (function(_this) {
return function() {
return !sampler(_this.name);
};
})(this));
shouldSkip = this._try("Skipper", (function(_this) {
return function() {
return _this._options.skipper();
};
})(this));
skipReason = (function() {
switch (false) {
case !hasNoBehaviors:
return "No behaviors defined";
case !shouldNotSample:
return "Sampler returned false";
case !shouldSkip:
return "Skipper returned true";
}
})();
if (skipReason) {
this._try("Skip handler", (function(_this) {
return function() {
return _this.emit('skip', _this, skipReason);
};
})(this));
return this._behaviors.control();
}
observations = _(this._behaviors).chain().keys().shuffle().map((function(_this) {
return function(key) {
return new Observation(key, _this._behaviors[key], _this._options);
};
})(this)).value();
control = _.find(observations, {
name: 'control'
});
candidates = _.without(observations, control);
this._sendResults([control].concat(candidates));
return control.evaluation();
};
Experiment.prototype._sendResults = function(observations) {
var mapped;
mapped = this._try("Map", (function(_this) {
return function() {
return _.invoke(observations, 'map', _this._mapper.bind(_this));
};
})(this));
if (!mapped) {
return;
}
return Promise.map(mapped, this._settle.bind(this)).spread((function(_this) {
return function() {
var candidates, control, result;
control = arguments[0], candidates = 2 <= arguments.length ? slice.call(arguments, 1) : [];
result = _this._try("Comparison", function() {
return new Result(_this, control, candidates);
});
if (!result) {
return;
}
return _this._try("Result handler", function() {
return _this.emit('result', result);
});
};
})(this)).done();
};
Experiment.prototype.context = function(context) {
return _.extend(this._options.context, context);
};
Experiment.prototype.async = expects('boolean', function(async) {
return this._options.async = async;
});
Experiment.prototype.skipWhen = expects('function', function(skipper) {
return this._options.skipper = skipper;
});
Experiment.prototype.map = expects('function', function(mapper) {
return this._options.mapper = mapper;
});
Experiment.prototype.ignore = expects('function', function(ignorer) {
return this._options.ignorers.push(ignorer);
});
Experiment.prototype.compare = expects('function', function(comparator) {
return this._options.comparator = comparator;
});
Experiment.prototype.clean = expects('function', function(cleaner) {
return this._options.cleaner = cleaner;
});
Experiment.prototype._try = function(operation, block) {
var err, error;
try {
return block();
} catch (error) {
err = error;
this.emit('error', this._decorateError(err, operation + " failed"));
return null;
}
};
Experiment.prototype._settle = function(observation) {
if (this._options.async) {
return observation.settle();
} else {
return observation;
}
};
Experiment.prototype._mapper = function(val) {
var result;
if (this._options.async) {
result = this._options.mapper(Promise.resolve(val));
if (!_.isFunction(result != null ? result.then : void 0)) {
throw Error("Result of async mapping must be a thenable, got " + result);
}
return result;
} else {
return this._options.mapper(val);
}
};
Experiment.prototype._decorateError = function(err, prefix) {
err.message = prefix + ": " + err.message;
err.experiment = this;
err.context = this.context();
return err;
};
return Experiment;
})(EventEmitter);
module.exports = Experiment;