summaryrefslogtreecommitdiffstats
path: root/toolkit/components/messaging-system/experiments
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/messaging-system/experiments')
-rw-r--r--toolkit/components/messaging-system/experiments/@types/ExperimentManager.d.ts49
-rw-r--r--toolkit/components/messaging-system/experiments/ExperimentAPI.jsm266
-rw-r--r--toolkit/components/messaging-system/experiments/ExperimentManager.jsm476
-rw-r--r--toolkit/components/messaging-system/experiments/ExperimentStore.jsm196
4 files changed, 987 insertions, 0 deletions
diff --git a/toolkit/components/messaging-system/experiments/@types/ExperimentManager.d.ts b/toolkit/components/messaging-system/experiments/@types/ExperimentManager.d.ts
new file mode 100644
index 0000000000..ee41deccb1
--- /dev/null
+++ b/toolkit/components/messaging-system/experiments/@types/ExperimentManager.d.ts
@@ -0,0 +1,49 @@
+export interface FeatureConfig {
+ featureId: "cfr" | "aboutwelcome";
+ enabled: boolean;
+ value: { [key: string]: any } | null;
+}
+
+export interface Branch {
+ slug: string;
+ ratio: number;
+ feature: FeatureConfig;
+}
+
+interface BucketConfig {
+ namespace: string;
+ randomizationUnit: string;
+ start: number;
+ count: number;
+ total: number;
+}
+
+export interface RecipeArgs {
+ slug: string;
+ isEnrollmentPaused: boolean;
+ experimentType?: string;
+ branches: Branch[];
+ bucketConfig: BucketConfig;
+}
+
+export interface Recipe {
+ id: string;
+ // Processed by Remote Settings, Normandy
+ filter_expression?: string;
+ // Processed by RemoteSettingsExperimentLoader
+ targeting?: string;
+ arguments: RecipeArgs;
+}
+
+export interface Enrollment {
+ slug: string;
+ enrollmentId: string;
+ branch: Branch;
+ active: boolean;
+ experimentType: string;
+ source: string;
+ // Shown in about:studies
+ userFacingName: string;
+ userFacingDescription: string;
+ lastSeen: string;
+}
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);
+});
diff --git a/toolkit/components/messaging-system/experiments/ExperimentManager.jsm b/toolkit/components/messaging-system/experiments/ExperimentManager.jsm
new file mode 100644
index 0000000000..5c3eb02251
--- /dev/null
+++ b/toolkit/components/messaging-system/experiments/ExperimentManager.jsm
@@ -0,0 +1,476 @@
+/* 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").RecipeArgs} RecipeArgs
+ * @typedef {import("./@types/ExperimentManager").Enrollment} Enrollment
+ * @typedef {import("./@types/ExperimentManager").Branch} Branch
+ */
+
+const EXPORTED_SYMBOLS = ["ExperimentManager", "_ExperimentManager"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ClientEnvironment: "resource://normandy/lib/ClientEnvironment.jsm",
+ ExperimentStore:
+ "resource://messaging-system/experiments/ExperimentStore.jsm",
+ NormandyUtils: "resource://normandy/lib/NormandyUtils.jsm",
+ Sampling: "resource://gre/modules/components-utils/Sampling.jsm",
+ TelemetryEvents: "resource://normandy/lib/TelemetryEvents.jsm",
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.jsm",
+ FirstStartup: "resource://gre/modules/FirstStartup.jsm",
+ Services: "resource://gre/modules/Services.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(this, "log", () => {
+ const { Logger } = ChromeUtils.import(
+ "resource://messaging-system/lib/Logger.jsm"
+ );
+ return new Logger("ExperimentManager");
+});
+
+// This is included with event telemetry e.g. "enroll"
+// TODO: Add a new type called "messaging_study"
+const EVENT_TELEMETRY_STUDY_TYPE = "preference_study";
+// This is used by Telemetry.setExperimentActive
+const TELEMETRY_EXPERIMENT_TYPE_PREFIX = "normandy-";
+// Also included in telemetry
+const DEFAULT_EXPERIMENT_TYPE = "messaging_experiment";
+const STUDIES_OPT_OUT_PREF = "app.shield.optoutstudies.enabled";
+const EXPOSURE_EVENT_CATEGORY = "normandy";
+const EXPOSURE_EVENT_METHOD = "expose";
+
+/**
+ * A module for processes Experiment recipes, choosing and storing enrollment state,
+ * and sending experiment-related Telemetry.
+ */
+class _ExperimentManager {
+ constructor({ id = "experimentmanager", store } = {}) {
+ this.id = id;
+ this.store = store || new ExperimentStore();
+ this.sessions = new Map();
+ this._onExposureEvent = this._onExposureEvent.bind(this);
+ Services.prefs.addObserver(STUDIES_OPT_OUT_PREF, this);
+ }
+
+ /**
+ * Creates a targeting context with following filters:
+ *
+ * * `activeExperiments`: an array of slugs of all the active experiments
+ * * `isFirstStartup`: a boolean indicating whether or not the current enrollment
+ * is performed during the first startup
+ *
+ * @returns {Object} A context object
+ * @memberof _ExperimentManager
+ */
+ createTargetingContext() {
+ let context = {
+ isFirstStartup: FirstStartup.state === FirstStartup.IN_PROGRESS,
+ };
+ Object.defineProperty(context, "activeExperiments", {
+ get: async () => {
+ await this.store.ready();
+ return this.store.getAllActive().map(exp => exp.slug);
+ },
+ });
+ return context;
+ }
+
+ /**
+ * Runs on startup, including before first run
+ */
+ async onStartup() {
+ await this.store.init();
+ this.store.on("exposure", this._onExposureEvent);
+ const restoredExperiments = this.store.getAllActive();
+
+ for (const experiment of restoredExperiments) {
+ this.setExperimentActive(experiment);
+ }
+ }
+
+ /**
+ * Runs every time a Recipe is updated or seen for the first time.
+ * @param {RecipeArgs} recipe
+ * @param {string} source
+ */
+ async onRecipe(recipe, source) {
+ const { slug, isEnrollmentPaused } = recipe;
+
+ if (!source) {
+ throw new Error("When calling onRecipe, you must specify a source.");
+ }
+
+ if (!this.sessions.has(source)) {
+ this.sessions.set(source, new Set());
+ }
+ this.sessions.get(source).add(slug);
+
+ if (this.store.has(slug)) {
+ this.updateEnrollment(recipe);
+ } else if (isEnrollmentPaused) {
+ log.debug(`Enrollment is paused for "${slug}"`);
+ } else if (!(await this.isInBucketAllocation(recipe.bucketConfig))) {
+ log.debug("Client was not enrolled because of the bucket sampling");
+ } else {
+ await this.enroll(recipe, source);
+ }
+ }
+
+ /**
+ * Runs when the all recipes been processed during an update, including at first run.
+ * @param {string} sourceToCheck
+ */
+ onFinalize(sourceToCheck) {
+ if (!sourceToCheck) {
+ throw new Error("When calling onFinalize, you must specify a source.");
+ }
+ const activeExperiments = this.store.getAllActive();
+
+ for (const experiment of activeExperiments) {
+ const { slug, source } = experiment;
+ if (sourceToCheck !== source) {
+ continue;
+ }
+ if (!this.sessions.get(source)?.has(slug)) {
+ log.debug(`Stopping study for recipe ${slug}`);
+ try {
+ this.unenroll(slug, "recipe-not-seen");
+ } catch (err) {
+ Cu.reportError(err);
+ }
+ }
+ }
+
+ this.sessions.delete(sourceToCheck);
+ }
+
+ /**
+ * Bucket configuration specifies a specific percentage of clients that can
+ * be enrolled.
+ * @param {BucketConfig} bucketConfig
+ * @returns {Promise<boolean>}
+ */
+ isInBucketAllocation(bucketConfig) {
+ if (!bucketConfig) {
+ log.debug("Cannot enroll if recipe bucketConfig is not set.");
+ return false;
+ }
+
+ let id;
+ if (bucketConfig.randomizationUnit === "normandy_id") {
+ id = ClientEnvironment.userId;
+ } else {
+ // Others not currently supported.
+ log.debug(`Invalid randomizationUnit: ${bucketConfig.randomizationUnit}`);
+ return false;
+ }
+
+ return Sampling.bucketSample(
+ [id, bucketConfig.namespace],
+ bucketConfig.start,
+ bucketConfig.count,
+ bucketConfig.total
+ );
+ }
+
+ /**
+ * Start a new experiment by enrolling the users
+ *
+ * @param {RecipeArgs} recipe
+ * @param {string} source
+ * @returns {Promise<Enrollment>} The experiment object stored in the data store
+ * @rejects {Error}
+ * @memberof _ExperimentManager
+ */
+ async enroll(
+ {
+ slug,
+ branches,
+ experimentType = DEFAULT_EXPERIMENT_TYPE,
+ userFacingName,
+ userFacingDescription,
+ },
+ source
+ ) {
+ if (this.store.has(slug)) {
+ this.sendFailureTelemetry("enrollFailed", slug, "name-conflict");
+ throw new Error(`An experiment with the slug "${slug}" already exists.`);
+ }
+
+ const enrollmentId = NormandyUtils.generateUuid();
+ const branch = await this.chooseBranch(slug, branches);
+
+ if (
+ this.store.hasExperimentForFeature(
+ // Extract out only the feature names from the branch
+ branch.feature?.featureId
+ )
+ ) {
+ log.debug(
+ `Skipping enrollment for "${slug}" because there is an existing experiment for its feature.`
+ );
+ this.sendFailureTelemetry("enrollFailed", slug, "feature-conflict");
+ throw new Error(
+ `An experiment with a conflicting feature already exists.`
+ );
+ }
+
+ /** @type {Enrollment} */
+ const experiment = {
+ slug,
+ branch,
+ active: true,
+ // Sent first time feature value is used
+ exposurePingSent: false,
+ enrollmentId,
+ experimentType,
+ source,
+ userFacingName,
+ userFacingDescription,
+ lastSeen: new Date().toJSON(),
+ };
+
+ this.store.addExperiment(experiment);
+ this.setExperimentActive(experiment);
+ this.sendEnrollmentTelemetry(experiment);
+
+ log.debug(`New experiment started: ${slug}, ${branch.slug}`);
+
+ return experiment;
+ }
+
+ /**
+ * Update an enrollment that was already set
+ *
+ * @param {RecipeArgs} recipe
+ */
+ updateEnrollment(recipe) {
+ /** @type Enrollment */
+ const experiment = this.store.get(recipe.slug);
+
+ // Don't update experiments that were already unenrolled.
+ if (experiment.active === false) {
+ log.debug(`Enrollment ${recipe.slug} has expired, aborting.`);
+ return;
+ }
+
+ // Stay in the same branch, don't re-sample every time.
+ const branch = recipe.branches.find(
+ branch => branch.slug === experiment.branch.slug
+ );
+
+ if (!branch) {
+ // Our branch has been removed. Unenroll.
+ this.unenroll(recipe.slug, "branch-removed");
+ }
+ }
+
+ /**
+ * Stop an experiment that is currently active
+ *
+ * @param {string} slug
+ * @param {string} reason
+ */
+ unenroll(slug, reason = "unknown") {
+ const experiment = this.store.get(slug);
+ if (!experiment) {
+ this.sendFailureTelemetry("unenrollFailed", slug, "does-not-exist");
+ throw new Error(`Could not find an experiment with the slug "${slug}"`);
+ }
+
+ if (!experiment.active) {
+ this.sendFailureTelemetry("unenrollFailed", slug, "already-unenrolled");
+ throw new Error(
+ `Cannot stop experiment "${slug}" because it is already expired`
+ );
+ }
+
+ this.store.updateExperiment(slug, { active: false });
+
+ TelemetryEnvironment.setExperimentInactive(slug);
+ TelemetryEvents.sendEvent("unenroll", EVENT_TELEMETRY_STUDY_TYPE, slug, {
+ reason,
+ branch: experiment.branch.slug,
+ enrollmentId:
+ experiment.enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+
+ log.debug(`Experiment unenrolled: ${slug}`);
+ }
+
+ /**
+ * Unenroll from all active studies if user opts out.
+ */
+ observe(aSubject, aTopic, aPrefName) {
+ if (Services.prefs.getBoolPref(STUDIES_OPT_OUT_PREF)) {
+ return;
+ }
+ for (const { slug } of this.store.getAllActive()) {
+ this.unenroll(slug, "studies-opt-out");
+ }
+ }
+
+ /**
+ * Send Telemetry for undesired event
+ *
+ * @param {string} eventName
+ * @param {string} slug
+ * @param {string} reason
+ */
+ sendFailureTelemetry(eventName, slug, reason) {
+ TelemetryEvents.sendEvent(eventName, EVENT_TELEMETRY_STUDY_TYPE, slug, {
+ reason,
+ });
+ }
+
+ /**
+ *
+ * @param {Enrollment} experiment
+ */
+ sendEnrollmentTelemetry({ slug, branch, experimentType, enrollmentId }) {
+ TelemetryEvents.sendEvent("enroll", EVENT_TELEMETRY_STUDY_TYPE, slug, {
+ experimentType,
+ branch: branch.slug,
+ enrollmentId: enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ });
+ }
+
+ async _onExposureEvent(event, experimentData) {
+ await this.store.ready();
+ this.store.updateExperiment(experimentData.experimentSlug, {
+ exposurePingSent: true,
+ });
+ // featureId is not validated and might be rejected by recordEvent if not
+ // properly defined in Events.yaml. Saving experiment state regardless of
+ // the result, no use retrying.
+ try {
+ Services.telemetry.recordEvent(
+ EXPOSURE_EVENT_CATEGORY,
+ EXPOSURE_EVENT_METHOD,
+ "feature_study",
+ experimentData.experimentSlug,
+ {
+ branchSlug: experimentData.branchSlug,
+ featureId: experimentData.featureId,
+ }
+ );
+ } catch (e) {
+ Cu.reportError(e);
+ }
+
+ log.debug(`Experiment exposure: ${experimentData.experimentSlug}`);
+ }
+
+ /**
+ * Sets Telemetry when activating an experiment.
+ *
+ * @param {Enrollment} experiment
+ * @memberof _ExperimentManager
+ */
+ setExperimentActive(experiment) {
+ TelemetryEnvironment.setExperimentActive(
+ experiment.slug,
+ experiment.branch.slug,
+ {
+ type: `${TELEMETRY_EXPERIMENT_TYPE_PREFIX}${experiment.experimentType}`,
+ enrollmentId:
+ experiment.enrollmentId || TelemetryEvents.NO_ENROLLMENT_ID_MARKER,
+ }
+ );
+ }
+
+ /**
+ * Generate Normandy UserId respective to a branch
+ * for a given experiment.
+ *
+ * @param {string} slug
+ * @param {Array<{slug: string; ratio: number}>} branches
+ * @param {string} namespace
+ * @param {number} start
+ * @param {number} count
+ * @param {number} total
+ * @returns {Promise<{[branchName: string]: string}>} An object where
+ * the keys are branch names and the values are user IDs that will enroll
+ * a user for that particular branch. Also includes a `notInExperiment` value
+ * that will not enroll the user in the experiment
+ */
+ async generateTestIds({ slug, branches, namespace, start, count, total }) {
+ const branchValues = {};
+
+ if (!slug || !namespace) {
+ throw new Error(`slug, namespace not in expected format`);
+ }
+
+ if (!(start < total && count < total)) {
+ throw new Error("Must include start, count, and total as integers");
+ }
+
+ if (
+ !Array.isArray(branches) ||
+ branches.filter(branch => branch.slug && branch.ratio).length !==
+ branches.length
+ ) {
+ throw new Error("branches parameter not in expected format");
+ }
+
+ while (Object.keys(branchValues).length < branches.length + 1) {
+ const id = NormandyUtils.generateUuid();
+ const enrolls = await Sampling.bucketSample(
+ [id, namespace],
+ start,
+ count,
+ total
+ );
+ // Does this id enroll the user in the experiment
+ if (enrolls) {
+ // Choose a random branch
+ const { slug: pickedBranch } = await this.chooseBranch(
+ slug,
+ branches,
+ id
+ );
+
+ if (!Object.keys(branchValues).includes(pickedBranch)) {
+ branchValues[pickedBranch] = id;
+ log.debug(`Found a value for "${pickedBranch}"`);
+ }
+ } else if (!branchValues.notInExperiment) {
+ branchValues.notInExperiment = id;
+ }
+ }
+ return branchValues;
+ }
+
+ /**
+ * Choose a branch randomly.
+ *
+ * @param {string} slug
+ * @param {Branch[]} branches
+ * @returns {Promise<Branch>}
+ * @memberof _ExperimentManager
+ */
+ async chooseBranch(slug, branches, userId = ClientEnvironment.userId) {
+ const ratios = branches.map(({ ratio = 1 }) => ratio);
+
+ // It's important that the input be:
+ // - Unique per-user (no one is bucketed alike)
+ // - Unique per-experiment (bucketing differs across multiple experiments)
+ // - Differs from the input used for sampling the recipe (otherwise only
+ // branches that contain the same buckets as the recipe sampling will
+ // receive users)
+ const input = `${this.id}-${userId}-${slug}-branch`;
+
+ const index = await Sampling.ratioSample(input, ratios);
+ return branches[index];
+ }
+}
+
+const ExperimentManager = new _ExperimentManager();
diff --git a/toolkit/components/messaging-system/experiments/ExperimentStore.jsm b/toolkit/components/messaging-system/experiments/ExperimentStore.jsm
new file mode 100644
index 0000000000..9e1e8cb0e8
--- /dev/null
+++ b/toolkit/components/messaging-system/experiments/ExperimentStore.jsm
@@ -0,0 +1,196 @@
+/* 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 = ["ExperimentStore"];
+
+const { SharedDataMap } = ChromeUtils.import(
+ "resource://messaging-system/lib/SharedDataMap.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+const IS_MAIN_PROCESS =
+ Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT;
+
+const SYNC_DATA_PREF = "messaging-system.syncdatastore.data";
+let tryJSONParse = data => {
+ try {
+ return JSON.parse(data);
+ } catch (e) {}
+
+ return {};
+};
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "syncDataStore",
+ SYNC_DATA_PREF,
+ {},
+ // aOnUpdate
+ (data, prev, latest) => tryJSONParse(latest),
+ // aTransform
+ tryJSONParse
+);
+
+const DEFAULT_STORE_ID = "ExperimentStoreData";
+// Experiment feature configs that should be saved to prefs for
+// fast access on startup.
+const SYNC_ACCESS_FEATURES = ["newtab", "aboutwelcome"];
+
+class ExperimentStore extends SharedDataMap {
+ constructor(sharedDataKey, options = { isParent: IS_MAIN_PROCESS }) {
+ super(sharedDataKey || DEFAULT_STORE_ID, options);
+ }
+
+ /**
+ * Given a feature identifier, find an active experiment that matches that feature identifier.
+ * This assumes, for now, that there is only one active experiment per feature per browser.
+ *
+ * @param {string} featureId
+ * @returns {Enrollment|undefined} An active experiment if it exists
+ * @memberof ExperimentStore
+ */
+ getExperimentForFeature(featureId) {
+ return this.getAllActive().find(
+ experiment => experiment.branch.feature?.featureId === featureId
+ );
+ }
+
+ /**
+ * Return FeatureConfig from first active experiment where it can be found
+ * @param {{slug: string, featureId: string, sendExposurePing: bool}}
+ * @returns {Branch | null}
+ */
+ activateBranch({ slug, featureId, sendExposurePing = true }) {
+ for (let experiment of this.getAllActive()) {
+ if (
+ experiment?.branch.feature.featureId === featureId ||
+ experiment.slug === slug
+ ) {
+ if (sendExposurePing) {
+ this._emitExperimentExposure({
+ experimentSlug: experiment.slug,
+ branchSlug: experiment.branch.slug,
+ featureId,
+ });
+ }
+ // Default to null for feature-less experiments where we're only
+ // interested in exposure.
+ return experiment?.branch || null;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if an active experiment already exists for a feature
+ *
+ * @param {string} featureId
+ * @returns {boolean} Does an active experiment exist for that feature?
+ * @memberof ExperimentStore
+ */
+ hasExperimentForFeature(featureId) {
+ if (!featureId) {
+ return false;
+ }
+ if (this.activateBranch({ featureId })?.feature.featureId === featureId) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * @returns {Enrollment[]}
+ */
+ getAll() {
+ if (!this._data) {
+ return Object.values(syncDataStore);
+ }
+
+ return Object.values(this._data);
+ }
+
+ /**
+ * @returns {Enrollment[]}
+ */
+ getAllActive() {
+ return this.getAll().filter(experiment => experiment.active);
+ }
+
+ _emitExperimentUpdates(experiment) {
+ this.emit(`update:${experiment.slug}`, experiment);
+ if (experiment.branch.feature) {
+ this.emit(`update:${experiment.branch.feature.featureId}`, experiment);
+ }
+ }
+
+ /**
+ * @param {{featureId: string, experimentSlug: string, branchSlug: string}} experimentData
+ */
+ _emitExperimentExposure(experimentData) {
+ this.emit("exposure", experimentData);
+ }
+
+ /**
+ * @param {Enrollment} experiment
+ */
+ _updateSyncStore(experiment) {
+ if (SYNC_ACCESS_FEATURES.includes(experiment.branch.feature?.featureId)) {
+ if (!experiment.active) {
+ // Remove experiments on un-enroll, otherwise nothing to do
+ if (syncDataStore[experiment.slug]) {
+ delete syncDataStore[experiment.slug];
+ }
+ } else {
+ syncDataStore[experiment.slug] = experiment;
+ }
+ Services.prefs.setStringPref(
+ SYNC_DATA_PREF,
+ JSON.stringify(syncDataStore)
+ );
+ }
+ }
+
+ /**
+ * Add an experiment. Short form for .set(slug, experiment)
+ * @param {Enrollment} experiment
+ */
+ addExperiment(experiment) {
+ if (!experiment || !experiment.slug) {
+ throw new Error(
+ `Tried to add an experiment but it didn't have a .slug property.`
+ );
+ }
+ this.set(experiment.slug, experiment);
+ this._emitExperimentUpdates(experiment);
+ this._updateSyncStore(experiment);
+ }
+
+ /**
+ * Merge new properties into the properties of an existing experiment
+ * @param {string} slug
+ * @param {Partial<Enrollment>} newProperties
+ */
+ updateExperiment(slug, newProperties) {
+ const oldProperties = this.get(slug);
+ if (!oldProperties) {
+ throw new Error(
+ `Tried to update experiment ${slug} bug it doesn't exist`
+ );
+ }
+ const updatedExperiment = { ...oldProperties, ...newProperties };
+ this.set(slug, updatedExperiment);
+ this._emitExperimentUpdates(updatedExperiment);
+ this._updateSyncStore(updatedExperiment);
+ }
+}