diff options
Diffstat (limited to 'toolkit/components/nimbus/lib/ExperimentManager.sys.mjs')
-rw-r--r-- | toolkit/components/nimbus/lib/ExperimentManager.sys.mjs | 1344 |
1 files changed, 1344 insertions, 0 deletions
diff --git a/toolkit/components/nimbus/lib/ExperimentManager.sys.mjs b/toolkit/components/nimbus/lib/ExperimentManager.sys.mjs new file mode 100644 index 0000000000..cd34f5ad03 --- /dev/null +++ b/toolkit/components/nimbus/lib/ExperimentManager.sys.mjs @@ -0,0 +1,1344 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs", + ExperimentStore: "resource://nimbus/lib/ExperimentStore.sys.mjs", + FirstStartup: "resource://gre/modules/FirstStartup.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs", + PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs", + Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + const { Logger } = ChromeUtils.importESModule( + "resource://messaging-system/lib/Logger.sys.mjs" + ); + return new Logger("ExperimentManager"); +}); + +const TELEMETRY_EVENT_OBJECT = "nimbus_experiment"; +const TELEMETRY_EXPERIMENT_ACTIVE_PREFIX = "nimbus-"; +const TELEMETRY_DEFAULT_EXPERIMENT_TYPE = "nimbus"; + +const UPLOAD_ENABLED_PREF = "datareporting.healthreport.uploadEnabled"; +const STUDIES_OPT_OUT_PREF = "app.shield.optoutstudies.enabled"; + +const STUDIES_ENABLED_CHANGED = "nimbus:studies-enabled-changed"; + +function featuresCompat(branch) { + if (!branch || (!branch.feature && !branch.features)) { + return []; + } + let { features } = branch; + // In <=v1.5.0 of the Nimbus API, experiments had single feature + if (!features) { + features = [branch.feature]; + } + + return features; +} + +function getFeatureFromBranch(branch, featureId) { + return featuresCompat(branch).find( + featureConfig => featureConfig.featureId === featureId + ); +} + +/** + * A module for processes Experiment recipes, choosing and storing enrollment state, + * and sending experiment-related Telemetry. + */ +export class _ExperimentManager { + constructor({ id = "experimentmanager", store } = {}) { + this.id = id; + this.store = store || new lazy.ExperimentStore(); + this.sessions = new Map(); + // By default, no extra context. + this.extraContext = {}; + Services.prefs.addObserver(UPLOAD_ENABLED_PREF, this); + Services.prefs.addObserver(STUDIES_OPT_OUT_PREF, this); + + // A Map from pref names to pref observers and metadata. See + // `_updatePrefObservers` for the full structure. + this._prefs = new Map(); + // A Map from enrollment slugs to a Set of prefs that enrollment is setting + // or would set (e.g., if the enrollment is a rollout and there wasn't an + // active experiment already setting it). + this._prefsBySlug = new Map(); + } + + get studiesEnabled() { + return ( + Services.prefs.getBoolPref(UPLOAD_ENABLED_PREF) && + Services.prefs.getBoolPref(STUDIES_OPT_OUT_PREF) + ); + } + + /** + * 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: lazy.FirstStartup.state === lazy.FirstStartup.IN_PROGRESS, + ...this.extraContext, + }; + Object.defineProperty(context, "activeExperiments", { + get: async () => { + await this.store.ready(); + return this.store.getAllActiveExperiments().map(exp => exp.slug); + }, + }); + Object.defineProperty(context, "activeRollouts", { + get: async () => { + await this.store.ready(); + return this.store.getAllActiveRollouts().map(rollout => rollout.slug); + }, + }); + return context; + } + + /** + * Runs on startup, including before first run. + * + * @param {object} extraContext extra targeting context provided by the + * ambient environment. + */ + async onStartup(extraContext = {}) { + await this.store.init(); + this.extraContext = extraContext; + + const restoredExperiments = this.store.getAllActiveExperiments(); + const restoredRollouts = this.store.getAllActiveRollouts(); + + for (const experiment of restoredExperiments) { + this.setExperimentActive(experiment); + if (this._restoreEnrollmentPrefs(experiment)) { + this._updatePrefObservers(experiment); + } + } + for (const rollout of restoredRollouts) { + this.setExperimentActive(rollout); + if (this._restoreEnrollmentPrefs(rollout)) { + this._updatePrefObservers(rollout); + } + } + + this.observe(); + } + + /** + * 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)) { + await this.updateEnrollment(recipe, source); + } else if (isEnrollmentPaused) { + lazy.log.debug(`Enrollment is paused for "${slug}"`); + } else if (!(await this.isInBucketAllocation(recipe.bucketConfig))) { + lazy.log.debug("Client was not enrolled because of the bucket sampling"); + } else { + await this.enroll(recipe, source); + } + } + + _checkUnseenEnrollments( + enrollments, + sourceToCheck, + recipeMismatches, + invalidRecipes, + invalidBranches, + invalidFeatures, + missingLocale, + missingL10nIds + ) { + for (const enrollment of enrollments) { + const { slug, source } = enrollment; + if (sourceToCheck !== source) { + continue; + } + if (!this.sessions.get(source)?.has(slug)) { + lazy.log.debug(`Stopping study for recipe ${slug}`); + try { + let reason; + if (recipeMismatches.includes(slug)) { + reason = "targeting-mismatch"; + } else if (invalidRecipes.includes(slug)) { + reason = "invalid-recipe"; + } else if (invalidBranches.has(slug) || invalidFeatures.has(slug)) { + reason = "invalid-branch"; + } else if (missingLocale.includes(slug)) { + reason = "l10n-missing-locale"; + } else if (missingL10nIds.has(slug)) { + reason = "l10n-missing-entry"; + } else { + reason = "recipe-not-seen"; + } + this.unenroll(slug, reason); + } catch (err) { + console.error(err); + } + } + } + } + + /** + * Removes stored enrollments that were not seen after syncing with Remote Settings + * Runs when the all recipes been processed during an update, including at first run. + * @param {string} sourceToCheck + * @param {object} options Extra context used in telemetry reporting + * @param {string[]} options.recipeMismatches + * The list of experiments that do not match targeting. + * @param {string[]} options.invalidRecipes + * The list of recipes that do not match + * @param {Map<string, string[]>} options.invalidBranches + * A mapping of experiment slugs to a list of branches that failed + * feature validation. + * @param {Map<string, string[]>} options.invalidFeatures + * The mapping of experiment slugs to a list of invalid feature IDs. + * @param {string[]} options.missingLocale + * The list of experiment slugs missing an entry in the localization + * table for the current locale. + * @param {Map<string, string[]>} options.missingL10nIds + * The mapping of experiment slugs to the IDs of localization entries + * missing from the current locale. + * @param {string | null} options.locale + * The current locale. + * @param {boolean} options.validationEnabled + * Whether or not schema validation was enabled. + */ + onFinalize( + sourceToCheck, + { + recipeMismatches = [], + invalidRecipes = [], + invalidBranches = new Map(), + invalidFeatures = new Map(), + missingLocale = [], + missingL10nIds = new Map(), + locale = null, + validationEnabled = true, + } = {} + ) { + if (!sourceToCheck) { + throw new Error("When calling onFinalize, you must specify a source."); + } + const activeExperiments = this.store.getAllActiveExperiments(); + const activeRollouts = this.store.getAllActiveRollouts(); + this._checkUnseenEnrollments( + activeExperiments, + sourceToCheck, + recipeMismatches, + invalidRecipes, + invalidBranches, + invalidFeatures, + missingLocale, + missingL10nIds + ); + this._checkUnseenEnrollments( + activeRollouts, + sourceToCheck, + recipeMismatches, + invalidRecipes, + invalidBranches, + invalidFeatures, + missingLocale, + missingL10nIds + ); + + // If schema validation is disabled, then we will never send these + // validation failed telemetry events + if (validationEnabled) { + for (const slug of invalidRecipes) { + this.sendValidationFailedTelemetry(slug, "invalid-recipe"); + } + for (const [slug, branches] of invalidBranches.entries()) { + for (const branch of branches) { + this.sendValidationFailedTelemetry(slug, "invalid-branch", { + branch, + }); + } + } + for (const [slug, featureIds] of invalidFeatures.entries()) { + for (const featureId of featureIds) { + this.sendValidationFailedTelemetry(slug, "invalid-feature", { + feature: featureId, + }); + } + } + } + + if (locale) { + for (const slug of missingLocale.values()) { + this.sendValidationFailedTelemetry(slug, "l10n-missing-locale", { + locale, + }); + } + + for (const [slug, ids] of missingL10nIds.entries()) { + this.sendValidationFailedTelemetry(slug, "l10n-missing-entry", { + l10n_ids: ids.join(","), + locale, + }); + } + } + + 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) { + lazy.log.debug("Cannot enroll if recipe bucketConfig is not set."); + return false; + } + + let id; + if (bucketConfig.randomizationUnit === "normandy_id") { + id = lazy.ClientEnvironment.userId; + } else { + // Others not currently supported. + lazy.log.debug( + `Invalid randomizationUnit: ${bucketConfig.randomizationUnit}` + ); + return false; + } + + return lazy.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 + * @param {object} options + * @param {boolean} options.reenroll - Allow re-enrollment. Only allowed for rollouts. + * @returns {Promise<Enrollment>} The experiment object stored in the data store + * @rejects {Error} + * @memberof _ExperimentManager + */ + async enroll(recipe, source, { reenroll = false } = {}) { + let { slug, branches } = recipe; + + const enrollment = this.store.get(slug); + + if ( + enrollment && + (enrollment.isActive || !enrollment.isRollout || !reenroll) + ) { + this.sendFailureTelemetry("enrollFailed", slug, "name-conflict"); + throw new Error(`An experiment with the slug "${slug}" already exists.`); + } + + let storeLookupByFeature = recipe.isRollout + ? this.store.getRolloutForFeature.bind(this.store) + : this.store.hasExperimentForFeature.bind(this.store); + const branch = await this.chooseBranch(slug, branches); + const features = featuresCompat(branch); + for (let feature of features) { + if (storeLookupByFeature(feature?.featureId)) { + lazy.log.debug( + `Skipping enrollment for "${slug}" because there is an existing ${ + recipe.isRollout ? "rollout" : "experiment" + } for this feature.` + ); + this.sendFailureTelemetry("enrollFailed", slug, "feature-conflict"); + + return null; + } + } + + return this._enroll(recipe, branch, source); + } + + _enroll( + { + slug, + experimentType = TELEMETRY_DEFAULT_EXPERIMENT_TYPE, + userFacingName, + userFacingDescription, + featureIds, + isRollout, + localizations, + }, + branch, + source, + options = {} + ) { + const { prefs, prefsToSet } = this._getPrefsForBranch(branch, isRollout); + + /** @type {Enrollment} */ + const experiment = { + slug, + branch, + active: true, + enrollmentId: lazy.NormandyUtils.generateUuid(), + experimentType, + source, + userFacingName, + userFacingDescription, + lastSeen: new Date().toJSON(), + featureIds, + prefs, + }; + + if (localizations) { + experiment.localizations = localizations; + } + + if (typeof isRollout !== "undefined") { + experiment.isRollout = isRollout; + } + + // Tag this as a forced enrollment. This prevents all unenrolling unless + // manually triggered from about:studies + if (options.force) { + experiment.force = true; + } + + if (isRollout) { + experiment.experimentType = "rollout"; + this.store.addEnrollment(experiment); + this.setExperimentActive(experiment); + } else { + this.store.addEnrollment(experiment); + this.setExperimentActive(experiment); + } + this.sendEnrollmentTelemetry(experiment); + + this._setEnrollmentPrefs(prefsToSet); + this._updatePrefObservers(experiment); + + lazy.log.debug( + `New ${isRollout ? "rollout" : "experiment"} started: ${slug}, ${ + branch.slug + }` + ); + + return experiment; + } + + forceEnroll(recipe, branch, source = "force-enrollment") { + /** + * If we happen to be enrolled in an experiment for the same feature + * we need to unenroll from that experiment. + * If the experiment has the same slug after unenrollment adding it to the + * store will overwrite the initial experiment. + */ + const features = featuresCompat(branch); + for (let feature of features) { + const isRollout = recipe.isRollout ?? false; + let enrollment = isRollout + ? this.store.getRolloutForFeature(feature?.featureId) + : this.store.getExperimentForFeature(feature?.featureId); + if (enrollment) { + lazy.log.debug( + `Existing ${ + isRollout ? "rollout" : "experiment" + } found for the same feature ${feature.featureId}, unenrolling.` + ); + + this.unenroll(enrollment.slug, source); + } + } + + recipe.userFacingName = `${recipe.userFacingName} - Forced enrollment`; + + const slug = `optin-${recipe.slug}`; + const enrollment = this._enroll( + { + ...recipe, + slug, + }, + branch, + source, + { force: true } + ); + + Services.obs.notifyObservers(null, "nimbus:enrollments-updated", slug); + + return enrollment; + } + + /** + * Update an enrollment that was already set + * + * @param {RecipeArgs} recipe + * @returns {boolean} whether the enrollment is still active + */ + async updateEnrollment(recipe, source) { + /** @type Enrollment */ + const enrollment = this.store.get(recipe.slug); + + // Don't update experiments that were already unenrolled. + if (enrollment.active === false && !recipe.isRollout) { + lazy.log.debug(`Enrollment ${recipe.slug} has expired, aborting.`); + return false; + } + + if (recipe.isRollout) { + if (!(await this.isInBucketAllocation(recipe.bucketConfig))) { + lazy.log.debug( + `No longer meet bucketing for "${recipe.slug}"; unenrolling...` + ); + this.unenroll(recipe.slug, "bucketing"); + return false; + } else if ( + !enrollment.active && + enrollment.unenrollReason !== "individual-opt-out" + ) { + lazy.log.debug(`Re-enrolling in rollout "${recipe.slug}`); + return !!(await this.enroll(recipe, source, { reenroll: true })); + } + } + + // Stay in the same branch, don't re-sample every time. + const branch = recipe.branches.find( + branch => branch.slug === enrollment.branch.slug + ); + + if (!branch) { + // Our branch has been removed. Unenroll. + this.unenroll(recipe.slug, "branch-removed"); + return false; + } + + return true; + } + + /** + * Stop an enrollment that is currently active + * + * @param {string} slug + * The slug of the enrollment to stop. + * @param {string} reason + * An optional reason for the unenrollment. + * + * This will be reported in telemetry. + */ + unenroll(slug, reason = "unknown") { + const enrollment = this.store.get(slug); + if (!enrollment) { + this.sendFailureTelemetry("unenrollFailed", slug, "does-not-exist"); + throw new Error(`Could not find an experiment with the slug "${slug}"`); + } + + this._unenroll(enrollment, { reason }); + } + + /** + * Stop an enrollment that is currently active. + * + * @param {Enrollment} enrollment + * The enrollment to end. + * + * @param {object} options + * @param {string} options.reason + * An optional reason for the unenrollment. + * + * This will be reported in telemetry. + * + * @param {object?} options.changedPref + * If the unenrollment was due to pref change, this will contain the + * information about the pref that changed. + * + * @param {string} options.changedPref.name + * The name of the pref that caused the unenrollment. + * + * @param {string} options.changedPref.branch + * The branch that was changed ("user" or "default"). + */ + _unenroll( + enrollment, + { reason = "unknown", changedPref = undefined, duringRestore = false } = {} + ) { + const { slug } = enrollment; + + if (!enrollment.active) { + this.sendFailureTelemetry("unenrollFailed", slug, "already-unenrolled"); + throw new Error( + `Cannot stop experiment "${slug}" because it is already expired` + ); + } + + lazy.TelemetryEnvironment.setExperimentInactive(slug); + // We also need to set the experiment inactive in the Glean Experiment API + Services.fog.setExperimentInactive(slug); + this.store.updateExperiment(slug, { + active: false, + unenrollReason: reason, + }); + + lazy.TelemetryEvents.sendEvent("unenroll", TELEMETRY_EVENT_OBJECT, slug, { + reason, + branch: enrollment.branch.slug, + enrollmentId: + enrollment.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + // Sent Glean event equivalent + Glean.nimbusEvents.unenrollment.record({ + experiment: slug, + branch: enrollment.branch.slug, + enrollment_id: + enrollment.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + reason, + }); + + this._unsetEnrollmentPrefs(enrollment, { changedPref, duringRestore }); + + lazy.log.debug(`Recipe unenrolled: ${slug}`); + } + + /** + * Unenroll from all active studies if user opts out. + */ + observe(aSubject, aTopic, aPrefName) { + if (!this.studiesEnabled) { + for (const { slug } of this.store.getAllActiveExperiments()) { + this.unenroll(slug, "studies-opt-out"); + } + for (const { slug } of this.store.getAllActiveRollouts()) { + this.unenroll(slug, "studies-opt-out"); + } + } + + Services.obs.notifyObservers(null, STUDIES_ENABLED_CHANGED); + } + + /** + * Send Telemetry for undesired event + * + * @param {string} eventName + * @param {string} slug + * @param {string} reason + */ + sendFailureTelemetry(eventName, slug, reason) { + lazy.TelemetryEvents.sendEvent(eventName, TELEMETRY_EVENT_OBJECT, slug, { + reason, + }); + if (eventName == "enrollFailed") { + Glean.nimbusEvents.enrollFailed.record({ + experiment: slug, + reason, + }); + } else if (eventName == "unenrollFailed") { + Glean.nimbusEvents.unenrollFailed.record({ + experiment: slug, + reason, + }); + } + } + + sendValidationFailedTelemetry(slug, reason, extra) { + lazy.TelemetryEvents.sendEvent( + "validationFailed", + TELEMETRY_EVENT_OBJECT, + slug, + { + reason, + ...extra, + } + ); + Glean.nimbusEvents.validationFailed.record({ + experiment: slug, + reason, + ...extra, + }); + } + + /** + * + * @param {Enrollment} experiment + */ + sendEnrollmentTelemetry({ slug, branch, experimentType, enrollmentId }) { + lazy.TelemetryEvents.sendEvent("enroll", TELEMETRY_EVENT_OBJECT, slug, { + experimentType, + branch: branch.slug, + enrollmentId: + enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + Glean.nimbusEvents.enrollment.record({ + experiment: slug, + branch: branch.slug, + enrollment_id: + enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + experiment_type: experimentType, + }); + } + + /** + * Sets Telemetry when activating an experiment. + * + * @param {Enrollment} experiment + */ + setExperimentActive(experiment) { + lazy.TelemetryEnvironment.setExperimentActive( + experiment.slug, + experiment.branch.slug, + { + type: `${TELEMETRY_EXPERIMENT_ACTIVE_PREFIX}${experiment.experimentType}`, + enrollmentId: + experiment.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + // Report the experiment to the Glean Experiment API + Services.fog.setExperimentActive(experiment.slug, experiment.branch.slug, { + type: `${TELEMETRY_EXPERIMENT_ACTIVE_PREFIX}${experiment.experimentType}`, + enrollmentId: + experiment.enrollmentId || lazy.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 if not 100% enrollment. + */ + async generateTestIds(recipe) { + // Older recipe structure had bucket config values at the top level while + // newer recipes group them into a bucketConfig object + const { slug, branches, namespace, start, count, total } = { + ...recipe, + ...recipe.bucketConfig, + }; + const branchValues = {}; + const includeNot = count < total; + + 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 + includeNot) { + const id = lazy.NormandyUtils.generateUuid(); + const enrolls = await lazy.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; + lazy.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 = lazy.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 lazy.Sampling.ratioSample(input, ratios); + return branches[index]; + } + + /** + * Generate the list of prefs a recipe will set. + * + * @params {object} branch The recipe branch that will be enrolled. + * @params {boolean} isRollout Whether or not this recipe is a rollout. + * + * @returns {object} An object with the following keys: + * + * `prefs`: + * The full list of prefs that this recipe would set, + * if there are no conflicts. This will include prefs + * that, for example, will not be set because this + * enrollment is a rollout and there is an active + * experiment that set the same pref. + * + * `prefsToSet`: + * Prefs that should be set once enrollment is + * complete. + */ + _getPrefsForBranch(branch, isRollout = false) { + const prefs = []; + const prefsToSet = []; + + const getConflictingEnrollment = this._makeEnrollmentCache(isRollout); + + for (const { featureId, value: featureValue } of featuresCompat(branch)) { + const feature = lazy.NimbusFeatures[featureId]; + + if (!feature) { + continue; + } + + // It is possible to enroll in both an experiment and a rollout, so we + // need to check if we have another enrollment for the same feature. + const conflictingEnrollment = getConflictingEnrollment(featureId); + + const prefBranch = + feature.manifest.isEarlyStartup ?? false ? "user" : "default"; + + for (const [variable, value] of Object.entries(featureValue)) { + const prefName = feature.getSetPrefName(variable); + + if (prefName) { + let originalValue; + const conflictingPref = conflictingEnrollment?.prefs?.find( + p => p.name === prefName + ); + + if (conflictingPref) { + // If there is another enrollment that has already set the pref we + // care about, we use its stored originalValue. + originalValue = conflictingPref.originalValue; + } else if ( + prefBranch === "user" && + !Services.prefs.prefHasUserValue(prefName) + ) { + // If there is a default value set, then attempting to read the user + // branch would result in returning the default branch value. + originalValue = null; + } else { + originalValue = lazy.PrefUtils.getPref(prefName, { + branch: prefBranch, + }); + } + + prefs.push({ + name: prefName, + branch: prefBranch, + featureId, + variable, + originalValue, + }); + + // An experiment takes precedence if there is already a pref set. + if (!isRollout || !conflictingPref) { + prefsToSet.push({ name: prefName, value, prefBranch }); + } + } + } + } + + return { prefs, prefsToSet }; + } + + /** + * Set a list of prefs from enrolling in an experiment or rollout. + * + * The ExperimentManager's pref observers will be disabled while setting each + * pref so as not to accidentally unenroll an existing rollout that an + * experiment would override. + * + * @param {object[]} prefsToSet + * A list of objects containing the prefs to set. + * + * Each object has the following properties: + * + * * `name`: The name of the pref. + * * `value`: The value of the pref. + * * `prefBranch`: The branch to set the pref on (either "user" or "default"). + */ + _setEnrollmentPrefs(prefsToSet) { + for (const { name, value, prefBranch } of prefsToSet) { + const entry = this._prefs.get(name); + + // If another enrollment exists that has set this pref, temporarily + // disable the pref observer so as not to cause unenrollment. + if (entry) { + entry.enrollmentChanging = true; + } + + lazy.PrefUtils.setPref(name, value, { branch: prefBranch }); + + if (entry) { + entry.enrollmentChanging = false; + } + } + } + + /** + * Unset prefs set during this enrollment. + * + * If this enrollment is an experiment and there is an existing rollout that + * would set a pref that was covered by this enrollment, the pref will be + * updated to that rollout's value. + * + * Otherwise, it will be set to the original value from before the enrollment + * began. + * + * @param {Enrollment} enrollment + * The enrollment that has ended. + * + * @param {object} options + * + * @param {object?} options.changedPref + * If provided, a changed pref that caused the unenrollment that + * triggered unsetting these prefs. This is provided as to not + * overwrite a changed pref with an original value. + * + * @param {string} options.changedPref.name + * The name of the changed pref. + * + * @param {string} options.changedPref.branch + * The branch that was changed ("user" or "default"). + * + * @param {boolean} options.duringRestore + * The unenrollment was caused during restore. + */ + _unsetEnrollmentPrefs(enrollment, { changedPref, duringRestore } = {}) { + if (!enrollment.prefs?.length) { + return; + } + + const getConflictingEnrollment = this._makeEnrollmentCache( + enrollment.isRollout + ); + + for (const pref of enrollment.prefs) { + this._removePrefObserver(pref.name, enrollment.slug); + + if ( + changedPref?.name == pref.name && + changedPref.branch === pref.branch + ) { + // Resetting the original value would overwite the pref the user just + // set. Skip it. + continue; + } + + let newValue = pref.originalValue; + + // If we are unenrolling from an experiment during a restore, we must + // ignore any potential conflicting rollout in the store, because its + // hasn't gone through `_restoreEnrollmentPrefs`, which might also cause + // it to unenroll. + // + // Both enrollments will have the same `originalValue` stored, so it will + // always be restored. + if (!duringRestore || enrollment.isRollout) { + const conflictingEnrollment = getConflictingEnrollment(pref.featureId); + const conflictingPref = conflictingEnrollment?.prefs?.find( + p => p.name === pref.name + ); + + if (conflictingPref) { + if (enrollment.isRollout) { + // If we are unenrolling from a rollout, we have an experiment that + // has set the pref. Since experiments take priority, we do not unset + // it. + continue; + } else { + // If we are an unenrolling from an experiment, we have a rollout that would + // set the same pref, so we update the pref to that value instead of + // the original value. + newValue = getFeatureFromBranch( + conflictingEnrollment.branch, + pref.featureId + ).value[pref.variable]; + } + } + } + + // If another enrollment exists that has set this pref, temporarily + // disable the pref observer so as not to cause unenrollment when we + // update the pref to its value. + const entry = this._prefs.get(pref.name); + if (entry) { + entry.enrollmentChanging = true; + } + + lazy.PrefUtils.setPref(pref.name, newValue, { + branch: pref.branch, + }); + + if (entry) { + entry.enrollmentChanging = false; + } + } + } + + /** + * Restore the prefs set by an enrollment. + * + * @param {object} enrollment The enrollment. + * @param {object} enrollment.branch The branch that was enrolled. + * @param {object[]} enrollment.prefs The prefs that are set by the enrollment. + * @param {object[]} enrollment.isRollout The prefs that are set by the enrollment. + * + * @returns {boolean} Whether the restore was successful. If false, the + * enrollment has ended. + */ + _restoreEnrollmentPrefs(enrollment) { + const { branch, prefs = [], isRollout } = enrollment; + + if (!prefs?.length) { + return false; + } + + const featuresById = Object.assign( + ...featuresCompat(branch).map(f => ({ [f.featureId]: f })) + ); + + for (const { name, featureId, variable } of prefs) { + // If the feature no longer exists, unenroll. + if (!Object.hasOwn(lazy.NimbusFeatures, featureId)) { + this._unenroll(enrollment, { + reason: "invalid-feature", + duringRestore: true, + }); + return false; + } + + const variables = lazy.NimbusFeatures[featureId].manifest.variables; + + // If the feature is missing a variable that set a pref, unenroll. + if (!Object.hasOwn(variables, variable)) { + this._unenroll(enrollment, { + reason: "pref-variable-missing", + duringRestore: true, + }); + return false; + } + + const variableDef = variables[variable]; + + // If the variable is no longer a pref-setting variable, unenroll. + if (!Object.hasOwn(variableDef, "setPref")) { + this._unenroll(enrollment, { + reason: "pref-variable-no-longer", + duringRestore: true, + }); + return false; + } + + // If the variable is setting a different preference, unenroll. + if (variableDef.setPref !== name) { + this._unenroll(enrollment, { + reason: "pref-variable-changed", + duringRestore: true, + }); + return false; + } + } + + for (const { name, branch: prefBranch, featureId, variable } of prefs) { + // User prefs are already persisted. + if (prefBranch === "user") { + continue; + } + + // If we are a rollout, we need to check for an existing experiment that + // has set the same pref. If so, we do not need to set the pref because + // experiments take priority. + if (isRollout) { + const conflictingEnrollment = + this.store.getExperimentForFeature(featureId); + const conflictingPref = conflictingEnrollment?.prefs?.find( + p => p.name === name + ); + + if (conflictingPref) { + continue; + } + } + + const value = featuresById[featureId].value[variable]; + + if (prefBranch !== "user") { + lazy.PrefUtils.setPref(name, value, { branch: prefBranch }); + } + } + + return true; + } + + /** + * Make a cache to look up enrollments of the oppposite kind by feature ID. + * + * @param {boolean} isRollout Whether or not the current enrollment is a + * rollout. If true, the cache will return + * experiments. If false, the cache will return + * rollouts. + * + * @returns {function} The cache, as a callable function. + */ + _makeEnrollmentCache(isRollout) { + const getOtherEnrollment = ( + isRollout + ? this.store.getExperimentForFeature + : this.store.getRolloutForFeature + ).bind(this.store); + + const conflictingEnrollments = {}; + return featureId => { + if (!Object.hasOwn(conflictingEnrollments, featureId)) { + conflictingEnrollments[featureId] = getOtherEnrollment(featureId); + } + + return conflictingEnrollments[featureId]; + }; + } + + /** + * Update the set of observers with prefs set by the given enrollment. + * + * @param {Enrollment} enrollment + * The enrollment that is setting prefs. + */ + _updatePrefObservers({ slug, prefs }) { + if (!prefs?.length) { + return; + } + + for (const pref of prefs) { + const { name } = pref; + + if (!this._prefs.has(name)) { + const observer = () => this._onExperimentPrefChanged(pref); + const entry = { + slugs: new Set([slug]), + enrollmentChanging: false, + observer, + }; + + Services.prefs.addObserver(name, observer); + + this._prefs.set(name, entry); + } else { + this._prefs.get(name).slugs.add(slug); + } + + if (!this._prefsBySlug.has(slug)) { + this._prefsBySlug.set(slug, new Set([name])); + } else { + this._prefsBySlug.get(slug).add(name); + } + } + } + + /** + * Remove an entry for the pref observer for the given pref and slug. + * + * If there are no more enrollments listening to a pref, the observer will be removed. + * + * This is called when an enrollment is ending. + * + * @param {string} name The name of the pref. + * @param {string} slug The slug of the enrollment that is being unenrolled. + */ + _removePrefObserver(name, slug) { + // Update the pref observer that the current enrollment is no longer + // involved in the pref. + // + // If no enrollments have a variable setting the pref, then we can remove + // the observers. + const entry = this._prefs.get(name); + + // If this is happening due to a pref change, the observers will already be removed. + if (entry) { + entry.slugs.delete(slug); + if (entry.slugs.size == 0) { + Services.prefs.removeObserver(name, entry.observer); + this._prefs.delete(name); + } + } + + const bySlug = this._prefsBySlug.get(slug); + if (bySlug) { + bySlug.delete(name); + if (bySlug.size == 0) { + this._prefsBySlug.delete(slug); + } + } + } + + /** + * Handle a change to a pref set by enrollments by ending those enrollments. + * + * @param {object} pref + * Information about the pref that was changed. + * + * @param {string} pref.name + * The name of the pref that was changed. + * + * @param {string} pref.branch + * The branch enrollments set the pref on. + * + * @param {string} pref.featureId + * The feature ID of the feature containing the variable that set the + * pref. + * + * @param {string} pref.variable + * The variable in the given feature whose value determined the pref's + * value. + */ + _onExperimentPrefChanged(pref) { + const entry = this._prefs.get(pref.name); + // If this was triggered while we are enrolling or unenrolling from an + // experiment, then we don't want to unenroll from the rollout because the + // experiment's value is taking precendence. + // + // Otherwise, all enrollments that set the variable corresponding to this + // pref must be unenrolled. + if (entry.enrollmentChanging) { + return; + } + + // Copy the `Set` into an `Array` because we modify the set later in + // `_removePrefObserver` and we need to iterate over it multiple times. + const slugs = Array.from(entry.slugs); + + // Remove all pref observers set by enrollments. We are potentially about + // to set these prefs during unenrollment, so we don't want to trigger + // them and cause nested unenrollments. + for (const slug of slugs) { + const toRemove = Array.from(this._prefsBySlug.get(slug) ?? []); + for (const name of toRemove) { + this._removePrefObserver(name, slug); + } + } + + // Unenroll from the rollout first to save calls to setPref. + const enrollments = Array.from(slugs).map(slug => this.store.get(slug)); + + // There is a maximum of two enrollments (one experiment and one rollout). + if (enrollments.length == 2) { + // Order enrollments so that we unenroll from the rollout first. + if (!enrollments[0].isRollout) { + enrollments.reverse(); + } + } + + // We want to know what branch was changed so we can know if we should + // restore prefs. (e.g., if we have a pref set on the user branch and the + // user branch changed, we do not want to then overwrite the user's choice). + + // This is not complicated if a pref simply changed. However, we also must + // detect `nsIPrefBranch::clearUserPref()`, which wipes out the user branch + // and leaves the default branch untouched. That is where this gets + // complicated: + + let branch; + if (Services.prefs.prefHasUserValue(pref.name)) { + // If there is a user branch value, then the user branch changed. + branch = "user"; + } else if (!Services.prefs.prefHasDefaultValue(pref.name)) { + // If there is not default branch value, then the user branch must have + // been cleared becuase you cannot clear the default branch. + branch = "user"; + } else if (pref.branch === "default") { + const feature = getFeatureFromBranch( + enrollments.at(-1).branch, + pref.featureId + ); + const expectedValue = feature.value[pref.variable]; + const value = lazy.PrefUtils.getPref(pref.name, { branch: pref.branch }); + + if (value === expectedValue) { + // If the pref was set on the default branch and still matches the + // expected value, then the user branch must have been cleared. + branch = "user"; + } else { + branch = "default"; + } + } else { + // If the pref was set on the user branch and we don't have a user branch + // value, then the user branch must have been cleared. + branch = "user"; + } + + const changedPref = { + name: pref.name, + branch, + }; + + for (const enrollment of enrollments) { + this._unenroll(enrollment, { reason: "changed-pref", changedPref }); + } + } +} + +export const ExperimentManager = new _ExperimentManager(); |