diff options
Diffstat (limited to 'toolkit/components/messaging-system/experiments')
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); + } +} |