fune/toolkit/components/messaging-system/experiments/ExperimentManager.jsm

329 lines
9.5 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/. */
"use strict";
/**
* @typedef {import("./@types/ExperimentManager").RecipeArgs} RecipeArgs
* @typedef {import("./@types/ExperimentManager").Enrollment} Enrollment
* @typedef {import("./@types/ExperimentManager").Branch} Branch
*/
const EXPORTED_SYMBOLS = ["ExperimentManager", "_ExperimentManager"];
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
ClientEnvironment: "resource://normandy/lib/ClientEnvironment.jsm",
ExperimentStore:
"resource://messaging-system/experiments/ExperimentStore.jsm",
NormandyUtils: "resource://normandy/lib/NormandyUtils.jsm",
Sampling: "resource://gre/modules/components-utils/Sampling.jsm",
TelemetryEvents: "resource://normandy/lib/TelemetryEvents.jsm",
TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.jsm",
FirstStartup: "resource://gre/modules/FirstStartup.jsm",
});
XPCOMUtils.defineLazyGetter(this, "log", () => {
const { Logger } = ChromeUtils.import(
"resource://messaging-system/lib/Logger.jsm"
);
return new Logger("ExperimentManager");
});
// This is included with event telemetry e.g. "enroll"
// TODO: Add a new type called "messaging_study"
const EVENT_TELEMETRY_STUDY_TYPE = "preference_study";
// This is used by Telemetry.setExperimentActive
const TELEMETRY_EXPERIMENT_TYPE_PREFIX = "normandy-";
// Also included in telemetry
const DEFAULT_EXPERIMENT_TYPE = "messaging_experiment";
/**
* A module for processes Experiment recipes, choosing and storing enrollment state,
* and sending experiment-related Telemetry.
*/
class _ExperimentManager {
constructor({ id = "experimentmanager", store } = {}) {
this.id = id;
this.store = store || new ExperimentStore();
this.sessions = new Map();
}
/**
* Creates a targeting context with following filters:
*
* * `activeExperiments`: an array of slugs of all the active experiments
* * `isFirstStartup`: a boolean indicating whether or not the current enrollment
* is performed during the first startup
*
* @returns {Object} A context object
* @memberof _ExperimentManager
*/
createTargetingContext() {
let context = {
isFirstStartup: FirstStartup.state === FirstStartup.IN_PROGRESS,
};
Object.defineProperty(context, "activeExperiments", {
get: async () => {
await this.store.ready();
return this.store.getAllActive().map(exp => exp.slug);
},
});
return context;
}
/**
* Runs on startup, including before first run
*/
async onStartup() {
await this.store.init();
const restoredExperiments = this.store.getAllActive();
for (const experiment of restoredExperiments) {
this.setExperimentActive(experiment);
}
}
/**
* Runs every time a Recipe is updated or seen for the first time.
* @param {RecipeArgs} recipe
* @param {string} source
*/
async onRecipe(recipe, source) {
const { slug, isEnrollmentPaused } = recipe;
if (!source) {
throw new Error("When calling onRecipe, you must specify a source.");
}
if (!this.sessions.has(source)) {
this.sessions.set(source, new Set());
}
this.sessions.get(source).add(slug);
if (this.store.has(slug)) {
this.updateEnrollment(recipe);
} else if (isEnrollmentPaused) {
log.debug(`Enrollment is paused for "${slug}"`);
} else {
await this.enroll(recipe, source);
}
}
/**
* Runs when the all recipes been processed during an update, including at first run.
* @param {string} sourceToCheck
*/
onFinalize(sourceToCheck) {
if (!sourceToCheck) {
throw new Error("When calling onFinalize, you must specify a source.");
}
const activeExperiments = this.store.getAllActive();
for (const experiment of activeExperiments) {
const { slug, source } = experiment;
if (sourceToCheck !== source) {
continue;
}
if (!this.sessions.get(source)?.has(slug)) {
log.debug(`Stopping study for recipe ${slug}`);
try {
this.unenroll(slug, "recipe-not-seen");
} catch (err) {
Cu.reportError(err);
}
}
}
this.sessions.delete(sourceToCheck);
}
/**
* Start a new experiment by enrolling the users
*
* @param {RecipeArgs} recipe
* @param {string} source
* @returns {Promise<Enrollment>} The experiment object stored in the data store
* @rejects {Error}
* @memberof _ExperimentManager
*/
async enroll(
{
slug,
branches,
experimentType = DEFAULT_EXPERIMENT_TYPE,
userFacingName,
userFacingDescription,
},
source
) {
if (this.store.has(slug)) {
this.sendFailureTelemetry("enrollFailed", slug, "name-conflict");
throw new Error(`An experiment with the slug "${slug}" already exists.`);
}
const enrollmentId = NormandyUtils.generateUuid();
const branch = await this.chooseBranch(slug, branches);
if (branch.groups && this.store.hasExperimentForGroups(branch.groups)) {
log.debug(
`Skipping enrollment for "${slug}" because there is an existing experiment for one of its groups.`
);
this.sendFailureTelemetry("enrollFailed", slug, "group-conflict");
throw new Error(`An experiment with a conflicting group already exists.`);
}
/** @type {Enrollment} */
const experiment = {
slug,
branch,
active: true,
enrollmentId,
experimentType,
source,
userFacingName,
userFacingDescription,
lastSeen: new Date().toJSON(),
};
this.store.addExperiment(experiment);
this.setExperimentActive(experiment);
this.sendEnrollmentTelemetry(experiment);
log.debug(`New experiment started: ${slug}, ${branch.slug}`);
return experiment;
}
/**
* Update an enrollment that was already set
*
* @param {RecipeArgs} recipe
*/
updateEnrollment(recipe) {
/** @type Enrollment */
const experiment = this.store.get(recipe.slug);
// Don't update experiments that were already unenrolled.
if (experiment.active === false) {
log.debug(`Enrollment ${recipe.slug} has expired, aborting.`);
return;
}
// Stay in the same branch, don't re-sample every time.
const branch = recipe.branches.find(
branch => branch.slug === experiment.branch.slug
);
if (!branch) {
// Our branch has been removed. Unenroll.
this.unenroll(recipe.slug, "branch-removed");
}
}
/**
* Stop an experiment that is currently active
*
* @param {string} slug
* @param {string} reason
*/
unenroll(slug, reason = "unknown") {
const experiment = this.store.get(slug);
if (!experiment) {
this.sendFailureTelemetry("unenrollFailed", slug, "does-not-exist");
throw new Error(`Could not find an experiment with the slug "${slug}"`);
}
if (!experiment.active) {
this.sendFailureTelemetry("unenrollFailed", slug, "already-unenrolled");
throw new Error(
`Cannot stop experiment "${slug}" because it is already expired`
);
}
this.store.updateExperiment(slug, { active: false });
TelemetryEnvironment.setExperimentInactive(slug);
TelemetryEvents.sendEvent("unenroll", EVENT_TELEMETRY_STUDY_TYPE, slug, {
reason,
branch: experiment.branch.slug,
enrollmentId:
experiment.enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
});
log.debug(`Experiment unenrolled: ${slug}`);
}
/**
* Send Telemetry for undesired event
*
* @param {string} eventName
* @param {string} slug
* @param {string} reason
*/
sendFailureTelemetry(eventName, slug, reason) {
TelemetryEvents.sendEvent(eventName, EVENT_TELEMETRY_STUDY_TYPE, slug, {
reason,
});
}
/**
*
* @param {Enrollment} experiment
*/
sendEnrollmentTelemetry({ slug, branch, experimentType, enrollmentId }) {
TelemetryEvents.sendEvent("enroll", EVENT_TELEMETRY_STUDY_TYPE, slug, {
experimentType,
branch: branch.slug,
enrollmentId: enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
});
}
/**
* Sets Telemetry when activating an experiment.
*
* @param {Enrollment} experiment
* @memberof _ExperimentManager
*/
setExperimentActive(experiment) {
TelemetryEnvironment.setExperimentActive(
experiment.slug,
experiment.branch.slug,
{
type: `${TELEMETRY_EXPERIMENT_TYPE_PREFIX}${experiment.experimentType}`,
enrollmentId:
experiment.enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
}
);
}
/**
* Choose a branch randomly.
*
* @param {string} slug
* @param {Branch[]} branches
* @returns {Promise<Branch>}
* @memberof _ExperimentManager
*/
async chooseBranch(slug, branches) {
const ratios = branches.map(({ ratio = 1 }) => ratio);
const userId = 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 = `${this.id}-${userId}-${slug}-branch`;
const index = await Sampling.ratioSample(input, ratios);
return branches[index];
}
}
const ExperimentManager = new _ExperimentManager();