diff options
Diffstat (limited to 'toolkit/components/nimbus/ExperimentAPI.jsm')
-rw-r--r-- | toolkit/components/nimbus/ExperimentAPI.jsm | 529 |
1 files changed, 529 insertions, 0 deletions
diff --git a/toolkit/components/nimbus/ExperimentAPI.jsm b/toolkit/components/nimbus/ExperimentAPI.jsm new file mode 100644 index 0000000000..2c05318af1 --- /dev/null +++ b/toolkit/components/nimbus/ExperimentAPI.jsm @@ -0,0 +1,529 @@ +/* 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"; + +const EXPORTED_SYMBOLS = [ + "ExperimentAPI", + "NimbusFeatures", + "_ExperimentFeature", +]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + ExperimentStore: "resource://nimbus/lib/ExperimentStore.jsm", + ExperimentManager: "resource://nimbus/lib/ExperimentManager.jsm", + RemoteSettings: "resource://services-settings/remote-settings.js", + FeatureManifest: "resource://nimbus/FeatureManifest.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( + lazy, + "COLLECTION_ID", + COLLECTION_ID_PREF, + COLLECTION_ID_FALLBACK +); +const EXPOSURE_EVENT_CATEGORY = "normandy"; +const EXPOSURE_EVENT_METHOD = "expose"; +const EXPOSURE_EVENT_OBJECT = "nimbus_experiment"; + +function parseJSON(value) { + if (value) { + try { + return JSON.parse(value); + } catch (e) { + Cu.reportError(e); + } + } + return null; +} + +function featuresCompat(branch) { + if (!branch) { + return []; + } + let { features } = branch; + // In <=v1.5.0 of the Nimbus API, experiments had single feature + if (!features) { + features = [branch.feature]; + } + + return features; +} + +const experimentBranchAccessor = { + get: (target, prop) => { + // Offer an API where we can access `branch.feature.*`. + // This is a useful shorthand that hides the fact that + // even single-feature recipes are still represented + // as an array with 1 item + if (!(prop in target) && target.features) { + return target.features.find(f => f.featureId === prop); + } else if (target.feature?.featureId === prop) { + // Backwards compatibility for version 1.6.2 and older + return target.feature; + } + + return target[prop]; + }, +}; + +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 event + * + * @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}} A matching experiment if one is found. + */ + getExperiment({ slug, featureId } = {}) { + if (!slug && !featureId) { + throw new Error( + "getExperiment(options) must include a slug or a feature." + ); + } + let experimentData; + try { + if (slug) { + experimentData = this._store.get(slug); + } else if (featureId) { + experimentData = this._store.getExperimentForFeature(featureId); + } + } catch (e) { + Cu.reportError(e); + } + if (experimentData) { + return { + slug: experimentData.slug, + active: experimentData.active, + branch: new Proxy(experimentData.branch, experimentBranchAccessor), + }; + } + + return null; + }, + + /** + * Used by getExperimentMetaData and getRolloutMetaData + * + * @param {{slug: string, featureId: string}} options Enrollment identifier + * @param isRollout Is enrollment an experiment or a rollout + * @returns {object} Enrollment metadata + */ + getEnrollmentMetaData({ slug, featureId }, isRollout) { + if (!slug && !featureId) { + throw new Error( + "getExperiment(options) must include a slug or a feature." + ); + } + + let experimentData; + try { + if (slug) { + experimentData = this._store.get(slug); + } else if (featureId) { + if (isRollout) { + experimentData = this._store.getRolloutForFeature(featureId); + } else { + experimentData = this._store.getExperimentForFeature(featureId); + } + } + } catch (e) { + Cu.reportError(e); + } + if (experimentData) { + return { + slug: experimentData.slug, + active: experimentData.active, + branch: { slug: experimentData.branch.slug }, + }; + } + + return null; + }, + + /** + * Return experiment slug its status and the enrolled branch slug + * Does NOT send exposure event because you only have access to the slugs + */ + getExperimentMetaData(options) { + return this.getEnrollmentMetaData(options); + }, + + /** + * Return rollout slug its status and the enrolled branch slug + * Does NOT send exposure event because you only have access to the slugs + */ + getRolloutMetaData(options) { + return this.getEnrollmentMetaData(options, true); + }, + + /** + * Return FeatureConfig from first active experiment where it can be found + * @param {{slug: string, featureId: string }} + * @returns {Branch | null} + */ + getActiveBranch({ slug, featureId }) { + let experiment = null; + try { + if (slug) { + experiment = this._store.get(slug); + } else if (featureId) { + experiment = this._store.getExperimentForFeature(featureId); + } + } catch (e) { + Cu.reportError(e); + } + + if (!experiment) { + return null; + } + + // Default to null for feature-less experiments where we're only + // interested in exposure. + return experiment?.branch || null; + }, + + /** + * 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}`; + + if (this._store._isReady) { + 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) { + // If an error occurs in .get(), an empty list is returned and the destructuring + // assignment will throw. + 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.map( + branch => new Proxy(branch, experimentBranchAccessor) + ); + }, + + recordExposureEvent({ featureId, experimentSlug, branchSlug }) { + Services.telemetry.setEventRecordingEnabled(EXPOSURE_EVENT_CATEGORY, true); + try { + Services.telemetry.recordEvent( + EXPOSURE_EVENT_CATEGORY, + EXPOSURE_EVENT_METHOD, + EXPOSURE_EVENT_OBJECT, + experimentSlug, + { + branchSlug, + featureId, + } + ); + } catch (e) { + Cu.reportError(e); + } + Glean.nimbusEvents.exposure.record({ + experiment: experimentSlug, + branch: branchSlug, + feature_id: featureId, + }); + }, +}; + +/** + * Singleton that holds lazy references to _ExperimentFeature instances + * defined by the FeatureManifest + */ +const NimbusFeatures = {}; +for (let feature in lazy.FeatureManifest) { + XPCOMUtils.defineLazyGetter(NimbusFeatures, feature, () => { + return new _ExperimentFeature(feature); + }); +} + +class _ExperimentFeature { + constructor(featureId, manifest) { + this.featureId = featureId; + this.prefGetters = {}; + this.manifest = manifest || lazy.FeatureManifest[featureId]; + if (!this.manifest) { + Cu.reportError( + `No manifest entry for ${featureId}. Please add one to toolkit/components/nimbus/FeatureManifest.js` + ); + } + this._didSendExposureEvent = false; + const variables = this.manifest?.variables || {}; + + Object.keys(variables).forEach(key => { + const { type, fallbackPref } = variables[key]; + if (fallbackPref) { + XPCOMUtils.defineLazyPreferenceGetter( + this.prefGetters, + key, + fallbackPref, + null, + () => { + ExperimentAPI._store._emitFeatureUpdate( + this.featureId, + "pref-updated" + ); + }, + type === "json" ? parseJSON : val => val + ); + } + }); + } + + getSetPrefName(variable) { + return this.manifest?.variables?.[variable]?.setPref; + } + + getFallbackPrefName(variable) { + return this.manifest?.variables?.[variable]?.fallbackPref; + } + + /** + * Wait for ExperimentStore to load giving access to experiment features that + * do not have a pref cache + */ + ready() { + return ExperimentAPI.ready(); + } + + /** + * Lookup feature variables in experiments, prefs, and remote defaults. + * @param {{defaultValues?: {[variableName: string]: any}}} options + * @returns {{[variableName: string]: any}} The feature value + */ + getAllVariables({ defaultValues = null } = {}) { + const branch = ExperimentAPI.getActiveBranch({ featureId: this.featureId }); + const featureValue = featuresCompat(branch).find( + ({ featureId }) => featureId === this.featureId + )?.value; + + return { + ...this.prefGetters, + ...defaultValues, + ...(featureValue ? featureValue : this.getRollout()?.value), + }; + } + + getVariable(variable) { + if (!this.manifest?.variables?.[variable]) { + // Only throw in nightly/tests + if (Cu.isInAutomation || AppConstants.NIGHTLY_BUILD) { + throw new Error( + `Nimbus: Warning - variable "${variable}" is not defined in FeatureManifest.js` + ); + } + } + + // Next, check if an experiment is defined + const branch = ExperimentAPI.getActiveBranch({ + featureId: this.featureId, + }); + const experimentValue = featuresCompat(branch).find( + ({ featureId }) => featureId === this.featureId + )?.value?.[variable]; + + if (typeof experimentValue !== "undefined") { + return experimentValue; + } + + // Next, check remote defaults + const remoteValue = this.getRollout()?.value?.[variable]; + if (typeof remoteValue !== "undefined") { + return remoteValue; + } + + // Return the default preference value + const prefName = this.getFallbackPrefName(variable); + return prefName ? this.prefGetters[variable] : undefined; + } + + getRollout() { + let remoteConfig = ExperimentAPI._store.getRolloutForFeature( + this.featureId + ); + if (!remoteConfig) { + return null; + } + + if (remoteConfig.branch?.features) { + return remoteConfig.branch?.features.find( + f => f.featureId === this.featureId + ); + } + + // This path is deprecated and will be removed in the future + if (remoteConfig.branch?.feature) { + return remoteConfig.branch.feature; + } + + return null; + } + + recordExposureEvent({ once = false } = {}) { + if (once && this._didSendExposureEvent) { + return; + } + + let enrollmentData = ExperimentAPI.getExperimentMetaData({ + featureId: this.featureId, + }); + if (!enrollmentData) { + enrollmentData = ExperimentAPI.getRolloutMetaData({ + featureId: this.featureId, + }); + } + + // Exposure only sent if user is enrolled in an experiment + if (enrollmentData) { + ExperimentAPI.recordExposureEvent({ + featureId: this.featureId, + experimentSlug: enrollmentData.slug, + branchSlug: enrollmentData.branch?.slug, + }); + this._didSendExposureEvent = true; + } + } + + onUpdate(callback) { + ExperimentAPI._store._onFeatureUpdate(this.featureId, callback); + } + + off(callback) { + ExperimentAPI._store._offFeatureUpdate(this.featureId, callback); + } + + /** + * The applications this feature applies to. + * + */ + get applications() { + return this.manifest.applications ?? ["firefox-desktop"]; + } + + debug() { + return { + variables: this.getAllVariables(), + experiment: ExperimentAPI.getExperimentMetaData({ + featureId: this.featureId, + }), + fallbackPrefs: Object.keys(this.prefGetters).map(prefName => [ + prefName, + this.prefGetters[prefName], + ]), + rollouts: this.getRollout(), + }; + } +} + +XPCOMUtils.defineLazyGetter(ExperimentAPI, "_store", function() { + return IS_MAIN_PROCESS + ? lazy.ExperimentManager.store + : new lazy.ExperimentStore(); +}); + +XPCOMUtils.defineLazyGetter(ExperimentAPI, "_remoteSettingsClient", function() { + return lazy.RemoteSettings(lazy.COLLECTION_ID); +}); |