diff options
Diffstat (limited to '')
-rw-r--r-- | toolkit/components/normandy/actions/PreferenceExperimentAction.sys.mjs | 278 |
1 files changed, 278 insertions, 0 deletions
diff --git a/toolkit/components/normandy/actions/PreferenceExperimentAction.sys.mjs b/toolkit/components/normandy/actions/PreferenceExperimentAction.sys.mjs new file mode 100644 index 0000000000..310d1b08fd --- /dev/null +++ b/toolkit/components/normandy/actions/PreferenceExperimentAction.sys.mjs @@ -0,0 +1,278 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { BaseStudyAction } from "resource://normandy/actions/BaseStudyAction.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ActionSchemas: "resource://normandy/actions/schemas/index.sys.mjs", + BaseAction: "resource://normandy/actions/BaseAction.sys.mjs", + ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs", + PreferenceExperiments: + "resource://normandy/lib/PreferenceExperiments.sys.mjs", + Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs", +}); + +/** + * Enrolls a user in a preference experiment, in which we assign the + * user to an experiment branch and modify a preference temporarily to + * measure how it affects Firefox via Telemetry. + */ +export class PreferenceExperimentAction extends BaseStudyAction { + get schema() { + return lazy.ActionSchemas["multi-preference-experiment"]; + } + + constructor() { + super(); + this.seenExperimentSlugs = new Set(); + } + + async _processRecipe(recipe, suitability) { + const { + branches, + isHighPopulation, + isEnrollmentPaused, + slug, + userFacingName, + userFacingDescription, + } = recipe.arguments || {}; + + let experiment; + // Slug might not exist, because if suitability is ARGUMENTS_INVALID, the + // arguments is not guaranteed to match the schema. + if (slug) { + this.seenExperimentSlugs.add(slug); + + try { + experiment = await lazy.PreferenceExperiments.get(slug); + } catch (err) { + // This is probably that the experiment doesn't exist. If that's not the + // case, re-throw the error. + if (!(err instanceof lazy.PreferenceExperiments.NotFoundError)) { + throw err; + } + } + } + + switch (suitability) { + case lazy.BaseAction.suitability.SIGNATURE_ERROR: { + this._considerTemporaryError({ experiment, reason: "signature-error" }); + break; + } + + case lazy.BaseAction.suitability.CAPABILITIES_MISMATCH: { + if (experiment && !experiment.expired) { + await lazy.PreferenceExperiments.stop(slug, { + resetValue: true, + reason: "capability-mismatch", + caller: + "PreferenceExperimentAction._processRecipe::capabilities_mismatch", + }); + } + break; + } + + case lazy.BaseAction.suitability.FILTER_MATCH: { + // If we're not in the experiment, try to enroll + if (!experiment) { + // Check all preferences that could be used by this experiment. + // If there's already an active experiment that has set that preference, abort. + const activeExperiments = + await lazy.PreferenceExperiments.getAllActive(); + for (const branch of branches) { + const conflictingPrefs = Object.keys(branch.preferences).filter( + preferenceName => { + return activeExperiments.some(exp => + exp.preferences.hasOwnProperty(preferenceName) + ); + } + ); + if (conflictingPrefs.length) { + throw new Error( + `Experiment ${slug} ignored; another active experiment is already using the + ${conflictingPrefs[0]} preference.` + ); + } + } + + // Determine if enrollment is currently paused for this experiment. + if (isEnrollmentPaused) { + this.log.debug(`Enrollment is paused for experiment "${slug}"`); + return; + } + + // Otherwise, enroll! + const branch = await this.chooseBranch(slug, branches); + const experimentType = isHighPopulation ? "exp-highpop" : "exp"; + await lazy.PreferenceExperiments.start({ + slug, + actionName: this.name, + branch: branch.slug, + preferences: branch.preferences, + experimentType, + userFacingName, + userFacingDescription, + }); + } else if (experiment.expired) { + this.log.debug(`Experiment ${slug} has expired, aborting.`); + } else { + experiment.temporaryErrorDeadline = null; + await lazy.PreferenceExperiments.update(experiment); + await lazy.PreferenceExperiments.markLastSeen(slug); + } + break; + } + + case lazy.BaseAction.suitability.FILTER_MISMATCH: { + if (experiment && !experiment.expired) { + await lazy.PreferenceExperiments.stop(slug, { + resetValue: true, + reason: "filter-mismatch", + caller: + "PreferenceExperimentAction._processRecipe::filter_mismatch", + }); + } + break; + } + + case lazy.BaseAction.suitability.FILTER_ERROR: { + this._considerTemporaryError({ experiment, reason: "filter-error" }); + break; + } + + case lazy.BaseAction.suitability.ARGUMENTS_INVALID: { + if (experiment && !experiment.expired) { + await lazy.PreferenceExperiments.stop(slug, { + resetValue: true, + reason: "arguments-invalid", + caller: + "PreferenceExperimentAction._processRecipe::arguments_invalid", + }); + } + break; + } + + default: { + throw new Error(`Unknown recipe suitability "${suitability}".`); + } + } + } + + async _run(recipe) { + throw new Error("_run shouldn't be called anymore"); + } + + async chooseBranch(slug, branches) { + const ratios = branches.map(branch => branch.ratio); + const userId = lazy.ClientEnvironment.userId; + + // It's important that the input be: + // - Unique per-user (no one is bucketed alike) + // - Unique per-experiment (bucketing differs across multiple experiments) + // - Differs from the input used for sampling the recipe (otherwise only + // branches that contain the same buckets as the recipe sampling will + // receive users) + const input = `${userId}-${slug}-branch`; + + const index = await lazy.Sampling.ratioSample(input, ratios); + return branches[index]; + } + + /** + * End any experiments which we didn't see during this session. + * This is the "normal" way experiments end, as they are disabled on + * the server and so we stop seeing them. This can also happen if + * the user doesn't match the filter any more. + */ + async _finalize({ noRecipes } = {}) { + const activeExperiments = await lazy.PreferenceExperiments.getAllActive(); + + if (noRecipes && this.seenExperimentSlugs.size) { + throw new PreferenceExperimentAction.BadNoRecipesArg(); + } + + return Promise.all( + activeExperiments.map(experiment => { + if (this.name != experiment.actionName) { + // Another action is responsible for cleaning this one + // up. Leave it alone. + return null; + } + + if (noRecipes) { + return this._considerTemporaryError({ + experiment, + reason: "no-recipes", + }); + } + + if (this.seenExperimentSlugs.has(experiment.slug)) { + return null; + } + + return lazy.PreferenceExperiments.stop(experiment.slug, { + resetValue: true, + reason: "recipe-not-seen", + caller: "PreferenceExperimentAction._finalize", + }).catch(e => { + this.log.warn(`Stopping experiment ${experiment.slug} failed: ${e}`); + }); + }) + ); + } + + /** + * Given that a temporary error has occurred for an experiment, check if it + * should be temporarily ignored, or if the deadline has passed. If the + * deadline is passed, the experiment will be ended. If this is the first + * temporary error, a deadline will be generated. Otherwise, nothing will + * happen. + * + * If a temporary deadline exists but cannot be parsed, a new one will be + * made. + * + * The deadline is 7 days from the first time that recipe failed, as + * reckoned by the client's clock. + * + * @param {Object} args + * @param {Experiment} args.experiment The enrolled experiment to potentially unenroll. + * @param {String} args.reason If the recipe should end, the reason it is ending. + */ + async _considerTemporaryError({ experiment, reason }) { + if (!experiment || experiment.expired) { + return; + } + + let now = Date.now(); // milliseconds-since-epoch + let day = 24 * 60 * 60 * 1000; + let newDeadline = new Date(now + 7 * day).toJSON(); + + if (experiment.temporaryErrorDeadline) { + let deadline = new Date(experiment.temporaryErrorDeadline); + // if deadline is an invalid date, set it to one week from now. + if (isNaN(deadline)) { + experiment.temporaryErrorDeadline = newDeadline; + await lazy.PreferenceExperiments.update(experiment); + return; + } + + if (now > deadline) { + await lazy.PreferenceExperiments.stop(experiment.slug, { + resetValue: true, + reason, + caller: "PreferenceExperimentAction._considerTemporaryFailure", + }); + } + } else { + // there is no deadline, so set one + experiment.temporaryErrorDeadline = newDeadline; + await lazy.PreferenceExperiments.update(experiment); + } + } +} + +PreferenceExperimentAction.BadNoRecipesArg = class extends Error { + message = "noRecipes is true, but some recipes observed"; +}; |