1036 lines
34 KiB
JavaScript
1036 lines
34 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.
|
|
* @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} 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",
|
|
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,
|
|
}
|
|
);
|
|
|
|
// 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(
|
|
([, 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;
|
|
}
|
|
};
|
|
};
|
|
},
|
|
|
|
/**
|
|
* 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);
|
|
|
|
/** @type {Experiment} */
|
|
const experiment = {
|
|
slug,
|
|
actionName,
|
|
branch,
|
|
expired: false,
|
|
lastSeen: new Date().toJSON(),
|
|
preferences,
|
|
experimentType,
|
|
userFacingName,
|
|
userFacingDescription,
|
|
};
|
|
|
|
store.data.experiments[slug] = experiment;
|
|
store.saveSoon();
|
|
|
|
// Record telemetry that the experiment started
|
|
lazy.TelemetryEnvironment.setExperimentActive(slug, branch, {
|
|
type: EXPERIMENT_TYPE_PREFIX + experimentType,
|
|
});
|
|
lazy.TelemetryEvents.sendEvent("enroll", "preference_study", slug, {
|
|
experimentType,
|
|
branch,
|
|
});
|
|
|
|
// 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,
|
|
}
|
|
);
|
|
},
|
|
|
|
/**
|
|
* 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,
|
|
};
|
|
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,
|
|
...(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.sys.mjs` 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();
|
|
},
|
|
},
|
|
};
|