diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /toolkit/components/normandy/lib/PreferenceExperiments.sys.mjs | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/normandy/lib/PreferenceExperiments.sys.mjs')
-rw-r--r-- | toolkit/components/normandy/lib/PreferenceExperiments.sys.mjs | 1069 |
1 files changed, 1069 insertions, 0 deletions
diff --git a/toolkit/components/normandy/lib/PreferenceExperiments.sys.mjs b/toolkit/components/normandy/lib/PreferenceExperiments.sys.mjs new file mode 100644 index 0000000000..0c96745df9 --- /dev/null +++ b/toolkit/components/normandy/lib/PreferenceExperiments.sys.mjs @@ -0,0 +1,1069 @@ +/* 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/. */ + +/** + * Preference Experiments temporarily change a preference to one of several test + * values for the duration of the experiment. Telemetry packets are annotated to + * show what experiments are active, and we use this data to measure the + * effectiveness of the preference change. + * + * Info on active and past experiments is stored in a JSON file in the profile + * folder. + * + * Active preference experiments are stopped if they aren't active on the recipe + * server. They also expire if Firefox isn't able to contact the recipe server + * after a period of time, as well as if the user modifies the preference during + * an active experiment. + */ + +/** + * Experiments store info about an active or expired preference experiment. + * @typedef {Object} Experiment + * @property {string} slug + * A string uniquely identifying the experiment. Used for telemetry, and other + * machine-oriented use cases. Used as a display name if `userFacingName` is + * null. + * @property {string|null} userFacingName + * A user-friendly name for the experiment. Null on old-style single-preference + * experiments, which do not have a userFacingName. + * @property {string|null} userFacingDescription + * A user-friendly description of the experiment. Null on old-style + * single-preference experiments, which do not have a userFacingDescription. + * @property {string} branch + * Experiment branch that the user was matched to + * @property {boolean} expired + * If false, the experiment is active. + * ISO-formatted date string of when the experiment was last seen from the + * recipe server. + * @property {string|null} temporaryErrorDeadline + * ISO-formatted date string of when temporary errors with this experiment + * should not longer be considered temporary. After this point, further errors + * will result in unenrollment. + * @property {Object} preferences + * An object consisting of all the preferences that are set by this experiment. + * Keys are the name of each preference affected by this experiment. Values are + * Preference Objects, about which see below. + * @property {string} experimentType + * The type to report to Telemetry's experiment marker API. + * @property {string} enrollmentId + * A random ID generated at time of enrollment. It should be included on all + * telemetry related to this experiment. It should not be re-used by other + * studies, or any other purpose. May be null on old experiments. + * @property {string} actionName + * The action who knows about this experiment and is responsible for cleaning + * it up. This should correspond to the `name` of some BaseAction subclass. + */ + +/** + * Each Preference stores information about a preference that an + * experiment sets. + * @property {string|integer|boolean} preferenceValue + * Value to change the preference to during the experiment. + * @property {string} preferenceType + * Type of the preference value being set. + * @property {string|integer|boolean|undefined} previousPreferenceValue + * Value of the preference prior to the experiment, or undefined if it was + * unset. + * @property {PreferenceBranchType} preferenceBranchType + * Controls how we modify the preference to affect the client. + * + * If "default", when the experiment is active, the default value for the + * preference is modified on startup of the add-on. If "user", the user value + * for the preference is modified when the experiment starts, and is reset to + * its original value when the experiment ends. + * @property {boolean} overridden + * Tracks if this preference has been changed away from the experimental value. + */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { CleanupManager } from "resource://normandy/lib/CleanupManager.sys.mjs"; +import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", + NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs", + PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", + TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs", +}); + +const EXPERIMENT_FILE = "shield-preference-experiments.json"; +const STARTUP_EXPERIMENT_PREFS_BRANCH = "app.normandy.startupExperimentPrefs."; + +const MAX_EXPERIMENT_TYPE_LENGTH = 20; // enforced by TelemetryEnvironment +const EXPERIMENT_TYPE_PREFIX = "normandy-"; +const MAX_EXPERIMENT_SUBTYPE_LENGTH = + MAX_EXPERIMENT_TYPE_LENGTH - EXPERIMENT_TYPE_PREFIX.length; + +const PREFERENCE_TYPE_MAP = { + boolean: Services.prefs.PREF_BOOL, + string: Services.prefs.PREF_STRING, + integer: Services.prefs.PREF_INT, +}; + +const UserPreferences = Services.prefs; +const DefaultPreferences = Services.prefs.getDefaultBranch(""); + +/** + * Enum storing Preference modules for each type of preference branch. + * @enum {Object} + */ +const PreferenceBranchType = { + user: UserPreferences, + default: DefaultPreferences, +}; + +/** + * Asynchronously load the JSON file that stores experiment status in the profile. + */ +let gStorePromise; +function ensureStorage() { + if (gStorePromise === undefined) { + const path = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + EXPERIMENT_FILE + ); + const storage = new lazy.JSONFile({ path }); + // `storage.load()` is defined as being infallible: It won't ever throw an + // error. However, if there are are I/O errors, such as a corrupt, missing, + // or unreadable file the data loaded will be an empty object. This can + // happen ever after our migrations have run. If that happens, edit the + // storage to match our expected schema before returning it to the rest of + // the module. + gStorePromise = storage.load().then(() => { + if (!storage.data.experiments) { + storage.data = { ...storage.data, experiments: {} }; + } + return storage; + }); + } + return gStorePromise; +} + +const log = LogManager.getLogger("preference-experiments"); + +// List of active preference observers. Cleaned up on shutdown. +let experimentObservers = new Map(); +CleanupManager.addCleanupHandler(() => + PreferenceExperiments.stopAllObservers() +); + +export var PreferenceExperiments = { + /** + * Update the the experiment storage with changes that happened during early startup. + * @param {object} studyPrefsChanged Map from pref name to previous pref value + */ + async recordOriginalValues(studyPrefsChanged) { + const store = await ensureStorage(); + + for (const experiment of Object.values(store.data.experiments)) { + for (const [prefName, prefInfo] of Object.entries( + experiment.preferences + )) { + if (studyPrefsChanged.hasOwnProperty(prefName)) { + if (experiment.expired) { + log.warn( + "Expired preference experiment changed value during startup" + ); + } + if (prefInfo.preferenceBranch !== "default") { + log.warn( + "Non-default branch preference experiment changed value during startup" + ); + } + prefInfo.previousPreferenceValue = studyPrefsChanged[prefName]; + } + } + } + + // not calling store.saveSoon() because if the data doesn't get + // written, it will get updated with fresher data next time the + // browser starts. + }, + + /** + * Set the default preference value for active experiments that use the + * default preference branch. + */ + async init() { + CleanupManager.addCleanupHandler(() => this.saveStartupPrefs()); + + for (const experiment of await this.getAllActive()) { + // Check that the current value of the preference is still what we set it to + for (const [preferenceName, spec] of Object.entries( + experiment.preferences + )) { + if ( + !spec.overridden && + lazy.PrefUtils.getPref(preferenceName) !== spec.preferenceValue + ) { + // if not, record the difference + await this.recordPrefChange({ + experiment, + preferenceName, + reason: "sideload", + }); + } + } + + // Notify Telemetry of experiments we're running, since they don't persist between restarts + lazy.TelemetryEnvironment.setExperimentActive( + experiment.slug, + experiment.branch, + { + type: EXPERIMENT_TYPE_PREFIX + experiment.experimentType, + enrollmentId: + experiment.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + + // Watch for changes to the experiment's preference + this.startObserver(experiment.slug, experiment.preferences); + } + }, + + /** + * Save in-progress, default-branch preference experiments in a sub-branch of + * the normandy preferences. On startup, we read these to set the + * experimental values. + * + * This is needed because the default branch does not persist between Firefox + * restarts. To compensate for that, Normandy sets the default branch to the + * experiment values again every startup. The values to set the preferences + * to are stored in user-branch preferences because preferences have minimal + * impact on the performance of startup. + */ + async saveStartupPrefs() { + const prefBranch = Services.prefs.getBranch( + STARTUP_EXPERIMENT_PREFS_BRANCH + ); + for (const pref of prefBranch.getChildList("")) { + prefBranch.clearUserPref(pref); + } + + // Only store prefs to set on the default branch. + // Be careful not to store user branch prefs here, because this + // would cause the default branch to match the user branch, + // causing the user branch pref to get cleared. + const allExperiments = await this.getAllActive(); + const defaultBranchPrefs = allExperiments + .flatMap(exp => Object.entries(exp.preferences)) + .filter( + ([preferenceName, preferenceInfo]) => + preferenceInfo.preferenceBranchType === "default" + ); + for (const [preferenceName, { preferenceValue }] of defaultBranchPrefs) { + switch (typeof preferenceValue) { + case "string": + prefBranch.setCharPref(preferenceName, preferenceValue); + break; + + case "number": + prefBranch.setIntPref(preferenceName, preferenceValue); + break; + + case "boolean": + prefBranch.setBoolPref(preferenceName, preferenceValue); + break; + + default: + throw new Error(`Invalid preference type ${typeof preferenceValue}`); + } + } + }, + + /** + * Test wrapper that temporarily replaces the stored experiment data with fake + * data for testing. + */ + withMockExperiments(prefExperiments = []) { + return function wrapper(testFunction) { + return async function wrappedTestFunction(args) { + const experiments = {}; + + for (const exp of prefExperiments) { + if (exp.name) { + throw new Error( + "Preference experiments 'name' field has been replaced by 'slug' and 'userFacingName', please update." + ); + } + + experiments[exp.slug] = exp; + } + const data = { experiments }; + + const oldPromise = gStorePromise; + gStorePromise = Promise.resolve({ + data, + saveSoon() {}, + }); + const oldObservers = experimentObservers; + experimentObservers = new Map(); + try { + await testFunction({ ...args, prefExperiments }); + } finally { + gStorePromise = oldPromise; + PreferenceExperiments.stopAllObservers(); + experimentObservers = oldObservers; + } + }; + }; + }, + + /** When Telemetry is disabled, clear all identifiers from the stored experiments. */ + async onTelemetryDisabled() { + const store = await ensureStorage(); + for (const experiment of Object.values(store.data.experiments)) { + experiment.enrollmentId = lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER; + } + store.saveSoon(); + }, + + /** + * Clear all stored data about active and past experiments. + */ + async clearAllExperimentStorage() { + const store = await ensureStorage(); + store.data = { + experiments: {}, + }; + store.saveSoon(); + }, + + /** + * Start a new preference experiment. + * @param {Object} experiment + * @param {string} experiment.slug + * @param {string} experiment.actionName The action who knows about this + * experiment and is responsible for cleaning it up. This should + * correspond to the name of some BaseAction subclass. + * @param {string} experiment.branch + * @param {string} experiment.preferenceName + * @param {string|integer|boolean} experiment.preferenceValue + * @param {PreferenceBranchType} experiment.preferenceBranchType + * @returns {Experiment} The experiment object stored in the data store + * @rejects {Error} + * - If an experiment with the given name already exists + * - if an experiment for the given preference is active + * - If the given preferenceType does not match the existing stored preference + */ + async start({ + name = null, // To check if old code is still using `name` instead of `slug`, and provide a nice error message + slug, + actionName, + branch, + preferences, + experimentType = "exp", + userFacingName = null, + userFacingDescription = null, + }) { + if (name) { + throw new Error( + "Preference experiments 'name' field has been replaced by 'slug' and 'userFacingName', please update." + ); + } + + log.debug(`PreferenceExperiments.start(${slug}, ${branch})`); + + const store = await ensureStorage(); + if (slug in store.data.experiments) { + lazy.TelemetryEvents.sendEvent("enrollFailed", "preference_study", slug, { + reason: "name-conflict", + }); + throw new Error( + `A preference experiment with the slug "${slug}" already exists.` + ); + } + + const activeExperiments = Object.values(store.data.experiments).filter( + e => !e.expired + ); + const preferencesWithConflicts = Object.keys(preferences).filter( + preferenceName => { + return activeExperiments.some(e => + e.preferences.hasOwnProperty(preferenceName) + ); + } + ); + + if (preferencesWithConflicts.length) { + lazy.TelemetryEvents.sendEvent("enrollFailed", "preference_study", slug, { + reason: "pref-conflict", + }); + throw new Error( + `Another preference experiment for the pref "${preferencesWithConflicts[0]}" is currently active.` + ); + } + + if (experimentType.length > MAX_EXPERIMENT_SUBTYPE_LENGTH) { + lazy.TelemetryEvents.sendEvent("enrollFailed", "preference_study", slug, { + reason: "experiment-type-too-long", + }); + throw new Error( + `experimentType must be less than ${MAX_EXPERIMENT_SUBTYPE_LENGTH} characters. ` + + `"${experimentType}" is ${experimentType.length} long.` + ); + } + + // Sanity check each preference + for (const [preferenceName, preferenceInfo] of Object.entries( + preferences + )) { + // Ensure preferenceBranchType is set, using the default from + // the schema. This also modifies the preferenceInfo for use in + // the rest of the function. + preferenceInfo.preferenceBranchType = + preferenceInfo.preferenceBranchType || "default"; + const { preferenceBranchType, preferenceType } = preferenceInfo; + if ( + !(preferenceBranchType === "user" || preferenceBranchType === "default") + ) { + lazy.TelemetryEvents.sendEvent( + "enrollFailed", + "preference_study", + slug, + { + reason: "invalid-branch", + prefBranch: preferenceBranchType.slice(0, 80), + } + ); + throw new Error( + `Invalid value for preferenceBranchType: ${preferenceBranchType}` + ); + } + + const prevPrefType = Services.prefs.getPrefType(preferenceName); + const givenPrefType = PREFERENCE_TYPE_MAP[preferenceType]; + + if (!preferenceType || !givenPrefType) { + lazy.TelemetryEvents.sendEvent( + "enrollFailed", + "preference_study", + slug, + { + reason: "invalid-type", + } + ); + throw new Error( + `Invalid preferenceType provided (given "${preferenceType}")` + ); + } + + if ( + prevPrefType !== Services.prefs.PREF_INVALID && + prevPrefType !== givenPrefType + ) { + lazy.TelemetryEvents.sendEvent( + "enrollFailed", + "preference_study", + slug, + { + reason: "invalid-type", + } + ); + throw new Error( + `Previous preference value is of type "${prevPrefType}", but was given ` + + `"${givenPrefType}" (${preferenceType})` + ); + } + + preferenceInfo.previousPreferenceValue = lazy.PrefUtils.getPref( + preferenceName, + { branch: preferenceBranchType } + ); + } + + const alreadyOverriddenPrefs = new Set(); + for (const [preferenceName, preferenceInfo] of Object.entries( + preferences + )) { + const { preferenceValue, preferenceBranchType } = preferenceInfo; + + if (preferenceBranchType === "default") { + // Only set the pref if there is no user-branch value, because + // changing the default-branch value to the same value as the + // user-branch will effectively delete the user value. + if (Services.prefs.prefHasUserValue(preferenceName)) { + alreadyOverriddenPrefs.add(preferenceName); + } else { + lazy.PrefUtils.setPref(preferenceName, preferenceValue, { + branch: preferenceBranchType, + }); + } + } else if (preferenceBranchType === "user") { + // The original value was already backed up above. + lazy.PrefUtils.setPref(preferenceName, preferenceValue, { + branch: preferenceBranchType, + }); + } else { + log.error(`Unexpected preference branch type ${preferenceBranchType}`); + } + } + PreferenceExperiments.startObserver(slug, preferences); + + const enrollmentId = lazy.NormandyUtils.generateUuid(); + + /** @type {Experiment} */ + const experiment = { + slug, + actionName, + branch, + expired: false, + lastSeen: new Date().toJSON(), + preferences, + experimentType, + userFacingName, + userFacingDescription, + enrollmentId, + }; + + store.data.experiments[slug] = experiment; + store.saveSoon(); + + // Record telemetry that the experiment started + lazy.TelemetryEnvironment.setExperimentActive(slug, branch, { + type: EXPERIMENT_TYPE_PREFIX + experimentType, + enrollmentId: + enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + lazy.TelemetryEvents.sendEvent("enroll", "preference_study", slug, { + experimentType, + branch, + enrollmentId: + enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }); + + // Send events for any default branch preferences set that already had user + // values overriding them. + for (const preferenceName of alreadyOverriddenPrefs) { + await this.recordPrefChange({ + experiment, + preferenceName, + reason: "onEnroll", + }); + } + await this.saveStartupPrefs(); + + return experiment; + }, + + /** + * Register a preference observer that stops an experiment when the user + * modifies the preference. + * @param {string} experimentSlug + * @param {string} preferenceName + * @param {string|integer|boolean} preferenceValue + * @throws {Error} + * If an observer for the experiment is already active. + */ + startObserver(experimentSlug, preferences) { + log.debug(`PreferenceExperiments.startObserver(${experimentSlug})`); + + if (experimentObservers.has(experimentSlug)) { + throw new Error( + `An observer for the preference experiment ${experimentSlug} is already active.` + ); + } + + const observerInfo = { + preferences, + observe(aSubject, aTopic, preferenceName) { + const prefInfo = preferences[preferenceName]; + // if `preferenceName` is one of the experiment prefs but with more on + // the end (ie, foo.bar vs foo.bar.baz) then this can be triggered for + // changes we don't care about. Check for that. + if (!prefInfo) { + return; + } + const originalValue = prefInfo.preferenceValue; + const newValue = lazy.PrefUtils.getPref(preferenceName); + if (newValue !== originalValue) { + PreferenceExperiments.recordPrefChange({ + experimentSlug, + preferenceName, + reason: "observer", + }); + Services.prefs.removeObserver(preferenceName, observerInfo); + } + }, + }; + experimentObservers.set(experimentSlug, observerInfo); + for (const [preferenceName, spec] of Object.entries(preferences)) { + if (!spec.overridden) { + Services.prefs.addObserver(preferenceName, observerInfo); + } + } + }, + + /** + * Check if a preference observer is active for an experiment. + * @param {string} experimentSlug + * @return {Boolean} + */ + hasObserver(experimentSlug) { + log.debug(`PreferenceExperiments.hasObserver(${experimentSlug})`); + return experimentObservers.has(experimentSlug); + }, + + /** + * Disable a preference observer for an experiment. + * @param {string} experimentSlug + * @throws {Error} + * If there is no active observer for the experiment. + */ + stopObserver(experimentSlug) { + log.debug(`PreferenceExperiments.stopObserver(${experimentSlug})`); + + if (!experimentObservers.has(experimentSlug)) { + throw new Error( + `No observer for the preference experiment ${experimentSlug} found.` + ); + } + + const observer = experimentObservers.get(experimentSlug); + for (const preferenceName of Object.keys(observer.preferences)) { + Services.prefs.removeObserver(preferenceName, observer); + } + experimentObservers.delete(experimentSlug); + }, + + /** + * Disable all currently-active preference observers for experiments. + */ + stopAllObservers() { + log.debug("PreferenceExperiments.stopAllObservers()"); + for (const observer of experimentObservers.values()) { + for (const preferenceName of Object.keys(observer.preferences)) { + Services.prefs.removeObserver(preferenceName, observer); + } + } + experimentObservers.clear(); + }, + + /** + * Update the timestamp storing when Normandy last sent a recipe for the + * experiment. + * @param {string} experimentSlug + * @rejects {Error} + * If there is no stored experiment with the given slug. + */ + async markLastSeen(experimentSlug) { + log.debug(`PreferenceExperiments.markLastSeen(${experimentSlug})`); + + const store = await ensureStorage(); + if (!(experimentSlug in store.data.experiments)) { + throw new Error( + `Could not find a preference experiment with the slug "${experimentSlug}"` + ); + } + + store.data.experiments[experimentSlug].lastSeen = new Date().toJSON(); + store.saveSoon(); + }, + + /** + * Called when an experimental pref has changed away from its experimental + * value for the first time. + * + * One of `experiment` or `slug` must be passed. + * + * @param {object} options + * @param {Experiment} [options.experiment] + * The experiment that had a pref change. If this is passed, slug is ignored. + * @param {string} [options.slug] + * The slug of the experiment that had a pref change. This will be used to + * fetch an experiment if none was passed. + * @param {string} options.preferenceName The preference changed. + * @param {string} options.reason The reason the preference change was detected. + */ + async recordPrefChange({ + experiment = null, + experimentSlug = null, + preferenceName, + reason, + }) { + if (!experiment) { + experiment = await PreferenceExperiments.get(experimentSlug); + } + let preferenceSpecification = experiment.preferences[preferenceName]; + if (!preferenceSpecification) { + throw new PreferenceExperiments.InvalidPreferenceName( + `Preference "${preferenceName}" is not a part of experiment "${experimentSlug}"` + ); + } + + preferenceSpecification.overridden = true; + await this.update(experiment); + + lazy.TelemetryEvents.sendEvent( + "expPrefChanged", + "preference_study", + experiment.slug, + { + preferenceName, + reason, + enrollmentId: + experiment.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + } + ); + }, + + /** + * Stop an active experiment, deactivate preference watchers, and optionally + * reset the associated preference to its previous value. + * @param {string} experimentSlug + * @param {Object} options + * @param {boolean} [options.resetValue = true] + * If true, reset the preference to its original value prior to + * the experiment. Optional, defaults to true. + * @param {String} [options.reason = "unknown"] + * Reason that the experiment is ending. Optional, defaults to + * "unknown". + * @rejects {Error} + * If there is no stored experiment with the given slug, or if the + * experiment has already expired. + */ + async stop( + experimentSlug, + { resetValue = true, reason = "unknown", changedPref, caller } = {} + ) { + log.debug( + `PreferenceExperiments.stop(${experimentSlug}, {resetValue: ${resetValue}, reason: ${reason}, changedPref: ${changedPref}, caller: ${caller}})` + ); + if (reason === "unknown") { + log.warn(`experiment ${experimentSlug} ending for unknown reason`); + } + + const store = await ensureStorage(); + if (!(experimentSlug in store.data.experiments)) { + lazy.TelemetryEvents.sendEvent( + "unenrollFailed", + "preference_study", + experimentSlug, + { + reason: "does-not-exist", + originalReason: reason, + ...(changedPref ? { changedPref } : {}), + } + ); + throw new Error( + `Could not find a preference experiment with the slug "${experimentSlug}"` + ); + } + + const experiment = store.data.experiments[experimentSlug]; + if (experiment.expired) { + const extra = { + reason: "already-unenrolled", + originalReason: reason, + enrollmentId: + experiment.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + }; + if (changedPref) { + extra.changedPref = changedPref; + } + if (caller && AppConstants.NIGHTLY_BUILD) { + extra.caller = caller; + } + lazy.TelemetryEvents.sendEvent( + "unenrollFailed", + "preference_study", + experimentSlug, + extra + ); + throw new Error( + `Cannot stop preference experiment "${experimentSlug}" because it is already expired` + ); + } + + if (PreferenceExperiments.hasObserver(experimentSlug)) { + PreferenceExperiments.stopObserver(experimentSlug); + } + + if (resetValue) { + for (const [ + preferenceName, + { previousPreferenceValue, preferenceBranchType, overridden }, + ] of Object.entries(experiment.preferences)) { + // Overridden user prefs should keep their new value, even if that value + // is the same as the experimental value, since it is the value the user + // chose. + if (overridden && preferenceBranchType === "user") { + continue; + } + + const preferences = PreferenceBranchType[preferenceBranchType]; + + if (previousPreferenceValue !== null) { + lazy.PrefUtils.setPref(preferenceName, previousPreferenceValue, { + branch: preferenceBranchType, + }); + } else if (preferenceBranchType === "user") { + // Remove the "user set" value (which Shield set), but leave the default intact. + preferences.clearUserPref(preferenceName); + } else { + log.warn( + `Can't revert pref ${preferenceName} for experiment ${experimentSlug} ` + + `because it had no default value. ` + + `Preference will be reset at the next restart.` + ); + // It would seem that Services.prefs.deleteBranch() could be used for + // this, but in Normandy's case it does not work. See bug 1502410. + } + } + } + + experiment.expired = true; + if (experiment.temporaryErrorDeadline) { + experiment.temporaryErrorDeadline = null; + } + await store.saveSoon(); + + lazy.TelemetryEnvironment.setExperimentInactive(experimentSlug); + lazy.TelemetryEvents.sendEvent( + "unenroll", + "preference_study", + experimentSlug, + { + didResetValue: resetValue ? "true" : "false", + branch: experiment.branch, + reason, + enrollmentId: + experiment.enrollmentId || + lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER, + ...(changedPref ? { changedPref } : {}), + } + ); + await this.saveStartupPrefs(); + Services.obs.notifyObservers( + null, + "normandy:preference-experiment:stopped", + experimentSlug + ); + }, + + /** + * Clone an experiment using knowledge of its structure to avoid + * having to serialize/deserialize it. + * + * We do this in places where return experiments so clients can't + * accidentally mutate our data underneath us. + */ + _cloneExperiment(experiment) { + return { + ...experiment, + preferences: { + ...experiment.preferences, + }, + }; + }, + + /** + * Get the experiment object for the experiment. + * @param {string} experimentSlug + * @resolves {Experiment} + * @rejects {Error} + * If no preference experiment exists with the given slug. + */ + async get(experimentSlug) { + log.debug(`PreferenceExperiments.get(${experimentSlug})`); + const store = await ensureStorage(); + if (!(experimentSlug in store.data.experiments)) { + throw new PreferenceExperiments.NotFoundError( + `Could not find a preference experiment with the slug "${experimentSlug}"` + ); + } + + return this._cloneExperiment(store.data.experiments[experimentSlug]); + }, + + /** + * Get a list of all stored experiment objects. + * @resolves {Experiment[]} + */ + async getAll() { + const store = await ensureStorage(); + return Object.values(store.data.experiments).map(experiment => + this._cloneExperiment(experiment) + ); + }, + + /** + * Get a list of experiment objects for all active experiments. + * @resolves {Experiment[]} + */ + async getAllActive() { + const store = await ensureStorage(); + return Object.values(store.data.experiments) + .filter(e => !e.expired) + .map(e => this._cloneExperiment(e)); + }, + + /** + * Check if an experiment exists with the given slug. + * @param {string} experimentSlug + * @resolves {boolean} True if the experiment exists, false if it doesn't. + */ + async has(experimentSlug) { + log.debug(`PreferenceExperiments.has(${experimentSlug})`); + const store = await ensureStorage(); + return experimentSlug in store.data.experiments; + }, + + /** + * Update an experiment in the data store. If an experiment with the given + * slug is not already in the store, an error will be thrown. + * + * @param experiment {Experiment} The experiment to update + * @param experiment.slug {String} The experiment must have a slug + */ + async update(experiment) { + const store = await ensureStorage(); + + if (!(experiment.slug in store.data.experiments)) { + throw new Error( + `Could not update a preference experiment with the slug "${experiment.slug}"` + ); + } + + store.data.experiments[experiment.slug] = experiment; + store.saveSoon(); + }, + + NotFoundError: class extends Error {}, + InvalidPreferenceName: class extends Error {}, + + /** + * These migrations should only be called from `NormandyMigrations.jsm` and tests. + */ + migrations: { + /** Move experiments into a specific key. */ + async migration01MoveExperiments(storage = null) { + if (storage === null) { + storage = await ensureStorage(); + } + if (Object.hasOwnProperty.call(storage.data, "experiments")) { + return; + } + storage.data = { + experiments: storage.data, + }; + delete storage.data.experiments.__version; + storage.saveSoon(); + }, + + /** Migrate storage.data to multi-preference format */ + async migration02MultiPreference(storage = null) { + if (storage === null) { + storage = await ensureStorage(); + } + + const oldExperiments = storage.data.experiments; + const v2Experiments = {}; + + for (let [expName, oldExperiment] of Object.entries(oldExperiments)) { + if (expName == "__version") { + // A stray "__version" entry snuck in, likely from old migrations. + // Ignore it and continue. It won't be propagated to future + // migrations, since `v2Experiments` won't have it. + continue; + } + if (oldExperiment.preferences) { + // experiment is already migrated + v2Experiments[expName] = oldExperiment; + continue; + } + v2Experiments[expName] = { + name: oldExperiment.name, + branch: oldExperiment.branch, + expired: oldExperiment.expired, + lastSeen: oldExperiment.lastSeen, + preferences: { + [oldExperiment.preferenceName]: { + preferenceBranchType: oldExperiment.preferenceBranchType, + preferenceType: oldExperiment.preferenceType, + preferenceValue: oldExperiment.preferenceValue, + previousPreferenceValue: oldExperiment.previousPreferenceValue, + }, + }, + experimentType: oldExperiment.experimentType, + }; + } + storage.data.experiments = v2Experiments; + storage.saveSoon(); + }, + + /** Add "actionName" field for experiments that don't have it. */ + async migration03AddActionName(storage = null) { + if (storage === null) { + storage = await ensureStorage(); + } + + for (const experiment of Object.values(storage.data.experiments)) { + if (!experiment.actionName) { + // Assume SinglePreferenceExperimentAction because as of this + // writing, no multi-pref experiment recipe has launched. + experiment.actionName = "SinglePreferenceExperimentAction"; + } + } + storage.saveSoon(); + }, + + async migration04RenameNameToSlug(storage = null) { + if (!storage) { + storage = await ensureStorage(); + } + // Rename "name" to "slug" to match the intended purpose of the field. + for (const experiment of Object.values(storage.data.experiments)) { + if (experiment.name && !experiment.slug) { + experiment.slug = experiment.name; + delete experiment.name; + } + } + storage.saveSoon(); + }, + + async migration05RemoveOldAction() { + const experiments = await PreferenceExperiments.getAllActive(); + for (const experiment of experiments) { + if (experiment.actionName == "SinglePreferenceExperimentAction") { + try { + await PreferenceExperiments.stop(experiment.slug, { + resetValue: true, + reason: "migration-removing-single-pref-action", + caller: "migration05RemoveOldAction", + }); + } catch (e) { + log.error( + `Stopping preference experiment ${experiment.slug} during migration failed: ${e}` + ); + } + } + } + }, + + async migration06TrackOverriddenPrefs(storage = null) { + if (!storage) { + storage = await ensureStorage(); + } + for (const experiment of Object.values(storage.data.experiments)) { + for (const [preferenceName, specification] of Object.entries( + experiment.preferences + )) { + if (specification.overridden !== undefined) { + continue; + } + specification.overridden = + lazy.PrefUtils.getPref(preferenceName) !== + specification.preferenceValue; + } + } + storage.saveSoon(); + }, + }, +}; |