summaryrefslogtreecommitdiffstats
path: root/toolkit/components/messaging-system/experiments/ExperimentAPI.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/messaging-system/experiments/ExperimentAPI.jsm')
-rw-r--r--toolkit/components/messaging-system/experiments/ExperimentAPI.jsm266
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);
+});