/* 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/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs", ClientID: "resource://gre/modules/ClientID.sys.mjs", ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", ExperimentStore: "resource://nimbus/lib/ExperimentStore.sys.mjs", FirstStartup: "resource://gre/modules/FirstStartup.sys.mjs", NimbusEnrollments: "resource://nimbus/lib/Enrollments.sys.mjs", NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", NimbusTelemetry: "resource://nimbus/lib/Telemetry.sys.mjs", NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs", PrefFlipsFeature: "resource://nimbus/lib/PrefFlipsFeature.sys.mjs", PrefUtils: "moz-src:///toolkit/modules/PrefUtils.sys.mjs", EnrollmentsContext: "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs", MatchStatus: "resource://nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs", Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "log", () => { const { Logger } = ChromeUtils.importESModule( "resource://messaging-system/lib/Logger.sys.mjs" ); return new Logger("ExperimentManager"); }); /** @typedef {import("./PrefFlipsFeature.sys.mjs").PrefBranch} PrefBranch */ const CannotEnrollFeatureReason = Object.freeze({ /** * The feature does not exist. */ DOES_NOT_EXIST: "does-not-exist", /** * There is already another experiment or recipe enrolled in features and the * feature does not support co-enrollment. */ ENROLLED_IN_FEATURE: "enrolled-in-feature", /** * The enrollment is paused. */ ENROLLMENT_PAUSED: "enrollment-paused", }); /** * @typedef {T[keyof T]} EnumValuesOf * @template {type} T */ /** @typedef {EnumValuesOf} CannotEnrollFeatureReason */ /** * @typedef {object} _CanEnrollResult * @property {true} ok Whether or not enrollment is possible. */ /** * @typedef {object} _CannotEnrollResult * @property {false} ok Whether or not enrollment is possible. * @property {string|undefined} featureId If reason = DOES_NOT_EXIST, the * feature that does not exist. * @property {Set|undefined} conflictingEnrollments * If reason = ENROLLED_IN_FEATURE, an array of slugs that conflict based on * feature ID. * @property {CannotEnrollFeatureReason} reason Why enrollment is not possible. * @property {string | undefined} slug Optionally, a slug of a conflicting * enrollment. */ /** * Whether or not enrollment is possible in a given recipe. * * @typedef {_CanEnrollResult | _CannotEnrollResult} CanEnrollResult * @property {boolean} ok */ const IS_MAIN_PROCESS = Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT; export const UnenrollmentCause = { fromCheckRecipeResult(result) { const { UnenrollReason } = lazy.NimbusTelemetry; let reason; const extra = {}; if (result.ok) { switch (result.status) { case lazy.MatchStatus.NOT_SEEN: reason = UnenrollReason.RECIPE_NOT_SEEN; break; case lazy.MatchStatus.NO_MATCH: reason = UnenrollReason.TARGETING_MISMATCH; break; case lazy.MatchStatus.TARGETING_ONLY: reason = UnenrollReason.BUCKETING; break; case lazy.MatchStatus.UNENROLLED_IN_ANOTHER_PROFILE: reason = UnenrollReason.UNENROLLED_IN_ANOTHER_PROFILE; break; // TARGETING_AND_BUCKETING cannot cause unenrollment. } } else { reason = result.reason; switch (reason) { case UnenrollReason.L10N_MISSING_ENTRY: case UnenrollReason.L10N_MISSING_LOCALE: extra.locale = result.locale; break; } } return { reason, ...extra }; }, fromReason(reason) { return { reason }; }, /** * An unenrollment caused by a pref change. * * @param {object} changedPref * @param {string} changedPref.name The pref that changed. * @param {string} changedPref.branch The branch on which the pref * was changed. * @param {boolean} isAboutConfigChange Whether or not the change was caused * by the user via about:config. * * @returns {object} The unenrollment cause. */ ChangedPref(changedPref, isAboutConfigChange) { return { reason: lazy.NimbusTelemetry.UnenrollReason.CHANGED_PREF, changedPref, isAboutConfigChange, }; }, MissingLocale(locale) { return { reason: lazy.NimbusTelemetry.UnenrollReason.L10N_MISSING_LOCALE, locale, }; }, PrefFlipsConflict(conflictingSlug) { return { reason: lazy.NimbusTelemetry.UnenrollReason.PREF_FLIPS_CONFLICT, conflictingSlug, }; }, PrefFlipsFailed(prefName, prefType) { return { reason: lazy.NimbusTelemetry.UnenrollReason.PREF_FLIPS_FAILED, prefName, prefType, }; }, Migration(migration) { return { reason: lazy.NimbusTelemetry.UnenrollReason.MIGRATION, migration, }; }, Unknown() { return { reason: lazy.NimbusTelemetry.UnenrollReason.UNKNOWN, }; }, }; /** * An entry in the list of opt-in recipes, which includes the recipe and its * source. * * @typedef {object} OptInEntry * @property {object} recipe * @property {string} source */ /** * A module for processes Experiment recipes, choosing and storing enrollment state, * and sending experiment-related Telemetry. */ export class ExperimentManager { /** @type AboutConfigObserver */ #aboutConfigObserver; constructor({ id = "experimentmanager", store } = {}) { this.id = id; this.store = store || new lazy.ExperimentStore(); /** @type {OptInEntry[]} */ this.optIns = []; // By default, no extra context. this.extraContext = {}; // A Map from pref names to pref observers and metadata. See // `_updatePrefObservers` for the full structure. // // This can only be used in the parent process ExperimentManager. this._prefs = null; // 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 can only be used in the parent process ExperimentManager. this._prefsBySlug = null; // The PrefFlipsFeature instance for managing arbitrary pref flips. // // This can only be used in the parent process ExperimentManager. this._prefFlips = null; this.#aboutConfigObserver = new AboutConfigObserver(); } /** * 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 */ createTargetingContext() { let context = { ...this.extraContext, isFirstStartup: lazy.FirstStartup.state === lazy.FirstStartup.IN_PROGRESS, isNonStubFirstRun: !Services.prefs.getBoolPref( "nimbus.firstUpdateComplete", false ), get currentDate() { return new Date(); }, }; Object.defineProperty(context, "activeExperiments", { enumerable: true, get: async () => { await this.store.ready(); return this.store.getAllActiveExperiments().map(exp => exp.slug); }, }); Object.defineProperty(context, "activeRollouts", { enumerable: true, get: async () => { await this.store.ready(); return this.store.getAllActiveRollouts().map(rollout => rollout.slug); }, }); Object.defineProperty(context, "previousExperiments", { enumerable: true, get: async () => { await this.store.ready(); return this.store .getAll() .filter(enrollment => !enrollment.active && !enrollment.isRollout) .map(exp => exp.slug); }, }); Object.defineProperty(context, "previousRollouts", { enumerable: true, get: async () => { await this.store.ready(); return this.store .getAll() .filter(enrollment => !enrollment.active && enrollment.isRollout) .map(rollout => rollout.slug); }, }); Object.defineProperty(context, "enrollments", { enumerable: true, get: async () => { await this.store.ready(); return this.store.getAll().map(enrollment => enrollment.slug); }, }); Object.defineProperty(context, "enrollmentsMap", { enumerable: true, get: async () => { await this.store.ready(); return this.store.getAll().reduce((acc, enrollment) => { acc[enrollment.slug] = enrollment.branch.slug; return acc; }, {}); }, }); return context; } /** * Runs on startup, including before first run. * * @param {object} extraContext extra targeting context provided by the * ambient environment. */ async onStartup(extraContext = {}) { if (!IS_MAIN_PROCESS) { throw new Error( "ExperimentManager.onStartup() can only be called from the main process" ); } lazy.log.debug("onStartup"); this._prefs = new Map(); this._prefsBySlug = new Map(); this._prefFlips = new lazy.PrefFlipsFeature({ manager: this }); await this.store.ready(); this.extraContext = extraContext; const restoredExperiments = this.store.getAllActiveExperiments(); const restoredRollouts = this.store.getAllActiveRollouts(); for (const experiment of restoredExperiments) { lazy.NimbusTelemetry.setExperimentActive(experiment); if (await this._restoreEnrollmentPrefs(experiment)) { this._updatePrefObservers(experiment); } } for (const rollout of restoredRollouts) { lazy.NimbusTelemetry.setExperimentActive(rollout); if (await this._restoreEnrollmentPrefs(rollout)) { this._updatePrefObservers(rollout); } } if ( lazy.ExperimentAPI.labsEnabled && lazy.NimbusEnrollments.readFromDatabaseEnabled ) { // If labs are disabled, we will immediately clear the list of opt-in // recipes after initialization. this.optIns = await lazy.NimbusEnrollments.loadThirdPartyOptInRecipes(); } this._prefFlips.init(); if (!lazy.ExperimentAPI.labsEnabled) { this._handleLabsDisabled(); } if (!lazy.ExperimentAPI.rolloutsEnabled) { this._handleRolloutsOptOut(); } if (!lazy.ExperimentAPI.studiesEnabled) { this._handleStudiesOptOut(); } lazy.NimbusFeatures.nimbusTelemetry.onUpdate(() => { // Providing default values ensure we disable metrics when unenrolling. const cfg = { metrics_enabled: { "nimbus_targeting_environment.targeting_context_value": false, }, }; const overrides = lazy.NimbusFeatures.nimbusTelemetry.getVariable( "gleanMetricConfiguration" ) ?? {}; for (const [key, value] of Object.entries(overrides)) { cfg[key] = { ...(cfg[key] ?? {}), ...value }; } Services.fog.applyServerKnobsConfig(JSON.stringify(cfg)); }); } /** * Handle a recipe from a source. * * If the recipe is already enrolled we will update the enrollment. Otherwise * enrollment will be attempted. * * @param {object} recipe * The recipe. * * @param {string} source * The source of the recipe, e.g., "rs-loader". * * @param {object} result * The result of validation, targeting, and bucketing. * * See `CheckRecipeResult` for details. */ async onRecipe(recipe, source, result) { const { EnrollmentStatus, EnrollmentStatusReason } = lazy.NimbusTelemetry; const enrollment = this.store.get(recipe.slug); if (enrollment) { await this.updateEnrollment(enrollment, recipe, source, result); return; } if (!result.ok) { lazy.NimbusTelemetry.recordEnrollmentStatus({ slug: recipe.slug, status: EnrollmentStatus.DISQUALIFIED, reason: EnrollmentStatusReason.ERROR, error_string: result.reason, }); return; } if ( recipe.isFirefoxLabsOptIn && result.status !== lazy.MatchStatus.DISABLED ) { if (!this.registerOptIn(recipe, source)) { lazy.log.error( `Could not register opt-in with slug ${recipe.slug}: an opt-in already exists with that slug` ); } return; } switch (result.status) { case lazy.MatchStatus.DISABLED: lazy.NimbusTelemetry.recordEnrollmentStatus({ slug: recipe.slug, status: EnrollmentStatus.NOT_ENROLLED, reason: EnrollmentStatusReason.OPT_OUT, }); break; case lazy.MatchStatus.ENROLLMENT_PAUSED: lazy.NimbusTelemetry.recordEnrollmentStatus({ slug: recipe.slug, status: EnrollmentStatus.NOT_ENROLLED, reason: EnrollmentStatusReason.ENROLLMENTS_PAUSED, }); break; case lazy.MatchStatus.NO_MATCH: lazy.NimbusTelemetry.recordEnrollmentStatus({ slug: recipe.slug, status: EnrollmentStatus.NOT_ENROLLED, reason: EnrollmentStatusReason.NOT_TARGETED, }); break; case lazy.MatchStatus.TARGETING_ONLY: lazy.NimbusTelemetry.recordEnrollmentStatus({ slug: recipe.slug, status: EnrollmentStatus.NOT_ENROLLED, reason: EnrollmentStatusReason.NOT_SELECTED, }); break; case lazy.MatchStatus.UNENROLLED_IN_ANOTHER_PROFILE: lazy.NimbusTelemetry.recordEnrollmentStatus({ slug: recipe.slug, status: EnrollmentStatus.NOT_ENROLLED, reason: EnrollmentStatusReason.UNENROLLED_IN_ANOTHER_PROFILE, }); break; case lazy.MatchStatus.TARGETING_AND_BUCKETING: await this.enroll(recipe, source); break; // This function will not be called with MatchStatus.NOT_SEEN -- // RemoteSettingsExperimentLoader will call updateEnrollment directly // instead. } } /** * Determine userId based on bucketConfig.randomizationUnit; * either "normandy_id" or "group_id". * * @param {object} bucketConfig */ async getUserId(bucketConfig) { let id; if (bucketConfig.randomizationUnit === "normandy_id") { id = lazy.ClientEnvironment.userId; } else if (bucketConfig.randomizationUnit === "group_id") { id = await lazy.ClientID.getProfileGroupID(); } else { // Others not currently supported. lazy.log.debug( `Invalid randomizationUnit: ${bucketConfig.randomizationUnit}` ); } return id; } /** * Get the list of opt-ins that are available for enrollment. * * @returns {OptInEntry[]} The opt-in recipes and their sources. */ async getAvailableOptIns() { const enrollmentsCtx = new lazy.EnrollmentsContext(this, null, { validationEnabled: false, }); // RemoteSettingsExperimentLoader could be in a middle of updating recipes // so let's wait for the update to finish and this promise to resolve. await lazy.ExperimentAPI._rsLoader.finishedUpdating(); // RemoteSettingsExperimentLoader should have finished updating at least // once. Prevent concurrent updates while we filter through the list of // available opt-in recipes. const entries = await lazy.ExperimentAPI._rsLoader.withUpdateLock( async () => { const filtered = []; for (const entry of this.optIns) { if ( (await enrollmentsCtx.checkTargeting(entry.recipe)) && (await this.isInBucketAllocation(entry.recipe.bucketConfig)) && (this.store.get(entry.recipe.slug)?.active || this.canEnroll(entry.recipe).ok) ) { filtered.push(entry); } } return filtered; }, { mode: "shared" } ); entries.sort( (a, b) => new Date(a.recipe.publishedDate ?? 0) - new Date(b.recipe.publishedDate ?? 0) ); return entries; } /** * Determine if this client falls into the bucketing specified in bucketConfig * * @param {object} bucketConfig * @param {string} bucketConfig.randomizationUnit * The randomization unit to use for bucketing. This must be * either "normandy_id" or "group_id". * @param {number} bucketConfig.start * The start of the bucketing range (inclusive). * @param {number} bucketConfig.count * The number of buckets in the range. * @param {number} bucketConfig.total * The total number of buckets. * @param {string} bucketConfig.namespace * A namespace used to seed the RNG used in the sampling * algorithm. Given an otherwise identical bucketConfig with * different namespaces, the client will fall into different a * different bucket. * @returns {Promise} * Whether or not the client falls into the bucketing range. */ async isInBucketAllocation(bucketConfig) { if (!bucketConfig) { lazy.log.debug("Cannot enroll if recipe bucketConfig is not set."); return false; } const id = await this.getUserId(bucketConfig); if (!id) { return false; } return lazy.Sampling.bucketSample( [id, bucketConfig.namespace], bucketConfig.start, bucketConfig.count, bucketConfig.total ); } /** * Determine if enrollment in the given recipe is possible. * * @param {object} recipe The recipe in question. * * @returns {CanEnrollResult} Whether or not we can enroll into a given recipe. */ canEnroll(recipe) { const storeLookupByFeature = recipe.isRollout ? this.store.getRolloutForFeature.bind(this.store) : this.store.getExperimentForFeature.bind(this.store); if (recipe.isEnrollmentPaused) { return { ok: false, reason: CannotEnrollFeatureReason.ENROLLMENT_PAUSED, }; } const conflictingEnrollments = new Set(); for (const featureId of recipe.featureIds) { const feature = lazy.NimbusFeatures[featureId]; if (!feature) { return { ok: false, reason: CannotEnrollFeatureReason.DOES_NOT_EXIST, featureId, }; } if (feature.allowCoenrollment) { continue; } const enrollment = storeLookupByFeature(featureId); if (enrollment) { conflictingEnrollments.add(enrollment.slug); } } if (conflictingEnrollments.size) { return { ok: false, reason: CannotEnrollFeatureReason.ENROLLED_IN_FEATURE, conflictingEnrollments, }; } return { ok: true }; } /** * Start a new experiment by enrolling the users * * @param {object} recipe * The recipe to enroll in. * @param {string} source * The source of the experiment (e.g., "rs-loader" for recipes * from Remote Settings). * @param {object} options * @param {boolean} options.reenroll * Allow re-enrollment. Only supported for rollouts. * @param {string} options.branchSlug * If enrolling in a Firefox Labs opt-in experiment, this * option is required and will dictate which branch to enroll * in. * * @returns {Promise} * The experiment object stored in the data store. * * @throws {Error} If a recipe already exists in the store with the same slug * as `recipe` and re-enrollment is prevented. */ async enroll(recipe, source, { reenroll = false, branchSlug } = {}) { if (typeof source !== "string") { throw new Error("source is required"); } let { slug, branches, bucketConfig, isFirefoxLabsOptIn } = recipe; const enrollment = this.store.get(slug); if ( enrollment && (enrollment.active || (!isFirefoxLabsOptIn && (!enrollment.isRollout || !reenroll))) ) { lazy.NimbusTelemetry.recordEnrollmentFailure( slug, lazy.NimbusTelemetry.EnrollmentFailureReason.NAME_CONFLICT ); lazy.NimbusTelemetry.recordEnrollmentStatus({ slug, status: lazy.NimbusTelemetry.EnrollmentStatus.NOT_ENROLLED, reason: lazy.NimbusTelemetry.EnrollmentStatusReason.NAME_CONFLICT, }); throw new Error(`An experiment with the slug "${slug}" already exists.`); } let branch; if (isFirefoxLabsOptIn) { if (typeof branchSlug === "undefined") { throw new TypeError( `Branch slug not provided for Firefox Labs opt in recipe: "${slug}"` ); } else { branch = branches.find(branch => branch.slug === branchSlug); if (!branch) { throw new Error( `Invalid branch slug provided for Firefox Labs opt in recipe: "${slug}"` ); } } } else if (typeof branchSlug !== "undefined") { throw new TypeError( "branchSlug only supported for recipes with isFirefoxLabsOptIn = true" ); } else { // Here recipe is not a Firefox Labs opt-in, so we use a ratio sampled // branch. const userId = await this.getUserId(bucketConfig); branch = await this.chooseBranch(slug, branches, userId); } const result = this.canEnroll(recipe); if (!result.canEnroll) { switch (result.reason) { case CannotEnrollFeatureReason.DOES_NOT_EXIST: // We do not submit telemetry about this because, if validation was // enabled, we would have already rejected the recipe in // RemoteSettingsExperimentLoader. This will likely only happen in a // test where enroll is called directly. lazy.log.debug( `Skipping enrollment for ${slug}: no such feature ${result.featureId}` ); return null; case CannotEnrollFeatureReason.ENROLLED_IN_FEATURE: lazy.log.debug( `Skipping enrollment for "${slug}" because there is an existing ${ recipe.isRollout ? "rollout" : "experiment" } for this feature.` ); lazy.NimbusTelemetry.recordEnrollmentFailure( slug, lazy.NimbusTelemetry.EnrollmentFailureReason.FEATURE_CONFLICT ); lazy.NimbusTelemetry.recordEnrollmentStatus({ slug, status: lazy.NimbusTelemetry.EnrollmentStatus.NOT_ENROLLED, reason: lazy.NimbusTelemetry.EnrollmentStatusReason.FEATURE_CONFLICT, conflict_slug: Array.from(result.conflictingEnrollments).join(","), }); return null; } } return this._enroll(recipe, branch.slug, source); } /** * Enroll in a specific branch of a recipe. * * @param {object} recipe * The experiment recipe. * * @param {string} branchSlug * The slug of the branch to enroll in. This must exist in the recipe. * * @param {string} source * The source associated with the enrollment. * * @returns {object} The computed enrollment. */ _enroll(recipe, branchSlug, source) { const { slug, isRollout } = recipe; const { enrollment, prefsToSet } = this.createEnrollment( recipe, branchSlug, source ); // Unenroll in any conflicting prefFlips enrollments. if (prefsToSet.length) { this._prefFlips._handleSetPrefConflict( slug, enrollment.prefs.map(p => p.name) ); } this.store.addEnrollment(enrollment, recipe); this._setEnrollmentPrefs(prefsToSet); this._updatePrefObservers(enrollment); lazy.NimbusTelemetry.recordEnrollment(enrollment); lazy.log.debug( `New ${isRollout ? "rollout" : "experiment"} started: ${slug}, ${ branchSlug }` ); return enrollment; } /** * @typedef {object} CreateEnrollmentResult * * @property {object} enrollment * The created enrollment. * * @property {PrefToSet[] | null} prefsToSet * Prefs that should be set upon enrollment. */ /** * Create an enrollment * * @param {object} recipe * The experiment recipe. * * @param {string} branchSlug * The slug of the branch to enroll in. This must exist in the recipe. * * @param {string} source * The source associated with the enrollment. * * @param {object} properties * Additional properties to overwrite on the enrollment. * * @param {boolean} options.active * Whether or not the enrollment should be active (enrolled). * * @returns {CreateEnrollmentResult} * * @throws If the branch does not exist. */ createEnrollment( recipe, branchSlug, source, { active = true, ...extra } = {} ) { const { slug, userFacingName, userFacingDescription, featureIds, isRollout = false, localizations = null, isFirefoxLabsOptIn, firefoxLabsTitle, firefoxLabsDescription, firefoxLabsDescriptionLinks, firefoxLabsGroup, requiresRestart, } = recipe; const branch = recipe.branches.find(b => b.slug === branchSlug); if (typeof branch === "undefined") { throw new Error(`${recipe.slug}: no such branch ${branchSlug}`); } const enrollment = { slug, source, userFacingName, userFacingDescription, lastSeen: new Date().toJSON(), featureIds, isRollout, prefs: [], active, branch, localizations, }; if (typeof isFirefoxLabsOptIn !== "undefined") { Object.assign(enrollment, { isFirefoxLabsOptIn, firefoxLabsTitle, firefoxLabsDescription, firefoxLabsDescriptionLinks, firefoxLabsGroup, requiresRestart, }); } let prefsToSet = null; if (active) { this._prefFlips._annotateEnrollment(enrollment); const result = this._getPrefsForBranch(enrollment.branch, isRollout); enrollment.prefs = result.prefs; prefsToSet = result.prefsToSet; } Object.assign(enrollment, extra); return { enrollment, prefsToSet }; } /** * Force enrollment in a recipe. * * The resulting enrollment will have a slug prefixed with `optin-` to * distinguish it from regular enrollments in telemetry. * * @param {object} recipe The recipe to enroll in. * @param {object|string} branchOrBranchSlug Either the slug of the branch to * enroll in or the branch object. * * @returns {object} The resulting enrollment. */ forceEnroll(recipe, branchOrBranchSlug) { let branch; if (typeof branchOrBranchSlug === "string") { branch = recipe.branches.find(b => b.slug === branchOrBranchSlug); if (!branch) { throw new Error( `Could not force enroll into ${recipe.slug}: no such branch ${branchOrBranchSlug}` ); } } else { lazy.log.warn( "forceEnroll with an object branch is deprecated and will be removed in a future version" ); branch = branchOrBranchSlug; } const result = this.canEnroll(recipe); if (!result.ok) { switch (result.reason) { case CannotEnrollFeatureReason.ENROLLMENT_PAUSED: // Ignore this reason. break; case CannotEnrollFeatureReason.DOES_NOT_EXIST: throw new Error( `Cannot enroll in recipe ${recipe.slug}: feature ${result.featureId} does not exist` ); case CannotEnrollFeatureReason.ENROLLED_IN_FEATURE: for (const conflictingSlug of result.conflictingEnrollments) { lazy.log.debug( `Existing ${ recipe.isRollout ? "rollout" : "experiment" } ${conflictingSlug} found for the same feature, unenrolling.` ); this.unenroll( conflictingSlug, UnenrollmentCause.fromReason( lazy.NimbusTelemetry.UnenrollReason.FORCE_ENROLLMENT ) ); } break; } } const optInRecipe = structuredClone(recipe); optInRecipe.userFacingName = `${recipe.userFacingName} - Forced enrollment`; optInRecipe.slug = `optin-${recipe.slug}`; // If there is an existing active enrollment with this slug, we must // unenroll from it first, otherwise _enroll() will overwrite it without // going through the appropriate flow (e.g., updating the enrollment store // and triggering update callbacks). const existingEnrollment = this.store.get(optInRecipe.slug); if (existingEnrollment?.active) { // We need only unenroll -- when we call _enroll() below, we will // overwrite the existing enrollment. this.unenroll( optInRecipe.slug, UnenrollmentCause.fromReason( lazy.NimbusTelemetry.UnenrollReason.FORCE_ENROLLMENT ) ); } // If there is an existing Firefox Labs entry for a recipe with this slug, // we must remove it because we are replacing the enrollment. this.unregisterOptIn(optInRecipe.slug); const enrollment = this._enroll( optInRecipe, branch.slug, lazy.NimbusTelemetry.EnrollmentSource.FORCE_ENROLLMENT ); // The entry must be registered *after* enrollment so that the new // enrollment lines up correctly with the recipe. if (optInRecipe.isFirefoxLabsOptIn) { this.registerOptIn( optInRecipe, lazy.NimbusTelemetry.EnrollmentSource.FORCE_ENROLLMENT ); } Services.obs.notifyObservers( null, "nimbus:enrollments-updated", optInRecipe.slug ); return enrollment; } /** * Update an existing enrollment. * * @param {object} enrollment * The enrollment to update. * * @param {object?} recipe * The recipe to update the enrollment with, if any * * @param {string} source * The source of the recipe, e.g., "rs-loader". * * @param {object} result * The result of validation, targeting, and bucketing. * * See `CheckRecipeResult` for details. */ async updateEnrollment(enrollment, recipe, source, result) { const { EnrollmentStatus, EnrollmentStatusReason, UnenrollReason } = lazy.NimbusTelemetry; if (enrollment.source !== source) { // In practice this function is only called with source == "rs-loader". // Therefore this condition can only really happen if the user has // force-enrolled into an experiment via the console or nimbus-devtools. // // Either way their state is "corrupted" and the only way to fix it is to // manually delete the entry from the enrollment database. // // Report the error and move on. lazy.log.error( `Refusing to update enrollment for recipe ${recipe.slug} from source ${source}: the existing enrollment has a different source (${enrollment.source})` ); return; } if (result.ok) { // Unenrollment due to studies or rollouts becoming disabled are handled in // `_handleStudiesOptOut` or `_handleRolloutsOptOut` respectively. if (result.status === lazy.MatchStatus.DISABLED) { lazy.NimbusTelemetry.recordEnrollmentStatus({ slug: recipe.slug, status: EnrollmentStatus.NOT_ENROLLED, reason: EnrollmentStatusReason.OPT_OUT, }); return; } if (recipe?.isFirefoxLabsOptIn && !this.registerOptIn(recipe, source)) { // This *should* be unreachable because: // // * we have already returned if enrollment.source !== source; // * this function is in practice only called with source = "rs-loader" // (either directly or indirectly from // RemoteSettingsExerimentLoader.updateRecipes()) // * we clear the opt-in list for "rs-loader" in updateRecipes(); and // * slugs are unique across Remote Settings records. // // However, the user could be playing with devtools at precisely the // wrong time, so we must make sure to handle this gracefully. lazy.log.error( `Unexpected error: could not register opt-in with slug ${recipe.slug}: an opt-in already exists with that slug` ); return; } } if (enrollment.active) { if (!result.ok) { // If the recipe failed validation then we must unenroll. this._unenroll( enrollment, UnenrollmentCause.fromCheckRecipeResult(result) ); return; } if (result.status === lazy.MatchStatus.NOT_SEEN) { // If the recipe was not present in the source we must unenroll. this._unenroll( enrollment, UnenrollmentCause.fromCheckRecipeResult(result) ); return; } if (!recipe.branches.find(b => b.slug === enrollment.branch.slug)) { // Our branch has been removed so we must unenroll. // // This should not happen in practice. this._unenroll( enrollment, UnenrollmentCause.fromReason(UnenrollReason.BRANCH_REMOVED) ); return; } if (result.status === lazy.MatchStatus.NO_MATCH) { // If we have an active enrollment and we no longer match targeting we // must unenroll. this._unenroll( enrollment, UnenrollmentCause.fromCheckRecipeResult(result) ); return; } if ( enrollment.isRollout && result.status === lazy.MatchStatus.TARGETING_ONLY ) { // If we no longer fall in the bucketing allocation for this rollout we // must unenroll. this._unenroll( enrollment, UnenrollmentCause.fromCheckRecipeResult(result) ); return; } if (result.status === lazy.MatchStatus.UNENROLLED_IN_ANOTHER_PROFILE) { this._unenroll( enrollment, UnenrollmentCause.fromCheckRecipeResult(result) ); return; } if (result.status === lazy.MatchStatus.TARGETING_AND_BUCKETING) { lazy.NimbusTelemetry.recordEnrollmentStatus({ slug: enrollment.slug, branch: enrollment.branch.slug, status: EnrollmentStatus.ENROLLED, reason: EnrollmentStatusReason.QUALIFIED, }); } // Either this recipe is not a rollout or both targeting matches and we // are in the bucket allocation. For the former, we do not re-evaluate // bucketing for experiments because the bucketing cannot change. For the // latter, we are already active so we don't need to enroll. return; } if (!enrollment.isRollout || enrollment.isFirefoxLabsOptIn) { // We can only re-enroll into rollouts and we do not enroll directly into // Firefox Labs Opt-Ins. return; } if ( !enrollment.active && result.status === lazy.MatchStatus.TARGETING_AND_BUCKETING && [ UnenrollReason.BUCKETING, UnenrollReason.TARGETING_MISMATCH, UnenrollReason.ROLLOUTS_OPT_OUT, UnenrollReason.STUDIES_OPT_OUT, UnenrollReason.RECIPE_NOT_SEEN, ].includes(enrollment.unenrollReason) ) { // We only re-enroll if we match targeting and bucketing and the unenroll // reason is one of the above reasons. lazy.log.debug(`Re-enrolling in rollout "${recipe.slug}`); await this.enroll(recipe, source, { reenroll: true }); } } /** * Stop an enrollment that is currently active * * @param {string} slug * The slug of the enrollment to stop. * @param {object?} cause * The cause of this unenrollment. All non-object causes will be * coerced into the "unknown" reason. * * See `UnenrollCause` for details. */ unenroll(slug, cause) { const enrollment = this.store.get(slug); if (!enrollment) { lazy.NimbusTelemetry.recordUnenrollmentFailure( slug, lazy.NimbusTelemetry.UnenrollmentFailureReason.DOES_NOT_EXIST ); lazy.log.error(`Could not find an experiment with the slug "${slug}"`); return null; } return this._unenroll( enrollment, typeof cause === "object" && cause !== null ? cause : UnenrollmentCause.Unknown() ); } /** * Stop an enrollment that is currently active. * * @param {Enrollment} enrollment * The enrollment to end. * * @param {object} cause * The cause of this unenrollment. * * See `UnenrollmentCause` for details. * * @param {object?} options * * @param {boolean} options.duringRestore * If true, this indicates that this was during the call to * `_restoreEnrollmentPrefs`. * * @param {boolean} options.unsetEnrollmentPrefs * Whether or not to unset the prefs set by enrollment. */ _unenroll( enrollment, cause, { duringRestore = false, unsetEnrollmentPrefs = true } = {} ) { const { slug } = enrollment; if (!enrollment.active) { lazy.NimbusTelemetry.recordUnenrollmentFailure( slug, lazy.NimbusTelemetry.UnenrollmentFailureReason.ALREADY_UNENROLLED ); throw new Error( `Cannot stop experiment "${slug}" because it is already expired` ); } this.store.deactivateEnrollment(slug, cause.reason); lazy.NimbusTelemetry.recordUnenrollment(enrollment, cause); if (unsetEnrollmentPrefs) { this._unsetEnrollmentPrefs(enrollment, cause, { duringRestore }); } else if (enrollment.prefs) { // If we're not unsetting enrollment prefs, we must remove our listeners. for (const pref of enrollment.prefs) { this._removePrefObserver(pref.name, enrollment.slug); } } lazy.log.debug(`Recipe unenrolled: ${slug} (${cause.reason})`); } /** * Unenroll from all active rollouts if user opts out. */ _handleRolloutsOptOut() { const enrollments = this.store .getAll() .filter(e => e.active && !e.isFirefoxLabsOptIn && e.isRollout); for (const enrollment of enrollments) { this._unenroll( enrollment, UnenrollmentCause.fromReason( lazy.NimbusTelemetry.UnenrollReason.ROLLOUTS_OPT_OUT ) ); } } /** * Unenroll from all active studies if user opts out. */ _handleStudiesOptOut() { const enrollments = this.store .getAll() .filter(e => e.active && !e.isFirefoxLabsOptIn && !e.isRollout); for (const enrollment of enrollments) { this._unenroll( enrollment, UnenrollmentCause.fromReason( lazy.NimbusTelemetry.UnenrollReason.STUDIES_OPT_OUT ) ); } } /** * Unenroll from all active Firefox Labs opt-ins if Labs becomes disabled. */ _handleLabsDisabled() { const enrollments = this.store .getAll() .filter(e => e.active && e.isFirefoxLabsOptIn); for (const enrollment of enrollments) { this._unenroll( enrollment, UnenrollmentCause.fromReason( lazy.NimbusTelemetry.UnenrollReason.LABS_DISABLED ) ); } this.optIns = []; } /** * 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 * @param {string} userId * @returns {Promise} */ 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]; } /** * An annotation generated for a setPref variable for an enrollment. * * @typedef {object} SetPrefAnnotation * * @property {string} name * The name of the pref. * * @property {"user"|"default"} * The branch the pref is to be set on. * * @property {string} featureId * The featureId of the variable controlling this pref. * * @property {string} variable * The variable controlling this pref. * * @property {string|number|boolean|null} originalvalue * The original value of the pref. */ /** * Information about a pref that should be set upon enrollment in a recipe. * * @typedef {object} PrefToSet * * @property {string} name * The name of the pref. * * @property {string|number|boolean} value * The value of the pref. * * @property {"user"|"default"} prefBranch * The branch on which the pref should be set. */ /** * Information about what prefs should be set as a result of enrollment in a * specific branch. * * @typedef {object} PrefsForBranch * * @property {SetPrefAnnotation[]} prefs * Pref annotations to be added to the enrollment. * * This list will include prefs that will not be set because the enrollment * corresponds to a rollout and there is an active experiment controlling the * same pref. * * @property {PrefToSet[]} prefsToSet * Prefs that should be set upon enrollment. */ /** * Generate the list of prefs a recipe will set. * * @param {object} branch The recipe branch that will be enrolled. * @param {boolean} isRollout Whether or not this recipe is a rollout. * * @returns {PrefsForBranch} */ _getPrefsForBranch(branch, isRollout = false) { const prefs = []; const prefsToSet = []; const getConflictingEnrollment = this._makeEnrollmentCache(isRollout); for (const { featureId, value: featureValue } of branch.features) { 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); for (let [variable, value] of Object.entries(featureValue)) { const setPref = feature.getSetPref(variable); if (setPref) { const { pref: prefName, branch: prefBranch } = setPref; 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 there is an active prefFlips experiment for this pref on this // branch, we must use its originalValue. const prefFlipValue = this._prefFlips._getOriginalValue( prefName, prefBranch ); if (typeof prefFlipValue !== "undefined") { originalValue = prefFlipValue; } else { originalValue = lazy.PrefUtils.getPrefStrict( prefName, prefBranch ); } } prefs.push({ name: prefName, branch: prefBranch, featureId, variable, originalValue, }); // An experiment takes precedence if there is already a pref set. if (!isRollout || !conflictingPref) { if ( lazy.NimbusFeatures[featureId].manifest.variables[variable] .type === "json" ) { value = JSON.stringify(value); } 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 {PrefToSet[]} prefsToSet * An array of the prefs that should be set. */ _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 {object} enrollment * The enrollment that has ended. * * @param {object} cause * The cause of the unenrollment. * * See `UnenrollmentCause` for details. * * @param {object} options * * @param {boolean} options.duringRestore * The unenrollment was caused during restore. */ _unsetEnrollmentPrefs(enrollment, cause, { 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 ( cause.reason === lazy.NimbusTelemetry.UnenrollReason.CHANGED_PREF && cause.changedPref.name === pref.name && cause.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 = ExperimentManager.getFeatureConfigFromBranch( 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 {Promise} Whether the restore was successful. If false, the * enrollment has ended. */ async _restoreEnrollmentPrefs(enrollment) { const { UnenrollReason } = lazy.NimbusTelemetry; const { branch, prefs = [], isRollout } = enrollment; if (!prefs?.length) { return false; } const featuresById = Object.fromEntries( branch.features.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, UnenrollmentCause.fromReason(UnenrollReason.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, UnenrollmentCause.fromReason(UnenrollReason.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, UnenrollmentCause.fromReason(UnenrollReason.PREF_VARIABLE_NO_LONGER), { duringRestore: true } ); return false; } // If the variable is setting a different preference, unenroll. const prefName = typeof variableDef.setPref === "object" ? variableDef.setPref.pref : variableDef.setPref; if (prefName !== name) { this._unenroll( enrollment, UnenrollmentCause.fromReason(UnenrollReason.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; } } let value = featuresById[featureId].value[variable]; if ( lazy.NimbusFeatures[featureId].manifest.variables[variable].type === "json" ) { value = JSON.stringify(value); } 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 = (aSubject, aTopic, aData) => { // This observer will be called for changes to `name` as well as any // other pref that begins with `name.`, so we have to filter to // exactly the pref we care about. if (aData === name) { 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) { /// This may be called before the ExperimentManager has finished initializing. if (!this._prefs) { return; } // 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(); } } const feature = ExperimentManager.getFeatureConfigFromBranch( enrollments.at(-1).branch, pref.featureId ); const cause = UnenrollmentCause.ChangedPref( { name: pref.name, branch: lazy.PrefFlipsFeature.determinePrefChangeBranch( pref.name, pref.branch, feature.value[pref.variable] ), }, this.isPrefBeingChangedViaAboutConfig(pref.name) ); for (const enrollment of enrollments) { this._unenroll(enrollment, cause); } } /** * Handle a potential conflict between a setPref experiment and a prefFlips * rollout. * * This should only be called by this manager's `PrefFlipsFeature` instance. * * @param {string} conflictingSlug * The enrolling prefFlips slug. * * @param {[string, PrefBranch][]>} prefs * The prefs that will be set by the pref flip experiment, along with * the branch each pref will be set on. * * @returns {Record} * The original values of any prefs that were being set by setPref * enrollments. */ _handlePrefFlipsConflict(conflictingSlug, prefs) { const originalValues = {}; for (const [pref, branch] of prefs) { const entry = this._prefs.get(pref); if (!entry) { continue; } // We are going to unenroll even if the setPref experiment was using the // same pref on a different branch. for (const slug of entry.slugs) { const enrollment = this.store.get(slug); // The branch and originalValue are not stored in the entry, but are // instead stored on the enrollment. if (!Object.hasOwn(originalValues, pref)) { const prefInfo = enrollment.prefs.find( p => p.name === pref && p.branch === branch ); if (prefInfo) { originalValues[pref] = prefInfo.originalValue; } } this._unenroll( enrollment, UnenrollmentCause.PrefFlipsConflict(conflictingSlug) ); } } return originalValues; } /** * Return whether or not the given pref is being changed by a user on * about:config. * * @param {string} pref The preference to check. * * @returns {boolean} */ isPrefBeingChangedViaAboutConfig(pref) { return this.#aboutConfigObserver.isBeingChanged(pref); } /** * Clear the opt-in list. * * @param {string} source * Only recipes from this source will be removed. * @param {object} options * @param {Set | undefined} options.onlyFeatureIds * If provided, only recipes that contain at least one of the features in this * set will be removed. * * Otherwise, all recipes will be removed. */ _clearOptIns(source, { onlyFeatureIds = undefined } = {}) { this.optIns = this.optIns.filter( entry => source !== entry.source || (typeof onlyFeatureIds !== "undefined" && entry.recipe.featureIds.every( featureId => !onlyFeatureIds.has(featureId) )) ); } /** * Register an opt-in recipe from a source. * * @param {object} recipe The recipe. * @param {string} source The source. * * @returns {boolean} True if the opt-in was registered or false if there was a conflict. */ registerOptIn(recipe, source) { if (!recipe.isFirefoxLabsOptIn) { return false; } if (this.optIns.find(entry => entry.recipe.slug === recipe.slug)) { return false; } // Prevent enrollment if there is an existing enrollment that either does // not match the source or is not a Firefox Labs opt-in. const enrollment = this.store.get(recipe.slug); if ( enrollment && (enrollment.source !== source || !enrollment.isFirefoxLabsOptIn) ) { return false; } this.optIns.push({ recipe, source }); return true; } /** * Unregister an opt-in recipe from a source. * * NB: This is only intended to be used during force enrollment or by * nimbus-devtools. * * @param {string} slug The slug of the recipe to remove. * * @returns {boolean} True if the opt-in was removed or false if it was not found. */ unregisterOptIn(slug) { const index = this.optIns.findIndex(entry => entry.recipe.slug === slug); if (index >= 0) { this.optIns.splice(index, 1); return true; } return false; } /** * Return the feature configuration with the matching feature ID from the * given branch. * * @param {object} branch * The branch object. * * @param {string} featureId * The feature to search for. * * @returns {object} * The feature configuration, including the feature ID and the value. */ static getFeatureConfigFromBranch(branch, featureId) { return branch.features.find(f => f.featureId === featureId); } } const ABOUT_CONFIG_WILL_CHANGE_PREF_TOPIC = "about-config-will-change-pref"; const ABOUT_CONFIG_CHANGED_PREF_TOPIC = "about-config-changed-pref"; /** * Keep track of what prefs are being changed via about:config. */ class AboutConfigObserver { /** * Prefs that are currently being changed via about:config. * * @type Set */ #changes; constructor() { this.#changes = new Set(); Services.obs.addObserver(this, ABOUT_CONFIG_WILL_CHANGE_PREF_TOPIC); Services.obs.addObserver(this, ABOUT_CONFIG_CHANGED_PREF_TOPIC); } /** * Handle a notification from about:config. * * @param {any} _subject Unused. * @param {string} topic The topic that indicates the rising vs. the falling * edge of the event. * @param {string} data The name of the pref being changed. * @returns */ observe(_subject, topic, data) { switch (topic) { case ABOUT_CONFIG_WILL_CHANGE_PREF_TOPIC: this.#onWillChange(data); break; case ABOUT_CONFIG_CHANGED_PREF_TOPIC: this.#onChanged(data); break; } } /** * Record that a pref is about to change. * * @param {string} pref The pref. */ #onWillChange(pref) { this.#changes.add(pref); } /** * Record that a pref has finished changing. * * @param {string} pref The pref. */ #onChanged(pref) { this.#changes.delete(pref); } /** * Return whether or not a pref is in the process of being changed via * `about:config`. * * @param {string} pref The pref in question. * * @returns {boolean} Whether or not the pref is being changed. */ isBeingChanged(pref) { return this.#changes.has(pref); } }