425 lines
15 KiB
JavaScript
425 lines
15 KiB
JavaScript
/* 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;
|
|
},
|
|
};
|