summaryrefslogtreecommitdiffstats
path: root/toolkit/components/normandy/actions/PreferenceExperimentAction.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/normandy/actions/PreferenceExperimentAction.sys.mjs')
-rw-r--r--toolkit/components/normandy/actions/PreferenceExperimentAction.sys.mjs278
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";
+};