Source code

Revision control

Copy as Markdown

Other Tools

/* 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();
},
},
};