summaryrefslogtreecommitdiffstats
path: root/toolkit/components/normandy/lib
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/normandy/lib')
-rw-r--r--toolkit/components/normandy/lib/ActionsManager.sys.mjs100
-rw-r--r--toolkit/components/normandy/lib/AddonRollouts.sys.mjs224
-rw-r--r--toolkit/components/normandy/lib/AddonStudies.sys.mjs485
-rw-r--r--toolkit/components/normandy/lib/CleanupManager.sys.mjs49
-rw-r--r--toolkit/components/normandy/lib/ClientEnvironment.sys.mjs123
-rw-r--r--toolkit/components/normandy/lib/EventEmitter.sys.mjs59
-rw-r--r--toolkit/components/normandy/lib/Heartbeat.sys.mjs381
-rw-r--r--toolkit/components/normandy/lib/LegacyHeartbeat.sys.mjs48
-rw-r--r--toolkit/components/normandy/lib/LogManager.sys.mjs34
-rw-r--r--toolkit/components/normandy/lib/NormandyAddonManager.sys.mjs112
-rw-r--r--toolkit/components/normandy/lib/NormandyApi.sys.mjs157
-rw-r--r--toolkit/components/normandy/lib/NormandyUtils.sys.mjs10
-rw-r--r--toolkit/components/normandy/lib/PrefUtils.sys.mjs132
-rw-r--r--toolkit/components/normandy/lib/PreferenceExperiments.sys.mjs1069
-rw-r--r--toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs350
-rw-r--r--toolkit/components/normandy/lib/RecipeRunner.sys.mjs645
-rw-r--r--toolkit/components/normandy/lib/ShieldPreferences.sys.mjs78
-rw-r--r--toolkit/components/normandy/lib/Storage.sys.mjs90
-rw-r--r--toolkit/components/normandy/lib/TelemetryEvents.sys.mjs30
-rw-r--r--toolkit/components/normandy/lib/Uptake.sys.mjs67
20 files changed, 4243 insertions, 0 deletions
diff --git a/toolkit/components/normandy/lib/ActionsManager.sys.mjs b/toolkit/components/normandy/lib/ActionsManager.sys.mjs
new file mode 100644
index 0000000000..6f811613af
--- /dev/null
+++ b/toolkit/components/normandy/lib/ActionsManager.sys.mjs
@@ -0,0 +1,100 @@
+/* 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/. */
+
+import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonRollbackAction:
+ "resource://normandy/actions/AddonRollbackAction.sys.mjs",
+ AddonRolloutAction: "resource://normandy/actions/AddonRolloutAction.sys.mjs",
+ BaseAction: "resource://normandy/actions/BaseAction.sys.mjs",
+ BranchedAddonStudyAction:
+ "resource://normandy/actions/BranchedAddonStudyAction.sys.mjs",
+ ConsoleLogAction: "resource://normandy/actions/ConsoleLogAction.sys.mjs",
+ MessagingExperimentAction:
+ "resource://normandy/actions/MessagingExperimentAction.sys.mjs",
+ PreferenceExperimentAction:
+ "resource://normandy/actions/PreferenceExperimentAction.sys.mjs",
+ PreferenceRollbackAction:
+ "resource://normandy/actions/PreferenceRollbackAction.sys.mjs",
+ PreferenceRolloutAction:
+ "resource://normandy/actions/PreferenceRolloutAction.sys.mjs",
+ ShowHeartbeatAction:
+ "resource://normandy/actions/ShowHeartbeatAction.sys.mjs",
+ Uptake: "resource://normandy/lib/Uptake.sys.mjs",
+});
+
+const log = LogManager.getLogger("recipe-runner");
+
+/**
+ * A class to manage the actions that recipes can use in Normandy.
+ */
+export class ActionsManager {
+ constructor() {
+ this.finalized = false;
+
+ this.localActions = {};
+ for (const [name, Constructor] of Object.entries(
+ ActionsManager.actionConstructors
+ )) {
+ this.localActions[name] = new Constructor();
+ }
+ }
+
+ static actionConstructors = {
+ "addon-rollback": lazy.AddonRollbackAction,
+ "addon-rollout": lazy.AddonRolloutAction,
+ "branched-addon-study": lazy.BranchedAddonStudyAction,
+ "console-log": lazy.ConsoleLogAction,
+ "messaging-experiment": lazy.MessagingExperimentAction,
+ "multi-preference-experiment": lazy.PreferenceExperimentAction,
+ "preference-rollback": lazy.PreferenceRollbackAction,
+ "preference-rollout": lazy.PreferenceRolloutAction,
+ "show-heartbeat": lazy.ShowHeartbeatAction,
+ };
+
+ static getCapabilities() {
+ // Prefix each action name with "action." to turn it into a capability name.
+ let capabilities = new Set();
+ for (const actionName of Object.keys(ActionsManager.actionConstructors)) {
+ capabilities.add(`action.${actionName}`);
+ }
+ return capabilities;
+ }
+
+ async processRecipe(recipe, suitability) {
+ let actionName = recipe.action;
+
+ if (actionName in this.localActions) {
+ log.info(`Executing recipe "${recipe.name}" (action=${recipe.action})`);
+ const action = this.localActions[actionName];
+ await action.processRecipe(recipe, suitability);
+
+ // If the recipe doesn't have matching capabilities, then a missing action
+ // is expected. In this case, don't send an error
+ } else if (
+ suitability !== lazy.BaseAction.suitability.CAPABILITIES_MISMATCH
+ ) {
+ log.error(
+ `Could not execute recipe ${recipe.name}:`,
+ `Action ${recipe.action} is either missing or invalid.`
+ );
+ await lazy.Uptake.reportRecipe(recipe, lazy.Uptake.RECIPE_INVALID_ACTION);
+ }
+ }
+
+ async finalize(options) {
+ if (this.finalized) {
+ throw new Error("ActionsManager has already been finalized");
+ }
+ this.finalized = true;
+
+ // Finalize local actions
+ for (const action of Object.values(this.localActions)) {
+ action.finalize(options);
+ }
+ }
+}
diff --git a/toolkit/components/normandy/lib/AddonRollouts.sys.mjs b/toolkit/components/normandy/lib/AddonRollouts.sys.mjs
new file mode 100644
index 0000000000..6bfc2a70a7
--- /dev/null
+++ b/toolkit/components/normandy/lib/AddonRollouts.sys.mjs
@@ -0,0 +1,224 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs",
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+ TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs",
+});
+
+/**
+ * AddonRollouts store info about an active or expired addon rollouts.
+ * @typedef {object} AddonRollout
+ * @property {int} recipeId
+ * The ID of the recipe.
+ * @property {string} slug
+ * Unique slug of the rollout.
+ * @property {string} state
+ * The current state of the rollout: "active", or "rolled-back".
+ * Active means that Normandy is actively managing therollout. Rolled-back
+ * means that the rollout was previously active, but has been rolled back for
+ * this user.
+ * @property {int} extensionApiId
+ * The ID used to look up the extension in Normandy's API.
+ * @property {string} addonId
+ * The add-on ID for this particular rollout.
+ * @property {string} addonVersion
+ * The rollout add-on version number
+ * @property {string} xpiUrl
+ * URL that the add-on was installed from.
+ * @property {string} xpiHash
+ * The hash of the XPI file.
+ * @property {string} xpiHashAlgorithm
+ * The algorithm used to hash the XPI file.
+ * @property {string} enrollmentId
+ * A random ID generated at time of enrollment. It should be included on all
+ * telemetry related to this rollout. It should not be re-used by other
+ * rollouts, or any other purpose. May be null on old rollouts.
+ */
+
+const DB_NAME = "normandy-addon-rollout";
+const STORE_NAME = "addon-rollouts";
+const DB_OPTIONS = { version: 1 };
+
+/**
+ * Create a new connection to the database.
+ */
+function openDatabase() {
+ return lazy.IndexedDB.open(DB_NAME, DB_OPTIONS, db => {
+ db.createObjectStore(STORE_NAME, {
+ keyPath: "slug",
+ });
+ });
+}
+
+/**
+ * Cache the database connection so that it is shared among multiple operations.
+ */
+let databasePromise;
+function getDatabase() {
+ if (!databasePromise) {
+ databasePromise = openDatabase();
+ }
+ return databasePromise;
+}
+
+/**
+ * Get a transaction for interacting with the rollout store.
+ *
+ * @param {IDBDatabase} db
+ * @param {String} mode Either "readonly" or "readwrite"
+ *
+ * NOTE: Methods on the store returned by this function MUST be called
+ * synchronously, otherwise the transaction with the store will expire.
+ * This is why the helper takes a database as an argument; if we fetched the
+ * database in the helper directly, the helper would be async and the
+ * transaction would expire before methods on the store were called.
+ */
+function getStore(db, mode) {
+ if (!mode) {
+ throw new Error("mode is required");
+ }
+ return db.objectStore(STORE_NAME, mode);
+}
+
+export const AddonRollouts = {
+ STATE_ACTIVE: "active",
+ STATE_ROLLED_BACK: "rolled-back",
+
+ async init() {
+ for (const rollout of await this.getAllActive()) {
+ lazy.TelemetryEnvironment.setExperimentActive(
+ rollout.slug,
+ rollout.state,
+ {
+ type: "normandy-addonrollout",
+ }
+ );
+ }
+ },
+
+ /** When Telemetry is disabled, clear all identifiers from the stored rollouts. */
+ async onTelemetryDisabled() {
+ const rollouts = await this.getAll();
+ for (const rollout of rollouts) {
+ rollout.enrollmentId = lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER;
+ }
+ await this.updateMany(rollouts);
+ },
+
+ /**
+ * Add a new rollout
+ * @param {AddonRollout} rollout
+ */
+ async add(rollout) {
+ const db = await getDatabase();
+ return getStore(db, "readwrite").add(rollout);
+ },
+
+ /**
+ * Update an existing rollout
+ * @param {AddonRollout} rollout
+ * @throws If a matching rollout does not exist.
+ */
+ async update(rollout) {
+ if (!(await this.has(rollout.slug))) {
+ throw new Error(
+ `Tried to update ${rollout.slug}, but it doesn't already exist.`
+ );
+ }
+ const db = await getDatabase();
+ return getStore(db, "readwrite").put(rollout);
+ },
+
+ /**
+ * Update many existing rollouts. More efficient than calling `update` many
+ * times in a row.
+ * @param {Array<PreferenceRollout>} rollouts
+ * @throws If any of the passed rollouts have a slug that doesn't exist in the database already.
+ */
+ async updateMany(rollouts) {
+ // Don't touch the database if there is nothing to do
+ if (!rollouts.length) {
+ return;
+ }
+
+ // Both of the below operations use .map() instead of a normal loop becaues
+ // once we get the object store, we can't let it expire by spinning the
+ // event loop. This approach queues up all the interactions with the store
+ // immediately, preventing it from expiring too soon.
+
+ const db = await getDatabase();
+ let store = await getStore(db, "readonly");
+ await Promise.all(
+ rollouts.map(async ({ slug }) => {
+ let existingRollout = await store.get(slug);
+ if (!existingRollout) {
+ throw new Error(`Tried to update ${slug}, but it doesn't exist.`);
+ }
+ })
+ );
+
+ // awaiting spun the event loop, so the store is now invalid. Get a new
+ // store. This is also a chance to get it in readwrite mode.
+ store = await getStore(db, "readwrite");
+ await Promise.all(rollouts.map(rollout => store.put(rollout)));
+ },
+
+ /**
+ * Test whether there is a rollout in storage with the given slug.
+ * @param {string} slug
+ * @returns {Promise<boolean>}
+ */
+ async has(slug) {
+ const db = await getDatabase();
+ const rollout = await getStore(db, "readonly").get(slug);
+ return !!rollout;
+ },
+
+ /**
+ * Get a rollout by slug
+ * @param {string} slug
+ */
+ async get(slug) {
+ const db = await getDatabase();
+ return getStore(db, "readonly").get(slug);
+ },
+
+ /** Get all rollouts in the database. */
+ async getAll() {
+ const db = await getDatabase();
+ return getStore(db, "readonly").getAll();
+ },
+
+ /** Get all rollouts in the "active" state. */
+ async getAllActive() {
+ const rollouts = await this.getAll();
+ return rollouts.filter(rollout => rollout.state === this.STATE_ACTIVE);
+ },
+
+ /**
+ * Test wrapper that temporarily replaces the stored rollout data with fake
+ * data for testing.
+ */
+ withTestMock() {
+ return function (testFunction) {
+ return async function inner(...args) {
+ let db = await getDatabase();
+ const oldData = await getStore(db, "readonly").getAll();
+ await getStore(db, "readwrite").clear();
+ try {
+ await testFunction(...args);
+ } finally {
+ db = await getDatabase();
+ await getStore(db, "readwrite").clear();
+ const store = getStore(db, "readwrite");
+ await Promise.all(oldData.map(d => store.add(d)));
+ }
+ };
+ };
+ },
+};
diff --git a/toolkit/components/normandy/lib/AddonStudies.sys.mjs b/toolkit/components/normandy/lib/AddonStudies.sys.mjs
new file mode 100644
index 0000000000..233bd4b715
--- /dev/null
+++ b/toolkit/components/normandy/lib/AddonStudies.sys.mjs
@@ -0,0 +1,485 @@
+/* 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/. */
+
+/**
+ * @typedef {Object} Study
+ * @property {Number} recipeId
+ * ID of the recipe that created the study. Used as the primary key of the
+ * study.
+ * @property {Number} slug
+ * String code used to identify the study for use in Telemetry and logging.
+ * @property {string} userFacingName
+ * Name of the study to show to the user
+ * @property {string} userFacingDescription
+ * Description of the study and its intent.
+ * @property {string} branch
+ * The branch the user is enrolled in
+ * @property {boolean} active
+ * Is the study still running?
+ * @property {string} addonId
+ * Add-on ID for this particular study.
+ * @property {string} addonUrl
+ * URL that the study add-on was installed from.
+ * @property {string} addonVersion
+ * Study add-on version number
+ * @property {int} extensionApiId
+ * The ID used to look up the extension in Normandy's API.
+ * @property {string} extensionHash
+ * The hash of the XPI file.
+ * @property {string} extensionHashAlgorithm
+ * The algorithm used to hash the XPI file.
+ * @property {Date} studyStartDate
+ * Date when the study was started.
+ * @property {Date|null} studyEndDate
+ * Date when the study was ended.
+ * @property {Date|null} temporaryErrorDeadline
+ * Date of when temporary errors with this experiment should no longer be
+ * considered temporary. After this point, further errors will result in
+ * unenrollment.
+ * @property {string} enrollmentId
+ * A random ID generated at time of enrollment. It should be included on all
+ * telemetry related to this study. It should not be re-used by other studies,
+ * or any other purpose. May be null on old study.
+ */
+
+import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ BranchedAddonStudyAction:
+ "resource://normandy/actions/BranchedAddonStudyAction.sys.mjs",
+ CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs",
+ IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs",
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+ TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs",
+});
+
+const DB_NAME = "shield";
+const STORE_NAME = "addon-studies";
+const VERSION_STORE_NAME = "addon-studies-version";
+const DB_VERSION = 2;
+const STUDY_ENDED_TOPIC = "shield-study-ended";
+const log = LogManager.getLogger("addon-studies");
+
+/**
+ * Create a new connection to the database.
+ */
+function openDatabase() {
+ return lazy.IndexedDB.open(DB_NAME, DB_VERSION, async (db, event) => {
+ if (event.oldVersion < 1) {
+ db.createObjectStore(STORE_NAME, {
+ keyPath: "recipeId",
+ });
+ }
+
+ if (event.oldVersion < 2) {
+ db.createObjectStore(VERSION_STORE_NAME);
+ }
+ });
+}
+
+/**
+ * Cache the database connection so that it is shared among multiple operations.
+ */
+let databasePromise;
+async function getDatabase() {
+ if (!databasePromise) {
+ databasePromise = openDatabase();
+ }
+ return databasePromise;
+}
+
+/**
+ * Get a transaction for interacting with the study store.
+ *
+ * @param {IDBDatabase} db
+ * @param {String} mode Either "readonly" or "readwrite"
+ *
+ * NOTE: Methods on the store returned by this function MUST be called
+ * synchronously, otherwise the transaction with the store will expire.
+ * This is why the helper takes a database as an argument; if we fetched the
+ * database in the helper directly, the helper would be async and the
+ * transaction would expire before methods on the store were called.
+ */
+function getStore(db, mode) {
+ if (!mode) {
+ throw new Error("mode is required");
+ }
+ return db.objectStore(STORE_NAME, mode);
+}
+
+export var AddonStudies = {
+ /**
+ * Test wrapper that temporarily replaces the stored studies with the given
+ * ones. The original stored studies are restored upon completion.
+ *
+ * This is defined here instead of in test code since it needs to access the
+ * getDatabase, which we don't expose to avoid outside modules relying on the
+ * type of storage used for studies.
+ *
+ * @param {Array} [addonStudies=[]]
+ */
+ withStudies(addonStudies = []) {
+ return function wrapper(testFunction) {
+ return async function wrappedTestFunction(args) {
+ const oldStudies = await AddonStudies.getAll();
+ let db = await getDatabase();
+ await AddonStudies.clear();
+ const store = getStore(db, "readwrite");
+ await Promise.all(addonStudies.map(study => store.add(study)));
+
+ try {
+ await testFunction({ ...args, addonStudies });
+ } finally {
+ db = await getDatabase();
+ await AddonStudies.clear();
+ const store = getStore(db, "readwrite");
+ await Promise.all(oldStudies.map(study => store.add(study)));
+ }
+ };
+ };
+ },
+
+ async init() {
+ for (const study of await this.getAllActive()) {
+ // If an active study's add-on has been removed since we last ran, stop it.
+ const addon = await lazy.AddonManager.getAddonByID(study.addonId);
+ if (!addon) {
+ await this.markAsEnded(study, "uninstalled-sideload");
+ continue;
+ }
+
+ // Otherwise mark that study as active in Telemetry
+ lazy.TelemetryEnvironment.setExperimentActive(study.slug, study.branch, {
+ type: "normandy-addonstudy",
+ enrollmentId:
+ study.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+ }
+
+ // Listen for add-on uninstalls so we can stop the corresponding studies.
+ lazy.AddonManager.addAddonListener(this);
+ lazy.CleanupManager.addCleanupHandler(() => {
+ lazy.AddonManager.removeAddonListener(this);
+ });
+ },
+
+ /** When Telemetry is disabled, clear all identifiers from the stored studies. */
+ async onTelemetryDisabled() {
+ const studies = await this.getAll();
+ for (const study of studies) {
+ study.enrollmentId = lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER;
+ }
+ await this.updateMany(studies);
+ },
+
+ /**
+ * These migrations should only be called from `NormandyMigrations.jsm` and
+ * tests.
+ */
+ migrations: {
+ /**
+ * Change from "name" and "description" to "slug", "userFacingName",
+ * and "userFacingDescription".
+ */
+ async migration01AddonStudyFieldsToSlugAndUserFacingFields() {
+ const db = await getDatabase();
+ const studies = await db.objectStore(STORE_NAME, "readonly").getAll();
+
+ // If there are no studies, stop here to avoid opening the DB again.
+ if (studies.length === 0) {
+ return;
+ }
+
+ // Object stores expire after `await`, so this method accumulates a bunch of
+ // promises, and then awaits them at the end.
+ const writePromises = [];
+ const objectStore = db.objectStore(STORE_NAME, "readwrite");
+
+ for (const study of studies) {
+ // use existing name as slug
+ if (!study.slug) {
+ study.slug = study.name;
+ }
+
+ // Rename `name` and `description` as `userFacingName` and `userFacingDescription`
+ if (study.name && !study.userFacingName) {
+ study.userFacingName = study.name;
+ }
+ delete study.name;
+ if (study.description && !study.userFacingDescription) {
+ study.userFacingDescription = study.description;
+ }
+ delete study.description;
+
+ // Specify that existing recipes don't have branches
+ if (!study.branch) {
+ study.branch = AddonStudies.NO_BRANCHES_MARKER;
+ }
+
+ writePromises.push(objectStore.put(study));
+ }
+
+ await Promise.all(writePromises);
+ },
+
+ async migration02RemoveOldAddonStudyAction() {
+ const studies = await AddonStudies.getAllActive({
+ branched: AddonStudies.FILTER_NOT_BRANCHED,
+ });
+ if (!studies.length) {
+ return;
+ }
+ const action = new lazy.BranchedAddonStudyAction();
+ for (const study of studies) {
+ try {
+ await action.unenroll(
+ study.recipeId,
+ "migration-removing-unbranched-action"
+ );
+ } catch (e) {
+ log.error(
+ `Stopping add-on study ${study.slug} during migration failed: ${e}`
+ );
+ }
+ }
+ },
+ },
+
+ /**
+ * If a study add-on is uninstalled, mark the study as having ended.
+ * @param {Addon} addon
+ */
+ async onUninstalled(addon) {
+ const activeStudies = (await this.getAll()).filter(study => study.active);
+ const matchingStudy = activeStudies.find(
+ study => study.addonId === addon.id
+ );
+ if (matchingStudy) {
+ await this.markAsEnded(matchingStudy, "uninstalled");
+ }
+ },
+
+ /**
+ * Remove all stored studies.
+ */
+ async clear() {
+ const db = await getDatabase();
+ await getStore(db, "readwrite").clear();
+ },
+
+ /**
+ * Test whether there is a study in storage for the given recipe ID.
+ * @param {Number} recipeId
+ * @returns {Boolean}
+ */
+ async has(recipeId) {
+ const db = await getDatabase();
+ const study = await getStore(db, "readonly").get(recipeId);
+ return !!study;
+ },
+
+ /**
+ * Fetch a study from storage.
+ * @param {Number} recipeId
+ * @return {Study} The requested study, or null if none with that ID exist.
+ */
+ async get(recipeId) {
+ const db = await getDatabase();
+ return getStore(db, "readonly").get(recipeId);
+ },
+
+ FILTER_BRANCHED_ONLY: Symbol("FILTER_BRANCHED_ONLY"),
+ FILTER_NOT_BRANCHED: Symbol("FILTER_NOT_BRANCHED"),
+ FILTER_ALL: Symbol("FILTER_ALL"),
+
+ /**
+ * Fetch all studies in storage.
+ * @return {Array<Study>}
+ */
+ async getAll({ branched = AddonStudies.FILTER_ALL } = {}) {
+ const db = await getDatabase();
+ let results = await getStore(db, "readonly").getAll();
+
+ if (branched == AddonStudies.FILTER_BRANCHED_ONLY) {
+ results = results.filter(
+ study => study.branch != AddonStudies.NO_BRANCHES_MARKER
+ );
+ } else if (branched == AddonStudies.FILTER_NOT_BRANCHED) {
+ results = results.filter(
+ study => study.branch == AddonStudies.NO_BRANCHES_MARKER
+ );
+ }
+ return results;
+ },
+
+ /**
+ * Fetch all studies in storage.
+ * @return {Array<Study>}
+ */
+ async getAllActive(options) {
+ return (await this.getAll(options)).filter(study => study.active);
+ },
+
+ /**
+ * Add a study to storage.
+ * @return {Promise<void, Error>} Resolves when the study is stored, or rejects with an error.
+ */
+ async add(study) {
+ const db = await getDatabase();
+ return getStore(db, "readwrite").add(study);
+ },
+
+ /**
+ * Update a study in storage.
+ * @return {Promise<void, Error>} Resolves when the study is updated, or rejects with an error.
+ */
+ async update(study) {
+ const db = await getDatabase();
+ return getStore(db, "readwrite").put(study);
+ },
+
+ /**
+ * Update many existing studies. More efficient than calling `update` many
+ * times in a row.
+ * @param {Array<AddonStudy>} studies
+ * @throws If any of the passed studies have a slug that doesn't exist in the database already.
+ */
+ async updateMany(studies) {
+ // Don't touch the database if there is nothing to do
+ if (!studies.length) {
+ return;
+ }
+
+ // Both of the below operations use .map() instead of a normal loop becaues
+ // once we get the object store, we can't let it expire by spinning the
+ // event loop. This approach queues up all the interactions with the store
+ // immediately, preventing it from expiring too soon.
+
+ const db = await getDatabase();
+ let store = await getStore(db, "readonly");
+ await Promise.all(
+ studies.map(async ({ recipeId }) => {
+ let existingStudy = await store.get(recipeId);
+ if (!existingStudy) {
+ throw new Error(
+ `Tried to update addon study ${recipeId}, but it doesn't exist.`
+ );
+ }
+ })
+ );
+
+ // awaiting spun the event loop, so the store is now invalid. Get a new
+ // store. This is also a chance to get it in readwrite mode.
+ store = await getStore(db, "readwrite");
+ await Promise.all(studies.map(study => store.put(study)));
+ },
+
+ /**
+ * Remove a study from storage
+ * @param recipeId The recipeId of the study to delete
+ * @return {Promise<void, Error>} Resolves when the study is deleted, or rejects with an error.
+ */
+ async delete(recipeId) {
+ const db = await getDatabase();
+ return getStore(db, "readwrite").delete(recipeId);
+ },
+
+ /**
+ * Mark a study object as having ended. Modifies the study in-place.
+ * @param {IDBDatabase} db
+ * @param {Study} study
+ * @param {String} reason Why the study is ending.
+ */
+ async markAsEnded(study, reason = "unknown") {
+ if (reason === "unknown") {
+ log.warn(`Study ${study.slug} ending for unknown reason.`);
+ }
+
+ study.active = false;
+ study.temporaryErrorDeadline = null;
+ study.studyEndDate = new Date();
+ const db = await getDatabase();
+ await getStore(db, "readwrite").put(study);
+
+ Services.obs.notifyObservers(study, STUDY_ENDED_TOPIC, `${study.recipeId}`);
+ lazy.TelemetryEvents.sendEvent("unenroll", "addon_study", study.slug, {
+ addonId: study.addonId || AddonStudies.NO_ADDON_MARKER,
+ addonVersion: study.addonVersion || AddonStudies.NO_ADDON_MARKER,
+ reason,
+ branch: study.branch,
+ enrollmentId:
+ study.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+ lazy.TelemetryEnvironment.setExperimentInactive(study.slug);
+
+ await this.callUnenrollListeners(study.addonId, reason);
+ },
+
+ // Maps extension id -> Set(callbacks)
+ _unenrollListeners: new Map(),
+
+ /**
+ * Register a callback to be invoked when a given study ends.
+ *
+ * @param {string} id The extension id
+ * @param {function} listener The callback
+ */
+ addUnenrollListener(id, listener) {
+ let listeners = this._unenrollListeners.get(id);
+ if (!listeners) {
+ listeners = new Set();
+ this._unenrollListeners.set(id, listeners);
+ }
+ listeners.add(listener);
+ },
+
+ /**
+ * Unregister a callback to be invoked when a given study ends.
+ *
+ * @param {string} id The extension id
+ * @param {function} listener The callback
+ */
+ removeUnenrollListener(id, listener) {
+ let listeners = this._unenrollListeners.get(id);
+ if (listeners) {
+ listeners.delete(listener);
+ }
+ },
+
+ /**
+ * Invoke the unenroll callback (if any) for the given extension
+ *
+ * @param {string} id The extension id
+ * @param {string} reason Why the study is ending
+ *
+ * @returns {Promise} A Promise resolved after the unenroll listener
+ * (if any) has finished its unenroll tasks.
+ */
+ async callUnenrollListeners(id, reason) {
+ let callbacks = this._unenrollListeners.get(id) || [];
+
+ async function callCallback(cb, reason) {
+ try {
+ await cb(reason);
+ } catch (err) {
+ console.error(err);
+ }
+ }
+
+ let promises = [];
+ for (let callback of callbacks) {
+ promises.push(callCallback(callback, reason));
+ }
+
+ // Wait for all the promises to be settled. This won't throw even if some of
+ // the listeners fail.
+ await Promise.all(promises);
+ },
+};
+
+AddonStudies.NO_BRANCHES_MARKER = "__NO_BRANCHES__";
+AddonStudies.NO_ADDON_MARKER = "__NO_ADDON__";
diff --git a/toolkit/components/normandy/lib/CleanupManager.sys.mjs b/toolkit/components/normandy/lib/CleanupManager.sys.mjs
new file mode 100644
index 0000000000..9101f0f63c
--- /dev/null
+++ b/toolkit/components/normandy/lib/CleanupManager.sys.mjs
@@ -0,0 +1,49 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+});
+
+class CleanupManagerClass {
+ constructor() {
+ this.handlers = new Set();
+ this.cleanupPromise = null;
+ }
+
+ addCleanupHandler(handler) {
+ this.handlers.add(handler);
+ }
+
+ removeCleanupHandler(handler) {
+ this.handlers.delete(handler);
+ }
+
+ async cleanup() {
+ if (this.cleanupPromise === null) {
+ this.cleanupPromise = (async () => {
+ for (const handler of this.handlers) {
+ try {
+ await handler();
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ })();
+
+ // Block shutdown to ensure any cleanup tasks that write data are
+ // finished.
+ lazy.AsyncShutdown.profileBeforeChange.addBlocker(
+ "ShieldRecipeClient: Cleaning up",
+ this.cleanupPromise
+ );
+ }
+
+ return this.cleanupPromise;
+ }
+}
+
+export var CleanupManager = new CleanupManagerClass();
diff --git a/toolkit/components/normandy/lib/ClientEnvironment.sys.mjs b/toolkit/components/normandy/lib/ClientEnvironment.sys.mjs
new file mode 100644
index 0000000000..16645dd3b2
--- /dev/null
+++ b/toolkit/components/normandy/lib/ClientEnvironment.sys.mjs
@@ -0,0 +1,123 @@
+/* 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/. */
+
+import { ClientEnvironmentBase } from "resource://gre/modules/components-utils/ClientEnvironment.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonRollouts: "resource://normandy/lib/AddonRollouts.sys.mjs",
+ AddonStudies: "resource://normandy/lib/AddonStudies.sys.mjs",
+ NormandyApi: "resource://normandy/lib/NormandyApi.sys.mjs",
+ PreferenceExperiments:
+ "resource://normandy/lib/PreferenceExperiments.sys.mjs",
+ PreferenceRollouts: "resource://normandy/lib/PreferenceRollouts.sys.mjs",
+});
+
+// Cached API request for client attributes that are determined by the Normandy
+// service.
+let _classifyRequest = null;
+
+export class ClientEnvironment extends ClientEnvironmentBase {
+ /**
+ * Fetches information about the client that is calculated on the server,
+ * like geolocation and the current time.
+ *
+ * The server request is made lazily and is cached for the entire browser
+ * session.
+ */
+ static async getClientClassification() {
+ if (!_classifyRequest) {
+ _classifyRequest = lazy.NormandyApi.classifyClient();
+ }
+ return _classifyRequest;
+ }
+
+ static clearClassifyCache() {
+ _classifyRequest = null;
+ }
+
+ /**
+ * Test wrapper that mocks the server request for classifying the client.
+ * @param {Object} data Fake server data to use
+ * @param {Function} testFunction Test function to execute while mock data is in effect.
+ */
+ static withMockClassify(data, testFunction) {
+ return async function inner() {
+ const oldRequest = _classifyRequest;
+ _classifyRequest = Promise.resolve(data);
+ await testFunction();
+ _classifyRequest = oldRequest;
+ };
+ }
+
+ static get userId() {
+ return ClientEnvironment.randomizationId;
+ }
+
+ static get country() {
+ return (async () => {
+ const { country } = await ClientEnvironment.getClientClassification();
+ return country;
+ })();
+ }
+
+ static get request_time() {
+ return (async () => {
+ const { request_time } =
+ await ClientEnvironment.getClientClassification();
+ return request_time;
+ })();
+ }
+
+ static get experiments() {
+ return (async () => {
+ const names = { all: [], active: [], expired: [] };
+
+ for (const {
+ slug,
+ expired,
+ } of await lazy.PreferenceExperiments.getAll()) {
+ names.all.push(slug);
+ if (expired) {
+ names.expired.push(slug);
+ } else {
+ names.active.push(slug);
+ }
+ }
+
+ return names;
+ })();
+ }
+
+ static get studies() {
+ return (async () => {
+ const rv = { pref: {}, addon: {} };
+ for (const prefStudy of await lazy.PreferenceExperiments.getAll()) {
+ rv.pref[prefStudy.slug] = prefStudy;
+ }
+ for (const addonStudy of await lazy.AddonStudies.getAll()) {
+ rv.addon[addonStudy.slug] = addonStudy;
+ }
+ return rv;
+ })();
+ }
+
+ static get rollouts() {
+ return (async () => {
+ const rv = { pref: {}, addon: {} };
+ for (const prefRollout of await lazy.PreferenceRollouts.getAll()) {
+ rv.pref[prefRollout.slug] = prefRollout;
+ }
+ for (const addonRollout of await lazy.AddonRollouts.getAll()) {
+ rv.addon[addonRollout.slug] = addonRollout;
+ }
+ return rv;
+ })();
+ }
+
+ static get isFirstRun() {
+ return Services.prefs.getBoolPref("app.normandy.first_run", true);
+ }
+}
diff --git a/toolkit/components/normandy/lib/EventEmitter.sys.mjs b/toolkit/components/normandy/lib/EventEmitter.sys.mjs
new file mode 100644
index 0000000000..551fc4d6b2
--- /dev/null
+++ b/toolkit/components/normandy/lib/EventEmitter.sys.mjs
@@ -0,0 +1,59 @@
+/* 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/. */
+
+import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs";
+
+const log = LogManager.getLogger("event-emitter");
+
+export var EventEmitter = function () {
+ const listeners = {};
+
+ return {
+ emit(eventName, event) {
+ // Fire events async
+ Promise.resolve().then(() => {
+ if (!(eventName in listeners)) {
+ log.debug(
+ `EventEmitter: Event fired with no listeners: ${eventName}`
+ );
+ return;
+ }
+ // Clone callbacks array to avoid problems with mutation while iterating
+ const callbacks = Array.from(listeners[eventName]);
+ for (const cb of callbacks) {
+ // Clone event so it can't by modified by the handler
+ let eventToPass = event;
+ if (typeof event === "object") {
+ eventToPass = Object.assign({}, event);
+ }
+ cb(eventToPass);
+ }
+ });
+ },
+
+ on(eventName, callback) {
+ if (!(eventName in listeners)) {
+ listeners[eventName] = [];
+ }
+ listeners[eventName].push(callback);
+ },
+
+ off(eventName, callback) {
+ if (eventName in listeners) {
+ const index = listeners[eventName].indexOf(callback);
+ if (index !== -1) {
+ listeners[eventName].splice(index, 1);
+ }
+ }
+ },
+
+ once(eventName, callback) {
+ const inner = event => {
+ callback(event);
+ this.off(eventName, inner);
+ };
+ this.on(eventName, inner);
+ },
+ };
+};
diff --git a/toolkit/components/normandy/lib/Heartbeat.sys.mjs b/toolkit/components/normandy/lib/Heartbeat.sys.mjs
new file mode 100644
index 0000000000..fa66844acb
--- /dev/null
+++ b/toolkit/components/normandy/lib/Heartbeat.sys.mjs
@@ -0,0 +1,381 @@
+/* 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/. */
+
+import { Preferences } from "resource://gre/modules/Preferences.sys.mjs";
+import { TelemetryController } from "resource://gre/modules/TelemetryController.sys.mjs";
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+import { CleanupManager } from "resource://normandy/lib/CleanupManager.sys.mjs";
+import { EventEmitter } from "resource://normandy/lib/EventEmitter.sys.mjs";
+import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs";
+
+const PREF_SURVEY_DURATION = "browser.uitour.surveyDuration";
+const NOTIFICATION_TIME = 3000;
+const HEARTBEAT_CSS_URI = Services.io.newURI(
+ "resource://normandy/skin/shared/Heartbeat.css"
+);
+const log = LogManager.getLogger("heartbeat");
+const windowsWithInjectedCss = new WeakSet();
+let anyWindowsWithInjectedCss = false;
+
+// Add cleanup handler for CSS injected into windows by Heartbeat
+CleanupManager.addCleanupHandler(() => {
+ if (anyWindowsWithInjectedCss) {
+ for (let window of Services.wm.getEnumerator("navigator:browser")) {
+ if (windowsWithInjectedCss.has(window)) {
+ const utils = window.windowUtils;
+ utils.removeSheet(HEARTBEAT_CSS_URI, window.AGENT_SHEET);
+ windowsWithInjectedCss.delete(window);
+ }
+ }
+ }
+});
+
+/**
+ * Show the Heartbeat UI to request user feedback.
+ *
+ * @param chromeWindow
+ * The chrome window that the heartbeat notification is displayed in.
+ * @param {Object} options Options object.
+ * @param {String} options.message
+ * The message, or question, to display on the notification.
+ * @param {String} options.thanksMessage
+ * The thank you message to display after user votes.
+ * @param {String} options.flowId
+ * An identifier for this rating flow. Please note that this is only used to
+ * identify the notification box.
+ * @param {String} [options.engagementButtonLabel=null]
+ * The text of the engagement button to use instead of stars. If this is null
+ * or invalid, rating stars are used.
+ * @param {String} [options.learnMoreMessage=null]
+ * The label of the learn more link. No link will be shown if this is null.
+ * @param {String} [options.learnMoreUrl=null]
+ * The learn more URL to open when clicking on the learn more link. No learn more
+ * will be shown if this is an invalid URL.
+ * @param {String} [options.surveyId]
+ * An ID for the survey, reflected in the Telemetry ping.
+ * @param {Number} [options.surveyVersion]
+ * Survey's version number, reflected in the Telemetry ping.
+ * @param {boolean} [options.testing]
+ * Whether this is a test survey, reflected in the Telemetry ping.
+ * @param {String} [options.postAnswerURL=null]
+ * The url to visit after the user answers the question.
+ */
+export var Heartbeat = class {
+ constructor(chromeWindow, options) {
+ if (typeof options.flowId !== "string") {
+ throw new Error(
+ `flowId must be a string, but got ${JSON.stringify(
+ options.flowId
+ )}, a ${typeof options.flowId}`
+ );
+ }
+
+ if (!options.flowId) {
+ throw new Error("flowId must not be an empty string");
+ }
+
+ if (typeof options.message !== "string") {
+ throw new Error(
+ `message must be a string, but got ${JSON.stringify(
+ options.message
+ )}, a ${typeof options.message}`
+ );
+ }
+
+ if (!options.message) {
+ throw new Error("message must not be an empty string");
+ }
+
+ if (options.postAnswerUrl) {
+ options.postAnswerUrl = new URL(options.postAnswerUrl);
+ } else {
+ options.postAnswerUrl = null;
+ }
+
+ if (options.learnMoreUrl) {
+ try {
+ options.learnMoreUrl = new URL(options.learnMoreUrl);
+ } catch (e) {
+ options.learnMoreUrl = null;
+ }
+ }
+
+ this.chromeWindow = chromeWindow;
+ this.eventEmitter = new EventEmitter();
+ this.options = options;
+ this.surveyResults = {};
+ this.buttons = [];
+
+ if (!windowsWithInjectedCss.has(chromeWindow)) {
+ windowsWithInjectedCss.add(chromeWindow);
+ const utils = chromeWindow.windowUtils;
+ utils.loadSheet(HEARTBEAT_CSS_URI, chromeWindow.AGENT_SHEET);
+ anyWindowsWithInjectedCss = true;
+ }
+
+ // so event handlers are consistent
+ this.handleWindowClosed = this.handleWindowClosed.bind(this);
+ this.close = this.close.bind(this);
+
+ // Add Learn More Link
+ if (this.options.learnMoreMessage && this.options.learnMoreUrl) {
+ this.buttons.push({
+ link: this.options.learnMoreUrl.toString(),
+ label: this.options.learnMoreMessage,
+ callback: () => {
+ this.maybeNotifyHeartbeat("LearnMore");
+ return true;
+ },
+ });
+ }
+
+ if (this.options.engagementButtonLabel) {
+ this.buttons.push({
+ label: this.options.engagementButtonLabel,
+ callback: () => {
+ // Let the consumer know user engaged.
+ this.maybeNotifyHeartbeat("Engaged");
+
+ this.userEngaged({
+ type: "button",
+ flowId: this.options.flowId,
+ });
+
+ // Return true so that the notification bar doesn't close itself since
+ // we have a thank you message to show.
+ return true;
+ },
+ });
+ }
+
+ this.notificationBox = this.chromeWindow.gNotificationBox;
+ this.notice = this.notificationBox.appendNotification(
+ "heartbeat-" + this.options.flowId,
+ {
+ label: this.options.message,
+ image: "resource://normandy/skin/shared/heartbeat-icon.svg",
+ priority: this.notificationBox.PRIORITY_SYSTEM,
+ eventCallback: eventType => {
+ if (eventType !== "removed") {
+ return;
+ }
+ this.maybeNotifyHeartbeat("NotificationClosed");
+ },
+ },
+ this.buttons
+ );
+ this.notice.classList.add("heartbeat");
+ this.notice.messageText.classList.add("heartbeat");
+
+ // Build the heartbeat stars
+ if (!this.options.engagementButtonLabel) {
+ const numStars = this.options.engagementButtonLabel ? 0 : 5;
+ this.ratingContainer = this.chromeWindow.document.createElement("span");
+ this.ratingContainer.id = "star-rating-container";
+
+ for (let i = 0; i < numStars; i++) {
+ // create a star rating element
+ const ratingElement =
+ this.chromeWindow.document.createXULElement("toolbarbutton");
+
+ // style it
+ const starIndex = numStars - i;
+ ratingElement.className = "plain star-x";
+ ratingElement.id = "star" + starIndex;
+ ratingElement.setAttribute("data-score", starIndex);
+
+ // Add the click handler
+ ratingElement.addEventListener("click", ev => {
+ const rating = parseInt(ev.target.getAttribute("data-score"));
+ this.maybeNotifyHeartbeat("Voted", { score: rating });
+ this.userEngaged({
+ type: "stars",
+ score: rating,
+ flowId: this.options.flowId,
+ });
+ });
+
+ this.ratingContainer.appendChild(ratingElement);
+ }
+
+ this.notice.buttonContainer.append(this.ratingContainer);
+ }
+
+ // Let the consumer know the notification was shown.
+ this.maybeNotifyHeartbeat("NotificationOffered");
+ this.chromeWindow.addEventListener(
+ "SSWindowClosing",
+ this.handleWindowClosed
+ );
+
+ const surveyDuration = Preferences.get(PREF_SURVEY_DURATION, 300) * 1000;
+ this.surveyEndTimer = setTimeout(() => {
+ this.maybeNotifyHeartbeat("SurveyExpired");
+ this.close();
+ }, surveyDuration);
+
+ CleanupManager.addCleanupHandler(this.close);
+ }
+
+ maybeNotifyHeartbeat(name, data = {}) {
+ if (this.pingSent) {
+ log.warn(
+ "Heartbeat event received after Telemetry ping sent. name:",
+ name,
+ "data:",
+ data
+ );
+ return;
+ }
+
+ const timestamp = Date.now();
+ let sendPing = false;
+ let cleanup = false;
+
+ const phases = {
+ NotificationOffered: () => {
+ this.surveyResults.flowId = this.options.flowId;
+ this.surveyResults.offeredTS = timestamp;
+ },
+ LearnMore: () => {
+ if (!this.surveyResults.learnMoreTS) {
+ this.surveyResults.learnMoreTS = timestamp;
+ }
+ },
+ Engaged: () => {
+ this.surveyResults.engagedTS = timestamp;
+ },
+ Voted: () => {
+ this.surveyResults.votedTS = timestamp;
+ this.surveyResults.score = data.score;
+ },
+ SurveyExpired: () => {
+ this.surveyResults.expiredTS = timestamp;
+ },
+ NotificationClosed: () => {
+ this.surveyResults.closedTS = timestamp;
+ cleanup = true;
+ sendPing = true;
+ },
+ WindowClosed: () => {
+ this.surveyResults.windowClosedTS = timestamp;
+ cleanup = true;
+ sendPing = true;
+ },
+ default: () => {
+ log.error("Unrecognized Heartbeat event:", name);
+ },
+ };
+
+ (phases[name] || phases.default)();
+
+ data.timestamp = timestamp;
+ data.flowId = this.options.flowId;
+ this.eventEmitter.emit(name, data);
+
+ if (sendPing) {
+ // Send the ping to Telemetry
+ const payload = Object.assign({ version: 1 }, this.surveyResults);
+ for (const meta of ["surveyId", "surveyVersion", "testing"]) {
+ if (this.options.hasOwnProperty(meta)) {
+ payload[meta] = this.options[meta];
+ }
+ }
+
+ log.debug("Sending telemetry");
+ TelemetryController.submitExternalPing("heartbeat", payload, {
+ addClientId: true,
+ addEnvironment: true,
+ });
+
+ // only for testing
+ this.eventEmitter.emit("TelemetrySent", payload);
+
+ // Survey is complete, clear out the expiry timer & survey configuration
+ this.endTimerIfPresent("surveyEndTimer");
+
+ this.pingSent = true;
+ this.surveyResults = null;
+ }
+
+ if (cleanup) {
+ this.cleanup();
+ }
+ }
+
+ userEngaged(engagementParams) {
+ // Make the heartbeat icon pulse twice
+ this.notice.label = this.options.thanksMessage;
+ this.notice.messageImage.classList.remove("pulse-onshow");
+ this.notice.messageImage.classList.add("pulse-twice");
+
+ // Remove the custom contents of the notice and the buttons
+ if (this.ratingContainer) {
+ this.ratingContainer.remove();
+ }
+ for (let button of this.notice.buttonContainer.querySelectorAll("button")) {
+ button.remove();
+ }
+
+ // Open the engagement tab if we have a valid engagement URL.
+ if (this.options.postAnswerUrl) {
+ for (const key in engagementParams) {
+ this.options.postAnswerUrl.searchParams.append(
+ key,
+ engagementParams[key]
+ );
+ }
+ // Open the engagement URL in a new tab.
+ let { gBrowser } = this.chromeWindow;
+ gBrowser.selectedTab = gBrowser.addWebTab(
+ this.options.postAnswerUrl.toString(),
+ {
+ triggeringPrincipal:
+ Services.scriptSecurityManager.createNullPrincipal({}),
+ }
+ );
+ }
+
+ this.endTimerIfPresent("surveyEndTimer");
+
+ this.engagementCloseTimer = setTimeout(
+ () => this.close(),
+ NOTIFICATION_TIME
+ );
+ }
+
+ endTimerIfPresent(timerName) {
+ if (this[timerName]) {
+ clearTimeout(this[timerName]);
+ this[timerName] = null;
+ }
+ }
+
+ handleWindowClosed() {
+ this.maybeNotifyHeartbeat("WindowClosed");
+ }
+
+ close() {
+ this.notificationBox.removeNotification(this.notice);
+ }
+
+ cleanup() {
+ // Kill the timers which might call things after we've cleaned up:
+ this.endTimerIfPresent("surveyEndTimer");
+ this.endTimerIfPresent("engagementCloseTimer");
+ // remove listeners
+ this.chromeWindow.removeEventListener(
+ "SSWindowClosing",
+ this.handleWindowClosed
+ );
+ // remove references for garbage collection
+ this.chromeWindow = null;
+ this.notificationBox = null;
+ this.notice = null;
+ this.ratingContainer = null;
+ this.eventEmitter = null;
+ // Ensure we don't re-enter and release the CleanupManager's reference to us:
+ CleanupManager.removeCleanupHandler(this.close);
+ }
+};
diff --git a/toolkit/components/normandy/lib/LegacyHeartbeat.sys.mjs b/toolkit/components/normandy/lib/LegacyHeartbeat.sys.mjs
new file mode 100644
index 0000000000..501c9f70af
--- /dev/null
+++ b/toolkit/components/normandy/lib/LegacyHeartbeat.sys.mjs
@@ -0,0 +1,48 @@
+/* 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/. */
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+});
+
+const FEATURE_ID = "legacyHeartbeat";
+
+/**
+ * A bridge between Nimbus and Normandy's Heartbeat implementation.
+ */
+export const LegacyHeartbeat = {
+ getHeartbeatRecipe() {
+ const survey = lazy.NimbusFeatures.legacyHeartbeat.getVariable("survey");
+
+ if (typeof survey == "undefined") {
+ return null;
+ }
+
+ let isRollout = false;
+ let enrollmentData = lazy.ExperimentAPI.getExperimentMetaData({
+ featureId: FEATURE_ID,
+ });
+
+ if (!enrollmentData) {
+ enrollmentData = lazy.ExperimentAPI.getRolloutMetaData({
+ featureId: FEATURE_ID,
+ });
+ isRollout = true;
+ }
+
+ return {
+ id: `nimbus:${enrollmentData.slug}`,
+ name: `Nimbus legacyHeartbeat ${isRollout ? "rollout" : "experiment"} ${
+ enrollmentData.slug
+ }`,
+ action: "show-heartbeat",
+ arguments: survey,
+ capabilities: ["action.show-heartbeat"],
+ filter_expression: "true",
+ use_only_baseline_capabilities: true,
+ };
+ },
+};
diff --git a/toolkit/components/normandy/lib/LogManager.sys.mjs b/toolkit/components/normandy/lib/LogManager.sys.mjs
new file mode 100644
index 0000000000..9c1fd0f8fa
--- /dev/null
+++ b/toolkit/components/normandy/lib/LogManager.sys.mjs
@@ -0,0 +1,34 @@
+/* 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/. */
+
+import { Log } from "resource://gre/modules/Log.sys.mjs";
+
+const ROOT_LOGGER_NAME = "app.normandy";
+let rootLogger = null;
+
+export var LogManager = {
+ /**
+ * Configure the root logger for the Recipe Client. Must be called at
+ * least once before using any loggers created via getLogger.
+ * @param {Number} loggingLevel
+ * Logging level to use as defined in Log.sys.mjs
+ */
+ configure(loggingLevel) {
+ if (!rootLogger) {
+ rootLogger = Log.repository.getLogger(ROOT_LOGGER_NAME);
+ rootLogger.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
+ }
+ rootLogger.level = loggingLevel;
+ },
+
+ /**
+ * Obtain a named logger with the recipe client logger as its parent.
+ * @param {String} name
+ * Name of the logger to obtain.
+ * @return {Logger}
+ */
+ getLogger(name) {
+ return Log.repository.getLogger(`${ROOT_LOGGER_NAME}.${name}`);
+ },
+};
diff --git a/toolkit/components/normandy/lib/NormandyAddonManager.sys.mjs b/toolkit/components/normandy/lib/NormandyAddonManager.sys.mjs
new file mode 100644
index 0000000000..cf57662b2d
--- /dev/null
+++ b/toolkit/components/normandy/lib/NormandyAddonManager.sys.mjs
@@ -0,0 +1,112 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
+});
+
+export const NormandyAddonManager = {
+ async downloadAndInstall({
+ createError,
+ extensionDetails,
+ applyNormandyChanges,
+ undoNormandyChanges,
+ onInstallStarted,
+ reportError,
+ }) {
+ const { extension_id, hash, hash_algorithm, version, xpi } =
+ extensionDetails;
+
+ const downloadDeferred = lazy.PromiseUtils.defer();
+ const installDeferred = lazy.PromiseUtils.defer();
+
+ const install = await lazy.AddonManager.getInstallForURL(xpi, {
+ hash: `${hash_algorithm}:${hash}`,
+ telemetryInfo: { source: "internal" },
+ });
+
+ const listener = {
+ onInstallStarted(cbInstall) {
+ const versionMatches = cbInstall.addon.version === version;
+ const idMatches = cbInstall.addon.id === extension_id;
+
+ if (!versionMatches || !idMatches) {
+ installDeferred.reject(createError("metadata-mismatch"));
+ return false; // cancel the installation, server metadata does not match downloaded add-on
+ }
+
+ if (onInstallStarted) {
+ return onInstallStarted(cbInstall, installDeferred);
+ }
+
+ return true;
+ },
+
+ onDownloadFailed() {
+ downloadDeferred.reject(
+ createError("download-failure", {
+ detail: lazy.AddonManager.errorToString(install.error),
+ })
+ );
+ },
+
+ onDownloadEnded() {
+ downloadDeferred.resolve();
+ return false; // temporarily pause installation for Normandy bookkeeping
+ },
+
+ onInstallFailed() {
+ installDeferred.reject(
+ createError("install-failure", {
+ detail: lazy.AddonManager.errorToString(install.error),
+ })
+ );
+ },
+
+ onInstallEnded() {
+ installDeferred.resolve();
+ },
+ };
+
+ install.addListener(listener);
+
+ // Download the add-on
+ try {
+ install.install();
+ await downloadDeferred.promise;
+ } catch (err) {
+ reportError(err);
+ install.removeListener(listener);
+ throw err;
+ }
+
+ // Complete any book-keeping
+ try {
+ await applyNormandyChanges(install);
+ } catch (err) {
+ reportError(err);
+ install.removeListener(listener);
+ install.cancel();
+ throw err;
+ }
+
+ // Finish paused installation
+ try {
+ install.install();
+ await installDeferred.promise;
+ } catch (err) {
+ reportError(err);
+ install.removeListener(listener);
+ await undoNormandyChanges();
+ throw err;
+ }
+
+ install.removeListener(listener);
+
+ return [install.addon.id, install.addon.version];
+ },
+};
diff --git a/toolkit/components/normandy/lib/NormandyApi.sys.mjs b/toolkit/components/normandy/lib/NormandyApi.sys.mjs
new file mode 100644
index 0000000000..cbb1af4bb3
--- /dev/null
+++ b/toolkit/components/normandy/lib/NormandyApi.sys.mjs
@@ -0,0 +1,157 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "CanonicalJSON",
+ "resource://gre/modules/CanonicalJSON.jsm"
+);
+
+const prefs = Services.prefs.getBranch("app.normandy.");
+
+let indexPromise = null;
+
+function getChainRootIdentifier() {
+ const normandy_url = Services.prefs.getCharPref("app.normandy.api_url");
+ if (normandy_url == "https://normandy.cdn.mozilla.net/api/v1") {
+ return Ci.nsIContentSignatureVerifier.ContentSignatureProdRoot;
+ }
+ if (normandy_url.includes("stage.")) {
+ return Ci.nsIContentSignatureVerifier.ContentSignatureStageRoot;
+ }
+ if (normandy_url.includes("dev.")) {
+ return Ci.nsIContentSignatureVerifier.ContentSignatureDevRoot;
+ }
+ if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) {
+ return Ci.nsIX509CertDB.AppXPCShellRoot;
+ }
+ return Ci.nsIContentSignatureVerifier.ContentSignatureLocalRoot;
+}
+
+export var NormandyApi = {
+ InvalidSignatureError: class InvalidSignatureError extends Error {},
+
+ clearIndexCache() {
+ indexPromise = null;
+ },
+
+ get(endpoint, data) {
+ const url = new URL(endpoint);
+ if (data) {
+ for (const key of Object.keys(data)) {
+ url.searchParams.set(key, data[key]);
+ }
+ }
+ return fetch(url.href, {
+ method: "get",
+ headers: { Accept: "application/json" },
+ credentials: "omit",
+ });
+ },
+
+ absolutify(url) {
+ if (url.startsWith("http")) {
+ return url;
+ }
+ const apiBase = prefs.getCharPref("api_url");
+ const server = new URL(apiBase).origin;
+ if (url.startsWith("/")) {
+ return server + url;
+ }
+ throw new Error("Can't use relative urls");
+ },
+
+ async getApiUrl(name) {
+ if (!indexPromise) {
+ const apiBase = new URL(prefs.getCharPref("api_url"));
+ if (!apiBase.pathname.endsWith("/")) {
+ apiBase.pathname += "/";
+ }
+ indexPromise = this.get(apiBase.toString()).then(res => res.json());
+ }
+ const index = await indexPromise;
+ if (!(name in index)) {
+ throw new Error(`API endpoint with name "${name}" not found.`);
+ }
+ const url = index[name];
+ return this.absolutify(url);
+ },
+
+ /**
+ * Verify content signature, by serializing the specified `object` as
+ * canonical JSON, and using the Normandy signer verifier to check that
+ * it matches the signature specified in `signaturePayload`.
+ *
+ * If the the signature is not valid, an error is thrown. Otherwise this
+ * function returns undefined.
+ *
+ * @param {object|String} data The object (or string) to be checked
+ * @param {object} signaturePayload The signature information
+ * @param {String} signaturePayload.x5u The certificate chain URL
+ * @param {String} signaturePayload.signature base64 signature bytes
+ * @param {String} type The object type (eg. `"recipe"`, `"action"`)
+ * @returns {Promise<undefined>} If the signature is valid, this function returns without error
+ * @throws {NormandyApi.InvalidSignatureError} if signature is invalid.
+ */
+ async verifyObjectSignature(data, signaturePayload, type) {
+ const { signature, x5u } = signaturePayload;
+ const certChainResponse = await this.get(this.absolutify(x5u));
+ const certChain = await certChainResponse.text();
+ const builtSignature = `p384ecdsa=${signature}`;
+
+ const serialized =
+ typeof data == "string" ? data : lazy.CanonicalJSON.stringify(data);
+
+ const verifier = Cc[
+ "@mozilla.org/security/contentsignatureverifier;1"
+ ].createInstance(Ci.nsIContentSignatureVerifier);
+
+ let valid;
+ try {
+ valid = await verifier.asyncVerifyContentSignature(
+ serialized,
+ builtSignature,
+ certChain,
+ "normandy.content-signature.mozilla.org",
+ getChainRootIdentifier()
+ );
+ } catch (err) {
+ throw new NormandyApi.InvalidSignatureError(
+ `${type} signature validation failed: ${err}`
+ );
+ }
+
+ if (!valid) {
+ throw new NormandyApi.InvalidSignatureError(
+ `${type} signature is not valid`
+ );
+ }
+ },
+
+ /**
+ * Fetch metadata about this client determined by the server.
+ * @return {object} Metadata specified by the server
+ */
+ async classifyClient() {
+ const classifyClientUrl = await this.getApiUrl("classify-client");
+ const response = await this.get(classifyClientUrl);
+ const clientData = await response.json();
+ clientData.request_time = new Date(clientData.request_time);
+ return clientData;
+ },
+
+ /**
+ * Fetch details for an extension from the server.
+ * @param extensionId {integer} The ID of the extension to look up
+ * @resolves {Object}
+ */
+ async fetchExtensionDetails(extensionId) {
+ const baseUrl = await this.getApiUrl("extension-list");
+ const extensionDetailsUrl = `${baseUrl}${extensionId}/`;
+ const response = await this.get(extensionDetailsUrl);
+ return response.json();
+ },
+};
diff --git a/toolkit/components/normandy/lib/NormandyUtils.sys.mjs b/toolkit/components/normandy/lib/NormandyUtils.sys.mjs
new file mode 100644
index 0000000000..7db12c59b7
--- /dev/null
+++ b/toolkit/components/normandy/lib/NormandyUtils.sys.mjs
@@ -0,0 +1,10 @@
+/* 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/. */
+
+export var NormandyUtils = {
+ generateUuid() {
+ // Generate a random UUID, convert it to a string, and slice the braces off the ends.
+ return Services.uuid.generateUUID().toString().slice(1, -1);
+ },
+};
diff --git a/toolkit/components/normandy/lib/PrefUtils.sys.mjs b/toolkit/components/normandy/lib/PrefUtils.sys.mjs
new file mode 100644
index 0000000000..445744d88f
--- /dev/null
+++ b/toolkit/components/normandy/lib/PrefUtils.sys.mjs
@@ -0,0 +1,132 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ LogManager: "resource://normandy/lib/LogManager.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "log", () => {
+ return lazy.LogManager.getLogger("preference-experiments");
+});
+
+const kPrefBranches = {
+ user: Services.prefs,
+ default: Services.prefs.getDefaultBranch(""),
+};
+
+export var PrefUtils = {
+ /**
+ * Get a preference of any type from the named branch.
+ * @param {string} pref
+ * @param {object} [options]
+ * @param {"default"|"user"} [options.branchName="user"] One of "default" or "user"
+ * @param {string|boolean|integer|null} [options.defaultValue]
+ * The value to return if the preference does not exist. Defaults to null.
+ */
+ getPref(pref, { branch = "user", defaultValue = null } = {}) {
+ const branchObj = kPrefBranches[branch];
+ if (!branchObj) {
+ throw new this.UnexpectedPreferenceBranch(
+ `"${branch}" is not a valid preference branch`
+ );
+ }
+ const type = branchObj.getPrefType(pref);
+
+ try {
+ switch (type) {
+ case Services.prefs.PREF_BOOL: {
+ return branchObj.getBoolPref(pref);
+ }
+ case Services.prefs.PREF_STRING: {
+ return branchObj.getStringPref(pref);
+ }
+ case Services.prefs.PREF_INT: {
+ return branchObj.getIntPref(pref);
+ }
+ case Services.prefs.PREF_INVALID: {
+ return defaultValue;
+ }
+ }
+ } catch (e) {
+ if (branch === "default" && e.result === Cr.NS_ERROR_UNEXPECTED) {
+ // There is a value for the pref on the user branch but not on the default branch. This is ok.
+ return defaultValue;
+ }
+ // Unexpected error, re-throw it
+ throw e;
+ }
+
+ // If `type` isn't any of the above, throw an error. Don't do this in a
+ // default branch of switch so that error handling is easier.
+ throw new TypeError(`Unknown preference type (${type}) for ${pref}.`);
+ },
+
+ /**
+ * Set a preference on the named branch
+ * @param {string} pref
+ * @param {string|boolean|integer|null} value The value to set.
+ * @param {object} options
+ * @param {"user"|"default"} options.branchName The branch to make the change on.
+ */
+ setPref(pref, value, { branch = "user" } = {}) {
+ if (value === null) {
+ this.clearPref(pref, { branch });
+ return;
+ }
+ const branchObj = kPrefBranches[branch];
+ if (!branchObj) {
+ throw new this.UnexpectedPreferenceBranch(
+ `"${branch}" is not a valid preference branch`
+ );
+ }
+ switch (typeof value) {
+ case "boolean": {
+ branchObj.setBoolPref(pref, value);
+ break;
+ }
+ case "string": {
+ branchObj.setStringPref(pref, value);
+ break;
+ }
+ case "number": {
+ branchObj.setIntPref(pref, value);
+ break;
+ }
+ default: {
+ throw new TypeError(
+ `Unexpected value type (${typeof value}) for ${pref}.`
+ );
+ }
+ }
+ },
+
+ /**
+ * Remove a preference from a branch. Note that default branch preferences
+ * cannot effectively be cleared. If "default" is passed for a branch, an
+ * error will be logged and nothing else will happen.
+ *
+ * @param {string} pref
+ * @param {object} options
+ * @param {"user"|"default"} options.branchName The branch to clear
+ */
+ clearPref(pref, { branch = "user" } = {}) {
+ if (branch === "user") {
+ kPrefBranches.user.clearUserPref(pref);
+ } else if (branch === "default") {
+ lazy.log.warn(
+ `Cannot reset pref ${pref} on the default branch. Pref will be cleared at next restart.`
+ );
+ } else {
+ throw new this.UnexpectedPreferenceBranch(
+ `"${branch}" is not a valid preference branch`
+ );
+ }
+ },
+
+ UnexpectedPreferenceType: class extends Error {},
+ UnexpectedPreferenceBranch: class extends Error {},
+};
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();
+ },
+ },
+};
diff --git a/toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs b/toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs
new file mode 100644
index 0000000000..52204d2fa5
--- /dev/null
+++ b/toolkit/components/normandy/lib/PreferenceRollouts.sys.mjs
@@ -0,0 +1,350 @@
+/* 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/. */
+
+import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs",
+ IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs",
+ PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs",
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+ TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs",
+});
+
+const log = LogManager.getLogger("recipe-runner");
+
+/**
+ * PreferenceRollouts store info about an active or expired preference rollout.
+ * @typedef {object} PreferenceRollout
+ * @property {string} slug
+ * Unique slug of the experiment
+ * @property {string} state
+ * The current state of the rollout: "active", "rolled-back", "graduated".
+ * Active means that Normandy is actively managing therollout. Rolled-back
+ * means that the rollout was previously active, but has been rolled back for
+ * this user. Graduated means that the built-in default now matches the
+ * rollout value, and so Normandy is no longer managing the preference.
+ * @property {Array<PreferenceSpec>} preferences
+ * An array of preferences specifications involved in the rollout.
+ * @property {string} enrollmentId
+ * A random ID generated at time of enrollment. It should be included on all
+ * telemetry related to this rollout. It should not be re-used by other
+ * rollouts, or any other purpose. May be null on old rollouts.
+ */
+
+/**
+ * PreferenceSpec describe how a preference should change during a rollout.
+ * @typedef {object} PreferenceSpec
+ * @property {string} preferenceName
+ * The preference to modify.
+ * @property {string} preferenceType
+ * Type of the preference being set.
+ * @property {string|integer|boolean} value
+ * The value to change the preference to.
+ * @property {string|integer|boolean} previousValue
+ * The value the preference would have on the default branch if this rollout
+ * were not active.
+ */
+
+const STARTUP_PREFS_BRANCH = "app.normandy.startupRolloutPrefs.";
+const DB_NAME = "normandy-preference-rollout";
+const STORE_NAME = "preference-rollouts";
+const DB_VERSION = 1;
+
+/**
+ * Create a new connection to the database.
+ */
+function openDatabase() {
+ return lazy.IndexedDB.open(DB_NAME, DB_VERSION, db => {
+ db.createObjectStore(STORE_NAME, {
+ keyPath: "slug",
+ });
+ });
+}
+
+/**
+ * Cache the database connection so that it is shared among multiple operations.
+ */
+let databasePromise;
+function getDatabase() {
+ if (!databasePromise) {
+ databasePromise = openDatabase();
+ }
+ return databasePromise;
+}
+
+/**
+ * Get a transaction for interacting with the rollout store.
+ *
+ * @param {IDBDatabase} db
+ * @param {String} mode Either "readonly" or "readwrite"
+ *
+ * NOTE: Methods on the store returned by this function MUST be called
+ * synchronously, otherwise the transaction with the store will expire.
+ * This is why the helper takes a database as an argument; if we fetched the
+ * database in the helper directly, the helper would be async and the
+ * transaction would expire before methods on the store were called.
+ */
+function getStore(db, mode) {
+ if (!mode) {
+ throw new Error("mode is required");
+ }
+ return db.objectStore(STORE_NAME, mode);
+}
+
+export var PreferenceRollouts = {
+ STATE_ACTIVE: "active",
+ STATE_ROLLED_BACK: "rolled-back",
+ STATE_GRADUATED: "graduated",
+
+ // A set of rollout slugs that are obsolete based on the code in this build of
+ // Firefox. This may include things like the preference no longer being
+ // applicable, or the feature changing in such a way that Normandy's automatic
+ // graduation system cannot detect that the rollout should hand off to the
+ // built-in code.
+ GRADUATION_SET: new Set([
+ "pref-webrender-intel-rollout-70-release",
+ "bug-1703186-rollout-http3-support-release-88-89",
+ "rollout-doh-nightly-rollout-to-all-us-desktop-users-nightly-74-80-bug-1613481",
+ "rollout-doh-beta-rollout-to-all-us-desktop-users-v2-beta-74-80-bug-1613489",
+ "rollout-doh-us-staged-rollout-to-all-us-desktop-users-release-73-77-bug-1586331",
+ "bug-1648229-rollout-comcast-steering-rollout-release-78-80",
+ "bug-1732206-rollout-fission-release-rollout-release-94-95",
+ "bug-1745237-rollout-fission-beta-96-97-rollout-beta-96-97",
+ "bug-1750601-rollout-doh-steering-in-canada-staggered-starting-for-release-97-98",
+ "bug-1758988-rollout-doh-enablment-to-new-countries-staggered-st-release-98-100",
+ "bug-1758818-rollout-enabling-doh-in-new-countries-staggered-sta-release-98-100",
+ ]),
+
+ /**
+ * Update the rollout database with changes that happened during early startup.
+ * @param {object} rolloutPrefsChanged Map from pref name to previous pref value
+ */
+ async recordOriginalValues(originalPreferences) {
+ for (const rollout of await this.getAllActive()) {
+ let shouldSaveRollout = false;
+
+ // Count the number of preferences in this rollout that are now redundant.
+ let prefMatchingDefaultCount = 0;
+
+ for (const prefSpec of rollout.preferences) {
+ const builtInDefault = originalPreferences[prefSpec.preferenceName];
+ if (prefSpec.value === builtInDefault) {
+ prefMatchingDefaultCount++;
+ }
+ // Store the current built-in default. That way, if the preference is
+ // rolled back during the current session (ie, until the browser is
+ // shut down), the correct value will be used.
+ if (prefSpec.previousValue !== builtInDefault) {
+ prefSpec.previousValue = builtInDefault;
+ shouldSaveRollout = true;
+ }
+ }
+
+ if (prefMatchingDefaultCount === rollout.preferences.length) {
+ // Firefox's builtin defaults have caught up to the rollout, making all
+ // of the rollout's changes redundant, so graduate the rollout.
+ await this.graduate(rollout, "all-prefs-match");
+ // `this.graduate` writes the rollout to the db, so we don't need to do it anymore.
+ shouldSaveRollout = false;
+ }
+
+ if (shouldSaveRollout) {
+ const db = await getDatabase();
+ await getStore(db, "readwrite").put(rollout);
+ }
+ }
+ },
+
+ async init() {
+ lazy.CleanupManager.addCleanupHandler(() => this.saveStartupPrefs());
+
+ for (const rollout of await this.getAllActive()) {
+ if (this.GRADUATION_SET.has(rollout.slug)) {
+ await this.graduate(rollout, "in-graduation-set");
+ continue;
+ }
+ lazy.TelemetryEnvironment.setExperimentActive(
+ rollout.slug,
+ rollout.state,
+ {
+ type: "normandy-prefrollout",
+ enrollmentId:
+ rollout.enrollmentId ||
+ lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ }
+ );
+ }
+ },
+
+ /** When Telemetry is disabled, clear all identifiers from the stored rollouts. */
+ async onTelemetryDisabled() {
+ const rollouts = await this.getAll();
+ for (const rollout of rollouts) {
+ rollout.enrollmentId = lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER;
+ }
+ await this.updateMany(rollouts);
+ },
+
+ /**
+ * Test wrapper that temporarily replaces the stored rollout data with fake
+ * data for testing.
+ */
+ withTestMock({
+ graduationSet = new Set(),
+ rollouts: prefRollouts = [],
+ } = {}) {
+ return testFunction => {
+ return async args => {
+ let db = await getDatabase();
+ const oldData = await getStore(db, "readonly").getAll();
+ await getStore(db, "readwrite").clear();
+ await Promise.all(prefRollouts.map(r => this.add(r)));
+ const oldGraduationSet = this.GRADUATION_SET;
+ this.GRADUATION_SET = graduationSet;
+
+ try {
+ await testFunction({ ...args, prefRollouts });
+ } finally {
+ this.GRADUATION_SET = oldGraduationSet;
+ db = await getDatabase();
+ await getStore(db, "readwrite").clear();
+ const store = getStore(db, "readwrite");
+ await Promise.all(oldData.map(d => store.add(d)));
+ }
+ };
+ };
+ },
+
+ /**
+ * Add a new rollout
+ * @param {PreferenceRollout} rollout
+ */
+ async add(rollout) {
+ if (!rollout.enrollmentId) {
+ throw new Error("Rollout must have an enrollment ID");
+ }
+ const db = await getDatabase();
+ return getStore(db, "readwrite").add(rollout);
+ },
+
+ /**
+ * Update an existing rollout
+ * @param {PreferenceRollout} rollout
+ * @throws If a matching rollout does not exist.
+ */
+ async update(rollout) {
+ if (!(await this.has(rollout.slug))) {
+ throw new Error(
+ `Tried to update ${rollout.slug}, but it doesn't already exist.`
+ );
+ }
+ const db = await getDatabase();
+ return getStore(db, "readwrite").put(rollout);
+ },
+
+ /**
+ * Update many existing rollouts. More efficient than calling `update` many
+ * times in a row.
+ * @param {Array<PreferenceRollout>} rollouts
+ * @throws If any of the passed rollouts have a slug that doesn't exist in the database already.
+ */
+ async updateMany(rollouts) {
+ // Don't touch the database if there is nothing to do
+ if (!rollouts.length) {
+ return;
+ }
+
+ // Both of the below operations use .map() instead of a normal loop becaues
+ // once we get the object store, we can't let it expire by spinning the
+ // event loop. This approach queues up all the interactions with the store
+ // immediately, preventing it from expiring too soon.
+
+ const db = await getDatabase();
+ let store = await getStore(db, "readonly");
+ await Promise.all(
+ rollouts.map(async ({ slug }) => {
+ let existingRollout = await store.get(slug);
+ if (!existingRollout) {
+ throw new Error(`Tried to update ${slug}, but it doesn't exist.`);
+ }
+ })
+ );
+
+ // awaiting spun the event loop, so the store is now invalid. Get a new
+ // store. This is also a chance to get it in readwrite mode.
+ store = await getStore(db, "readwrite");
+ await Promise.all(rollouts.map(rollout => store.put(rollout)));
+ },
+
+ /**
+ * Test whether there is a rollout in storage with the given slug.
+ * @param {string} slug
+ * @returns {boolean}
+ */
+ async has(slug) {
+ const db = await getDatabase();
+ const rollout = await getStore(db, "readonly").get(slug);
+ return !!rollout;
+ },
+
+ /**
+ * Get a rollout by slug
+ * @param {string} slug
+ */
+ async get(slug) {
+ const db = await getDatabase();
+ return getStore(db, "readonly").get(slug);
+ },
+
+ /** Get all rollouts in the database. */
+ async getAll() {
+ const db = await getDatabase();
+ return getStore(db, "readonly").getAll();
+ },
+
+ /** Get all rollouts in the "active" state. */
+ async getAllActive() {
+ const rollouts = await this.getAll();
+ return rollouts.filter(rollout => rollout.state === this.STATE_ACTIVE);
+ },
+
+ /**
+ * Save in-progress preference rollouts in a sub-branch of the normandy prefs.
+ * On startup, we read these to set the rollout values.
+ */
+ async saveStartupPrefs() {
+ const prefBranch = Services.prefs.getBranch(STARTUP_PREFS_BRANCH);
+ for (const pref of prefBranch.getChildList("")) {
+ prefBranch.clearUserPref(pref);
+ }
+
+ for (const rollout of await this.getAllActive()) {
+ for (const prefSpec of rollout.preferences) {
+ lazy.PrefUtils.setPref(
+ STARTUP_PREFS_BRANCH + prefSpec.preferenceName,
+ prefSpec.value
+ );
+ }
+ }
+ },
+
+ async graduate(rollout, reason) {
+ log.debug(`Graduating rollout: ${rollout.slug}`);
+ rollout.state = this.STATE_GRADUATED;
+ const db = await getDatabase();
+ await getStore(db, "readwrite").put(rollout);
+ lazy.TelemetryEvents.sendEvent(
+ "graduate",
+ "preference_rollout",
+ rollout.slug,
+ {
+ reason,
+ enrollmentId:
+ rollout.enrollmentId || lazy.TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ }
+ );
+ },
+};
diff --git a/toolkit/components/normandy/lib/RecipeRunner.sys.mjs b/toolkit/components/normandy/lib/RecipeRunner.sys.mjs
new file mode 100644
index 0000000000..ac55328a01
--- /dev/null
+++ b/toolkit/components/normandy/lib/RecipeRunner.sys.mjs
@@ -0,0 +1,645 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { LogManager } from "resource://normandy/lib/LogManager.sys.mjs";
+import { PromiseUtils } from "resource://gre/modules/PromiseUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "timerManager",
+ "@mozilla.org/updates/timer-manager;1",
+ "nsIUpdateTimerManager"
+);
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ActionsManager: "resource://normandy/lib/ActionsManager.sys.mjs",
+ BaseAction: "resource://normandy/actions/BaseAction.sys.mjs",
+ CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs",
+ ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
+ FilterExpressions:
+ "resource://gre/modules/components-utils/FilterExpressions.sys.mjs",
+ LegacyHeartbeat: "resource://normandy/lib/LegacyHeartbeat.sys.mjs",
+ Normandy: "resource://normandy/Normandy.sys.mjs",
+ NormandyApi: "resource://normandy/lib/NormandyApi.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ RemoteSettingsClient:
+ "resource://services-settings/RemoteSettingsClient.sys.mjs",
+ Storage: "resource://normandy/lib/Storage.sys.mjs",
+ TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs",
+ Uptake: "resource://normandy/lib/Uptake.sys.mjs",
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+const log = LogManager.getLogger("recipe-runner");
+const TIMER_NAME = "recipe-client-addon-run";
+const REMOTE_SETTINGS_COLLECTION = "normandy-recipes-capabilities";
+const PREF_CHANGED_TOPIC = "nsPref:changed";
+
+const RUN_INTERVAL_PREF = "app.normandy.run_interval_seconds";
+const FIRST_RUN_PREF = "app.normandy.first_run";
+const SHIELD_ENABLED_PREF = "app.normandy.enabled";
+const DEV_MODE_PREF = "app.normandy.dev_mode";
+const API_URL_PREF = "app.normandy.api_url";
+const LAZY_CLASSIFY_PREF = "app.normandy.experiments.lazy_classify";
+const ONSYNC_SKEW_SEC_PREF = "app.normandy.onsync_skew_sec";
+
+// Timer last update preference.
+// see https://searchfox.org/mozilla-central/rev/11cfa0462/toolkit/components/timermanager/UpdateTimerManager.jsm#8
+const TIMER_LAST_UPDATE_PREF = `app.update.lastUpdateTime.${TIMER_NAME}`;
+
+const PREFS_TO_WATCH = [RUN_INTERVAL_PREF, SHIELD_ENABLED_PREF, API_URL_PREF];
+
+XPCOMUtils.defineLazyGetter(lazy, "gRemoteSettingsClient", () => {
+ return lazy.RemoteSettings(REMOTE_SETTINGS_COLLECTION);
+});
+
+/**
+ * cacheProxy returns an object Proxy that will memoize properties of the target.
+ */
+function cacheProxy(target) {
+ const cache = new Map();
+ return new Proxy(target, {
+ get(target, prop, receiver) {
+ if (!cache.has(prop)) {
+ cache.set(prop, target[prop]);
+ }
+ return cache.get(prop);
+ },
+ set(target, prop, value, receiver) {
+ cache.set(prop, value);
+ return true;
+ },
+ has(target, prop) {
+ return cache.has(prop) || prop in target;
+ },
+ });
+}
+
+export var RecipeRunner = {
+ initializedPromise: PromiseUtils.defer(),
+
+ async init() {
+ this.running = false;
+ this.enabled = null;
+ this.loadFromRemoteSettings = false;
+ this._syncSkewTimeout = null;
+
+ this.checkPrefs(); // sets this.enabled
+ this.watchPrefs();
+ this.setUpRemoteSettings();
+
+ // Here "first run" means the first run this profile has ever done. This
+ // preference is set to true at the end of this function, and never reset to
+ // false.
+ const firstRun = Services.prefs.getBoolPref(FIRST_RUN_PREF, true);
+
+ // If we've seen a build ID from a previous run that doesn't match the
+ // current build ID, run immediately. This is probably an upgrade or
+ // downgrade, which may cause recipe eligibility to change.
+ let hasNewBuildID =
+ Services.appinfo.lastAppBuildID != null &&
+ Services.appinfo.lastAppBuildID != Services.appinfo.appBuildID;
+
+ // Dev mode is a mode used for development and QA that bypasses the normal
+ // timer function of Normandy, to make testing more convenient.
+ const devMode = Services.prefs.getBoolPref(DEV_MODE_PREF, false);
+
+ if (this.enabled && (devMode || firstRun || hasNewBuildID)) {
+ // In dev mode, if remote settings is enabled, force an immediate sync
+ // before running. This ensures that the latest data is used for testing.
+ // This is not needed for the first run case, because remote settings
+ // already handles empty collections well.
+ if (devMode) {
+ await lazy.gRemoteSettingsClient.sync();
+ }
+ let trigger;
+ if (devMode) {
+ trigger = "devMode";
+ } else if (firstRun) {
+ trigger = "firstRun";
+ } else if (hasNewBuildID) {
+ trigger = "newBuildID";
+ }
+
+ await this.run({ trigger });
+ }
+
+ // Update the firstRun pref, to indicate that Normandy has run at least once
+ // on this profile.
+ if (firstRun) {
+ Services.prefs.setBoolPref(FIRST_RUN_PREF, false);
+ }
+
+ this.initializedPromise.resolve();
+ },
+
+ enable() {
+ if (this.enabled) {
+ return;
+ }
+ this.registerTimer();
+ this.enabled = true;
+ },
+
+ disable() {
+ if (this.enabled) {
+ this.unregisterTimer();
+ }
+ // this.enabled may be null, so always set it to false
+ this.enabled = false;
+ },
+
+ /** Watch for prefs to change, and call this.observer when they do */
+ watchPrefs() {
+ for (const pref of PREFS_TO_WATCH) {
+ Services.prefs.addObserver(pref, this);
+ }
+
+ lazy.CleanupManager.addCleanupHandler(this.unwatchPrefs.bind(this));
+ },
+
+ unwatchPrefs() {
+ for (const pref of PREFS_TO_WATCH) {
+ Services.prefs.removeObserver(pref, this);
+ }
+ },
+
+ /** When prefs change, this is fired */
+ observe(subject, topic, data) {
+ switch (topic) {
+ case PREF_CHANGED_TOPIC: {
+ const prefName = data;
+
+ switch (prefName) {
+ case RUN_INTERVAL_PREF:
+ this.updateRunInterval();
+ break;
+
+ // explicit fall-through
+ case SHIELD_ENABLED_PREF:
+ case API_URL_PREF:
+ this.checkPrefs();
+ break;
+
+ default:
+ log.debug(
+ `Observer fired with unexpected pref change: ${prefName}`
+ );
+ }
+
+ break;
+ }
+ }
+ },
+
+ checkPrefs() {
+ if (!Services.prefs.getBoolPref(SHIELD_ENABLED_PREF)) {
+ log.debug(
+ `Disabling Shield because ${SHIELD_ENABLED_PREF} is set to false`
+ );
+ this.disable();
+ return;
+ }
+
+ const apiUrl = Services.prefs.getCharPref(API_URL_PREF);
+ if (!apiUrl) {
+ log.warn(`Disabling Shield because ${API_URL_PREF} is not set.`);
+ this.disable();
+ return;
+ }
+ if (!apiUrl.startsWith("https://")) {
+ log.warn(
+ `Disabling Shield because ${API_URL_PREF} is not an HTTPS url: ${apiUrl}.`
+ );
+ this.disable();
+ return;
+ }
+
+ log.debug(`Enabling Shield`);
+ this.enable();
+ },
+
+ registerTimer() {
+ this.updateRunInterval();
+ lazy.CleanupManager.addCleanupHandler(() =>
+ lazy.timerManager.unregisterTimer(TIMER_NAME)
+ );
+ },
+
+ unregisterTimer() {
+ lazy.timerManager.unregisterTimer(TIMER_NAME);
+ },
+
+ setUpRemoteSettings() {
+ if (this._alreadySetUpRemoteSettings) {
+ return;
+ }
+ this._alreadySetUpRemoteSettings = true;
+
+ if (!this._onSync) {
+ this._onSync = this.onSync.bind(this);
+ }
+ lazy.gRemoteSettingsClient.on("sync", this._onSync);
+
+ lazy.CleanupManager.addCleanupHandler(() => {
+ lazy.gRemoteSettingsClient.off("sync", this._onSync);
+ this._alreadySetUpRemoteSettings = false;
+ });
+ },
+
+ /** Called when our Remote Settings collection is updated */
+ async onSync() {
+ if (!this.enabled) {
+ return;
+ }
+
+ // Delay the Normandy run by a random amount, determined by preference.
+ // This helps alleviate server load, since we don't have a thundering
+ // herd of users trying to update all at once.
+ if (this._syncSkewTimeout) {
+ lazy.clearTimeout(this._syncSkewTimeout);
+ }
+ let minSkewSec = 1; // this is primarily is to avoid race conditions in tests
+ let maxSkewSec = Services.prefs.getIntPref(ONSYNC_SKEW_SEC_PREF, 0);
+ if (maxSkewSec >= minSkewSec) {
+ let skewMillis =
+ (minSkewSec + Math.random() * (maxSkewSec - minSkewSec)) * 1000;
+ log.debug(
+ `Delaying on-sync Normandy run for ${Math.floor(
+ skewMillis / 1000
+ )} seconds`
+ );
+ this._syncSkewTimeout = lazy.setTimeout(
+ () => this.run({ trigger: "sync" }),
+ skewMillis
+ );
+ } else {
+ log.debug(`Not skewing on-sync Normandy run`);
+ await this.run({ trigger: "sync" });
+ }
+ },
+
+ updateRunInterval() {
+ // Run once every `runInterval` wall-clock seconds. This is managed by setting a "last ran"
+ // timestamp, and running if it is more than `runInterval` seconds ago. Even with very short
+ // intervals, the timer will only fire at most once every few minutes.
+ const runInterval = Services.prefs.getIntPref(RUN_INTERVAL_PREF);
+ lazy.timerManager.registerTimer(TIMER_NAME, () => this.run(), runInterval);
+ },
+
+ async run({ trigger = "timer" } = {}) {
+ if (this.running) {
+ // Do nothing if already running.
+ return;
+ }
+ this.running = true;
+
+ await lazy.Normandy.defaultPrefsHaveBeenApplied.promise;
+
+ try {
+ this.running = true;
+ Services.obs.notifyObservers(null, "recipe-runner:start");
+
+ if (this._syncSkewTimeout) {
+ lazy.clearTimeout(this._syncSkewTimeout);
+ this._syncSkewTimeout = null;
+ }
+
+ this.clearCaches();
+ // Unless lazy classification is enabled, prep the classify cache.
+ if (!Services.prefs.getBoolPref(LAZY_CLASSIFY_PREF, false)) {
+ try {
+ await lazy.ClientEnvironment.getClientClassification();
+ } catch (err) {
+ // Try to go on without this data; the filter expressions will
+ // gracefully fail without this info if they need it.
+ }
+ }
+
+ // Fetch recipes before execution in case we fail and exit early.
+ let recipesAndSignatures;
+ try {
+ recipesAndSignatures = await lazy.gRemoteSettingsClient.get({
+ // Do not return an empty list if an error occurs.
+ emptyListFallback: false,
+ });
+ } catch (e) {
+ await lazy.Uptake.reportRunner(lazy.Uptake.RUNNER_SERVER_ERROR);
+ return;
+ }
+
+ const actionsManager = new lazy.ActionsManager();
+
+ const legacyHeartbeat = lazy.LegacyHeartbeat.getHeartbeatRecipe();
+ const noRecipes =
+ !recipesAndSignatures.length && legacyHeartbeat === null;
+
+ // Execute recipes, if we have any.
+ if (noRecipes) {
+ log.debug("No recipes to execute");
+ } else {
+ for (const { recipe, signature } of recipesAndSignatures) {
+ let suitability = await this.getRecipeSuitability(recipe, signature);
+ await actionsManager.processRecipe(recipe, suitability);
+ }
+
+ if (legacyHeartbeat !== null) {
+ await actionsManager.processRecipe(
+ legacyHeartbeat,
+ lazy.BaseAction.suitability.FILTER_MATCH
+ );
+ }
+ }
+
+ await actionsManager.finalize({ noRecipes });
+
+ await lazy.Uptake.reportRunner(lazy.Uptake.RUNNER_SUCCESS);
+ Services.obs.notifyObservers(null, "recipe-runner:end");
+ } finally {
+ this.running = false;
+ if (trigger != "timer") {
+ // `run()` was executed outside the scheduled timer.
+ // Update the last time it ran to make sure it is rescheduled later.
+ const lastUpdateTime = Math.round(Date.now() / 1000);
+ Services.prefs.setIntPref(TIMER_LAST_UPDATE_PREF, lastUpdateTime);
+ }
+ }
+ },
+
+ getFilterContext(recipe) {
+ const environment = cacheProxy(lazy.ClientEnvironment);
+ environment.recipe = {
+ id: recipe.id,
+ arguments: recipe.arguments,
+ };
+ return {
+ env: environment,
+ // Backwards compatibility -- see bug 1477255.
+ normandy: environment,
+ };
+ },
+
+ /**
+ * Return the set of capabilities this runner has.
+ *
+ * This is used to pre-filter recipes that aren't compatible with this client.
+ *
+ * @returns {Set<String>} The capabilities supported by this client.
+ */
+ getCapabilities() {
+ let capabilities = new Set([
+ "capabilities-v1", // The initial version of the capabilities system.
+ ]);
+
+ // Get capabilities from ActionsManager.
+ for (const actionCapability of lazy.ActionsManager.getCapabilities()) {
+ capabilities.add(actionCapability);
+ }
+
+ // Add a capability for each transform available to JEXL.
+ for (const transform of lazy.FilterExpressions.getAvailableTransforms()) {
+ capabilities.add(`jexl.transform.${transform}`);
+ }
+
+ // Add two capabilities for each top level key available in the context: one
+ // for the `normandy.` namespace, and another for the `env.` namespace.
+ capabilities.add("jexl.context.env");
+ capabilities.add("jexl.context.normandy");
+ let env = lazy.ClientEnvironment;
+ while (env && env.name) {
+ // Walk up the class chain for ClientEnvironment, collecting applicable
+ // properties as we go. Stop when we get to an unnamed object, which is
+ // usually just a plain function is the super class of a class that doesn't
+ // extend anything. Also stop if we get to an undefined object, just in
+ // case.
+ for (const [name, descriptor] of Object.entries(
+ Object.getOwnPropertyDescriptors(env)
+ )) {
+ // All of the properties we are looking for are are static getters (so
+ // will have a truthy `get` property) and are defined on the class, so
+ // will be configurable
+ if (descriptor.configurable && descriptor.get) {
+ capabilities.add(`jexl.context.env.${name}`);
+ capabilities.add(`jexl.context.normandy.${name}`);
+ }
+ }
+ // Check for the next parent
+ env = Object.getPrototypeOf(env);
+ }
+
+ return capabilities;
+ },
+
+ /**
+ * Decide if a recipe is suitable to run, and returns a value from
+ * `BaseAction.suitability`.
+ *
+ * This checks several things in order:
+ * - recipe signature
+ * - capabilities
+ * - filter expression
+ *
+ * If the provided signature does not match the provided recipe, then
+ * `SIGNATURE_ERROR` is returned. Recipes with this suitability should not be
+ * trusted. These recipes are included so that temporary signature errors on
+ * the server can be handled intelligently by actions.
+ *
+ * Capabilities are a simple set of strings in the recipe. If the Normandy
+ * client has all of the capabilities listed, then execution continues. If
+ * not, then `CAPABILITY_MISMATCH` is returned. Recipes with this suitability
+ * should be considered incompatible and treated with caution.
+ *
+ * If the capabilities check passes, then the filter expression is evaluated
+ * against the current environment. The result of the expression is cast to a
+ * boolean. If it is true, then `FILTER_MATCH` is returned. If not, then
+ * `FILTER_MISMATCH` is returned.
+ *
+ * If there is an error while evaluating the recipe's filter, `FILTER_ERROR`
+ * is returned instead.
+ *
+ * @param {object} recipe
+ * @param {object} signature
+ * @param {string} recipe.filter_expression The expression to evaluate against the environment.
+ * @param {Set<String>} runnerCapabilities The capabilities provided by this runner.
+ * @return {Promise<BaseAction.suitability>} The recipe's suitability
+ */
+ async getRecipeSuitability(recipe, signature) {
+ let generator = this.getAllSuitabilities(recipe, signature);
+ // For our purposes, only the first suitability matters, so pull the first
+ // value out of the async generator. This additionally guarantees if we fail
+ // a security or compatibility check, we won't continue to run other checks,
+ // which is good for the general case of running recipes.
+ let { value: suitability } = await generator.next();
+ switch (suitability) {
+ case lazy.BaseAction.suitability.SIGNATURE_ERROR: {
+ await lazy.Uptake.reportRecipe(
+ recipe,
+ lazy.Uptake.RECIPE_INVALID_SIGNATURE
+ );
+ break;
+ }
+
+ case lazy.BaseAction.suitability.CAPABILITIES_MISMATCH: {
+ await lazy.Uptake.reportRecipe(
+ recipe,
+ lazy.Uptake.RECIPE_INCOMPATIBLE_CAPABILITIES
+ );
+ break;
+ }
+
+ case lazy.BaseAction.suitability.FILTER_MATCH: {
+ // No telemetry needs to be sent for this right now.
+ break;
+ }
+
+ case lazy.BaseAction.suitability.FILTER_MISMATCH: {
+ // This represents a terminal state for the given recipe, so
+ // report its outcome. Others are reported when executed in
+ // ActionsManager.
+ await lazy.Uptake.reportRecipe(
+ recipe,
+ lazy.Uptake.RECIPE_DIDNT_MATCH_FILTER
+ );
+ break;
+ }
+
+ case lazy.BaseAction.suitability.FILTER_ERROR: {
+ await lazy.Uptake.reportRecipe(
+ recipe,
+ lazy.Uptake.RECIPE_FILTER_BROKEN
+ );
+ break;
+ }
+
+ case lazy.BaseAction.suitability.ARGUMENTS_INVALID: {
+ // This shouldn't ever occur, since the arguments schema is checked by
+ // BaseAction itself.
+ throw new Error(`Shouldn't get ${suitability} in RecipeRunner`);
+ }
+
+ default: {
+ throw new Error(`Unexpected recipe suitability ${suitability}`);
+ }
+ }
+
+ return suitability;
+ },
+
+ /**
+ * Some uses cases, such as Normandy Devtools, want the status of all
+ * suitabilities, not only the most important one. This checks the cases of
+ * suitabilities in order from most blocking to least blocking. The first
+ * yielded is the "primary" suitability to pass on to actions.
+ *
+ * If this function yields only [FILTER_MATCH], then the recipe fully matches
+ * and should be executed. If any other statuses are yielded, then the recipe
+ * should not be executed as normal.
+ *
+ * This is a generator so that the execution can be halted as needed. For
+ * example, after receiving a signature error, a caller can stop advancing
+ * the iterator to avoid exposing the browser to unneeded risk.
+ */
+ async *getAllSuitabilities(recipe, signature) {
+ try {
+ await lazy.NormandyApi.verifyObjectSignature(recipe, signature, "recipe");
+ } catch (e) {
+ yield lazy.BaseAction.suitability.SIGNATURE_ERROR;
+ }
+
+ const runnerCapabilities = this.getCapabilities();
+ if (Array.isArray(recipe.capabilities)) {
+ for (const recipeCapability of recipe.capabilities) {
+ if (!runnerCapabilities.has(recipeCapability)) {
+ log.debug(
+ `Recipe "${recipe.name}" requires unknown capabilities. ` +
+ `Recipe capabilities: ${JSON.stringify(recipe.capabilities)}. ` +
+ `Local runner capabilities: ${JSON.stringify(
+ Array.from(runnerCapabilities)
+ )}`
+ );
+ yield lazy.BaseAction.suitability.CAPABILITIES_MISMATCH;
+ }
+ }
+ }
+
+ const context = this.getFilterContext(recipe);
+ const targetingContext = new lazy.TargetingContext();
+ try {
+ if (await targetingContext.eval(recipe.filter_expression, context)) {
+ yield lazy.BaseAction.suitability.FILTER_MATCH;
+ } else {
+ yield lazy.BaseAction.suitability.FILTER_MISMATCH;
+ }
+ } catch (err) {
+ log.error(
+ `Error checking filter for "${recipe.name}". Filter: [${recipe.filter_expression}]. Error: "${err}"`
+ );
+ yield lazy.BaseAction.suitability.FILTER_ERROR;
+ }
+ },
+
+ /**
+ * Clear all caches of systems used by RecipeRunner, in preparation
+ * for a clean run.
+ */
+ clearCaches() {
+ lazy.ClientEnvironment.clearClassifyCache();
+ lazy.NormandyApi.clearIndexCache();
+ },
+
+ /**
+ * Clear out cached state and fetch/execute recipes from the given
+ * API url. This is used mainly by the mock-recipe-server JS that is
+ * executed in the browser console.
+ */
+ async testRun(baseApiUrl) {
+ const oldApiUrl = Services.prefs.getCharPref(API_URL_PREF);
+ Services.prefs.setCharPref(API_URL_PREF, baseApiUrl);
+
+ try {
+ lazy.Storage.clearAllStorage();
+ this.clearCaches();
+ await this.run();
+ } finally {
+ Services.prefs.setCharPref(API_URL_PREF, oldApiUrl);
+ this.clearCaches();
+ }
+ },
+
+ /**
+ * Offer a mechanism to get access to the lazily-instantiated
+ * gRemoteSettingsClient, because if someone instantiates it
+ * themselves, it won't have the options we provided in this module,
+ * and it will prevent instantiation by this module later.
+ *
+ * This is only meant to be used in testing, where it is a
+ * convenient hook to store data in the underlying remote-settings
+ * collection.
+ */
+ get _remoteSettingsClientForTesting() {
+ return lazy.gRemoteSettingsClient;
+ },
+
+ migrations: {
+ /**
+ * Delete the now-unused collection of recipes, since we are using the
+ * "normandy-recipes-capabilities" collection now.
+ */
+ async migration01RemoveOldRecipesCollection() {
+ // Don't bother to open IDB and clear on clean profiles.
+ const lastCheckPref =
+ "services.settings.main.normandy-recipes.last_check";
+ if (Services.prefs.prefHasUserValue(lastCheckPref)) {
+ // We instantiate a client, but it won't take part of sync.
+ const client = new lazy.RemoteSettingsClient("normandy-recipes");
+ await client.db.clear();
+ Services.prefs.clearUserPref(lastCheckPref);
+ }
+ },
+ },
+};
diff --git a/toolkit/components/normandy/lib/ShieldPreferences.sys.mjs b/toolkit/components/normandy/lib/ShieldPreferences.sys.mjs
new file mode 100644
index 0000000000..730febe975
--- /dev/null
+++ b/toolkit/components/normandy/lib/ShieldPreferences.sys.mjs
@@ -0,0 +1,78 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ BranchedAddonStudyAction:
+ "resource://normandy/actions/BranchedAddonStudyAction.sys.mjs",
+ AddonStudies: "resource://normandy/lib/AddonStudies.sys.mjs",
+ CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs",
+ PreferenceExperiments:
+ "resource://normandy/lib/PreferenceExperiments.sys.mjs",
+});
+
+const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed"; // from modules/libpref/nsIPrefBranch.idl
+const PREF_OPT_OUT_STUDIES_ENABLED = "app.shield.optoutstudies.enabled";
+
+/**
+ * Handles Shield-specific preferences, including their UI.
+ */
+export var ShieldPreferences = {
+ init() {
+ // Watch for changes to the Opt-out pref
+ Services.prefs.addObserver(PREF_OPT_OUT_STUDIES_ENABLED, this);
+
+ lazy.CleanupManager.addCleanupHandler(() => {
+ Services.prefs.removeObserver(PREF_OPT_OUT_STUDIES_ENABLED, this);
+ });
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case NS_PREFBRANCH_PREFCHANGE_TOPIC_ID:
+ this.observePrefChange(data);
+ break;
+ }
+ },
+
+ async observePrefChange(prefName) {
+ let prefValue;
+ switch (prefName) {
+ // If the opt-out pref changes to be false, disable all current studies.
+ case PREF_OPT_OUT_STUDIES_ENABLED: {
+ prefValue = Services.prefs.getBoolPref(PREF_OPT_OUT_STUDIES_ENABLED);
+ if (!prefValue) {
+ const action = new lazy.BranchedAddonStudyAction();
+ const studyPromises = (await lazy.AddonStudies.getAll()).map(
+ study => {
+ if (!study.active) {
+ return null;
+ }
+ return action.unenroll(study.recipeId, "general-opt-out");
+ }
+ );
+
+ const experimentPromises = (
+ await lazy.PreferenceExperiments.getAll()
+ ).map(experiment => {
+ if (experiment.expired) {
+ return null;
+ }
+ return lazy.PreferenceExperiments.stop(experiment.slug, {
+ reason: "general-opt-out",
+ caller: "observePrefChange::general-opt-out",
+ });
+ });
+
+ const allPromises = studyPromises
+ .concat(experimentPromises)
+ .map(p => p && p.catch(err => console.error(err)));
+ await Promise.all(allPromises);
+ }
+ break;
+ }
+ }
+ },
+};
diff --git a/toolkit/components/normandy/lib/Storage.sys.mjs b/toolkit/components/normandy/lib/Storage.sys.mjs
new file mode 100644
index 0000000000..8d563db24c
--- /dev/null
+++ b/toolkit/components/normandy/lib/Storage.sys.mjs
@@ -0,0 +1,90 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
+});
+
+// Lazy-load JSON file that backs Storage instances.
+XPCOMUtils.defineLazyGetter(lazy, "lazyStore", async function () {
+ const path = PathUtils.join(
+ PathUtils.profileDir,
+ "shield-recipe-client.json"
+ );
+ const store = new lazy.JSONFile({ path });
+ await store.load();
+ return store;
+});
+
+export var Storage = class {
+ constructor(prefix) {
+ this.prefix = prefix;
+ }
+
+ /**
+ * Clear ALL storage data and save to the disk.
+ */
+ static async clearAllStorage() {
+ const store = await lazy.lazyStore;
+ store.data = {};
+ store.saveSoon();
+ }
+
+ /**
+ * Sets an item in the prefixed storage.
+ * @returns {Promise}
+ * @resolves With the stored value, or null.
+ * @rejects Javascript exception.
+ */
+ async getItem(name) {
+ const store = await lazy.lazyStore;
+ const namespace = store.data[this.prefix] || {};
+ return namespace[name] || null;
+ }
+
+ /**
+ * Sets an item in the prefixed storage.
+ * @returns {Promise}
+ * @resolves When the operation is completed successfully
+ * @rejects Javascript exception.
+ */
+ async setItem(name, value) {
+ const store = await lazy.lazyStore;
+ if (!(this.prefix in store.data)) {
+ store.data[this.prefix] = {};
+ }
+ store.data[this.prefix][name] = value;
+ store.saveSoon();
+ }
+
+ /**
+ * Removes a single item from the prefixed storage.
+ * @returns {Promise}
+ * @resolves When the operation is completed successfully
+ * @rejects Javascript exception.
+ */
+ async removeItem(name) {
+ const store = await lazy.lazyStore;
+ if (this.prefix in store.data) {
+ delete store.data[this.prefix][name];
+ store.saveSoon();
+ }
+ }
+
+ /**
+ * Clears all storage for the prefix.
+ * @returns {Promise}
+ * @resolves When the operation is completed successfully
+ * @rejects Javascript exception.
+ */
+ async clear() {
+ const store = await lazy.lazyStore;
+ store.data[this.prefix] = {};
+ store.saveSoon();
+ }
+};
diff --git a/toolkit/components/normandy/lib/TelemetryEvents.sys.mjs b/toolkit/components/normandy/lib/TelemetryEvents.sys.mjs
new file mode 100644
index 0000000000..003b004e26
--- /dev/null
+++ b/toolkit/components/normandy/lib/TelemetryEvents.sys.mjs
@@ -0,0 +1,30 @@
+/* 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/. */
+
+const TELEMETRY_CATEGORY = "normandy";
+
+export const TelemetryEvents = {
+ NO_ENROLLMENT_ID_MARKER: "__NO_ENROLLMENT_ID__",
+
+ init() {
+ Services.telemetry.setEventRecordingEnabled(TELEMETRY_CATEGORY, true);
+ },
+
+ sendEvent(method, object, value, extra) {
+ for (const val of Object.values(extra)) {
+ if (val == null) {
+ throw new Error(
+ "Extra parameters in telemetry events must not be null"
+ );
+ }
+ }
+ Services.telemetry.recordEvent(
+ TELEMETRY_CATEGORY,
+ method,
+ object,
+ value,
+ extra
+ );
+ },
+};
diff --git a/toolkit/components/normandy/lib/Uptake.sys.mjs b/toolkit/components/normandy/lib/Uptake.sys.mjs
new file mode 100644
index 0000000000..73f3eb2068
--- /dev/null
+++ b/toolkit/components/normandy/lib/Uptake.sys.mjs
@@ -0,0 +1,67 @@
+/* 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/. */
+
+import { UptakeTelemetry } from "resource://services-common/uptake-telemetry.sys.mjs";
+
+const COMPONENT = "normandy";
+
+export var Uptake = {
+ // Action uptake
+ ACTION_NETWORK_ERROR: UptakeTelemetry.STATUS.NETWORK_ERROR,
+ ACTION_PRE_EXECUTION_ERROR: UptakeTelemetry.STATUS.CUSTOM_1_ERROR,
+ ACTION_POST_EXECUTION_ERROR: UptakeTelemetry.STATUS.CUSTOM_2_ERROR,
+ ACTION_SERVER_ERROR: UptakeTelemetry.STATUS.SERVER_ERROR,
+ ACTION_SUCCESS: UptakeTelemetry.STATUS.SUCCESS,
+
+ // Per-recipe uptake
+ RECIPE_ACTION_DISABLED: UptakeTelemetry.STATUS.CUSTOM_1_ERROR,
+ RECIPE_DIDNT_MATCH_FILTER: UptakeTelemetry.STATUS.BACKOFF,
+ RECIPE_INCOMPATIBLE_CAPABILITIES: UptakeTelemetry.STATUS.BACKOFF,
+ RECIPE_EXECUTION_ERROR: UptakeTelemetry.STATUS.APPLY_ERROR,
+ RECIPE_FILTER_BROKEN: UptakeTelemetry.STATUS.CONTENT_ERROR,
+ RECIPE_ARGUMENTS_INVALID: UptakeTelemetry.STATUS.CONTENT_ERROR,
+ RECIPE_INVALID_ACTION: UptakeTelemetry.STATUS.DOWNLOAD_ERROR,
+ RECIPE_SUCCESS: UptakeTelemetry.STATUS.SUCCESS,
+ RECIPE_INVALID_SIGNATURE: UptakeTelemetry.STATUS.SIGNATURE_ERROR,
+
+ // Uptake for the runner as a whole
+ RUNNER_NETWORK_ERROR: UptakeTelemetry.STATUS.NETWORK_ERROR,
+ RUNNER_SERVER_ERROR: UptakeTelemetry.STATUS.SERVER_ERROR,
+ RUNNER_SUCCESS: UptakeTelemetry.STATUS.SUCCESS,
+
+ async _report(status, source) {
+ // Telemetry doesn't help us much with error detection, so do some here.
+ if (!status) {
+ throw new Error(
+ `Uptake status is required (got "${JSON.stringify(status)}"`
+ );
+ }
+ if (!source) {
+ throw new Error(
+ `Uptake source is required (got "${JSON.stringify(status)}`
+ );
+ }
+ await UptakeTelemetry.report(COMPONENT, status, {
+ source: `${COMPONENT}/${source}`,
+ });
+ },
+
+ async reportRunner(status) {
+ await Uptake._report(status, "runner");
+ },
+
+ async reportRecipe(recipe, status) {
+ await Uptake._report(status, `recipe/${recipe.id}`);
+ const revisionId = parseInt(recipe.revision_id, 10);
+ Services.telemetry.keyedScalarSet(
+ "normandy.recipe_freshness",
+ recipe.id,
+ revisionId
+ );
+ },
+
+ async reportAction(actionName, status) {
+ await Uptake._report(status, `action/${actionName}`);
+ },
+};