diff --git a/configs/client-default.js b/configs/client-default.js index 50593d86..c0859368 100644 --- a/configs/client-default.js +++ b/configs/client-default.js @@ -709,6 +709,7 @@ module.exports = function(options) { premium: options.project.premium, } }, + "plugins/c9.ide.abtesting/abtesting", { packagePath: "plugins/c9.ide.welcome/welcome", staticPrefix: staticPrefix + "/plugins/c9.ide.welcome", diff --git a/configs/standalone.js b/configs/standalone.js index bb6f596e..44c95a36 100644 --- a/configs/standalone.js +++ b/configs/standalone.js @@ -158,7 +158,6 @@ module.exports = function(config, optimist) { "c9.vfs.client": true, "c9.cli.bridge": true, "c9.nodeapi": true, - "c9.ide.experiment": true, "saucelabs.preview": true, "salesforce.sync": true, "salesforce.language": true @@ -194,7 +193,6 @@ module.exports = function(config, optimist) { "./c9.vfs.server/statics", "./c9.analytics/mock_analytics", "./c9.metrics/mock_metrics", - "./c9.ide.experiment/mock_experiment", { packagePath: "./c9.vfs.server/vfs.connect.standalone", workspaceDir: baseProc, diff --git a/package.json b/package.json index 76560040..c1c55d05 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "c9.ide.language.javascript.infer": "#18acb93a3a", "c9.ide.language.jsonalyzer": "#4b329741b1", "c9.ide.language.codeintel": "#253ae15f5e", - "c9.ide.collab": "#33263e74c3", + "c9.ide.collab": "#410a420025", "c9.ide.local": "#10eb45842a", "c9.ide.find": "#e33fbaed2f", "c9.ide.find.infiles": "#c0a13737ef", diff --git a/plugins/c9.abtesting/abtesting.js b/plugins/c9.abtesting/abtesting.js new file mode 100644 index 00000000..04cfe261 --- /dev/null +++ b/plugins/c9.abtesting/abtesting.js @@ -0,0 +1,86 @@ +/** + * Server-side support for A/B testing experiments. + * + * Note that this plugin demands users and user IDs for most of its API, + * where this is optional in the client-side implementation. + */ +main.consumes = ["analytics"]; +main.provides = ["abtesting"]; +module.exports = main; + +function main(options, imports, register) { + var analytics = imports["analytics"]; + var outplan = require("outplan"); + + var MS_PER_DAY = 1000 * 60 * 60 * 24; + + outplan.configure({ + logFunction: function(e) { + var label = e.name + " - " + e.event; + analytics.track(label, { variation: e.params.name }); + } + }); + + function create(name, choices, options) { + return outplan.create(name, choices, options); + } + + function expose(experimentName, userId, options) { + return outplan.expose(experimentName, userId, options); + } + + function isUserCreatedAfter(experimentDate, user) { + if (!user || !user.date_add) + throw new Error("Expected: user"); + var diffDays = (experimentDate - experimentDate.now()) / MS_PER_DAY; + if (diffDays > 20) { + // Sanity check: new Date() takes zero-based month argument, one-based day argument + throw new Error("Passed a date far in the future to isUserCreatedAfter()"); + } + return user.date_add > experimentDate; + } + + register(null, { + "abtesting": { + /** + * Create a new experiment. Alias for require("outplan").create() + * + * @param {String} name + * The name of the experiment. + * @param {String[]|Object[]} choices + * A list of variations, e.g. ["A", "B"], + * or variation objects, e.g. [{ name: "A", color: "#AAA" }, { name: "B", color: "#BBB" }] + * @param {Object} [option] + * Options for the experiment. This may also include + * arguments for the distribution operator, e.g. weight. + * @param {Function} [options.operator] + * The distribution operator, e.g. outplan.WeightedChoice. + */ + create: create, + + /** + * Get the selected variation of an experiment, and call the log function with + * an "expose" event to track its exposure. + * + * @param {String} name The experiment name. + * @param {Number} userId A unique identifier for the current user. + * @param {Object} [options] Options + * @param {Boolean} [options.log=true] Whether to log an "exposure event" + */ + expose: expose, + + /** + * Helper to determine if the current user was created after the start of an experiment. + * + * @throws {Error} when a date in the future (~20 days from now) is passed. + * This error is thrown as a sanity check to make sure `new Date()` + * is called with a zero-based month argument (and a one-based day). + * + * @param {Date} experimentDate + * @param {Object} user A user object + * @param {Number} user.date_add + */ + isUserCreatedAfter: isUserCreatedAfter, + } + }); +} diff --git a/plugins/c9.ide.abtesting/abtesting.js b/plugins/c9.ide.abtesting/abtesting.js new file mode 100644 index 00000000..c1747bf8 --- /dev/null +++ b/plugins/c9.ide.abtesting/abtesting.js @@ -0,0 +1,105 @@ +/** + * Client-side support for A/B testing experiments. + */ +define(function(require, exports, module) { + "use strict"; + + main.consumes = ["Plugin", "info", "c9.analytics"]; + main.provides = ["abtesting"]; + return main; + + function main(options, imports, register) { + var Plugin = imports.Plugin; + var plugin = new Plugin("Ajax.org", main.consumes); + var info = imports.info; + var analytics = imports["c9.analytics"]; + var outplan = require("outplan"); + + var MS_PER_DAY = 1000 * 60 * 60 * 24; + + var userId; + + plugin.on("load", function() { + userId = info.getUser().id; + outplan.configure({ + logFunction: function(e) { + var label = e.name + " - " + e.event; + analytics.track(label, { variation: e.params.name }); + } + }); + }); + + plugin.on("unload", function() { + userId = undefined; + }); + + function create(name, choices, options) { + var experiment = outplan.create(name, choices, options); + experiment.expose = expose.bind(null, name); + return experiment; + } + + function expose(experimentName, overrideUserId, options) { + if (overrideUserId && typeof overrideUserId === "object") + return expose(experimentName, null, options); + + return outplan.expose(experimentName, overrideUserId == null ? userId : overrideUserId, options); + } + + function isUserCreatedAfter(experimentDate) { + var diffDays = (experimentDate - experimentDate.now()) / MS_PER_DAY; + if (diffDays > 20) { + // Sanity check: new Date() takes zero-based month argument, one-based day argument + throw new Error("Passed a date far in the future to isUserCreatedAfter()"); + } + return info.getUser().date_add > experimentDate; + } + + /** + * Support for A/B testing experiments. + */ + plugin.freezePublicAPI({ + /** + * Create a new experiment. Alias for require("outplan").create() + * + * @param {String} name + * The name of the experiment. + * @param {String[]|Object[]} choices + * A list of variations, e.g. ["A", "B"], + * or variation objects, e.g. [{ name: "A", color: "#AAA" }, { name: "B", color: "#BBB" }] + * @param {Object} [option] + * Options for the experiment. This may also include + * arguments for the distribution operator, e.g. weight. + * @param {Function} [options.operator] + * The distribution operator, e.g. outplan.WeightedChoice. + */ + create: create, + + /** + * Get the selected variation of an experiment, and call the log function with + * an "expose" event to track its exposure. + * + * @param {String} name The experiment name. + * @param {Number} [userId] A unique identifier for the current user. + * @param {Object} [options] Options + * @param {Boolean} [options.log=true] Whether to log an "exposure event" + */ + expose: expose, + + /** + * Helper to determine if the current user was created after the start of an experiment. + * + * @throws {Error} when a date in the future (~20 days from now) is passed. + * This error is thrown as a sanity check to make sure `new Date()` + * is called with a zero-based month argument (and a one-based day). + * + * @param {Date} experimentDate + */ + isUserCreatedAfter: isUserCreatedAfter, + }); + + register(null, { + "abtesting": plugin + }); + } +}); diff --git a/plugins/c9.ide.experiment/mock_experiment.js b/plugins/c9.ide.experiment/mock_experiment.js deleted file mode 100644 index e3b80734..00000000 --- a/plugins/c9.ide.experiment/mock_experiment.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Dummy implementation of experiments. - */ -"use strict"; - -plugin.consumes = []; -plugin.provides = ["experiment"]; - -module.exports = plugin; - -function plugin(options, imports, register) { - - register(null, { - "experiment": { - configure: function() {}, - onStart: function() { - var chain = { - variation: function() { - return chain; - } - }; - return chain; - } - } - }); -} \ No newline at end of file diff --git a/plugins/c9.ide.server/plugins.js b/plugins/c9.ide.server/plugins.js index 64c2fe89..d8ef4ca4 100644 --- a/plugins/c9.ide.server/plugins.js +++ b/plugins/c9.ide.server/plugins.js @@ -41,6 +41,7 @@ define(function(require, exports, module) { ui: "lib/ui", c9: "lib/c9", frontdoor: "lib/frontdoor", + outplan: "lib/outplan/dist/outplan", }; if (whitelist === "*") { @@ -70,6 +71,7 @@ define(function(require, exports, module) { "ui", "emmet", "frontdoor", + "outplan", "mocha", // TESTING "chai", // TESTING ].forEach(function(name) {