diff options
Diffstat (limited to '')
-rw-r--r-- | toolkit/components/normandy/lib/AddonStudies.jsm | 505 |
1 files changed, 505 insertions, 0 deletions
diff --git a/toolkit/components/normandy/lib/AddonStudies.jsm b/toolkit/components/normandy/lib/AddonStudies.jsm new file mode 100644 index 0000000000..4cc94418b8 --- /dev/null +++ b/toolkit/components/normandy/lib/AddonStudies.jsm @@ -0,0 +1,505 @@ +/* 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/. */ +"use strict"; + +/** + * @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. + */ + +const { LogManager } = ChromeUtils.import( + "resource://normandy/lib/LogManager.jsm" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs", +}); +ChromeUtils.defineModuleGetter( + lazy, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "BranchedAddonStudyAction", + "resource://normandy/actions/BranchedAddonStudyAction.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "CleanupManager", + "resource://normandy/lib/CleanupManager.jsm" +); +ChromeUtils.defineModuleGetter( + lazy, + "TelemetryEvents", + "resource://normandy/lib/TelemetryEvents.jsm" +); + +var EXPORTED_SYMBOLS = ["AddonStudies"]; + +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); +} + +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) { + Cu.reportError(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__"; |