278 lines
9.3 KiB
JavaScript
278 lines
9.3 KiB
JavaScript
/* 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 IceCat 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() {
|
|
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";
|
|
};
|