diff options
Diffstat (limited to 'toolkit/components/messaging-system/experiments/ExperimentStore.jsm')
-rw-r--r-- | toolkit/components/messaging-system/experiments/ExperimentStore.jsm | 196 |
1 files changed, 196 insertions, 0 deletions
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); + } +} |