diff options
Diffstat (limited to 'toolkit/components/messaging-system/experiments/ExperimentAPI.jsm')
-rw-r--r-- | toolkit/components/messaging-system/experiments/ExperimentAPI.jsm | 266 |
1 files changed, 266 insertions, 0 deletions
diff --git a/toolkit/components/messaging-system/experiments/ExperimentAPI.jsm b/toolkit/components/messaging-system/experiments/ExperimentAPI.jsm new file mode 100644 index 0000000000..8d2172fd47 --- /dev/null +++ b/toolkit/components/messaging-system/experiments/ExperimentAPI.jsm @@ -0,0 +1,266 @@ +/* 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 {import("./@types/ExperimentManager").Enrollment} Enrollment + * @typedef {import("./@types/ExperimentManager").FeatureConfig} FeatureConfig + */ + +const EXPORTED_SYMBOLS = ["ExperimentAPI"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + ExperimentStore: + "resource://messaging-system/experiments/ExperimentStore.jsm", + ExperimentManager: + "resource://messaging-system/experiments/ExperimentManager.jsm", + RemoteSettings: "resource://services-settings/remote-settings.js", +}); + +const IS_MAIN_PROCESS = + Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT; + +const COLLECTION_ID_PREF = "messaging-system.rsexperimentloader.collection_id"; +const COLLECTION_ID_FALLBACK = "nimbus-desktop-experiments"; +XPCOMUtils.defineLazyPreferenceGetter( + this, + "COLLECTION_ID", + COLLECTION_ID_PREF, + COLLECTION_ID_FALLBACK +); + +const ExperimentAPI = { + /** + * @returns {Promise} Resolves when the API has synchronized to the main store + */ + ready() { + return this._store.ready(); + }, + + /** + * Returns an experiment, including all its metadata + * Sends exposure ping + * + * @param {{slug?: string, featureId?: string}} options slug = An experiment identifier + * or feature = a stable identifier for a type of experiment + * @returns {{slug: string, active: bool, exposurePingSent: bool}} A matching experiment if one is found. + */ + getExperiment({ slug, featureId, sendExposurePing } = {}) { + if (!slug && !featureId) { + throw new Error( + "getExperiment(options) must include a slug or a feature." + ); + } + let experimentData; + if (slug) { + experimentData = this._store.get(slug); + } else if (featureId) { + experimentData = this._store.getExperimentForFeature(featureId); + } + if (experimentData) { + return { + slug: experimentData.slug, + active: experimentData.active, + exposurePingSent: experimentData.exposurePingSent, + branch: this.getFeatureBranch({ featureId, sendExposurePing }), + }; + } + + return null; + }, + + /** + * Return experiment slug its status and the enrolled branch slug + * Does NOT send exposure ping because you only have access to the slugs + */ + getExperimentMetaData({ slug, featureId }) { + if (!slug && !featureId) { + throw new Error( + "getExperiment(options) must include a slug or a feature." + ); + } + + let experimentData; + if (slug) { + experimentData = this._store.get(slug); + } else if (featureId) { + experimentData = this._store.getExperimentForFeature(featureId); + } + if (experimentData) { + return { + slug: experimentData.slug, + active: experimentData.active, + exposurePingSent: experimentData.exposurePingSent, + branch: { slug: experimentData.branch.slug }, + }; + } + + return null; + }, + + /** + * Lookup feature in active experiments and return status. + * Sends exposure ping + * @param {string} featureId Feature to lookup + * @param {boolean} defaultValue + * @returns {boolean} + */ + isFeatureEnabled(featureId, defaultValue) { + const branch = this.getFeatureBranch({ featureId }); + if (branch?.feature.enabled !== undefined) { + return branch.feature.enabled; + } + return defaultValue; + }, + + /** + * Lookup feature in active experiments and return value. + * By default, this will send an exposure event. + * @param {{featureId: string, sendExposurePing: boolean}} options + * @returns {obj} The feature value + */ + getFeatureValue(options) { + return this._store.activateBranch(options)?.feature.value; + }, + + /** + * Lookup feature in active experiments and returns the entire branch. + * By default, this will send an exposure event. + * @param {{featureId: string, sendExposurePing: boolean}} options + * @returns {Branch} + */ + getFeatureBranch(options) { + return this._store.activateBranch(options); + }, + + /** + * Registers an event listener. + * The following event names are used: + * `update` - an experiment is updated, for example it is no longer active + * + * @param {string} eventName must follow the pattern `event:slug-name` + * @param {{slug?: string, featureId: string?}} options + * @param {function} callback + + * @returns {void} + */ + on(eventName, options, callback) { + if (!options) { + throw new Error("Please include an experiment slug or featureId"); + } + let fullEventName = `${eventName}:${options.slug || options.featureId}`; + + // The update event will always fire after the event listener is added, either + // immediately if it is already ready, or on ready + this._store.ready().then(() => { + let experiment = this.getExperiment(options); + // Only if we have an experiment that matches what the caller requested + if (experiment) { + // If the store already has the experiment in the store then we should + // notify. This covers the startup scenario or cases where listeners + // are attached later than the `update` events. + callback(fullEventName, experiment); + } + }); + + this._store.on(fullEventName, callback); + }, + + /** + * Deregisters an event listener. + * @param {string} eventName + * @param {function} callback + */ + off(eventName, callback) { + this._store.off(eventName, callback); + }, + + /** + * Returns the recipe for a given experiment slug + * + * This should noly be called from the main process. + * + * Note that the recipe is directly fetched from RemoteSettings, which has + * all the recipe metadata available without relying on the `this._store`. + * Therefore, calling this function does not require to call `this.ready()` first. + * + * @param slug {String} An experiment identifier + * @returns {Recipe|undefined} A matching experiment recipe if one is found + */ + async getRecipe(slug) { + if (!IS_MAIN_PROCESS) { + throw new Error( + "getRecipe() should only be called from the main process" + ); + } + + let recipe; + + try { + [recipe] = await this._remoteSettingsClient.get({ + // Do not sync the RS store, let RemoteSettingsExperimentLoader do that + syncIfEmpty: false, + filters: { slug }, + }); + } catch (e) { + Cu.reportError(e); + recipe = undefined; + } + + return recipe; + }, + + /** + * Returns all the branches for a given experiment slug + * + * This should only be called from the main process. Like `getRecipe()`, + * calling this function does not require to call `this.ready()` first. + * + * @param slug {String} An experiment identifier + * @returns {[Branches]|undefined} An array of branches for the given slug + */ + async getAllBranches(slug) { + if (!IS_MAIN_PROCESS) { + throw new Error( + "getAllBranches() should only be called from the main process" + ); + } + + const recipe = await this.getRecipe(slug); + return recipe?.branches; + }, + + recordExposureEvent(name, { sent, experimentSlug, branchSlug }) { + if (!IS_MAIN_PROCESS) { + Cu.reportError("Need to call from Parent process"); + return false; + } + if (sent) { + return false; + } + + // Notify listener to record that the ping was sent + this._store._emitExperimentExposure({ + featureId: name, + experimentSlug, + branchSlug, + }); + + return true; + }, +}; + +XPCOMUtils.defineLazyGetter(ExperimentAPI, "_store", function() { + return IS_MAIN_PROCESS ? ExperimentManager.store : new ExperimentStore(); +}); + +XPCOMUtils.defineLazyGetter(ExperimentAPI, "_remoteSettingsClient", function() { + return RemoteSettings(COLLECTION_ID); +}); |