/* 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/. */ import { SharedDataMap } from "resource://nimbus/lib/SharedDataMap.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { FeatureManifest: "resource://nimbus/FeatureManifest.sys.mjs", PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs", }); const IS_MAIN_PROCESS = Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT; // This branch is used to store experiment data const SYNC_DATA_PREF_BRANCH = "nimbus.syncdatastore."; // This branch is used to store remote rollouts const SYNC_DEFAULTS_PREF_BRANCH = "nimbus.syncdefaultsstore."; let tryJSONParse = data => { try { return JSON.parse(data); } catch (e) {} return null; }; XPCOMUtils.defineLazyGetter(lazy, "syncDataStore", () => { let experimentsPrefBranch = Services.prefs.getBranch(SYNC_DATA_PREF_BRANCH); let defaultsPrefBranch = Services.prefs.getBranch(SYNC_DEFAULTS_PREF_BRANCH); return { _tryParsePrefValue(branch, pref) { try { return tryJSONParse(branch.getStringPref(pref, "")); } catch (e) { /* This is expected if we don't have anything stored */ } return null; }, _trySetPrefValue(branch, pref, value) { try { branch.setStringPref(pref, JSON.stringify(value)); } catch (e) { console.error(e); } }, _trySetTypedPrefValue(pref, value) { let variableType = typeof value; switch (variableType) { case "boolean": Services.prefs.setBoolPref(pref, value); break; case "number": Services.prefs.setIntPref(pref, value); break; case "string": Services.prefs.setStringPref(pref, value); break; case "object": Services.prefs.setStringPref(pref, JSON.stringify(value)); break; } }, _clearBranchChildValues(prefBranch) { const variablesBranch = Services.prefs.getBranch(prefBranch); const prefChildList = variablesBranch.getChildList(""); for (let variable of prefChildList) { variablesBranch.clearUserPref(variable); } }, /** * Given a branch pref returns all child prefs and values * { childPref: value } * where value is parsed to the appropriate type * * @returns {Object[]} */ _getBranchChildValues(prefBranch, featureId) { const branch = Services.prefs.getBranch(prefBranch); const prefChildList = branch.getChildList(""); let values = {}; if (!prefChildList.length) { return null; } for (const childPref of prefChildList) { let prefName = `${prefBranch}${childPref}`; let value = lazy.PrefUtils.getPref(prefName); // Try to parse string values that could be stringified objects if ( lazy.FeatureManifest[featureId]?.variables[childPref]?.type === "json" ) { let parsedValue = tryJSONParse(value); if (parsedValue) { value = parsedValue; } } values[childPref] = value; } return values; }, get(featureId) { let metadata = this._tryParsePrefValue(experimentsPrefBranch, featureId); if (!metadata) { return null; } let prefBranch = `${SYNC_DATA_PREF_BRANCH}${featureId}.`; metadata.branch.feature.value = this._getBranchChildValues( prefBranch, featureId ); return metadata; }, getDefault(featureId) { let metadata = this._tryParsePrefValue(defaultsPrefBranch, featureId); if (!metadata) { return null; } let prefBranch = `${SYNC_DEFAULTS_PREF_BRANCH}${featureId}.`; metadata.branch.feature.value = this._getBranchChildValues( prefBranch, featureId ); return metadata; }, set(featureId, value) { /* If the enrollment branch has variables we store those separately * in pref branches of appropriate type: * { featureId: "foo", value: { enabled: true } } * gets stored as `${SYNC_DATA_PREF_BRANCH}foo.enabled=true` */ if (value.branch?.feature?.value) { for (let variable of Object.keys(value.branch.feature.value)) { let prefName = `${SYNC_DATA_PREF_BRANCH}${featureId}.${variable}`; this._trySetTypedPrefValue( prefName, value.branch.feature.value[variable] ); } this._trySetPrefValue(experimentsPrefBranch, featureId, { ...value, branch: { ...value.branch, feature: { ...value.branch.feature, value: null, }, }, }); } else { this._trySetPrefValue(experimentsPrefBranch, featureId, value); } }, setDefault(featureId, enrollment) { /* We store configuration variables separately in pref branches of * appropriate type: * (feature: "foo") { variables: { enabled: true } } * gets stored as `${SYNC_DEFAULTS_PREF_BRANCH}foo.enabled=true` */ let { feature } = enrollment.branch; for (let variable of Object.keys(feature.value)) { let prefName = `${SYNC_DEFAULTS_PREF_BRANCH}${featureId}.${variable}`; this._trySetTypedPrefValue(prefName, feature.value[variable]); } this._trySetPrefValue(defaultsPrefBranch, featureId, { ...enrollment, branch: { ...enrollment.branch, feature: { ...enrollment.branch.feature, value: null, }, }, }); }, getAllDefaultBranches() { return defaultsPrefBranch.getChildList("").filter( // Filter out remote defaults variable prefs pref => !pref.includes(".") ); }, delete(featureId) { const prefBranch = `${SYNC_DATA_PREF_BRANCH}${featureId}.`; this._clearBranchChildValues(prefBranch); try { experimentsPrefBranch.clearUserPref(featureId); } catch (e) {} }, deleteDefault(featureId) { let prefBranch = `${SYNC_DEFAULTS_PREF_BRANCH}${featureId}.`; this._clearBranchChildValues(prefBranch); try { defaultsPrefBranch.clearUserPref(featureId); } catch (e) {} }, }; }); const DEFAULT_STORE_ID = "ExperimentStoreData"; /** * Returns all feature ids associated with the branch provided. * Fallback for when `featureIds` was not persisted to disk. Can be removed * after bug 1725240 has reached release. * * @param {Branch} branch * @returns {string[]} */ function getAllBranchFeatureIds(branch) { return featuresCompat(branch).map(f => f.featureId); } function featuresCompat(branch) { if (!branch || (!branch.feature && !branch.features)) { return []; } let { features } = branch; // In <=v1.5.0 of the Nimbus API, experiments had single feature if (!features) { features = [branch.feature]; } return features; } export class ExperimentStore extends SharedDataMap { static SYNC_DATA_PREF_BRANCH = SYNC_DATA_PREF_BRANCH; static SYNC_DEFAULTS_PREF_BRANCH = SYNC_DEFAULTS_PREF_BRANCH; constructor(sharedDataKey, options = { isParent: IS_MAIN_PROCESS }) { super(sharedDataKey || DEFAULT_STORE_ID, options); } async init() { await super.init(); this.getAllActiveExperiments().forEach(({ slug, branch, featureIds }) => { (featureIds || getAllBranchFeatureIds(branch)).forEach(featureId => this._emitFeatureUpdate(featureId, "feature-experiment-loaded") ); }); this.getAllActiveRollouts().forEach(({ featureIds }) => { featureIds.forEach(featureId => this._emitFeatureUpdate(featureId, "feature-rollout-loaded") ); }); Services.tm.idleDispatchToMainThread(() => this._cleanupOldRecipes()); } /** * 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. * Does not activate the experiment (send an exposure event) * * @param {string} featureId * @returns {Enrollment|undefined} An active experiment if it exists * @memberof ExperimentStore */ getExperimentForFeature(featureId) { return ( this.getAllActiveExperiments().find( experiment => experiment.featureIds?.includes(featureId) || // Supports enrollment.active && !enrollment.isRollout ); } /** * Returns all active rollouts * @returns {Enrollment[]} */ getAllActiveRollouts() { return this.getAll().filter( enrollment => enrollment.active && enrollment.isRollout ); } /** * Query the store for the remote configuration of a feature * @param {string} featureId The feature we want to query for * @returns {{Rollout}|undefined} Remote defaults if available */ getRolloutForFeature(featureId) { return ( this.getAllActiveRollouts().find(r => r.featureIds.includes(featureId)) || lazy.syncDataStore.getDefault(featureId) ); } /** * Check if an active rollout already exists for a feature. * Does not active the experiment (send an exposure event). * * @param {string} featureId * @returns {boolean} Does an active rollout exist for that feature? */ hasRolloutForFeature(featureId) { if (!featureId) { return false; } return !!this.getRolloutForFeature(featureId); } /** * Remove inactive enrollments older than 6 months */ _cleanupOldRecipes() { // Roughly six months const threshold = 15552000000; const nowTimestamp = new Date().getTime(); const recipesToRemove = this.getAll().filter( experiment => !experiment.active && // Flip the comparison here to catch scenarios in which lastSeen is // invalid or undefined. The result with be a comparison with NaN // which is always false !(nowTimestamp - new Date(experiment.lastSeen).getTime() < threshold) ); this._removeEntriesByKeys(recipesToRemove.map(r => r.slug)); } _emitUpdates(enrollment) { this.emit(`update:${enrollment.slug}`, enrollment); const featureIds = enrollment.featureIds || getAllBranchFeatureIds(enrollment.branch); const reason = enrollment.isRollout ? "rollout-updated" : "experiment-updated"; for (const featureId of featureIds) { this._emitFeatureUpdate(featureId, reason); } } _emitFeatureUpdate(featureId, reason) { this.emit(`featureUpdate:${featureId}`, reason); } _onFeatureUpdate(featureId, callback) { if (this._isReady) { const hasExperiment = this.hasExperimentForFeature(featureId); if (hasExperiment || this.hasRolloutForFeature(featureId)) { callback( `featureUpdate:${featureId}`, hasExperiment ? "experiment-updated" : "rollout-updated" ); } } this.on(`featureUpdate:${featureId}`, callback); } _offFeatureUpdate(featureId, callback) { this.off(`featureUpdate:${featureId}`, callback); } /** * Persists early startup experiments or rollouts * @param {Enrollment} enrollment Experiment or rollout */ _updateSyncStore(enrollment) { let features = featuresCompat(enrollment.branch); for (let feature of features) { if ( lazy.FeatureManifest[feature.featureId]?.isEarlyStartup || feature.isEarlyStartup ) { if (!enrollment.active) { // Remove experiments on un-enroll, no need to check if it exists if (enrollment.isRollout) { lazy.syncDataStore.deleteDefault(feature.featureId); } else { lazy.syncDataStore.delete(feature.featureId); } } else { let updateEnrollmentSyncStore = enrollment.isRollout ? lazy.syncDataStore.setDefault.bind(lazy.syncDataStore) : lazy.syncDataStore.set.bind(lazy.syncDataStore); updateEnrollmentSyncStore(feature.featureId, { ...enrollment, branch: { ...enrollment.branch, feature, // Only store the early startup feature features: null, }, }); } } } } /** * Add an enrollment and notify listeners * @param {Enrollment} enrollment */ addEnrollment(enrollment) { if (!enrollment || !enrollment.slug) { throw new Error( `Tried to add an experiment but it didn't have a .slug property.` ); } this.set(enrollment.slug, enrollment); this._updateSyncStore(enrollment); this._emitUpdates(enrollment); } /** * Merge new properties into the properties of an existing experiment * @param {string} slug * @param {Partial} newProperties */ updateExperiment(slug, newProperties) { const oldProperties = this.get(slug); if (!oldProperties) { throw new Error( `Tried to update experiment ${slug} but it doesn't exist` ); } const updatedExperiment = { ...oldProperties, ...newProperties }; this.set(slug, updatedExperiment); this._updateSyncStore(updatedExperiment); this._emitUpdates(updatedExperiment); } /** * Test only helper for cleanup * * @param slugOrFeatureId Can be called with slug (which removes the SharedDataMap entry) or * with featureId which removes the SyncDataStore entry for the feature */ _deleteForTests(slugOrFeatureId) { super._deleteForTests(slugOrFeatureId); lazy.syncDataStore.deleteDefault(slugOrFeatureId); lazy.syncDataStore.delete(slugOrFeatureId); } }