diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /toolkit/components/nimbus/lib | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/nimbus/lib')
-rw-r--r-- | toolkit/components/nimbus/lib/ExperimentManager.sys.mjs | 1404 | ||||
-rw-r--r-- | toolkit/components/nimbus/lib/ExperimentStore.sys.mjs | 484 | ||||
-rw-r--r-- | toolkit/components/nimbus/lib/NimbusFeatureManifest.inc.h | 18 | ||||
-rw-r--r-- | toolkit/components/nimbus/lib/NimbusFeatures.cpp | 208 | ||||
-rw-r--r-- | toolkit/components/nimbus/lib/NimbusFeatures.h | 51 | ||||
-rw-r--r-- | toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs | 738 | ||||
-rw-r--r-- | toolkit/components/nimbus/lib/SharedDataMap.sys.mjs | 177 |
7 files changed, 3080 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..8e1acc4803 --- /dev/null +++ b/toolkit/components/nimbus/lib/ExperimentManager.sys.mjs @@ -0,0 +1,1404 @@ +/* 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", + 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", +}); + +ChromeUtils.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 = { + ...this.extraContext, + + isFirstStartup: lazy.FirstStartup.state === lazy.FirstStartup.IN_PROGRESS, + + 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 = {}) { + 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, + 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, + Object.assign( + { + reason, + branch: enrollment.branch.slug, + }, + typeof changedPref !== "undefined" + ? { changedPref: changedPref.name } + : {} + ) + ); + // Sent Glean event equivalent + Glean.nimbusEvents.unenrollment.record( + Object.assign( + { + experiment: slug, + branch: enrollment.branch.slug, + reason, + }, + typeof changedPref !== "undefined" + ? { changed_pref: changedPref.name } + : {} + ) + ); + + 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 }) { + lazy.TelemetryEvents.sendEvent("enroll", TELEMETRY_EVENT_OBJECT, slug, { + experimentType, + branch: branch.slug, + }); + Glean.nimbusEvents.enrollment.record({ + experiment: slug, + branch: branch.slug, + 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}`, + } + ); + // Report the experiment to the Glean Experiment API + Services.fog.setExperimentActive(experiment.slug, experiment.branch.slug, { + type: `${TELEMETRY_EXPERIMENT_ACTIVE_PREFIX}${experiment.experimentType}`, + }); + } + + /** + * 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); + + for (const [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 ( + 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. + const prefName = + typeof variableDef.setPref === "object" + ? variableDef.setPref.pref + : variableDef.setPref; + + if (prefName !== 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 = (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) { + // 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(); diff --git a/toolkit/components/nimbus/lib/ExperimentStore.sys.mjs b/toolkit/components/nimbus/lib/ExperimentStore.sys.mjs new file mode 100644 index 0000000000..7fd7fd987e --- /dev/null +++ b/toolkit/components/nimbus/lib/ExperimentStore.sys.mjs @@ -0,0 +1,484 @@ +/* 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 { SharedDataMap } from "resource://nimbus/lib/SharedDataMap.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FeatureManifest: "resource://nimbus/FeatureManifest.sys.mjs", + PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs", +}); + +const IS_MAIN_PROCESS = + Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT; + +// This branch is used to store experiment data +const SYNC_DATA_PREF_BRANCH = "nimbus.syncdatastore."; +// This branch is used to store remote rollouts +const SYNC_DEFAULTS_PREF_BRANCH = "nimbus.syncdefaultsstore."; +let tryJSONParse = data => { + try { + return JSON.parse(data); + } catch (e) {} + + return null; +}; +ChromeUtils.defineLazyGetter(lazy, "syncDataStore", () => { + let experimentsPrefBranch = Services.prefs.getBranch(SYNC_DATA_PREF_BRANCH); + let defaultsPrefBranch = Services.prefs.getBranch(SYNC_DEFAULTS_PREF_BRANCH); + return { + _tryParsePrefValue(branch, pref) { + try { + return tryJSONParse(branch.getStringPref(pref, "")); + } catch (e) { + /* This is expected if we don't have anything stored */ + } + + return null; + }, + _trySetPrefValue(branch, pref, value) { + try { + branch.setStringPref(pref, JSON.stringify(value)); + } catch (e) { + console.error(e); + } + }, + _trySetTypedPrefValue(pref, value) { + let variableType = typeof value; + switch (variableType) { + case "boolean": + Services.prefs.setBoolPref(pref, value); + break; + case "number": + Services.prefs.setIntPref(pref, value); + break; + case "string": + Services.prefs.setStringPref(pref, value); + break; + case "object": + Services.prefs.setStringPref(pref, JSON.stringify(value)); + break; + } + }, + _clearBranchChildValues(prefBranch) { + const variablesBranch = Services.prefs.getBranch(prefBranch); + const prefChildList = variablesBranch.getChildList(""); + for (let variable of prefChildList) { + variablesBranch.clearUserPref(variable); + } + }, + /** + * Given a branch pref returns all child prefs and values + * { childPref: value } + * where value is parsed to the appropriate type + * + * @returns {Object[]} + */ + _getBranchChildValues(prefBranch, featureId) { + const branch = Services.prefs.getBranch(prefBranch); + const prefChildList = branch.getChildList(""); + let values = {}; + if (!prefChildList.length) { + return null; + } + for (const childPref of prefChildList) { + let prefName = `${prefBranch}${childPref}`; + let value = lazy.PrefUtils.getPref(prefName); + // Try to parse string values that could be stringified objects + if ( + lazy.FeatureManifest[featureId]?.variables[childPref]?.type === "json" + ) { + let parsedValue = tryJSONParse(value); + if (parsedValue) { + value = parsedValue; + } + } + values[childPref] = value; + } + + return values; + }, + get(featureId) { + let metadata = this._tryParsePrefValue(experimentsPrefBranch, featureId); + if (!metadata) { + return null; + } + let prefBranch = `${SYNC_DATA_PREF_BRANCH}${featureId}.`; + metadata.branch.feature.value = this._getBranchChildValues( + prefBranch, + featureId + ); + + return metadata; + }, + getDefault(featureId) { + let metadata = this._tryParsePrefValue(defaultsPrefBranch, featureId); + if (!metadata) { + return null; + } + let prefBranch = `${SYNC_DEFAULTS_PREF_BRANCH}${featureId}.`; + metadata.branch.feature.value = this._getBranchChildValues( + prefBranch, + featureId + ); + + return metadata; + }, + set(featureId, value) { + /* If the enrollment branch has variables we store those separately + * in pref branches of appropriate type: + * { featureId: "foo", value: { enabled: true } } + * gets stored as `${SYNC_DATA_PREF_BRANCH}foo.enabled=true` + */ + if (value.branch?.feature?.value) { + for (let variable of Object.keys(value.branch.feature.value)) { + let prefName = `${SYNC_DATA_PREF_BRANCH}${featureId}.${variable}`; + this._trySetTypedPrefValue( + prefName, + value.branch.feature.value[variable] + ); + } + this._trySetPrefValue(experimentsPrefBranch, featureId, { + ...value, + branch: { + ...value.branch, + feature: { + ...value.branch.feature, + value: null, + }, + }, + }); + } else { + this._trySetPrefValue(experimentsPrefBranch, featureId, value); + } + }, + setDefault(featureId, enrollment) { + /* We store configuration variables separately in pref branches of + * appropriate type: + * (feature: "foo") { variables: { enabled: true } } + * gets stored as `${SYNC_DEFAULTS_PREF_BRANCH}foo.enabled=true` + */ + let { feature } = enrollment.branch; + for (let variable of Object.keys(feature.value)) { + let prefName = `${SYNC_DEFAULTS_PREF_BRANCH}${featureId}.${variable}`; + this._trySetTypedPrefValue(prefName, feature.value[variable]); + } + this._trySetPrefValue(defaultsPrefBranch, featureId, { + ...enrollment, + branch: { + ...enrollment.branch, + feature: { + ...enrollment.branch.feature, + value: null, + }, + }, + }); + }, + getAllDefaultBranches() { + return defaultsPrefBranch.getChildList("").filter( + // Filter out remote defaults variable prefs + pref => !pref.includes(".") + ); + }, + delete(featureId) { + const prefBranch = `${SYNC_DATA_PREF_BRANCH}${featureId}.`; + this._clearBranchChildValues(prefBranch); + try { + experimentsPrefBranch.clearUserPref(featureId); + } catch (e) {} + }, + deleteDefault(featureId) { + let prefBranch = `${SYNC_DEFAULTS_PREF_BRANCH}${featureId}.`; + this._clearBranchChildValues(prefBranch); + try { + defaultsPrefBranch.clearUserPref(featureId); + } catch (e) {} + }, + }; +}); + +const DEFAULT_STORE_ID = "ExperimentStoreData"; + +/** + * Returns all feature ids associated with the branch provided. + * Fallback for when `featureIds` was not persisted to disk. Can be removed + * after bug 1725240 has reached release. + * + * @param {Branch} branch + * @returns {string[]} + */ +function getAllBranchFeatureIds(branch) { + return featuresCompat(branch).map(f => f.featureId); +} + +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; +} + +export class ExperimentStore extends SharedDataMap { + static SYNC_DATA_PREF_BRANCH = SYNC_DATA_PREF_BRANCH; + static SYNC_DEFAULTS_PREF_BRANCH = SYNC_DEFAULTS_PREF_BRANCH; + + constructor(sharedDataKey, options = { isParent: IS_MAIN_PROCESS }) { + super(sharedDataKey || DEFAULT_STORE_ID, options); + } + + async init() { + await super.init(); + + this.getAllActiveExperiments().forEach(({ slug, branch, featureIds }) => { + (featureIds || getAllBranchFeatureIds(branch)).forEach(featureId => + this._emitFeatureUpdate(featureId, "feature-experiment-loaded") + ); + }); + this.getAllActiveRollouts().forEach(({ featureIds }) => { + featureIds.forEach(featureId => + this._emitFeatureUpdate(featureId, "feature-rollout-loaded") + ); + }); + + Services.tm.idleDispatchToMainThread(() => this._cleanupOldRecipes()); + } + + /** + * Given a feature identifier, find an active experiment that matches that feature identifier. + * This assumes, for now, that there is only one active experiment per feature per browser. + * Does not activate the experiment (send an exposure event) + * + * @param {string} featureId + * @returns {Enrollment|undefined} An active experiment if it exists + * @memberof ExperimentStore + */ + getExperimentForFeature(featureId) { + return ( + this.getAllActiveExperiments().find( + experiment => + experiment.featureIds?.includes(featureId) || + // Supports <v1.3.0, which was when .featureIds was added + getAllBranchFeatureIds(experiment.branch).includes(featureId) + // Default to the pref store if data is not yet ready + ) || lazy.syncDataStore.get(featureId) + ); + } + + /** + * Check if an active experiment already exists for a feature. + * Does not activate the experiment (send an exposure event) + * + * @param {string} featureId + * @returns {boolean} Does an active experiment exist for that feature? + * @memberof ExperimentStore + */ + hasExperimentForFeature(featureId) { + if (!featureId) { + return false; + } + return !!this.getExperimentForFeature(featureId); + } + + /** + * @returns {Enrollment[]} + */ + getAll() { + let data = []; + try { + data = Object.values(this._data || {}); + } catch (e) { + console.error(e); + } + + return data; + } + + /** + * Returns all active experiments + * @returns {Enrollment[]} + */ + getAllActiveExperiments() { + return this.getAll().filter( + enrollment => enrollment.active && !enrollment.isRollout + ); + } + + /** + * Returns all active rollouts + * @returns {Enrollment[]} + */ + getAllActiveRollouts() { + return this.getAll().filter( + enrollment => enrollment.active && enrollment.isRollout + ); + } + + /** + * Query the store for the remote configuration of a feature + * @param {string} featureId The feature we want to query for + * @returns {{Rollout}|undefined} Remote defaults if available + */ + getRolloutForFeature(featureId) { + return ( + this.getAllActiveRollouts().find(r => r.featureIds.includes(featureId)) || + lazy.syncDataStore.getDefault(featureId) + ); + } + + /** + * Check if an active rollout already exists for a feature. + * Does not active the experiment (send an exposure event). + * + * @param {string} featureId + * @returns {boolean} Does an active rollout exist for that feature? + */ + hasRolloutForFeature(featureId) { + if (!featureId) { + return false; + } + return !!this.getRolloutForFeature(featureId); + } + + /** + * Remove inactive enrollments older than 6 months + */ + _cleanupOldRecipes() { + // Roughly six months + const threshold = 15552000000; + const nowTimestamp = new Date().getTime(); + const recipesToRemove = this.getAll().filter( + experiment => + !experiment.active && + // Flip the comparison here to catch scenarios in which lastSeen is + // invalid or undefined. The result with be a comparison with NaN + // which is always false + !(nowTimestamp - new Date(experiment.lastSeen).getTime() < threshold) + ); + this._removeEntriesByKeys(recipesToRemove.map(r => r.slug)); + } + + _emitUpdates(enrollment) { + this.emit(`update:${enrollment.slug}`, enrollment); + const featureIds = + enrollment.featureIds || getAllBranchFeatureIds(enrollment.branch); + const reason = enrollment.isRollout + ? "rollout-updated" + : "experiment-updated"; + + for (const featureId of featureIds) { + this._emitFeatureUpdate(featureId, reason); + } + } + + _emitFeatureUpdate(featureId, reason) { + this.emit(`featureUpdate:${featureId}`, reason); + } + + _onFeatureUpdate(featureId, callback) { + if (this._isReady) { + const hasExperiment = this.hasExperimentForFeature(featureId); + if (hasExperiment || this.hasRolloutForFeature(featureId)) { + callback( + `featureUpdate:${featureId}`, + hasExperiment ? "experiment-updated" : "rollout-updated" + ); + } + } + + this.on(`featureUpdate:${featureId}`, callback); + } + + _offFeatureUpdate(featureId, callback) { + this.off(`featureUpdate:${featureId}`, callback); + } + + /** + * Persists early startup experiments or rollouts + * @param {Enrollment} enrollment Experiment or rollout + */ + _updateSyncStore(enrollment) { + let features = featuresCompat(enrollment.branch); + for (let feature of features) { + if ( + lazy.FeatureManifest[feature.featureId]?.isEarlyStartup || + feature.isEarlyStartup + ) { + if (!enrollment.active) { + // Remove experiments on un-enroll, no need to check if it exists + if (enrollment.isRollout) { + lazy.syncDataStore.deleteDefault(feature.featureId); + } else { + lazy.syncDataStore.delete(feature.featureId); + } + } else { + let updateEnrollmentSyncStore = enrollment.isRollout + ? lazy.syncDataStore.setDefault.bind(lazy.syncDataStore) + : lazy.syncDataStore.set.bind(lazy.syncDataStore); + updateEnrollmentSyncStore(feature.featureId, { + ...enrollment, + branch: { + ...enrollment.branch, + feature, + // Only store the early startup feature + features: null, + }, + }); + } + } + } + } + + /** + * Add an enrollment and notify listeners + * @param {Enrollment} enrollment + */ + addEnrollment(enrollment) { + if (!enrollment || !enrollment.slug) { + throw new Error( + `Tried to add an experiment but it didn't have a .slug property.` + ); + } + + this.set(enrollment.slug, enrollment); + this._updateSyncStore(enrollment); + this._emitUpdates(enrollment); + } + + /** + * Merge new properties into the properties of an existing experiment + * @param {string} slug + * @param {Partial<Enrollment>} newProperties + */ + updateExperiment(slug, newProperties) { + const oldProperties = this.get(slug); + if (!oldProperties) { + throw new Error( + `Tried to update experiment ${slug} but it doesn't exist` + ); + } + const updatedExperiment = { ...oldProperties, ...newProperties }; + this.set(slug, updatedExperiment); + this._updateSyncStore(updatedExperiment); + this._emitUpdates(updatedExperiment); + } + + /** + * Test only helper for cleanup + * + * @param slugOrFeatureId Can be called with slug (which removes the SharedDataMap entry) or + * with featureId which removes the SyncDataStore entry for the feature + */ + _deleteForTests(slugOrFeatureId) { + super._deleteForTests(slugOrFeatureId); + lazy.syncDataStore.deleteDefault(slugOrFeatureId); + lazy.syncDataStore.delete(slugOrFeatureId); + } +} diff --git a/toolkit/components/nimbus/lib/NimbusFeatureManifest.inc.h b/toolkit/components/nimbus/lib/NimbusFeatureManifest.inc.h new file mode 100644 index 0000000000..4d3190635c --- /dev/null +++ b/toolkit/components/nimbus/lib/NimbusFeatureManifest.inc.h @@ -0,0 +1,18 @@ +/* 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/. */ + +Maybe<nsCString> GetNimbusFallbackPrefName(const nsACString& aFeatureId, + const nsACString& aVariable) { + nsAutoCString manifestKey; + manifestKey.Append(aFeatureId); + manifestKey.Append("_"); + manifestKey.Append(aVariable); + + for (const auto& pair : NIMBUS_FALLBACK_PREFS) { + if (pair.first.Equals(manifestKey.get())) { + return Some(pair.second); + } + } + return Nothing{}; +} diff --git a/toolkit/components/nimbus/lib/NimbusFeatures.cpp b/toolkit/components/nimbus/lib/NimbusFeatures.cpp new file mode 100644 index 0000000000..58481526ed --- /dev/null +++ b/toolkit/components/nimbus/lib/NimbusFeatures.cpp @@ -0,0 +1,208 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#include "mozilla/browser/NimbusFeatures.h" +#include "mozilla/browser/NimbusFeatureManifest.h" +#include "mozilla/Telemetry.h" +#include "mozilla/Try.h" +#include "mozilla/dom/ScriptSettings.h" +#include "jsapi.h" +#include "js/JSON.h" +#include "nsJSUtils.h" + +namespace mozilla { + +static nsTHashSet<nsCString> sExposureFeatureSet; + +void NimbusFeatures::GetPrefName(const nsACString& branchPrefix, + const nsACString& aFeatureId, + const nsACString& aVariable, + nsACString& aPref) { + nsAutoCString featureAndVariable; + featureAndVariable.Append(aFeatureId); + if (!aVariable.IsEmpty()) { + featureAndVariable.Append("."); + featureAndVariable.Append(aVariable); + } + aPref.Truncate(); + aPref.Append(branchPrefix); + aPref.Append(featureAndVariable); +} + +/** + * Returns the variable value configured via experiment or rollout. + * If a fallback pref is defined in the FeatureManifest and it + * has a user value set this takes precedence over remote configurations. + */ +bool NimbusFeatures::GetBool(const nsACString& aFeatureId, + const nsACString& aVariable, bool aDefault) { + nsAutoCString experimentPref; + GetPrefName(kSyncDataPrefBranch, aFeatureId, aVariable, experimentPref); + if (Preferences::HasUserValue(experimentPref.get())) { + return Preferences::GetBool(experimentPref.get(), aDefault); + } + + nsAutoCString rolloutPref; + GetPrefName(kSyncRolloutsPrefBranch, aFeatureId, aVariable, rolloutPref); + if (Preferences::HasUserValue(rolloutPref.get())) { + return Preferences::GetBool(rolloutPref.get(), aDefault); + } + + auto prefName = GetNimbusFallbackPrefName(aFeatureId, aVariable); + if (prefName.isSome()) { + return Preferences::GetBool(prefName->get(), aDefault); + } + return aDefault; +} + +/** + * Returns the variable value configured via experiment or rollout. + * If a fallback pref is defined in the FeatureManifest and it + * has a user value set this takes precedence over remote configurations. + */ +int NimbusFeatures::GetInt(const nsACString& aFeatureId, + const nsACString& aVariable, int aDefault) { + nsAutoCString experimentPref; + GetPrefName(kSyncDataPrefBranch, aFeatureId, aVariable, experimentPref); + if (Preferences::HasUserValue(experimentPref.get())) { + return Preferences::GetInt(experimentPref.get(), aDefault); + } + + nsAutoCString rolloutPref; + GetPrefName(kSyncRolloutsPrefBranch, aFeatureId, aVariable, rolloutPref); + if (Preferences::HasUserValue(rolloutPref.get())) { + return Preferences::GetInt(rolloutPref.get(), aDefault); + } + + auto prefName = GetNimbusFallbackPrefName(aFeatureId, aVariable); + if (prefName.isSome()) { + return Preferences::GetInt(prefName->get(), aDefault); + } + return aDefault; +} + +nsresult NimbusFeatures::OnUpdate(const nsACString& aFeatureId, + const nsACString& aVariable, + PrefChangedFunc aUserCallback, + void* aUserData) { + nsAutoCString experimentPref; + nsAutoCString rolloutPref; + GetPrefName(kSyncDataPrefBranch, aFeatureId, aVariable, experimentPref); + GetPrefName(kSyncRolloutsPrefBranch, aFeatureId, aVariable, rolloutPref); + nsresult rv = + Preferences::RegisterCallback(aUserCallback, experimentPref, aUserData); + NS_ENSURE_SUCCESS(rv, rv); + rv = Preferences::RegisterCallback(aUserCallback, rolloutPref, aUserData); + NS_ENSURE_SUCCESS(rv, rv); + + return NS_OK; +} + +nsresult NimbusFeatures::OffUpdate(const nsACString& aFeatureId, + const nsACString& aVariable, + PrefChangedFunc aUserCallback, + void* aUserData) { + nsAutoCString experimentPref; + nsAutoCString rolloutPref; + GetPrefName(kSyncDataPrefBranch, aFeatureId, aVariable, experimentPref); + GetPrefName(kSyncRolloutsPrefBranch, aFeatureId, aVariable, rolloutPref); + nsresult rv = + Preferences::UnregisterCallback(aUserCallback, experimentPref, aUserData); + NS_ENSURE_SUCCESS(rv, rv); + rv = Preferences::UnregisterCallback(aUserCallback, rolloutPref, aUserData); + NS_ENSURE_SUCCESS(rv, rv); + + return NS_OK; +} + +/** + * Attempt to read Nimbus preference to determine experiment and branch slug. + * Nimbus will store a pref with experiment metadata in the following format: + * { + * slug: "experiment slug", + * branch: { slug: "branch slug" }, + * ... + * } + * The naming convention for preference names is: + * `nimbus.syncdatastore.<feature_id>` + * These values are used to send `exposure` telemetry pings. + */ +nsresult NimbusFeatures::GetExperimentSlug(const nsACString& aFeatureId, + nsACString& aExperimentSlug, + nsACString& aBranchSlug) { + nsAutoCString prefName; + nsAutoString prefValue; + + aExperimentSlug.Truncate(); + aBranchSlug.Truncate(); + + GetPrefName(kSyncDataPrefBranch, aFeatureId, EmptyCString(), prefName); + MOZ_TRY(Preferences::GetString(prefName.get(), prefValue)); + if (prefValue.IsEmpty()) { + return NS_ERROR_UNEXPECTED; + } + dom::AutoJSAPI jsapi; + if (!jsapi.Init(xpc::PrivilegedJunkScope())) { + return NS_ERROR_UNEXPECTED; + } + JSContext* cx = jsapi.cx(); + JS::Rooted<JS::Value> json(cx, JS::NullValue()); + if (JS_ParseJSON(cx, prefValue.BeginReading(), prefValue.Length(), &json) && + json.isObject()) { + JS::Rooted<JSObject*> experimentJSON(cx, json.toObjectOrNull()); + JS::Rooted<JS::Value> expSlugValue(cx); + if (!JS_GetProperty(cx, experimentJSON, "slug", &expSlugValue)) { + return NS_ERROR_UNEXPECTED; + } + AssignJSString(cx, aExperimentSlug, expSlugValue.toString()); + + JS::Rooted<JS::Value> branchJSON(cx); + if (!JS_GetProperty(cx, experimentJSON, "branch", &branchJSON) && + !branchJSON.isObject()) { + return NS_ERROR_UNEXPECTED; + } + JS::Rooted<JSObject*> branchObj(cx, branchJSON.toObjectOrNull()); + JS::Rooted<JS::Value> branchSlugValue(cx); + if (!JS_GetProperty(cx, branchObj, "slug", &branchSlugValue)) { + return NS_ERROR_UNEXPECTED; + } + AssignJSString(cx, aBranchSlug, branchSlugValue.toString()); + } + + return NS_OK; +} + +/** + * Sends an exposure event for aFeatureId when enrolled in an experiment. + * By default attempt to send once per function call. For some usecases it might + * be useful to send only once, in which case set the optional aOnce to `true`. + */ +nsresult NimbusFeatures::RecordExposureEvent(const nsACString& aFeatureId, + const bool aOnce) { + nsAutoCString featureName(aFeatureId); + if (!sExposureFeatureSet.EnsureInserted(featureName) && aOnce) { + // We already sent (or tried to send) an exposure ping for this featureId + return NS_ERROR_ABORT; + } + nsAutoCString slugName; + nsAutoCString branchName; + MOZ_TRY(GetExperimentSlug(aFeatureId, slugName, branchName)); + if (slugName.IsEmpty() || branchName.IsEmpty()) { + // Failed getting experiment metadata or not enrolled in an experiment for + // this featureId + return NS_ERROR_UNEXPECTED; + } + Telemetry::SetEventRecordingEnabled("normandy"_ns, true); + nsTArray<Telemetry::EventExtraEntry> extra(2); + extra.AppendElement(Telemetry::EventExtraEntry{"branchSlug"_ns, branchName}); + extra.AppendElement(Telemetry::EventExtraEntry{"featureId"_ns, featureName}); + Telemetry::RecordEvent(Telemetry::EventID::Normandy_Expose_NimbusExperiment, + Some(slugName), Some(std::move(extra))); + + return NS_OK; +} + +} // namespace mozilla diff --git a/toolkit/components/nimbus/lib/NimbusFeatures.h b/toolkit/components/nimbus/lib/NimbusFeatures.h new file mode 100644 index 0000000000..7fe9ac8b5c --- /dev/null +++ b/toolkit/components/nimbus/lib/NimbusFeatures.h @@ -0,0 +1,51 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* 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/. */ + +#ifndef mozilla_NimbusFeatures_h +#define mozilla_NimbusFeatures_h + +#include "mozilla/Preferences.h" +#include "nsTHashSet.h" + +namespace mozilla { + +class NimbusFeatures { + private: + // This branch is used to store experiment data + static constexpr auto kSyncDataPrefBranch = "nimbus.syncdatastore."_ns; + // This branch is used to store rollouts data + static constexpr auto kSyncRolloutsPrefBranch = + "nimbus.syncdefaultsstore."_ns; + static void GetPrefName(const nsACString& branchPrefix, + const nsACString& aFeatureId, + const nsACString& aVariable, nsACString& aPref); + + static nsresult GetExperimentSlug(const nsACString& aFeatureId, + nsACString& aExperimentSlug, + nsACString& aBranchSlug); + + public: + static bool GetBool(const nsACString& aFeatureId, const nsACString& aVariable, + bool aDefault); + + static int GetInt(const nsACString& aFeatureId, const nsACString& aVariable, + int aDefault); + + static nsresult OnUpdate(const nsACString& aFeatureId, + const nsACString& aVariable, + PrefChangedFunc aUserCallback, void* aUserData); + + static nsresult OffUpdate(const nsACString& aFeatureId, + const nsACString& aVariable, + PrefChangedFunc aUserCallback, void* aUserData); + + static nsresult RecordExposureEvent(const nsACString& aFeatureId, + const bool aOnce = false); +}; + +} // namespace mozilla + +#endif diff --git a/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs b/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs new file mode 100644 index 0000000000..8e026e5cba --- /dev/null +++ b/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs @@ -0,0 +1,738 @@ +/* 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, { + _ExperimentFeature: "resource://nimbus/ExperimentAPI.sys.mjs", + ASRouterTargeting: + // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit + "resource:///modules/asrouter/ASRouterTargeting.sys.mjs", + CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs", + ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs", + JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "log", () => { + const { Logger } = ChromeUtils.importESModule( + "resource://messaging-system/lib/Logger.sys.mjs" + ); + return new Logger("RSLoader"); +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "timerManager", + "@mozilla.org/updates/timer-manager;1", + "nsIUpdateTimerManager" +); + +const COLLECTION_ID_PREF = "messaging-system.rsexperimentloader.collection_id"; +const COLLECTION_ID_FALLBACK = "nimbus-desktop-experiments"; +const ENABLED_PREF = "messaging-system.rsexperimentloader.enabled"; + +const TIMER_NAME = "rs-experiment-loader-timer"; +const TIMER_LAST_UPDATE_PREF = `app.update.lastUpdateTime.${TIMER_NAME}`; +// Use the same update interval as normandy +const RUN_INTERVAL_PREF = "app.normandy.run_interval_seconds"; +const NIMBUS_DEBUG_PREF = "nimbus.debug"; +const NIMBUS_VALIDATION_PREF = "nimbus.validation.enabled"; +const NIMBUS_APPID_PREF = "nimbus.appId"; + +const STUDIES_ENABLED_CHANGED = "nimbus:studies-enabled-changed"; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "COLLECTION_ID", + COLLECTION_ID_PREF, + COLLECTION_ID_FALLBACK +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "NIMBUS_DEBUG", + NIMBUS_DEBUG_PREF, + false +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "APP_ID", + NIMBUS_APPID_PREF, + "firefox-desktop" +); + +const SCHEMAS = { + get NimbusExperiment() { + return fetch("resource://nimbus/schemas/NimbusExperiment.schema.json", { + credentials: "omit", + }) + .then(rsp => rsp.json()) + .then(json => json.definitions.NimbusExperiment); + }, +}; + +export class _RemoteSettingsExperimentLoader { + constructor() { + // Has the timer been set? + this._initialized = false; + // Are we in the middle of updating recipes already? + this._updating = false; + + // Make it possible to override for testing + this.manager = lazy.ExperimentManager; + + ChromeUtils.defineLazyGetter(this, "remoteSettingsClient", () => { + return lazy.RemoteSettings(lazy.COLLECTION_ID); + }); + + Services.obs.addObserver(this, STUDIES_ENABLED_CHANGED); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "enabled", + ENABLED_PREF, + false, + this.onEnabledPrefChange.bind(this) + ); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "intervalInSeconds", + RUN_INTERVAL_PREF, + 21600, + () => this.setTimer() + ); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "validationEnabled", + NIMBUS_VALIDATION_PREF, + true + ); + } + + get studiesEnabled() { + return this.manager.studiesEnabled; + } + + /** + * Initialize the loader, updating recipes from Remote Settings. + * + * @param {Object} options additional options. + * @param {bool} options.forceSync force Remote Settings to sync recipe collection + * before updating recipes; throw if sync fails. + * @return {Promise} which resolves after initialization and recipes + * are updated. + */ + async init(options = {}) { + const { forceSync = false } = options; + + if (this._initialized || !this.enabled || !this.studiesEnabled) { + return; + } + + this.setTimer(); + lazy.CleanupManager.addCleanupHandler(() => this.uninit()); + this._initialized = true; + + await this.updateRecipes(undefined, { forceSync }); + } + + uninit() { + if (!this._initialized) { + return; + } + lazy.timerManager.unregisterTimer(TIMER_NAME); + this._initialized = false; + this._updating = false; + } + + /** + * Get all recipes from remote settings + * @param {string} trigger What caused the update to occur? + * @param {Object} options additional options. + * @param {bool} options.forceSync force Remote Settings to sync recipe collection + * before updating recipes; throw if sync fails. + * @return {Promise} which resolves after recipes are updated. + */ + async updateRecipes(trigger, options = {}) { + if (this._updating || !this._initialized) { + return; + } + + const { forceSync = false } = options; + + // Since this method is async, the enabled pref could change between await + // points. We don't want to half validate experiments, so we cache this to + // keep it consistent throughout updating. + const validationEnabled = this.validationEnabled; + + this._updating = true; + + lazy.log.debug( + "Updating recipes" + (trigger ? ` with trigger ${trigger}` : "") + ); + + let recipes; + let loadingError = false; + + try { + recipes = await this.remoteSettingsClient.get({ + forceSync, + // Throw instead of returning an empty list. + emptyListFallback: false, + }); + lazy.log.debug(`Got ${recipes.length} recipes from Remote Settings`); + } catch (e) { + lazy.log.debug("Error getting recipes from remote settings."); + loadingError = true; + console.error(e); + } + + recipes?.sort( + (a, b) => new Date(a.publishedDate ?? 0) - new Date(b.publishedDate ?? 0) + ); + + let recipeValidator; + + if (validationEnabled) { + recipeValidator = new lazy.JsonSchema.Validator( + await SCHEMAS.NimbusExperiment + ); + } + + const enrollmentsCtx = new EnrollmentsContext( + this.manager, + recipeValidator, + { validationEnabled, shouldCheckTargeting: true } + ); + + if (recipes && !loadingError) { + for (const recipe of recipes) { + if (await enrollmentsCtx.checkRecipe(recipe)) { + await this.manager.onRecipe(recipe, "rs-loader"); + } + } + + lazy.log.debug( + `${enrollmentsCtx.matches} recipes matched. Finalizing ExperimentManager.` + ); + this.manager.onFinalize("rs-loader", enrollmentsCtx.getResults()); + } + + if (trigger !== "timer") { + const lastUpdateTime = Math.round(Date.now() / 1000); + Services.prefs.setIntPref(TIMER_LAST_UPDATE_PREF, lastUpdateTime); + } + + Services.obs.notifyObservers(null, "nimbus:enrollments-updated"); + + this._updating = false; + + this.recordIsReady(); + } + + async optInToExperiment({ + slug, + branch: branchSlug, + collection, + applyTargeting = false, + }) { + lazy.log.debug(`Attempting force enrollment with ${slug} / ${branchSlug}`); + + if (!lazy.NIMBUS_DEBUG) { + lazy.log.debug( + `Force enrollment only works when '${NIMBUS_DEBUG_PREF}' is enabled.` + ); + // More generic error if no debug preference is on. + throw new Error("Could not opt in."); + } + + if (!this.studiesEnabled) { + lazy.log.debug( + "Force enrollment does not work when studies are disabled." + ); + throw new Error("Could not opt in: studies are disabled."); + } + + let recipes; + try { + recipes = await lazy + .RemoteSettings(collection || lazy.COLLECTION_ID) + .get({ + // Throw instead of returning an empty list. + emptyListFallback: false, + }); + } catch (e) { + console.error(e); + throw new Error("Error getting recipes from remote settings."); + } + + const recipe = recipes.find(r => r.slug === slug); + + if (!recipe) { + throw new Error( + `Could not find experiment slug ${slug} in collection ${ + collection || lazy.COLLECTION_ID + }.` + ); + } + + const recipeValidator = new lazy.JsonSchema.Validator( + await SCHEMAS.NimbusExperiment + ); + const enrollmentsCtx = new EnrollmentsContext( + this.manager, + recipeValidator, + { + validationEnabled: this.validationEnabled, + shouldCheckTargeting: applyTargeting, + } + ); + + if (!(await enrollmentsCtx.checkRecipe(recipe))) { + const results = enrollmentsCtx.getResults(); + + if (results.recipeMismatches.length) { + throw new Error(`Recipe ${recipe.slug} did not match targeting`); + } else if (results.invalidRecipes.length) { + console.error(`Recipe ${recipe.slug} did not match recipe schema`); + } else if (results.invalidBranches.size) { + // There will only be one entry becuase we only validated a single recipe. + for (const branches of results.invalidBranches.values()) { + for (const branch of branches) { + console.error( + `Recipe ${recipe.slug} failed feature validation for branch ${branch}` + ); + } + } + } else if (results.invalidFeatures.length) { + for (const featureIds of results.invalidFeatures.values()) { + for (const featureId of featureIds) { + console.error( + `Recipe ${recipe.slug} references unknown feature ID ${featureId}` + ); + } + } + } + + throw new Error( + `Recipe ${recipe.slug} failed validation: ${JSON.stringify(results)}` + ); + } + + let branch = recipe.branches.find(b => b.slug === branchSlug); + if (!branch) { + throw new Error(`Could not find branch slug ${branchSlug} in ${slug}.`); + } + + await this.manager.forceEnroll(recipe, branch); + } + + /** + * Handles feature status based on feature pref and STUDIES_OPT_OUT_PREF. + * Changing any of them to false will turn off any recipe fetching and + * processing. + */ + onEnabledPrefChange() { + if (this._initialized && !(this.enabled && this.studiesEnabled)) { + this.uninit(); + } else if (!this._initialized && this.enabled && this.studiesEnabled) { + // If the feature pref is turned on then turn on recipe processing. + // If the opt in pref is turned on then turn on recipe processing only if + // the feature pref is also enabled. + this.init(); + } + } + + observe(aSubect, aTopic, aData) { + if (aTopic === STUDIES_ENABLED_CHANGED) { + this.onEnabledPrefChange(); + } + } + + /** + * Sets a timer to update recipes every this.intervalInSeconds + */ + setTimer() { + if (this.intervalInSeconds === 0) { + // Used in tests where we want to turn this mechanism off + return; + } + // The callbacks will be called soon after the timer is registered + lazy.timerManager.registerTimer( + TIMER_NAME, + () => this.updateRecipes("timer"), + this.intervalInSeconds + ); + lazy.log.debug("Registered update timer"); + } + + recordIsReady() { + const eventCount = + lazy.NimbusFeatures.nimbusIsReady.getVariable("eventCount") ?? 1; + for (let i = 0; i < eventCount; i++) { + Glean.nimbusEvents.isReady.record(); + } + } +} + +export class EnrollmentsContext { + constructor( + experimentManager, + recipeValidator, + { validationEnabled = true, shouldCheckTargeting = true } = {} + ) { + this.experimentManager = experimentManager; + this.recipeValidator = recipeValidator; + + this.validationEnabled = validationEnabled; + this.shouldCheckTargeting = shouldCheckTargeting; + this.matches = 0; + + this.recipeMismatches = []; + this.invalidRecipes = []; + this.invalidBranches = new Map(); + this.invalidFeatures = new Map(); + this.validatorCache = {}; + this.missingLocale = []; + this.missingL10nIds = new Map(); + + this.locale = Services.locale.appLocaleAsBCP47; + } + + getResults() { + return { + recipeMismatches: this.recipeMismatches, + invalidRecipes: this.invalidRecipes, + invalidBranches: this.invalidBranches, + invalidFeatures: this.invalidFeatures, + missingLocale: this.missingLocale, + missingL10nIds: this.missingL10nIds, + locale: this.locale, + validationEnabled: this.validationEnabled, + }; + } + + async checkRecipe(recipe) { + if (recipe.appId !== "firefox-desktop") { + // Skip over recipes not intended for desktop. Experimenter publishes + // recipes into a collection per application (desktop goes to + // `nimbus-desktop-experiments`) but all preview experiments share the + // same collection (`nimbus-preview`). + // + // This is *not* the same as `lazy.APP_ID` which is used to + // distinguish between desktop Firefox and the desktop background + // updater. + return false; + } + + const validateFeatureSchemas = + this.validationEnabled && !recipe.featureValidationOptOut; + + if (this.validationEnabled) { + let validation = this.recipeValidator.validate(recipe); + if (!validation.valid) { + console.error( + `Could not validate experiment recipe ${recipe.id}: ${JSON.stringify( + validation.errors, + null, + 2 + )}` + ); + if (recipe.slug) { + this.invalidRecipes.push(recipe.slug); + } + return false; + } + } + + const featureIds = + recipe.featureIds ?? + recipe.branches + .flatMap(branch => branch.features ?? [branch.feature]) + .map(featureDef => featureDef.featureId); + + let haveAllFeatures = true; + + for (const featureId of featureIds) { + const feature = lazy.NimbusFeatures[featureId]; + + // If validation is enabled, we want to catch this later in + // _validateBranches to collect the correct stats for telemetry. + if (!feature) { + continue; + } + + if (!feature.applications.includes(lazy.APP_ID)) { + lazy.log.debug( + `${recipe.id} uses feature ${featureId} which is not enabled for this application (${lazy.APP_ID}) -- skipping` + ); + haveAllFeatures = false; + break; + } + } + + if (!haveAllFeatures) { + return false; + } + + if (this.shouldCheckTargeting) { + const match = await this.checkTargeting(recipe); + + if (match) { + const type = recipe.isRollout ? "rollout" : "experiment"; + lazy.log.debug(`[${type}] ${recipe.id} matched targeting`); + } else { + lazy.log.debug(`${recipe.id} did not match due to targeting`); + this.recipeMismatches.push(recipe.slug); + return false; + } + } + + this.matches++; + + if ( + typeof recipe.localizations === "object" && + recipe.localizations !== null + ) { + if ( + typeof recipe.localizations[this.locale] !== "object" || + recipe.localizations[this.locale] === null + ) { + this.missingLocale.push(recipe.slug); + lazy.log.debug( + `${recipe.id} is localized but missing locale ${this.locale}` + ); + return false; + } + } + + const result = await this._validateBranches(recipe, validateFeatureSchemas); + if (!result.valid) { + if (result.invalidBranchSlugs.length) { + this.invalidBranches.set(recipe.slug, result.invalidBranchSlugs); + } + if (result.invalidFeatureIds.length) { + this.invalidFeatures.set(recipe.slug, result.invalidFeatureIds); + } + if (result.missingL10nIds.length) { + this.missingL10nIds.set(recipe.slug, result.missingL10nIds); + } + lazy.log.debug(`${recipe.id} did not validate`); + return false; + } + + return true; + } + + async evaluateJexl(jexlString, customContext) { + if (customContext && !customContext.experiment) { + throw new Error( + "Expected an .experiment property in second param of this function" + ); + } + + if (!customContext.source) { + throw new Error( + "Expected a .source property that identifies which targeting expression is being evaluated." + ); + } + + const context = lazy.TargetingContext.combineContexts( + customContext, + this.experimentManager.createTargetingContext(), + lazy.ASRouterTargeting.Environment + ); + + lazy.log.debug("Testing targeting expression:", jexlString); + const targetingContext = new lazy.TargetingContext(context, { + source: customContext.source, + }); + + let result = null; + try { + result = await targetingContext.evalWithDefault(jexlString); + } catch (e) { + lazy.log.debug("Targeting failed because of an error", e); + console.error(e); + } + return result; + } + + /** + * Checks targeting of a recipe if it is defined + * @param {Recipe} recipe + * @param {{[key: string]: any}} customContext A custom filter context + * @returns {Promise<boolean>} Should we process the recipe? + */ + async checkTargeting(recipe) { + if (!recipe.targeting) { + lazy.log.debug("No targeting for recipe, so it matches automatically"); + return true; + } + + const result = await this.evaluateJexl(recipe.targeting, { + experiment: recipe, + source: recipe.slug, + }); + + return Boolean(result); + } + + /** + * Validate the branches of an experiment. + * + * @param {object} recipe The recipe object. + * @param {boolean} validateSchema Whether to validate the feature values + * using JSON schemas. + * + * @returns {object} The lists of invalid branch slugs and invalid feature + * IDs. + */ + async _validateBranches({ id, branches, localizations }, validateSchema) { + const invalidBranchSlugs = []; + const invalidFeatureIds = new Set(); + const missingL10nIds = new Set(); + + if (validateSchema || typeof localizations !== "undefined") { + for (const [branchIdx, branch] of branches.entries()) { + const features = branch.features ?? [branch.feature]; + for (const feature of features) { + const { featureId, value } = feature; + if (!lazy.NimbusFeatures[featureId]) { + console.error( + `Experiment ${id} has unknown featureId: ${featureId}` + ); + + invalidFeatureIds.add(featureId); + continue; + } + + let substitutedValue = value; + + if (localizations) { + // We already know that we have a localization table for this locale + // because we checked in `checkRecipe`. + try { + substitutedValue = + lazy._ExperimentFeature.substituteLocalizations( + value, + localizations[Services.locale.appLocaleAsBCP47], + missingL10nIds + ); + } catch (e) { + if (e?.reason === "l10n-missing-entry") { + // Skip validation because it *will* fail. + continue; + } + throw e; + } + } + + if (validateSchema) { + let validator; + if (this.validatorCache[featureId]) { + validator = this.validatorCache[featureId]; + } else if (lazy.NimbusFeatures[featureId].manifest.schema?.uri) { + const uri = lazy.NimbusFeatures[featureId].manifest.schema.uri; + try { + const schema = await fetch(uri, { + credentials: "omit", + }).then(rsp => rsp.json()); + + validator = this.validatorCache[featureId] = + new lazy.JsonSchema.Validator(schema); + } catch (e) { + throw new Error( + `Could not fetch schema for feature ${featureId} at "${uri}": ${e}` + ); + } + } else { + const schema = this._generateVariablesOnlySchema( + lazy.NimbusFeatures[featureId] + ); + validator = this.validatorCache[featureId] = + new lazy.JsonSchema.Validator(schema); + } + + const result = validator.validate(substitutedValue); + if (!result.valid) { + console.error( + `Experiment ${id} branch ${branchIdx} feature ${featureId} does not validate: ${JSON.stringify( + result.errors, + undefined, + 2 + )}` + ); + invalidBranchSlugs.push(branch.slug); + } + } + } + } + } + + return { + invalidBranchSlugs, + invalidFeatureIds: Array.from(invalidFeatureIds), + missingL10nIds: Array.from(missingL10nIds), + valid: + invalidBranchSlugs.length === 0 && + invalidFeatureIds.size === 0 && + missingL10nIds.size === 0, + }; + } + + _generateVariablesOnlySchema({ featureId, manifest }) { + // See-also: https://github.com/mozilla/experimenter/blob/main/app/experimenter/features/__init__.py#L21-L64 + const schema = { + $schema: "https://json-schema.org/draft/2019-09/schema", + title: featureId, + description: manifest.description, + type: "object", + properties: {}, + additionalProperties: true, + }; + + for (const [varName, desc] of Object.entries(manifest.variables)) { + const prop = {}; + switch (desc.type) { + case "boolean": + case "string": + prop.type = desc.type; + break; + + case "int": + prop.type = "integer"; + break; + + case "json": + // NB: Don't set a type of json fields, since they can be of any type. + break; + + default: + // NB: Experimenter doesn't outright reject invalid types either. + console.error( + `Feature ID ${featureId} has variable ${varName} with invalid FML type: ${prop.type}` + ); + break; + } + + if (prop.type === "string" && !!desc.enum) { + prop.enum = [...desc.enum]; + } + + schema.properties[varName] = prop; + } + + return schema; + } +} + +export const RemoteSettingsExperimentLoader = + new _RemoteSettingsExperimentLoader(); diff --git a/toolkit/components/nimbus/lib/SharedDataMap.sys.mjs b/toolkit/components/nimbus/lib/SharedDataMap.sys.mjs new file mode 100644 index 0000000000..ccf1c8b145 --- /dev/null +++ b/toolkit/components/nimbus/lib/SharedDataMap.sys.mjs @@ -0,0 +1,177 @@ +/* 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 { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", +}); + +const IS_MAIN_PROCESS = + Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT; + +export class SharedDataMap extends EventEmitter { + constructor(sharedDataKey, options = { isParent: IS_MAIN_PROCESS }) { + super(); + + this._sharedDataKey = sharedDataKey; + this._isParent = options.isParent; + this._isReady = false; + this._readyDeferred = Promise.withResolvers(); + this._data = null; + + if (this.isParent) { + // Lazy-load JSON file that backs Storage instances. + ChromeUtils.defineLazyGetter(this, "_store", () => { + let path = options.path; + let store = null; + if (!path) { + try { + const profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path; + path = PathUtils.join(profileDir, `${sharedDataKey}.json`); + } catch (e) { + console.error(e); + } + } + try { + store = new lazy.JSONFile({ path }); + } catch (e) { + console.error(e); + } + return store; + }); + } else { + this._syncFromParent(); + Services.cpmm.sharedData.addEventListener("change", this); + } + } + + async init() { + if (!this._isReady && this.isParent) { + try { + await this._store.load(); + this._data = this._store.data; + this._syncToChildren({ flush: true }); + this._checkIfReady(); + } catch (e) { + console.error(e); + } + } + } + + get sharedDataKey() { + return this._sharedDataKey; + } + + get isParent() { + return this._isParent; + } + + ready() { + return this._readyDeferred.promise; + } + + get(key) { + if (!this._data) { + return null; + } + + let entry = this._data[key]; + + return entry; + } + + set(key, value) { + if (!this.isParent) { + throw new Error( + "Setting values from within a content process is not allowed" + ); + } + this._store.data[key] = value; + this._store.saveSoon(); + this._syncToChildren(); + this._notifyUpdate(); + } + + /** + * Replace the stored data with an updated filtered dataset for cleanup + * purposes. We don't notify of update because we're only filtering out + * old unused entries. + * + * @param {string[]} keysToRemove - list of keys to remove from the persistent store + */ + _removeEntriesByKeys(keysToRemove) { + if (!keysToRemove.length) { + return; + } + for (let key of keysToRemove) { + try { + delete this._store.data[key]; + } catch (e) { + // It's ok if this fails + } + } + this._store.saveSoon(); + } + + // Only used in tests + _deleteForTests(key) { + if (!this.isParent) { + throw new Error( + "Setting values from within a content process is not allowed" + ); + } + if (this.has(key)) { + delete this._store.data[key]; + this._store.saveSoon(); + this._syncToChildren(); + this._notifyUpdate(); + } + } + + has(key) { + return Boolean(this.get(key)); + } + + /** + * Notify store listeners of updates + * Called both from Main and Content process + */ + _notifyUpdate(process = "parent") { + for (let key of Object.keys(this._data || {})) { + this.emit(`${process}-store-update:${key}`, this._data[key]); + } + } + + _syncToChildren({ flush = false } = {}) { + Services.ppmm.sharedData.set(this.sharedDataKey, { + ...this._data, + }); + if (flush) { + Services.ppmm.sharedData.flush(); + } + } + + _syncFromParent() { + this._data = Services.cpmm.sharedData.get(this.sharedDataKey); + this._checkIfReady(); + this._notifyUpdate("child"); + } + + _checkIfReady() { + if (!this._isReady && this._data) { + this._isReady = true; + this._readyDeferred.resolve(); + } + } + + handleEvent(event) { + if (event.type === "change") { + if (event.changedKeys.includes(this.sharedDataKey)) { + this._syncFromParent(); + } + } + } +} |