diff options
Diffstat (limited to 'toolkit/components/messaging-system/experiments/ExperimentManager.jsm')
-rw-r--r-- | toolkit/components/messaging-system/experiments/ExperimentManager.jsm | 476 |
1 files changed, 476 insertions, 0 deletions
diff --git a/toolkit/components/messaging-system/experiments/ExperimentManager.jsm b/toolkit/components/messaging-system/experiments/ExperimentManager.jsm new file mode 100644 index 0000000000..5c3eb02251 --- /dev/null +++ b/toolkit/components/messaging-system/experiments/ExperimentManager.jsm @@ -0,0 +1,476 @@ +/* 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" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.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", + Services: "resource://gre/modules/Services.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"; +const STUDIES_OPT_OUT_PREF = "app.shield.optoutstudies.enabled"; +const EXPOSURE_EVENT_CATEGORY = "normandy"; +const EXPOSURE_EVENT_METHOD = "expose"; + +/** + * 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(); + this._onExposureEvent = this._onExposureEvent.bind(this); + Services.prefs.addObserver(STUDIES_OPT_OUT_PREF, this); + } + + /** + * 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(); + this.store.on("exposure", this._onExposureEvent); + 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 if (!(await this.isInBucketAllocation(recipe.bucketConfig))) { + log.debug("Client was not enrolled because of the bucket sampling"); + } 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); + } + + /** + * Bucket configuration specifies a specific percentage of clients that can + * be enrolled. + * @param {BucketConfig} bucketConfig + * @returns {Promise<boolean>} + */ + isInBucketAllocation(bucketConfig) { + if (!bucketConfig) { + log.debug("Cannot enroll if recipe bucketConfig is not set."); + return false; + } + + let id; + if (bucketConfig.randomizationUnit === "normandy_id") { + id = ClientEnvironment.userId; + } else { + // Others not currently supported. + log.debug(`Invalid randomizationUnit: ${bucketConfig.randomizationUnit}`); + return false; + } + + return Sampling.bucketSample( + [id, bucketConfig.namespace], + bucketConfig.start, + bucketConfig.count, + bucketConfig.total + ); + } + + /** + * 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 ( + this.store.hasExperimentForFeature( + // Extract out only the feature names from the branch + branch.feature?.featureId + ) + ) { + log.debug( + `Skipping enrollment for "${slug}" because there is an existing experiment for its feature.` + ); + this.sendFailureTelemetry("enrollFailed", slug, "feature-conflict"); + throw new Error( + `An experiment with a conflicting feature already exists.` + ); + } + + /** @type {Enrollment} */ + const experiment = { + slug, + branch, + active: true, + // Sent first time feature value is used + exposurePingSent: false, + 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}`); + } + + /** + * Unenroll from all active studies if user opts out. + */ + observe(aSubject, aTopic, aPrefName) { + if (Services.prefs.getBoolPref(STUDIES_OPT_OUT_PREF)) { + return; + } + for (const { slug } of this.store.getAllActive()) { + this.unenroll(slug, "studies-opt-out"); + } + } + + /** + * 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, + }); + } + + async _onExposureEvent(event, experimentData) { + await this.store.ready(); + this.store.updateExperiment(experimentData.experimentSlug, { + exposurePingSent: true, + }); + // featureId is not validated and might be rejected by recordEvent if not + // properly defined in Events.yaml. Saving experiment state regardless of + // the result, no use retrying. + try { + Services.telemetry.recordEvent( + EXPOSURE_EVENT_CATEGORY, + EXPOSURE_EVENT_METHOD, + "feature_study", + experimentData.experimentSlug, + { + branchSlug: experimentData.branchSlug, + featureId: experimentData.featureId, + } + ); + } catch (e) { + Cu.reportError(e); + } + + log.debug(`Experiment exposure: ${experimentData.experimentSlug}`); + } + + /** + * 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, + } + ); + } + + /** + * Generate Normandy UserId respective to a branch + * for a given experiment. + * + * @param {string} slug + * @param {Array<{slug: string; ratio: number}>} branches + * @param {string} namespace + * @param {number} start + * @param {number} count + * @param {number} total + * @returns {Promise<{[branchName: string]: string}>} An object where + * the keys are branch names and the values are user IDs that will enroll + * a user for that particular branch. Also includes a `notInExperiment` value + * that will not enroll the user in the experiment + */ + async generateTestIds({ slug, branches, namespace, start, count, total }) { + const branchValues = {}; + + if (!slug || !namespace) { + throw new Error(`slug, namespace not in expected format`); + } + + if (!(start < total && count < total)) { + throw new Error("Must include start, count, and total as integers"); + } + + if ( + !Array.isArray(branches) || + branches.filter(branch => branch.slug && branch.ratio).length !== + branches.length + ) { + throw new Error("branches parameter not in expected format"); + } + + while (Object.keys(branchValues).length < branches.length + 1) { + const id = NormandyUtils.generateUuid(); + const enrolls = await Sampling.bucketSample( + [id, namespace], + start, + count, + total + ); + // Does this id enroll the user in the experiment + if (enrolls) { + // Choose a random branch + const { slug: pickedBranch } = await this.chooseBranch( + slug, + branches, + id + ); + + if (!Object.keys(branchValues).includes(pickedBranch)) { + branchValues[pickedBranch] = id; + log.debug(`Found a value for "${pickedBranch}"`); + } + } else if (!branchValues.notInExperiment) { + branchValues.notInExperiment = id; + } + } + return branchValues; + } + + /** + * Choose a branch randomly. + * + * @param {string} slug + * @param {Branch[]} branches + * @returns {Promise<Branch>} + * @memberof _ExperimentManager + */ + async chooseBranch(slug, branches, userId = ClientEnvironment.userId) { + const ratios = branches.map(({ ratio = 1 }) => ratio); + + // 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(); |