/* 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. * They are single-depth objects to simplify cloning. * @typedef {Object} Experiment * @property {string} name * Unique name of the experiment * @property {string} branch * Experiment branch that the user was matched to * @property {boolean} expired * If false, the experiment is active. * @property {string} lastSeen * ISO-formatted date string of when the experiment was last seen from the * recipe server. * @property {string} preferenceName * Name of the preference affected by this experiment. * @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. * @rejects {Error} * If the given preferenceType does not match the existing stored preference. * * 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. */ "use strict"; const {utils: Cu} = Components; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Services", "resource://gre/modules/Services.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "CleanupManager", "resource://shield-recipe-client/lib/CleanupManager.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "JSONFile", "resource://gre/modules/JSONFile.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "LogManager", "resource://shield-recipe-client/lib/LogManager.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Preferences", "resource://gre/modules/Preferences.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "TelemetryEnvironment", "resource://gre/modules/TelemetryEnvironment.jsm"); this.EXPORTED_SYMBOLS = ["PreferenceExperiments"]; const EXPERIMENT_FILE = "shield-preference-experiments.json"; const PREFERENCE_TYPE_MAP = { boolean: Services.prefs.PREF_BOOL, string: Services.prefs.PREF_STRING, integer: Services.prefs.PREF_INT, }; const DefaultPreferences = new Preferences({defaultBranch: true}); /** * Enum storing Preference modules for each type of preference branch. * @enum {Object} */ const PreferenceBranchType = { user: Preferences, default: DefaultPreferences, }; /** * Asynchronously load the JSON file that stores experiment status in the profile. */ let storePromise; function ensureStorage() { if (storePromise === undefined) { const path = OS.Path.join(OS.Constants.Path.profileDir, EXPERIMENT_FILE); const storage = new JSONFile({path}); storePromise = storage.load().then(() => storage); } return storePromise; } const log = LogManager.getLogger("preference-experiments"); // List of active preference observers. Cleaned up on shutdown. let experimentObservers = new Map(); CleanupManager.addCleanupHandler(() => PreferenceExperiments.stopAllObservers()); this.PreferenceExperiments = { /** * Set the default preference value for active experiments that use the * default preference branch. */ async init() { for (const experiment of await this.getAllActive()) { // Set experiment default preferences, since they don't persist between restarts if (experiment.preferenceBranchType === "default") { DefaultPreferences.set(experiment.preferenceName, experiment.preferenceValue); } // Check that the current value of the preference is still what we set it to if (Preferences.get(experiment.preferenceName, undefined) !== experiment.preferenceValue) { // if not, stop the experiment, and skip the remaining steps log.info(`Stopping experiment "${experiment.name}" because its value changed`); await this.stop(experiment.name, false); continue; } // Notify Telemetry of experiments we're running, since they don't persist between restarts TelemetryEnvironment.setExperimentActive(experiment.name, experiment.branch); // Watch for changes to the experiment's preference this.startObserver(experiment.name, experiment.preferenceName, experiment.preferenceValue); } }, /** * Test wrapper that temporarily replaces the stored experiment data with fake * data for testing. */ withMockExperiments(testFunction) { return async function inner(...args) { const oldPromise = storePromise; const mockExperiments = {}; storePromise = Promise.resolve({ data: mockExperiments, saveSoon() { }, }); const oldObservers = experimentObservers; experimentObservers = new Map(); try { await testFunction(...args, mockExperiments); } finally { storePromise = oldPromise; PreferenceExperiments.stopAllObservers(); experimentObservers = oldObservers; } }; }, /** * Clear all stored data about active and past experiments. */ async clearAllExperimentStorage() { const store = await ensureStorage(); store.data = {}; store.saveSoon(); }, /** * Start a new preference experiment. * @param {Object} experiment * @param {string} experiment.name * @param {string} experiment.branch * @param {string} experiment.preferenceName * @param {string|integer|boolean} experiment.preferenceValue * @param {PreferenceBranchType} experiment.preferenceBranchType * @rejects {Error} * If an experiment with the given name already exists, or if an experiment * for the given preference is active. */ async start({name, branch, preferenceName, preferenceValue, preferenceBranchType, preferenceType}) { log.debug(`PreferenceExperiments.start(${name}, ${branch})`); const store = await ensureStorage(); if (name in store.data) { throw new Error(`A preference experiment named "${name}" already exists.`); } const activeExperiments = Object.values(store.data).filter(e => !e.expired); const hasConflictingExperiment = activeExperiments.some( e => e.preferenceName === preferenceName ); if (hasConflictingExperiment) { throw new Error( `Another preference experiment for the pref "${preferenceName}" is currently active.` ); } const preferences = PreferenceBranchType[preferenceBranchType]; if (!preferences) { throw new Error(`Invalid value for preferenceBranchType: ${preferenceBranchType}`); } /** @type {Experiment} */ const experiment = { name, branch, expired: false, lastSeen: new Date().toJSON(), preferenceName, preferenceValue, preferenceType, previousPreferenceValue: preferences.get(preferenceName, undefined), preferenceBranchType, }; const prevPrefType = Services.prefs.getPrefType(preferenceName); const givenPrefType = PREFERENCE_TYPE_MAP[preferenceType]; if (!preferenceType || !givenPrefType) { throw new Error(`Invalid preferenceType provided (given "${preferenceType}")`); } if (prevPrefType !== Services.prefs.PREF_INVALID && prevPrefType !== givenPrefType) { throw new Error( `Previous preference value is of type "${prevPrefType}", but was given "${givenPrefType}" (${preferenceType})` ); } preferences.set(preferenceName, preferenceValue); PreferenceExperiments.startObserver(name, preferenceName, preferenceValue); store.data[name] = experiment; store.saveSoon(); TelemetryEnvironment.setExperimentActive(name, branch); }, /** * Register a preference observer that stops an experiment when the user * modifies the preference. * @param {string} experimentName * @param {string} preferenceName * @param {string|integer|boolean} preferenceValue * @throws {Error} * If an observer for the named experiment is already active. */ startObserver(experimentName, preferenceName, preferenceValue) { log.debug(`PreferenceExperiments.startObserver(${experimentName})`); if (experimentObservers.has(experimentName)) { throw new Error( `An observer for the preference experiment ${experimentName} is already active.` ); } const observerInfo = { preferenceName, observer(newValue) { if (newValue !== preferenceValue) { PreferenceExperiments.stop(experimentName, false); } }, }; experimentObservers.set(experimentName, observerInfo); Preferences.observe(preferenceName, observerInfo.observer); }, /** * Check if a preference observer is active for an experiment. * @param {string} experimentName * @return {Boolean} */ hasObserver(experimentName) { log.debug(`PreferenceExperiments.hasObserver(${experimentName})`); return experimentObservers.has(experimentName); }, /** * Disable a preference observer for the named experiment. * @param {string} experimentName * @throws {Error} * If there is no active observer for the named experiment. */ stopObserver(experimentName) { log.debug(`PreferenceExperiments.stopObserver(${experimentName})`); if (!experimentObservers.has(experimentName)) { throw new Error(`No observer for the preference experiment ${experimentName} found.`); } const {preferenceName, observer} = experimentObservers.get(experimentName); Preferences.ignore(preferenceName, observer); experimentObservers.delete(experimentName); }, /** * Disable all currently-active preference observers for experiments. */ stopAllObservers() { log.debug("PreferenceExperiments.stopAllObservers()"); for (const {preferenceName, observer} of experimentObservers.values()) { Preferences.ignore(preferenceName, observer); } experimentObservers.clear(); }, /** * Update the timestamp storing when Normandy last sent a recipe for the named * experiment. * @param {string} experimentName * @rejects {Error} * If there is no stored experiment with the given name. */ async markLastSeen(experimentName) { log.debug(`PreferenceExperiments.markLastSeen(${experimentName})`); const store = await ensureStorage(); if (!(experimentName in store.data)) { throw new Error(`Could not find a preference experiment named "${experimentName}"`); } store.data[experimentName].lastSeen = new Date().toJSON(); store.saveSoon(); }, /** * Stop an active experiment, deactivate preference watchers, and optionally * reset the associated preference to its previous value. * @param {string} experimentName * @param {boolean} [resetValue=true] * If true, reset the preference to its original value. * @rejects {Error} * If there is no stored experiment with the given name, or if the * experiment has already expired. */ async stop(experimentName, resetValue = true) { log.debug(`PreferenceExperiments.stop(${experimentName})`); const store = await ensureStorage(); if (!(experimentName in store.data)) { throw new Error(`Could not find a preference experiment named "${experimentName}"`); } const experiment = store.data[experimentName]; if (experiment.expired) { throw new Error( `Cannot stop preference experiment "${experimentName}" because it is already expired` ); } if (PreferenceExperiments.hasObserver(experimentName)) { PreferenceExperiments.stopObserver(experimentName); } if (resetValue) { const {preferenceName, previousPreferenceValue, preferenceBranchType} = experiment; const preferences = PreferenceBranchType[preferenceBranchType]; if (previousPreferenceValue !== undefined) { preferences.set(preferenceName, previousPreferenceValue); } else { // This does nothing if we're on the default branch, which is fine. The // preference will be reset on next restart, and most preferences should // have had a default value set before the experiment anyway. preferences.reset(preferenceName); } } experiment.expired = true; store.saveSoon(); TelemetryEnvironment.setExperimentInactive(experimentName, experiment.branch); }, /** * Get the experiment object for the named experiment. * @param {string} experimentName * @resolves {Experiment} * @rejects {Error} * If no preference experiment exists with the given name. */ async get(experimentName) { log.debug(`PreferenceExperiments.get(${experimentName})`); const store = await ensureStorage(); if (!(experimentName in store.data)) { throw new Error(`Could not find a preference experiment named "${experimentName}"`); } // Return a copy so mutating it doesn't affect the storage. return Object.assign({}, store.data[experimentName]); }, /** * Get a list of all stored experiment objects. * @resolves {Experiment[]} */ async getAll() { const store = await ensureStorage(); // Return copies so that mutating returned experiments doesn't affect the // stored values. return Object.values(store.data).map(experiment => Object.assign({}, experiment)); }, /** * Get a list of experiment objects for all active experiments. * @resolves {Experiment[]} */ async getAllActive() { log.debug("PreferenceExperiments.getAllActive()"); const store = await ensureStorage(); // Return copies so mutating them doesn't affect the storage. return Object.values(store.data).filter(e => !e.expired).map(e => Object.assign({}, e)); }, /** * Check if an experiment exists with the given name. * @param {string} experimentName * @resolves {boolean} True if the experiment exists, false if it doesn't. */ async has(experimentName) { log.debug(`PreferenceExperiments.has(${experimentName})`); const store = await ensureStorage(); return experimentName in store.data; }, };