UNPKG

minixperiment-client-js

Version:

Split testing client for Javascript

411 lines (369 loc) 13.6 kB
var experiments = require('./experiments'); var spade = require('./tracking/spade'); module.exports = MinixperimentClient; var MAX_BATCH_TIME = 60000; var DEFAULT_BATCH_TIME = 1000; var MAX_QUEUE_LENGTH = 50; var MAX_THROTTLE_TIME = 60000; /** * Create a new Minixperiment client object, from which assignments can be * determined. * @class * * @param {Object} config * Configuration object for the library. Valid properties: * `defaults` : an object mapping expected experiment UUIDs to their * default values, to be returned should an error occur * somewhere within the library. It is considered an error to * request an experimental treatment from an experiment * without a default value. * `deviceID` : the unique ID associated with the device using this library * `login` : the username of the current user * `overrides`: An object hash of experiment ID to forced assignment, * which causes the client to ignore any configuration and * simply return the overridden value. The value may be a * Promise, which is used if it resolves and is ignored if it * is rejected. * `platform` : The consumer of this library (e.g. `web`, `xboxone`) * `provider` : a minixperiment-defined Experiments provider * `Promise` : a Promises/A+-compliant implementation * `batchTimeOut`: A value in ms to batch the spade events for experiment_branch * `throttleTime`: A value in ms to throttle spade events. For a particular experiment - group combination only one * spade event will be sent `throttleTime` window */ function MinixperimentClient(config) { var configError = validateConfig(config); if (configError !== null) { throw configError; } this._config = getExperimentsConfiguration(config); this._Promise = config.Promise; this._deviceID = config.deviceID; this._platform = config.platform; this._username = config.login || null; this._overrides = config.overrides || {}; this._defaults = determineDefaults(config.Promise, config.defaults, this._overrides); this._assignments = determineAssignments( config.Promise, this._config, this._defaults, this._overrides, this._deviceID, this._username ); this._spade_url = determineSpadeUrl( this._config, spade.SPADE_URL_PROJECT_UUID ); this._batchTimer = 0; this._eventQueue = []; this._resolveQueue = []; this._batchTimeOut = isNaN(config.batchTimeOut) ? DEFAULT_BATCH_TIME : Math.min(config.batchTimeOut, MAX_BATCH_TIME); this._throttleTime = isNaN(config.throttleTime) ? 0 : Math.min(config.throttleTime, MAX_THROTTLE_TIME); this._throttleCache = {}; } /** * Get the treatment for a particular named experiment * * @param {String} experimentUUID * The UUID of the experiment from which the client will draw a treatment * @param {Object} options * `mustTrack` [default: false]: if true, then tracking must complete * before the assignment is returned to the caller * @return {Promise} * Resolves to the value of the treatment for the given experiment, or * the provided default value in the event of an error */ MinixperimentClient.prototype.get = function(experimentUUID, opts) { var options = applyDefaults(opts || {}, { mustTrack: false, channel: null, printt: false, }); var chanAssignment = this._Promise.all([this._config, this._assignments[experimentUUID]]).then( function(data) { var expConfig = data[0]; var defaultAssignment = data[1]; // if this is a channel_id experiment and channel is defined, fetch the treatment, if not, return default if (expConfig[experimentUUID].t == 3 && options.channel) { return experiments.selectTreatment(experimentUUID, expConfig[experimentUUID], options.channel); } return defaultAssignment }, function(err) { console.warn(err) return this._defaults[experimentUUID] || null }.bind(this) ).then(function(assignment) { // channel experiments must also respect overrides return this._Promise.resolve(this._overrides[experimentUUID]).then(function(override) { return (typeof override === 'string' ? override : assignment); }, function() { return assignment; }); }.bind(this)); var assignment = (chanAssignment || this._Promise.reject(new Error("No experiment with ID `" + experimentUUID + "`"))); var trackedEvent = this._Promise.all([this._config, assignment, this._spade_url]).then(function(data) { var expConfig = data[0]; var treatment = data[1]; var spadeUrl = data[2]; if (this._throttleTime > 0) { // See if tracking was already fired within the last throttleTime ms var cachedTimeStamp = this._throttleCache[experimentUUID + '_' + treatment]; if (Date.now() < (cachedTimeStamp * 1000) + this._throttleTime) { return Promise.resolve(); } } // only track assignments that are valid var trackingProperties = { // epoch time of the event, in seconds "client_time": (new Date()).getTime() / 1000, // user's unique device ID "device_id": this._deviceID, // experiment identifier "experiment_id": experimentUUID, // which group the user was assigned to "experiment_group": treatment, // the platform from which this experiment was experienced "platform": this._platform, // experiment version used "experiment_version": 0, }; if(this._throttleTime > 0) { // cache the client_time for experiment if throttling is on this._throttleCache[experimentUUID + '_' + treatment] = trackingProperties['client_time']; } // if not a deprecated experiment if (expConfig[experimentUUID]) { trackingProperties.experiment_name = expConfig[experimentUUID].name trackingProperties.experiment_version = expConfig[experimentUUID].v switch(expConfig[experimentUUID].t) { case 1: trackingProperties.experiment_type = "device_id"; break; case 2: trackingProperties.experiment_type = "user_id"; break; case 3: trackingProperties.experiment_type = "channel_id"; } } if (this._username !== null) { // the user's username, if logged in trackingProperties.login = this._username; } if (options.channel !== null) { // the channel the user's on, if provided trackingProperties.channel = options.channel; } var spadePromise; if (options.mustTrack || this._batchTimeOut === 0) { spadePromise = new this._Promise(function(resolve, _) { if(options.printt) console.log(trackingProperties) spade.sendEvent(spadeUrl, [{ event: 'experiment_branch', properties: trackingProperties }], resolve); }).then(null, function() { return null; }); } else { spadePromise = new this._Promise(function(resolve, _) { if(options.printt) console.log(trackingProperties) this._eventQueue.push({ event: 'experiment_branch', properties: trackingProperties }); this._resolveQueue.push(resolve); if (this._eventQueue.length >= MAX_QUEUE_LENGTH) { // Queue grown over 50, send all immediately clearTimeout(this._batchTimer); this._batchTimer = 0; this._sendQueuedEvents(spadeUrl, this._eventQueue, this._resolveQueue); this._eventQueue = []; this._resolveQueue = []; return; } if (this._batchTimer) { return; } this._batchTimer = setTimeout(function() { this._batchTimer = 0; this._sendQueuedEvents(spadeUrl, this._eventQueue, this._resolveQueue); this._eventQueue = []; this._resolveQueue = []; }.bind(this), this._batchTimeOut); }.bind(this)).then(null, function() { return null; }); } return spadePromise; }.bind(this)); return this._Promise.all([assignment, options.mustTrack ? trackedEvent : null]).then( function(data) { return data[0]; }, function(err) { console.warn(err); return this._defaults[experimentUUID] || null; }.bind(this) ); }; MinixperimentClient.prototype._sendQueuedEvents = function(spadeUrl, eventQueue, resolveQueue) { spade.sendEvent(spadeUrl, eventQueue, function() { resolveQueue.forEach(function(element) { element(); }); }.bind(this)); }; /** * Validate the Minixperiment client configuration, returning an error if there * are any issues, or `null` for "OK". * * @param {Object} config * @return {Error?} */ function validateConfig(config) { if (!config.defaults || Object.getPrototypeOf(config.defaults) !== Object.prototype) { return new Error("Invalid defaults; expected object, got " + JSON.stringify(config.defaults)); } else if (typeof config.deviceID !== 'string' || config.deviceID.length === 0) { return new Error("Invalid device ID; expected non-empty string, got `" + config.deviceID + "`"); } else if (typeof config.platform !== 'string' || config.platform.length === 0) { return new Error("Invalid platform; expected non-empty string, got `" + config.platform + "`"); } else if ( typeof config.provider !== 'object' || typeof config.provider.getExperimentConfiguration !== 'function' ) { return new Error("Invalid provider"); } else if (typeof config.Promise !== 'function') { return new Error("Invalid Promise implementation"); } return null; } function getExperimentsConfiguration(config) { return new config.Promise(function(resolve, reject) { config.provider.getExperimentConfiguration(resolve, reject); }).then(function(experimentConfig) { var error = experiments.validate(experimentConfig); if (error) { throw error; } return experimentConfig; }); } /** * Combine overrides with the defaults to generate the actual set of defaults * used when an issue is encountered. * * @param {*} _Promise The Promises/A+ implementation object * @param {Object<String, String>} defaults * @param {Object<String, (String|Promise<String>)>} overrides * @return {Object<String, Promise<String>>} */ function determineDefaults(_Promise, defaults, overrides) { var actualDefaults = {}; for (var uuid in defaults) { actualDefaults[uuid] = (function(expID) { return _Promise.resolve(overrides[uuid]).then( function(override) { // possibly an undefined override; ensure a valid value is given return (typeof override === 'string' ? override : defaults[expID]); }, function() { return defaults[expID]; } ); })(uuid); } return actualDefaults; } /** * Translate the experiment configuration and client configuration into a set of * experiment treatment assignments. This is resolved during client instantiation, * which prevents any reconfiguration after the client is running. * * @param {*} _Promise The Promises/A+ implementation object * @param {Promise<Object<UUID, ExperimentConfig>>} experimentConfig * @param {Object<UUID, String>} defaults * @param {Object<UUID, (String|Promise<String>)>} overrides * @param {String} deviceID * @param {String} username * @return {Object<UUID, Promise<String>>} */ function determineAssignments(_Promise, experimentConfig, defaults, overrides, deviceID, username) { var assignments = {}; for (var uuid in defaults) { if (!defaults.hasOwnProperty(uuid)) { continue; } assignments[uuid] = (function(expID) { return experimentConfig.then( function(cfg) { if (!cfg.hasOwnProperty(expID)) { throw new Error("Experiment `" + expID + "` is deprecated"); } if (!cfg[expID].t) { throw new Error("Experiment `" + expID + "` does not have a type"); } // if user_id experiment and username is defined if (cfg[expID].t == 1) { return experiments.selectTreatment(expID, cfg[expID], deviceID); } // if user_id experiment and username is defined if (username && cfg[expID].t == 2) { return experiments.selectTreatment(expID, cfg[expID], username); } // use default treatment if: // user_id experiment with no username, or // channel_id experiment (actual treatment will be generated at query time) return defaults[expID]; }, function(err) { return defaults[expID]; } ).then(function(assignment) { return _Promise.resolve(overrides[expID]).then(function(override) { return (typeof override === 'string' ? override : assignment); }, function() { return assignment; }); }); })(uuid); } return assignments; } /** * Determine the spade url for reporting events from the experiment configuration * * @param {Promise<Object<UUID, ExperimentConfig>>} experimentConfig * @param {String} The experiment uuid for the spade url project * @return {Promise<String>} */ function determineSpadeUrl(experimentConfig, spadeExp) { return experimentConfig.then( function(cfg) { if (cfg[spadeExp] && cfg[spadeExp].groups && cfg[spadeExp].groups[0]) { return cfg[spadeExp].groups[0].value; } return ""; }, function(err) { return ""; }); } /** * Creates a new object with the properties of the given source object, filling * in any missing/non-existent properties with values from the defaults object. * * @param {Object} src * @param {Object} defaults * @return {Object} */ function applyDefaults(src, defaults) { var prop; var rv = {}; for (prop in src) { if (src.hasOwnProperty(prop)) { rv[prop] = src[prop]; } } for (prop in defaults) { if (defaults.hasOwnProperty(prop) && !src.hasOwnProperty(prop)) { rv[prop] = defaults[prop]; } } return rv; }