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