summaryrefslogtreecommitdiffstats
path: root/toolkit/components/nimbus/lib
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/nimbus/lib
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/nimbus/lib')
-rw-r--r--toolkit/components/nimbus/lib/ExperimentManager.sys.mjs1404
-rw-r--r--toolkit/components/nimbus/lib/ExperimentStore.sys.mjs484
-rw-r--r--toolkit/components/nimbus/lib/NimbusFeatureManifest.inc.h18
-rw-r--r--toolkit/components/nimbus/lib/NimbusFeatures.cpp208
-rw-r--r--toolkit/components/nimbus/lib/NimbusFeatures.h51
-rw-r--r--toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs738
-rw-r--r--toolkit/components/nimbus/lib/SharedDataMap.sys.mjs177
7 files changed, 3080 insertions, 0 deletions
diff --git a/toolkit/components/nimbus/lib/ExperimentManager.sys.mjs b/toolkit/components/nimbus/lib/ExperimentManager.sys.mjs
new file mode 100644
index 0000000000..8e1acc4803
--- /dev/null
+++ b/toolkit/components/nimbus/lib/ExperimentManager.sys.mjs
@@ -0,0 +1,1404 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ClientEnvironment: "resource://normandy/lib/ClientEnvironment.sys.mjs",
+ ExperimentStore: "resource://nimbus/lib/ExperimentStore.sys.mjs",
+ FirstStartup: "resource://gre/modules/FirstStartup.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ NormandyUtils: "resource://normandy/lib/NormandyUtils.sys.mjs",
+ PrefUtils: "resource://normandy/lib/PrefUtils.sys.mjs",
+ Sampling: "resource://gre/modules/components-utils/Sampling.sys.mjs",
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+ TelemetryEvents: "resource://normandy/lib/TelemetryEvents.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "log", () => {
+ const { Logger } = ChromeUtils.importESModule(
+ "resource://messaging-system/lib/Logger.sys.mjs"
+ );
+ return new Logger("ExperimentManager");
+});
+
+const TELEMETRY_EVENT_OBJECT = "nimbus_experiment";
+const TELEMETRY_EXPERIMENT_ACTIVE_PREFIX = "nimbus-";
+const TELEMETRY_DEFAULT_EXPERIMENT_TYPE = "nimbus";
+
+const UPLOAD_ENABLED_PREF = "datareporting.healthreport.uploadEnabled";
+const STUDIES_OPT_OUT_PREF = "app.shield.optoutstudies.enabled";
+
+const STUDIES_ENABLED_CHANGED = "nimbus:studies-enabled-changed";
+
+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;
+}
+
+function getFeatureFromBranch(branch, featureId) {
+ return featuresCompat(branch).find(
+ featureConfig => featureConfig.featureId === featureId
+ );
+}
+
+/**
+ * A module for processes Experiment recipes, choosing and storing enrollment state,
+ * and sending experiment-related Telemetry.
+ */
+export class _ExperimentManager {
+ constructor({ id = "experimentmanager", store } = {}) {
+ this.id = id;
+ this.store = store || new lazy.ExperimentStore();
+ this.sessions = new Map();
+ // By default, no extra context.
+ this.extraContext = {};
+ Services.prefs.addObserver(UPLOAD_ENABLED_PREF, this);
+ Services.prefs.addObserver(STUDIES_OPT_OUT_PREF, this);
+
+ // A Map from pref names to pref observers and metadata. See
+ // `_updatePrefObservers` for the full structure.
+ this._prefs = new Map();
+ // A Map from enrollment slugs to a Set of prefs that enrollment is setting
+ // or would set (e.g., if the enrollment is a rollout and there wasn't an
+ // active experiment already setting it).
+ this._prefsBySlug = new Map();
+ }
+
+ get studiesEnabled() {
+ return (
+ Services.prefs.getBoolPref(UPLOAD_ENABLED_PREF) &&
+ Services.prefs.getBoolPref(STUDIES_OPT_OUT_PREF)
+ );
+ }
+
+ /**
+ * 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 = {
+ ...this.extraContext,
+
+ isFirstStartup: lazy.FirstStartup.state === lazy.FirstStartup.IN_PROGRESS,
+
+ get currentDate() {
+ return new Date();
+ },
+ };
+ Object.defineProperty(context, "activeExperiments", {
+ enumerable: true,
+ get: async () => {
+ await this.store.ready();
+ return this.store.getAllActiveExperiments().map(exp => exp.slug);
+ },
+ });
+ Object.defineProperty(context, "activeRollouts", {
+ enumerable: true,
+ get: async () => {
+ await this.store.ready();
+ return this.store.getAllActiveRollouts().map(rollout => rollout.slug);
+ },
+ });
+ Object.defineProperty(context, "previousExperiments", {
+ enumerable: true,
+ get: async () => {
+ await this.store.ready();
+ return this.store
+ .getAll()
+ .filter(enrollment => !enrollment.active && !enrollment.isRollout)
+ .map(exp => exp.slug);
+ },
+ });
+ Object.defineProperty(context, "previousRollouts", {
+ enumerable: true,
+ get: async () => {
+ await this.store.ready();
+ return this.store
+ .getAll()
+ .filter(enrollment => !enrollment.active && enrollment.isRollout)
+ .map(rollout => rollout.slug);
+ },
+ });
+ Object.defineProperty(context, "enrollments", {
+ enumerable: true,
+ get: async () => {
+ await this.store.ready();
+ return this.store.getAll().map(enrollment => enrollment.slug);
+ },
+ });
+ Object.defineProperty(context, "enrollmentsMap", {
+ enumerable: true,
+ get: async () => {
+ await this.store.ready();
+ return this.store.getAll().reduce((acc, enrollment) => {
+ acc[enrollment.slug] = enrollment.branch.slug;
+ return acc;
+ }, {});
+ },
+ });
+ return context;
+ }
+
+ /**
+ * Runs on startup, including before first run.
+ *
+ * @param {object} extraContext extra targeting context provided by the
+ * ambient environment.
+ */
+ async onStartup(extraContext = {}) {
+ await this.store.init();
+ this.extraContext = extraContext;
+
+ const restoredExperiments = this.store.getAllActiveExperiments();
+ const restoredRollouts = this.store.getAllActiveRollouts();
+
+ for (const experiment of restoredExperiments) {
+ this.setExperimentActive(experiment);
+ if (this._restoreEnrollmentPrefs(experiment)) {
+ this._updatePrefObservers(experiment);
+ }
+ }
+ for (const rollout of restoredRollouts) {
+ this.setExperimentActive(rollout);
+ if (this._restoreEnrollmentPrefs(rollout)) {
+ this._updatePrefObservers(rollout);
+ }
+ }
+
+ this.observe();
+ }
+
+ /**
+ * 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)) {
+ await this.updateEnrollment(recipe, source);
+ } else if (isEnrollmentPaused) {
+ lazy.log.debug(`Enrollment is paused for "${slug}"`);
+ } else if (!(await this.isInBucketAllocation(recipe.bucketConfig))) {
+ lazy.log.debug("Client was not enrolled because of the bucket sampling");
+ } else {
+ await this.enroll(recipe, source);
+ }
+ }
+
+ _checkUnseenEnrollments(
+ enrollments,
+ sourceToCheck,
+ recipeMismatches,
+ invalidRecipes,
+ invalidBranches,
+ invalidFeatures,
+ missingLocale,
+ missingL10nIds
+ ) {
+ for (const enrollment of enrollments) {
+ const { slug, source } = enrollment;
+ if (sourceToCheck !== source) {
+ continue;
+ }
+ if (!this.sessions.get(source)?.has(slug)) {
+ lazy.log.debug(`Stopping study for recipe ${slug}`);
+ try {
+ let reason;
+ if (recipeMismatches.includes(slug)) {
+ reason = "targeting-mismatch";
+ } else if (invalidRecipes.includes(slug)) {
+ reason = "invalid-recipe";
+ } else if (invalidBranches.has(slug) || invalidFeatures.has(slug)) {
+ reason = "invalid-branch";
+ } else if (missingLocale.includes(slug)) {
+ reason = "l10n-missing-locale";
+ } else if (missingL10nIds.has(slug)) {
+ reason = "l10n-missing-entry";
+ } else {
+ reason = "recipe-not-seen";
+ }
+ this.unenroll(slug, reason);
+ } catch (err) {
+ console.error(err);
+ }
+ }
+ }
+ }
+
+ /**
+ * Removes stored enrollments that were not seen after syncing with Remote Settings
+ * Runs when the all recipes been processed during an update, including at first run.
+ * @param {string} sourceToCheck
+ * @param {object} options Extra context used in telemetry reporting
+ * @param {string[]} options.recipeMismatches
+ * The list of experiments that do not match targeting.
+ * @param {string[]} options.invalidRecipes
+ * The list of recipes that do not match
+ * @param {Map<string, string[]>} options.invalidBranches
+ * A mapping of experiment slugs to a list of branches that failed
+ * feature validation.
+ * @param {Map<string, string[]>} options.invalidFeatures
+ * The mapping of experiment slugs to a list of invalid feature IDs.
+ * @param {string[]} options.missingLocale
+ * The list of experiment slugs missing an entry in the localization
+ * table for the current locale.
+ * @param {Map<string, string[]>} options.missingL10nIds
+ * The mapping of experiment slugs to the IDs of localization entries
+ * missing from the current locale.
+ * @param {string | null} options.locale
+ * The current locale.
+ * @param {boolean} options.validationEnabled
+ * Whether or not schema validation was enabled.
+ */
+ onFinalize(
+ sourceToCheck,
+ {
+ recipeMismatches = [],
+ invalidRecipes = [],
+ invalidBranches = new Map(),
+ invalidFeatures = new Map(),
+ missingLocale = [],
+ missingL10nIds = new Map(),
+ locale = null,
+ validationEnabled = true,
+ } = {}
+ ) {
+ if (!sourceToCheck) {
+ throw new Error("When calling onFinalize, you must specify a source.");
+ }
+ const activeExperiments = this.store.getAllActiveExperiments();
+ const activeRollouts = this.store.getAllActiveRollouts();
+ this._checkUnseenEnrollments(
+ activeExperiments,
+ sourceToCheck,
+ recipeMismatches,
+ invalidRecipes,
+ invalidBranches,
+ invalidFeatures,
+ missingLocale,
+ missingL10nIds
+ );
+ this._checkUnseenEnrollments(
+ activeRollouts,
+ sourceToCheck,
+ recipeMismatches,
+ invalidRecipes,
+ invalidBranches,
+ invalidFeatures,
+ missingLocale,
+ missingL10nIds
+ );
+
+ // If schema validation is disabled, then we will never send these
+ // validation failed telemetry events
+ if (validationEnabled) {
+ for (const slug of invalidRecipes) {
+ this.sendValidationFailedTelemetry(slug, "invalid-recipe");
+ }
+ for (const [slug, branches] of invalidBranches.entries()) {
+ for (const branch of branches) {
+ this.sendValidationFailedTelemetry(slug, "invalid-branch", {
+ branch,
+ });
+ }
+ }
+ for (const [slug, featureIds] of invalidFeatures.entries()) {
+ for (const featureId of featureIds) {
+ this.sendValidationFailedTelemetry(slug, "invalid-feature", {
+ feature: featureId,
+ });
+ }
+ }
+ }
+
+ if (locale) {
+ for (const slug of missingLocale.values()) {
+ this.sendValidationFailedTelemetry(slug, "l10n-missing-locale", {
+ locale,
+ });
+ }
+
+ for (const [slug, ids] of missingL10nIds.entries()) {
+ this.sendValidationFailedTelemetry(slug, "l10n-missing-entry", {
+ l10n_ids: ids.join(","),
+ locale,
+ });
+ }
+ }
+
+ 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) {
+ lazy.log.debug("Cannot enroll if recipe bucketConfig is not set.");
+ return false;
+ }
+
+ let id;
+ if (bucketConfig.randomizationUnit === "normandy_id") {
+ id = lazy.ClientEnvironment.userId;
+ } else {
+ // Others not currently supported.
+ lazy.log.debug(
+ `Invalid randomizationUnit: ${bucketConfig.randomizationUnit}`
+ );
+ return false;
+ }
+
+ return lazy.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
+ * @param {object} options
+ * @param {boolean} options.reenroll - Allow re-enrollment. Only allowed for rollouts.
+ * @returns {Promise<Enrollment>} The experiment object stored in the data store
+ * @rejects {Error}
+ * @memberof _ExperimentManager
+ */
+ async enroll(recipe, source, { reenroll = false } = {}) {
+ let { slug, branches } = recipe;
+
+ const enrollment = this.store.get(slug);
+
+ if (
+ enrollment &&
+ (enrollment.isActive || !enrollment.isRollout || !reenroll)
+ ) {
+ this.sendFailureTelemetry("enrollFailed", slug, "name-conflict");
+ throw new Error(`An experiment with the slug "${slug}" already exists.`);
+ }
+
+ let storeLookupByFeature = recipe.isRollout
+ ? this.store.getRolloutForFeature.bind(this.store)
+ : this.store.hasExperimentForFeature.bind(this.store);
+ const branch = await this.chooseBranch(slug, branches);
+ const features = featuresCompat(branch);
+ for (let feature of features) {
+ if (storeLookupByFeature(feature?.featureId)) {
+ lazy.log.debug(
+ `Skipping enrollment for "${slug}" because there is an existing ${
+ recipe.isRollout ? "rollout" : "experiment"
+ } for this feature.`
+ );
+ this.sendFailureTelemetry("enrollFailed", slug, "feature-conflict");
+
+ return null;
+ }
+ }
+
+ return this._enroll(recipe, branch, source);
+ }
+
+ _enroll(
+ {
+ slug,
+ experimentType = TELEMETRY_DEFAULT_EXPERIMENT_TYPE,
+ userFacingName,
+ userFacingDescription,
+ featureIds,
+ isRollout,
+ localizations,
+ },
+ branch,
+ source,
+ options = {}
+ ) {
+ const { prefs, prefsToSet } = this._getPrefsForBranch(branch, isRollout);
+
+ /** @type {Enrollment} */
+ const experiment = {
+ slug,
+ branch,
+ active: true,
+ experimentType,
+ source,
+ userFacingName,
+ userFacingDescription,
+ lastSeen: new Date().toJSON(),
+ featureIds,
+ prefs,
+ };
+
+ if (localizations) {
+ experiment.localizations = localizations;
+ }
+
+ if (typeof isRollout !== "undefined") {
+ experiment.isRollout = isRollout;
+ }
+
+ // Tag this as a forced enrollment. This prevents all unenrolling unless
+ // manually triggered from about:studies
+ if (options.force) {
+ experiment.force = true;
+ }
+
+ if (isRollout) {
+ experiment.experimentType = "rollout";
+ this.store.addEnrollment(experiment);
+ this.setExperimentActive(experiment);
+ } else {
+ this.store.addEnrollment(experiment);
+ this.setExperimentActive(experiment);
+ }
+ this.sendEnrollmentTelemetry(experiment);
+
+ this._setEnrollmentPrefs(prefsToSet);
+ this._updatePrefObservers(experiment);
+
+ lazy.log.debug(
+ `New ${isRollout ? "rollout" : "experiment"} started: ${slug}, ${
+ branch.slug
+ }`
+ );
+
+ return experiment;
+ }
+
+ forceEnroll(recipe, branch, source = "force-enrollment") {
+ /**
+ * If we happen to be enrolled in an experiment for the same feature
+ * we need to unenroll from that experiment.
+ * If the experiment has the same slug after unenrollment adding it to the
+ * store will overwrite the initial experiment.
+ */
+ const features = featuresCompat(branch);
+ for (let feature of features) {
+ const isRollout = recipe.isRollout ?? false;
+ let enrollment = isRollout
+ ? this.store.getRolloutForFeature(feature?.featureId)
+ : this.store.getExperimentForFeature(feature?.featureId);
+ if (enrollment) {
+ lazy.log.debug(
+ `Existing ${
+ isRollout ? "rollout" : "experiment"
+ } found for the same feature ${feature.featureId}, unenrolling.`
+ );
+
+ this.unenroll(enrollment.slug, source);
+ }
+ }
+
+ recipe.userFacingName = `${recipe.userFacingName} - Forced enrollment`;
+
+ const slug = `optin-${recipe.slug}`;
+ const enrollment = this._enroll(
+ {
+ ...recipe,
+ slug,
+ },
+ branch,
+ source,
+ { force: true }
+ );
+
+ Services.obs.notifyObservers(null, "nimbus:enrollments-updated", slug);
+
+ return enrollment;
+ }
+
+ /**
+ * Update an enrollment that was already set
+ *
+ * @param {RecipeArgs} recipe
+ * @returns {boolean} whether the enrollment is still active
+ */
+ async updateEnrollment(recipe, source) {
+ /** @type Enrollment */
+ const enrollment = this.store.get(recipe.slug);
+
+ // Don't update experiments that were already unenrolled.
+ if (enrollment.active === false && !recipe.isRollout) {
+ lazy.log.debug(`Enrollment ${recipe.slug} has expired, aborting.`);
+ return false;
+ }
+
+ if (recipe.isRollout) {
+ if (!(await this.isInBucketAllocation(recipe.bucketConfig))) {
+ lazy.log.debug(
+ `No longer meet bucketing for "${recipe.slug}"; unenrolling...`
+ );
+ this.unenroll(recipe.slug, "bucketing");
+ return false;
+ } else if (
+ !enrollment.active &&
+ enrollment.unenrollReason !== "individual-opt-out"
+ ) {
+ lazy.log.debug(`Re-enrolling in rollout "${recipe.slug}`);
+ return !!(await this.enroll(recipe, source, { reenroll: true }));
+ }
+ }
+
+ // Stay in the same branch, don't re-sample every time.
+ const branch = recipe.branches.find(
+ branch => branch.slug === enrollment.branch.slug
+ );
+
+ if (!branch) {
+ // Our branch has been removed. Unenroll.
+ this.unenroll(recipe.slug, "branch-removed");
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Stop an enrollment that is currently active
+ *
+ * @param {string} slug
+ * The slug of the enrollment to stop.
+ * @param {string} reason
+ * An optional reason for the unenrollment.
+ *
+ * This will be reported in telemetry.
+ */
+ unenroll(slug, reason = "unknown") {
+ const enrollment = this.store.get(slug);
+ if (!enrollment) {
+ this.sendFailureTelemetry("unenrollFailed", slug, "does-not-exist");
+ throw new Error(`Could not find an experiment with the slug "${slug}"`);
+ }
+
+ this._unenroll(enrollment, { reason });
+ }
+
+ /**
+ * Stop an enrollment that is currently active.
+ *
+ * @param {Enrollment} enrollment
+ * The enrollment to end.
+ *
+ * @param {object} options
+ * @param {string} options.reason
+ * An optional reason for the unenrollment.
+ *
+ * This will be reported in telemetry.
+ *
+ * @param {object?} options.changedPref
+ * If the unenrollment was due to pref change, this will contain the
+ * information about the pref that changed.
+ *
+ * @param {string} options.changedPref.name
+ * The name of the pref that caused the unenrollment.
+ *
+ * @param {string} options.changedPref.branch
+ * The branch that was changed ("user" or "default").
+ */
+ _unenroll(
+ enrollment,
+ { reason = "unknown", changedPref = undefined, duringRestore = false } = {}
+ ) {
+ const { slug } = enrollment;
+
+ if (!enrollment.active) {
+ this.sendFailureTelemetry("unenrollFailed", slug, "already-unenrolled");
+ throw new Error(
+ `Cannot stop experiment "${slug}" because it is already expired`
+ );
+ }
+
+ lazy.TelemetryEnvironment.setExperimentInactive(slug);
+ // We also need to set the experiment inactive in the Glean Experiment API
+ Services.fog.setExperimentInactive(slug);
+ this.store.updateExperiment(slug, {
+ active: false,
+ unenrollReason: reason,
+ });
+
+ lazy.TelemetryEvents.sendEvent(
+ "unenroll",
+ TELEMETRY_EVENT_OBJECT,
+ slug,
+ Object.assign(
+ {
+ reason,
+ branch: enrollment.branch.slug,
+ },
+ typeof changedPref !== "undefined"
+ ? { changedPref: changedPref.name }
+ : {}
+ )
+ );
+ // Sent Glean event equivalent
+ Glean.nimbusEvents.unenrollment.record(
+ Object.assign(
+ {
+ experiment: slug,
+ branch: enrollment.branch.slug,
+ reason,
+ },
+ typeof changedPref !== "undefined"
+ ? { changed_pref: changedPref.name }
+ : {}
+ )
+ );
+
+ this._unsetEnrollmentPrefs(enrollment, { changedPref, duringRestore });
+
+ lazy.log.debug(`Recipe unenrolled: ${slug}`);
+ }
+
+ /**
+ * Unenroll from all active studies if user opts out.
+ */
+ observe(aSubject, aTopic, aPrefName) {
+ if (!this.studiesEnabled) {
+ for (const { slug } of this.store.getAllActiveExperiments()) {
+ this.unenroll(slug, "studies-opt-out");
+ }
+ for (const { slug } of this.store.getAllActiveRollouts()) {
+ this.unenroll(slug, "studies-opt-out");
+ }
+ }
+
+ Services.obs.notifyObservers(null, STUDIES_ENABLED_CHANGED);
+ }
+
+ /**
+ * Send Telemetry for undesired event
+ *
+ * @param {string} eventName
+ * @param {string} slug
+ * @param {string} reason
+ */
+ sendFailureTelemetry(eventName, slug, reason) {
+ lazy.TelemetryEvents.sendEvent(eventName, TELEMETRY_EVENT_OBJECT, slug, {
+ reason,
+ });
+ if (eventName == "enrollFailed") {
+ Glean.nimbusEvents.enrollFailed.record({
+ experiment: slug,
+ reason,
+ });
+ } else if (eventName == "unenrollFailed") {
+ Glean.nimbusEvents.unenrollFailed.record({
+ experiment: slug,
+ reason,
+ });
+ }
+ }
+
+ sendValidationFailedTelemetry(slug, reason, extra) {
+ lazy.TelemetryEvents.sendEvent(
+ "validationFailed",
+ TELEMETRY_EVENT_OBJECT,
+ slug,
+ {
+ reason,
+ ...extra,
+ }
+ );
+ Glean.nimbusEvents.validationFailed.record({
+ experiment: slug,
+ reason,
+ ...extra,
+ });
+ }
+
+ /**
+ *
+ * @param {Enrollment} experiment
+ */
+ sendEnrollmentTelemetry({ slug, branch, experimentType }) {
+ lazy.TelemetryEvents.sendEvent("enroll", TELEMETRY_EVENT_OBJECT, slug, {
+ experimentType,
+ branch: branch.slug,
+ });
+ Glean.nimbusEvents.enrollment.record({
+ experiment: slug,
+ branch: branch.slug,
+ experiment_type: experimentType,
+ });
+ }
+
+ /**
+ * Sets Telemetry when activating an experiment.
+ *
+ * @param {Enrollment} experiment
+ */
+ setExperimentActive(experiment) {
+ lazy.TelemetryEnvironment.setExperimentActive(
+ experiment.slug,
+ experiment.branch.slug,
+ {
+ type: `${TELEMETRY_EXPERIMENT_ACTIVE_PREFIX}${experiment.experimentType}`,
+ }
+ );
+ // Report the experiment to the Glean Experiment API
+ Services.fog.setExperimentActive(experiment.slug, experiment.branch.slug, {
+ type: `${TELEMETRY_EXPERIMENT_ACTIVE_PREFIX}${experiment.experimentType}`,
+ });
+ }
+
+ /**
+ * 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 if not 100% enrollment.
+ */
+ async generateTestIds(recipe) {
+ // Older recipe structure had bucket config values at the top level while
+ // newer recipes group them into a bucketConfig object
+ const { slug, branches, namespace, start, count, total } = {
+ ...recipe,
+ ...recipe.bucketConfig,
+ };
+ const branchValues = {};
+ const includeNot = count < total;
+
+ 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 + includeNot) {
+ const id = lazy.NormandyUtils.generateUuid();
+ const enrolls = await lazy.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;
+ lazy.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 = lazy.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 lazy.Sampling.ratioSample(input, ratios);
+ return branches[index];
+ }
+
+ /**
+ * Generate the list of prefs a recipe will set.
+ *
+ * @params {object} branch The recipe branch that will be enrolled.
+ * @params {boolean} isRollout Whether or not this recipe is a rollout.
+ *
+ * @returns {object} An object with the following keys:
+ *
+ * `prefs`:
+ * The full list of prefs that this recipe would set,
+ * if there are no conflicts. This will include prefs
+ * that, for example, will not be set because this
+ * enrollment is a rollout and there is an active
+ * experiment that set the same pref.
+ *
+ * `prefsToSet`:
+ * Prefs that should be set once enrollment is
+ * complete.
+ */
+ _getPrefsForBranch(branch, isRollout = false) {
+ const prefs = [];
+ const prefsToSet = [];
+
+ const getConflictingEnrollment = this._makeEnrollmentCache(isRollout);
+
+ for (const { featureId, value: featureValue } of featuresCompat(branch)) {
+ const feature = lazy.NimbusFeatures[featureId];
+
+ if (!feature) {
+ continue;
+ }
+
+ // It is possible to enroll in both an experiment and a rollout, so we
+ // need to check if we have another enrollment for the same feature.
+ const conflictingEnrollment = getConflictingEnrollment(featureId);
+
+ for (const [variable, value] of Object.entries(featureValue)) {
+ const setPref = feature.getSetPref(variable);
+
+ if (setPref) {
+ const { pref: prefName, branch: prefBranch } = setPref;
+
+ let originalValue;
+ const conflictingPref = conflictingEnrollment?.prefs?.find(
+ p => p.name === prefName
+ );
+
+ if (conflictingPref) {
+ // If there is another enrollment that has already set the pref we
+ // care about, we use its stored originalValue.
+ originalValue = conflictingPref.originalValue;
+ } else if (
+ prefBranch === "user" &&
+ !Services.prefs.prefHasUserValue(prefName)
+ ) {
+ // If there is a default value set, then attempting to read the user
+ // branch would result in returning the default branch value.
+ originalValue = null;
+ } else {
+ originalValue = lazy.PrefUtils.getPref(prefName, {
+ branch: prefBranch,
+ });
+ }
+
+ prefs.push({
+ name: prefName,
+ branch: prefBranch,
+ featureId,
+ variable,
+ originalValue,
+ });
+
+ // An experiment takes precedence if there is already a pref set.
+ if (!isRollout || !conflictingPref) {
+ prefsToSet.push({
+ name: prefName,
+ value,
+ prefBranch,
+ });
+ }
+ }
+ }
+ }
+
+ return { prefs, prefsToSet };
+ }
+
+ /**
+ * Set a list of prefs from enrolling in an experiment or rollout.
+ *
+ * The ExperimentManager's pref observers will be disabled while setting each
+ * pref so as not to accidentally unenroll an existing rollout that an
+ * experiment would override.
+ *
+ * @param {object[]} prefsToSet
+ * A list of objects containing the prefs to set.
+ *
+ * Each object has the following properties:
+ *
+ * * `name`: The name of the pref.
+ * * `value`: The value of the pref.
+ * * `prefBranch`: The branch to set the pref on (either "user" or "default").
+ */
+ _setEnrollmentPrefs(prefsToSet) {
+ for (const { name, value, prefBranch } of prefsToSet) {
+ const entry = this._prefs.get(name);
+
+ // If another enrollment exists that has set this pref, temporarily
+ // disable the pref observer so as not to cause unenrollment.
+ if (entry) {
+ entry.enrollmentChanging = true;
+ }
+
+ lazy.PrefUtils.setPref(name, value, { branch: prefBranch });
+
+ if (entry) {
+ entry.enrollmentChanging = false;
+ }
+ }
+ }
+
+ /**
+ * Unset prefs set during this enrollment.
+ *
+ * If this enrollment is an experiment and there is an existing rollout that
+ * would set a pref that was covered by this enrollment, the pref will be
+ * updated to that rollout's value.
+ *
+ * Otherwise, it will be set to the original value from before the enrollment
+ * began.
+ *
+ * @param {Enrollment} enrollment
+ * The enrollment that has ended.
+ *
+ * @param {object} options
+ *
+ * @param {object?} options.changedPref
+ * If provided, a changed pref that caused the unenrollment that
+ * triggered unsetting these prefs. This is provided as to not
+ * overwrite a changed pref with an original value.
+ *
+ * @param {string} options.changedPref.name
+ * The name of the changed pref.
+ *
+ * @param {string} options.changedPref.branch
+ * The branch that was changed ("user" or "default").
+ *
+ * @param {boolean} options.duringRestore
+ * The unenrollment was caused during restore.
+ */
+ _unsetEnrollmentPrefs(enrollment, { changedPref, duringRestore } = {}) {
+ if (!enrollment.prefs?.length) {
+ return;
+ }
+
+ const getConflictingEnrollment = this._makeEnrollmentCache(
+ enrollment.isRollout
+ );
+
+ for (const pref of enrollment.prefs) {
+ this._removePrefObserver(pref.name, enrollment.slug);
+
+ if (
+ changedPref?.name == pref.name &&
+ changedPref.branch === pref.branch
+ ) {
+ // Resetting the original value would overwite the pref the user just
+ // set. Skip it.
+ continue;
+ }
+
+ let newValue = pref.originalValue;
+
+ // If we are unenrolling from an experiment during a restore, we must
+ // ignore any potential conflicting rollout in the store, because its
+ // hasn't gone through `_restoreEnrollmentPrefs`, which might also cause
+ // it to unenroll.
+ //
+ // Both enrollments will have the same `originalValue` stored, so it will
+ // always be restored.
+ if (!duringRestore || enrollment.isRollout) {
+ const conflictingEnrollment = getConflictingEnrollment(pref.featureId);
+ const conflictingPref = conflictingEnrollment?.prefs?.find(
+ p => p.name === pref.name
+ );
+
+ if (conflictingPref) {
+ if (enrollment.isRollout) {
+ // If we are unenrolling from a rollout, we have an experiment that
+ // has set the pref. Since experiments take priority, we do not unset
+ // it.
+ continue;
+ } else {
+ // If we are an unenrolling from an experiment, we have a rollout that would
+ // set the same pref, so we update the pref to that value instead of
+ // the original value.
+ newValue = getFeatureFromBranch(
+ conflictingEnrollment.branch,
+ pref.featureId
+ ).value[pref.variable];
+ }
+ }
+ }
+
+ // If another enrollment exists that has set this pref, temporarily
+ // disable the pref observer so as not to cause unenrollment when we
+ // update the pref to its value.
+ const entry = this._prefs.get(pref.name);
+ if (entry) {
+ entry.enrollmentChanging = true;
+ }
+
+ lazy.PrefUtils.setPref(pref.name, newValue, {
+ branch: pref.branch,
+ });
+
+ if (entry) {
+ entry.enrollmentChanging = false;
+ }
+ }
+ }
+
+ /**
+ * Restore the prefs set by an enrollment.
+ *
+ * @param {object} enrollment The enrollment.
+ * @param {object} enrollment.branch The branch that was enrolled.
+ * @param {object[]} enrollment.prefs The prefs that are set by the enrollment.
+ * @param {object[]} enrollment.isRollout The prefs that are set by the enrollment.
+ *
+ * @returns {boolean} Whether the restore was successful. If false, the
+ * enrollment has ended.
+ */
+ _restoreEnrollmentPrefs(enrollment) {
+ const { branch, prefs = [], isRollout } = enrollment;
+
+ if (!prefs?.length) {
+ return false;
+ }
+
+ const featuresById = Object.assign(
+ ...featuresCompat(branch).map(f => ({ [f.featureId]: f }))
+ );
+
+ for (const { name, featureId, variable } of prefs) {
+ // If the feature no longer exists, unenroll.
+ if (!Object.hasOwn(lazy.NimbusFeatures, featureId)) {
+ this._unenroll(enrollment, {
+ reason: "invalid-feature",
+ duringRestore: true,
+ });
+ return false;
+ }
+
+ const variables = lazy.NimbusFeatures[featureId].manifest.variables;
+
+ // If the feature is missing a variable that set a pref, unenroll.
+ if (!Object.hasOwn(variables, variable)) {
+ this._unenroll(enrollment, {
+ reason: "pref-variable-missing",
+ duringRestore: true,
+ });
+ return false;
+ }
+
+ const variableDef = variables[variable];
+
+ // If the variable is no longer a pref-setting variable, unenroll.
+ if (!Object.hasOwn(variableDef, "setPref")) {
+ this._unenroll(enrollment, {
+ reason: "pref-variable-no-longer",
+ duringRestore: true,
+ });
+ return false;
+ }
+
+ // If the variable is setting a different preference, unenroll.
+ const prefName =
+ typeof variableDef.setPref === "object"
+ ? variableDef.setPref.pref
+ : variableDef.setPref;
+
+ if (prefName !== name) {
+ this._unenroll(enrollment, {
+ reason: "pref-variable-changed",
+ duringRestore: true,
+ });
+ return false;
+ }
+ }
+
+ for (const { name, branch: prefBranch, featureId, variable } of prefs) {
+ // User prefs are already persisted.
+ if (prefBranch === "user") {
+ continue;
+ }
+
+ // If we are a rollout, we need to check for an existing experiment that
+ // has set the same pref. If so, we do not need to set the pref because
+ // experiments take priority.
+ if (isRollout) {
+ const conflictingEnrollment =
+ this.store.getExperimentForFeature(featureId);
+ const conflictingPref = conflictingEnrollment?.prefs?.find(
+ p => p.name === name
+ );
+
+ if (conflictingPref) {
+ continue;
+ }
+ }
+
+ const value = featuresById[featureId].value[variable];
+
+ if (prefBranch !== "user") {
+ lazy.PrefUtils.setPref(name, value, { branch: prefBranch });
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Make a cache to look up enrollments of the oppposite kind by feature ID.
+ *
+ * @param {boolean} isRollout Whether or not the current enrollment is a
+ * rollout. If true, the cache will return
+ * experiments. If false, the cache will return
+ * rollouts.
+ *
+ * @returns {function} The cache, as a callable function.
+ */
+ _makeEnrollmentCache(isRollout) {
+ const getOtherEnrollment = (
+ isRollout
+ ? this.store.getExperimentForFeature
+ : this.store.getRolloutForFeature
+ ).bind(this.store);
+
+ const conflictingEnrollments = {};
+ return featureId => {
+ if (!Object.hasOwn(conflictingEnrollments, featureId)) {
+ conflictingEnrollments[featureId] = getOtherEnrollment(featureId);
+ }
+
+ return conflictingEnrollments[featureId];
+ };
+ }
+
+ /**
+ * Update the set of observers with prefs set by the given enrollment.
+ *
+ * @param {Enrollment} enrollment
+ * The enrollment that is setting prefs.
+ */
+ _updatePrefObservers({ slug, prefs }) {
+ if (!prefs?.length) {
+ return;
+ }
+
+ for (const pref of prefs) {
+ const { name } = pref;
+
+ if (!this._prefs.has(name)) {
+ const observer = (aSubject, aTopic, aData) => {
+ // This observer will be called for changes to `name` as well as any
+ // other pref that begins with `name.`, so we have to filter to
+ // exactly the pref we care about.
+ if (aData === name) {
+ this._onExperimentPrefChanged(pref);
+ }
+ };
+ const entry = {
+ slugs: new Set([slug]),
+ enrollmentChanging: false,
+ observer,
+ };
+
+ Services.prefs.addObserver(name, observer);
+
+ this._prefs.set(name, entry);
+ } else {
+ this._prefs.get(name).slugs.add(slug);
+ }
+
+ if (!this._prefsBySlug.has(slug)) {
+ this._prefsBySlug.set(slug, new Set([name]));
+ } else {
+ this._prefsBySlug.get(slug).add(name);
+ }
+ }
+ }
+
+ /**
+ * Remove an entry for the pref observer for the given pref and slug.
+ *
+ * If there are no more enrollments listening to a pref, the observer will be removed.
+ *
+ * This is called when an enrollment is ending.
+ *
+ * @param {string} name The name of the pref.
+ * @param {string} slug The slug of the enrollment that is being unenrolled.
+ */
+ _removePrefObserver(name, slug) {
+ // Update the pref observer that the current enrollment is no longer
+ // involved in the pref.
+ //
+ // If no enrollments have a variable setting the pref, then we can remove
+ // the observers.
+ const entry = this._prefs.get(name);
+
+ // If this is happening due to a pref change, the observers will already be removed.
+ if (entry) {
+ entry.slugs.delete(slug);
+ if (entry.slugs.size == 0) {
+ Services.prefs.removeObserver(name, entry.observer);
+ this._prefs.delete(name);
+ }
+ }
+
+ const bySlug = this._prefsBySlug.get(slug);
+ if (bySlug) {
+ bySlug.delete(name);
+ if (bySlug.size == 0) {
+ this._prefsBySlug.delete(slug);
+ }
+ }
+ }
+
+ /**
+ * Handle a change to a pref set by enrollments by ending those enrollments.
+ *
+ * @param {object} pref
+ * Information about the pref that was changed.
+ *
+ * @param {string} pref.name
+ * The name of the pref that was changed.
+ *
+ * @param {string} pref.branch
+ * The branch enrollments set the pref on.
+ *
+ * @param {string} pref.featureId
+ * The feature ID of the feature containing the variable that set the
+ * pref.
+ *
+ * @param {string} pref.variable
+ * The variable in the given feature whose value determined the pref's
+ * value.
+ */
+ _onExperimentPrefChanged(pref) {
+ const entry = this._prefs.get(pref.name);
+ // If this was triggered while we are enrolling or unenrolling from an
+ // experiment, then we don't want to unenroll from the rollout because the
+ // experiment's value is taking precendence.
+ //
+ // Otherwise, all enrollments that set the variable corresponding to this
+ // pref must be unenrolled.
+ if (entry.enrollmentChanging) {
+ return;
+ }
+
+ // Copy the `Set` into an `Array` because we modify the set later in
+ // `_removePrefObserver` and we need to iterate over it multiple times.
+ const slugs = Array.from(entry.slugs);
+
+ // Remove all pref observers set by enrollments. We are potentially about
+ // to set these prefs during unenrollment, so we don't want to trigger
+ // them and cause nested unenrollments.
+ for (const slug of slugs) {
+ const toRemove = Array.from(this._prefsBySlug.get(slug) ?? []);
+ for (const name of toRemove) {
+ this._removePrefObserver(name, slug);
+ }
+ }
+
+ // Unenroll from the rollout first to save calls to setPref.
+ const enrollments = Array.from(slugs).map(slug => this.store.get(slug));
+
+ // There is a maximum of two enrollments (one experiment and one rollout).
+ if (enrollments.length == 2) {
+ // Order enrollments so that we unenroll from the rollout first.
+ if (!enrollments[0].isRollout) {
+ enrollments.reverse();
+ }
+ }
+
+ // We want to know what branch was changed so we can know if we should
+ // restore prefs. (e.g., if we have a pref set on the user branch and the
+ // user branch changed, we do not want to then overwrite the user's choice).
+
+ // This is not complicated if a pref simply changed. However, we also must
+ // detect `nsIPrefBranch::clearUserPref()`, which wipes out the user branch
+ // and leaves the default branch untouched. That is where this gets
+ // complicated:
+
+ let branch;
+ if (Services.prefs.prefHasUserValue(pref.name)) {
+ // If there is a user branch value, then the user branch changed.
+ branch = "user";
+ } else if (!Services.prefs.prefHasDefaultValue(pref.name)) {
+ // If there is not default branch value, then the user branch must have
+ // been cleared becuase you cannot clear the default branch.
+ branch = "user";
+ } else if (pref.branch === "default") {
+ const feature = getFeatureFromBranch(
+ enrollments.at(-1).branch,
+ pref.featureId
+ );
+ const expectedValue = feature.value[pref.variable];
+ const value = lazy.PrefUtils.getPref(pref.name, { branch: pref.branch });
+
+ if (value === expectedValue) {
+ // If the pref was set on the default branch and still matches the
+ // expected value, then the user branch must have been cleared.
+ branch = "user";
+ } else {
+ branch = "default";
+ }
+ } else {
+ // If the pref was set on the user branch and we don't have a user branch
+ // value, then the user branch must have been cleared.
+ branch = "user";
+ }
+
+ const changedPref = {
+ name: pref.name,
+ branch,
+ };
+
+ for (const enrollment of enrollments) {
+ this._unenroll(enrollment, { reason: "changed-pref", changedPref });
+ }
+ }
+}
+
+export const ExperimentManager = new _ExperimentManager();
diff --git a/toolkit/components/nimbus/lib/ExperimentStore.sys.mjs b/toolkit/components/nimbus/lib/ExperimentStore.sys.mjs
new file mode 100644
index 0000000000..7fd7fd987e
--- /dev/null
+++ b/toolkit/components/nimbus/lib/ExperimentStore.sys.mjs
@@ -0,0 +1,484 @@
+/* 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";
+
+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;
+};
+ChromeUtils.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 <v1.3.0, which was when .featureIds was added
+ getAllBranchFeatureIds(experiment.branch).includes(featureId)
+ // Default to the pref store if data is not yet ready
+ ) || lazy.syncDataStore.get(featureId)
+ );
+ }
+
+ /**
+ * Check if an active experiment already exists for a feature.
+ * Does not activate the experiment (send an exposure event)
+ *
+ * @param {string} featureId
+ * @returns {boolean} Does an active experiment exist for that feature?
+ * @memberof ExperimentStore
+ */
+ hasExperimentForFeature(featureId) {
+ if (!featureId) {
+ return false;
+ }
+ return !!this.getExperimentForFeature(featureId);
+ }
+
+ /**
+ * @returns {Enrollment[]}
+ */
+ getAll() {
+ let data = [];
+ try {
+ data = Object.values(this._data || {});
+ } catch (e) {
+ console.error(e);
+ }
+
+ return data;
+ }
+
+ /**
+ * Returns all active experiments
+ * @returns {Enrollment[]}
+ */
+ getAllActiveExperiments() {
+ return this.getAll().filter(
+ enrollment => 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<Enrollment>} 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);
+ }
+}
diff --git a/toolkit/components/nimbus/lib/NimbusFeatureManifest.inc.h b/toolkit/components/nimbus/lib/NimbusFeatureManifest.inc.h
new file mode 100644
index 0000000000..4d3190635c
--- /dev/null
+++ b/toolkit/components/nimbus/lib/NimbusFeatureManifest.inc.h
@@ -0,0 +1,18 @@
+/* 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/. */
+
+Maybe<nsCString> GetNimbusFallbackPrefName(const nsACString& aFeatureId,
+ const nsACString& aVariable) {
+ nsAutoCString manifestKey;
+ manifestKey.Append(aFeatureId);
+ manifestKey.Append("_");
+ manifestKey.Append(aVariable);
+
+ for (const auto& pair : NIMBUS_FALLBACK_PREFS) {
+ if (pair.first.Equals(manifestKey.get())) {
+ return Some(pair.second);
+ }
+ }
+ return Nothing{};
+}
diff --git a/toolkit/components/nimbus/lib/NimbusFeatures.cpp b/toolkit/components/nimbus/lib/NimbusFeatures.cpp
new file mode 100644
index 0000000000..58481526ed
--- /dev/null
+++ b/toolkit/components/nimbus/lib/NimbusFeatures.cpp
@@ -0,0 +1,208 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* 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/. */
+
+#include "mozilla/browser/NimbusFeatures.h"
+#include "mozilla/browser/NimbusFeatureManifest.h"
+#include "mozilla/Telemetry.h"
+#include "mozilla/Try.h"
+#include "mozilla/dom/ScriptSettings.h"
+#include "jsapi.h"
+#include "js/JSON.h"
+#include "nsJSUtils.h"
+
+namespace mozilla {
+
+static nsTHashSet<nsCString> sExposureFeatureSet;
+
+void NimbusFeatures::GetPrefName(const nsACString& branchPrefix,
+ const nsACString& aFeatureId,
+ const nsACString& aVariable,
+ nsACString& aPref) {
+ nsAutoCString featureAndVariable;
+ featureAndVariable.Append(aFeatureId);
+ if (!aVariable.IsEmpty()) {
+ featureAndVariable.Append(".");
+ featureAndVariable.Append(aVariable);
+ }
+ aPref.Truncate();
+ aPref.Append(branchPrefix);
+ aPref.Append(featureAndVariable);
+}
+
+/**
+ * Returns the variable value configured via experiment or rollout.
+ * If a fallback pref is defined in the FeatureManifest and it
+ * has a user value set this takes precedence over remote configurations.
+ */
+bool NimbusFeatures::GetBool(const nsACString& aFeatureId,
+ const nsACString& aVariable, bool aDefault) {
+ nsAutoCString experimentPref;
+ GetPrefName(kSyncDataPrefBranch, aFeatureId, aVariable, experimentPref);
+ if (Preferences::HasUserValue(experimentPref.get())) {
+ return Preferences::GetBool(experimentPref.get(), aDefault);
+ }
+
+ nsAutoCString rolloutPref;
+ GetPrefName(kSyncRolloutsPrefBranch, aFeatureId, aVariable, rolloutPref);
+ if (Preferences::HasUserValue(rolloutPref.get())) {
+ return Preferences::GetBool(rolloutPref.get(), aDefault);
+ }
+
+ auto prefName = GetNimbusFallbackPrefName(aFeatureId, aVariable);
+ if (prefName.isSome()) {
+ return Preferences::GetBool(prefName->get(), aDefault);
+ }
+ return aDefault;
+}
+
+/**
+ * Returns the variable value configured via experiment or rollout.
+ * If a fallback pref is defined in the FeatureManifest and it
+ * has a user value set this takes precedence over remote configurations.
+ */
+int NimbusFeatures::GetInt(const nsACString& aFeatureId,
+ const nsACString& aVariable, int aDefault) {
+ nsAutoCString experimentPref;
+ GetPrefName(kSyncDataPrefBranch, aFeatureId, aVariable, experimentPref);
+ if (Preferences::HasUserValue(experimentPref.get())) {
+ return Preferences::GetInt(experimentPref.get(), aDefault);
+ }
+
+ nsAutoCString rolloutPref;
+ GetPrefName(kSyncRolloutsPrefBranch, aFeatureId, aVariable, rolloutPref);
+ if (Preferences::HasUserValue(rolloutPref.get())) {
+ return Preferences::GetInt(rolloutPref.get(), aDefault);
+ }
+
+ auto prefName = GetNimbusFallbackPrefName(aFeatureId, aVariable);
+ if (prefName.isSome()) {
+ return Preferences::GetInt(prefName->get(), aDefault);
+ }
+ return aDefault;
+}
+
+nsresult NimbusFeatures::OnUpdate(const nsACString& aFeatureId,
+ const nsACString& aVariable,
+ PrefChangedFunc aUserCallback,
+ void* aUserData) {
+ nsAutoCString experimentPref;
+ nsAutoCString rolloutPref;
+ GetPrefName(kSyncDataPrefBranch, aFeatureId, aVariable, experimentPref);
+ GetPrefName(kSyncRolloutsPrefBranch, aFeatureId, aVariable, rolloutPref);
+ nsresult rv =
+ Preferences::RegisterCallback(aUserCallback, experimentPref, aUserData);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = Preferences::RegisterCallback(aUserCallback, rolloutPref, aUserData);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+nsresult NimbusFeatures::OffUpdate(const nsACString& aFeatureId,
+ const nsACString& aVariable,
+ PrefChangedFunc aUserCallback,
+ void* aUserData) {
+ nsAutoCString experimentPref;
+ nsAutoCString rolloutPref;
+ GetPrefName(kSyncDataPrefBranch, aFeatureId, aVariable, experimentPref);
+ GetPrefName(kSyncRolloutsPrefBranch, aFeatureId, aVariable, rolloutPref);
+ nsresult rv =
+ Preferences::UnregisterCallback(aUserCallback, experimentPref, aUserData);
+ NS_ENSURE_SUCCESS(rv, rv);
+ rv = Preferences::UnregisterCallback(aUserCallback, rolloutPref, aUserData);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ return NS_OK;
+}
+
+/**
+ * Attempt to read Nimbus preference to determine experiment and branch slug.
+ * Nimbus will store a pref with experiment metadata in the following format:
+ * {
+ * slug: "experiment slug",
+ * branch: { slug: "branch slug" },
+ * ...
+ * }
+ * The naming convention for preference names is:
+ * `nimbus.syncdatastore.<feature_id>`
+ * These values are used to send `exposure` telemetry pings.
+ */
+nsresult NimbusFeatures::GetExperimentSlug(const nsACString& aFeatureId,
+ nsACString& aExperimentSlug,
+ nsACString& aBranchSlug) {
+ nsAutoCString prefName;
+ nsAutoString prefValue;
+
+ aExperimentSlug.Truncate();
+ aBranchSlug.Truncate();
+
+ GetPrefName(kSyncDataPrefBranch, aFeatureId, EmptyCString(), prefName);
+ MOZ_TRY(Preferences::GetString(prefName.get(), prefValue));
+ if (prefValue.IsEmpty()) {
+ return NS_ERROR_UNEXPECTED;
+ }
+ dom::AutoJSAPI jsapi;
+ if (!jsapi.Init(xpc::PrivilegedJunkScope())) {
+ return NS_ERROR_UNEXPECTED;
+ }
+ JSContext* cx = jsapi.cx();
+ JS::Rooted<JS::Value> json(cx, JS::NullValue());
+ if (JS_ParseJSON(cx, prefValue.BeginReading(), prefValue.Length(), &json) &&
+ json.isObject()) {
+ JS::Rooted<JSObject*> experimentJSON(cx, json.toObjectOrNull());
+ JS::Rooted<JS::Value> expSlugValue(cx);
+ if (!JS_GetProperty(cx, experimentJSON, "slug", &expSlugValue)) {
+ return NS_ERROR_UNEXPECTED;
+ }
+ AssignJSString(cx, aExperimentSlug, expSlugValue.toString());
+
+ JS::Rooted<JS::Value> branchJSON(cx);
+ if (!JS_GetProperty(cx, experimentJSON, "branch", &branchJSON) &&
+ !branchJSON.isObject()) {
+ return NS_ERROR_UNEXPECTED;
+ }
+ JS::Rooted<JSObject*> branchObj(cx, branchJSON.toObjectOrNull());
+ JS::Rooted<JS::Value> branchSlugValue(cx);
+ if (!JS_GetProperty(cx, branchObj, "slug", &branchSlugValue)) {
+ return NS_ERROR_UNEXPECTED;
+ }
+ AssignJSString(cx, aBranchSlug, branchSlugValue.toString());
+ }
+
+ return NS_OK;
+}
+
+/**
+ * Sends an exposure event for aFeatureId when enrolled in an experiment.
+ * By default attempt to send once per function call. For some usecases it might
+ * be useful to send only once, in which case set the optional aOnce to `true`.
+ */
+nsresult NimbusFeatures::RecordExposureEvent(const nsACString& aFeatureId,
+ const bool aOnce) {
+ nsAutoCString featureName(aFeatureId);
+ if (!sExposureFeatureSet.EnsureInserted(featureName) && aOnce) {
+ // We already sent (or tried to send) an exposure ping for this featureId
+ return NS_ERROR_ABORT;
+ }
+ nsAutoCString slugName;
+ nsAutoCString branchName;
+ MOZ_TRY(GetExperimentSlug(aFeatureId, slugName, branchName));
+ if (slugName.IsEmpty() || branchName.IsEmpty()) {
+ // Failed getting experiment metadata or not enrolled in an experiment for
+ // this featureId
+ return NS_ERROR_UNEXPECTED;
+ }
+ Telemetry::SetEventRecordingEnabled("normandy"_ns, true);
+ nsTArray<Telemetry::EventExtraEntry> extra(2);
+ extra.AppendElement(Telemetry::EventExtraEntry{"branchSlug"_ns, branchName});
+ extra.AppendElement(Telemetry::EventExtraEntry{"featureId"_ns, featureName});
+ Telemetry::RecordEvent(Telemetry::EventID::Normandy_Expose_NimbusExperiment,
+ Some(slugName), Some(std::move(extra)));
+
+ return NS_OK;
+}
+
+} // namespace mozilla
diff --git a/toolkit/components/nimbus/lib/NimbusFeatures.h b/toolkit/components/nimbus/lib/NimbusFeatures.h
new file mode 100644
index 0000000000..7fe9ac8b5c
--- /dev/null
+++ b/toolkit/components/nimbus/lib/NimbusFeatures.h
@@ -0,0 +1,51 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* 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/. */
+
+#ifndef mozilla_NimbusFeatures_h
+#define mozilla_NimbusFeatures_h
+
+#include "mozilla/Preferences.h"
+#include "nsTHashSet.h"
+
+namespace mozilla {
+
+class NimbusFeatures {
+ private:
+ // This branch is used to store experiment data
+ static constexpr auto kSyncDataPrefBranch = "nimbus.syncdatastore."_ns;
+ // This branch is used to store rollouts data
+ static constexpr auto kSyncRolloutsPrefBranch =
+ "nimbus.syncdefaultsstore."_ns;
+ static void GetPrefName(const nsACString& branchPrefix,
+ const nsACString& aFeatureId,
+ const nsACString& aVariable, nsACString& aPref);
+
+ static nsresult GetExperimentSlug(const nsACString& aFeatureId,
+ nsACString& aExperimentSlug,
+ nsACString& aBranchSlug);
+
+ public:
+ static bool GetBool(const nsACString& aFeatureId, const nsACString& aVariable,
+ bool aDefault);
+
+ static int GetInt(const nsACString& aFeatureId, const nsACString& aVariable,
+ int aDefault);
+
+ static nsresult OnUpdate(const nsACString& aFeatureId,
+ const nsACString& aVariable,
+ PrefChangedFunc aUserCallback, void* aUserData);
+
+ static nsresult OffUpdate(const nsACString& aFeatureId,
+ const nsACString& aVariable,
+ PrefChangedFunc aUserCallback, void* aUserData);
+
+ static nsresult RecordExposureEvent(const nsACString& aFeatureId,
+ const bool aOnce = false);
+};
+
+} // namespace mozilla
+
+#endif
diff --git a/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs b/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs
new file mode 100644
index 0000000000..8e026e5cba
--- /dev/null
+++ b/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs
@@ -0,0 +1,738 @@
+/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ _ExperimentFeature: "resource://nimbus/ExperimentAPI.sys.mjs",
+ ASRouterTargeting:
+ // eslint-disable-next-line mozilla/no-browser-refs-in-toolkit
+ "resource:///modules/asrouter/ASRouterTargeting.sys.mjs",
+ CleanupManager: "resource://normandy/lib/CleanupManager.sys.mjs",
+ ExperimentManager: "resource://nimbus/lib/ExperimentManager.sys.mjs",
+ JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ TargetingContext: "resource://messaging-system/targeting/Targeting.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(lazy, "log", () => {
+ const { Logger } = ChromeUtils.importESModule(
+ "resource://messaging-system/lib/Logger.sys.mjs"
+ );
+ return new Logger("RSLoader");
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "timerManager",
+ "@mozilla.org/updates/timer-manager;1",
+ "nsIUpdateTimerManager"
+);
+
+const COLLECTION_ID_PREF = "messaging-system.rsexperimentloader.collection_id";
+const COLLECTION_ID_FALLBACK = "nimbus-desktop-experiments";
+const ENABLED_PREF = "messaging-system.rsexperimentloader.enabled";
+
+const TIMER_NAME = "rs-experiment-loader-timer";
+const TIMER_LAST_UPDATE_PREF = `app.update.lastUpdateTime.${TIMER_NAME}`;
+// Use the same update interval as normandy
+const RUN_INTERVAL_PREF = "app.normandy.run_interval_seconds";
+const NIMBUS_DEBUG_PREF = "nimbus.debug";
+const NIMBUS_VALIDATION_PREF = "nimbus.validation.enabled";
+const NIMBUS_APPID_PREF = "nimbus.appId";
+
+const STUDIES_ENABLED_CHANGED = "nimbus:studies-enabled-changed";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "COLLECTION_ID",
+ COLLECTION_ID_PREF,
+ COLLECTION_ID_FALLBACK
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "NIMBUS_DEBUG",
+ NIMBUS_DEBUG_PREF,
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "APP_ID",
+ NIMBUS_APPID_PREF,
+ "firefox-desktop"
+);
+
+const SCHEMAS = {
+ get NimbusExperiment() {
+ return fetch("resource://nimbus/schemas/NimbusExperiment.schema.json", {
+ credentials: "omit",
+ })
+ .then(rsp => rsp.json())
+ .then(json => json.definitions.NimbusExperiment);
+ },
+};
+
+export class _RemoteSettingsExperimentLoader {
+ constructor() {
+ // Has the timer been set?
+ this._initialized = false;
+ // Are we in the middle of updating recipes already?
+ this._updating = false;
+
+ // Make it possible to override for testing
+ this.manager = lazy.ExperimentManager;
+
+ ChromeUtils.defineLazyGetter(this, "remoteSettingsClient", () => {
+ return lazy.RemoteSettings(lazy.COLLECTION_ID);
+ });
+
+ Services.obs.addObserver(this, STUDIES_ENABLED_CHANGED);
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "enabled",
+ ENABLED_PREF,
+ false,
+ this.onEnabledPrefChange.bind(this)
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "intervalInSeconds",
+ RUN_INTERVAL_PREF,
+ 21600,
+ () => this.setTimer()
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "validationEnabled",
+ NIMBUS_VALIDATION_PREF,
+ true
+ );
+ }
+
+ get studiesEnabled() {
+ return this.manager.studiesEnabled;
+ }
+
+ /**
+ * Initialize the loader, updating recipes from Remote Settings.
+ *
+ * @param {Object} options additional options.
+ * @param {bool} options.forceSync force Remote Settings to sync recipe collection
+ * before updating recipes; throw if sync fails.
+ * @return {Promise} which resolves after initialization and recipes
+ * are updated.
+ */
+ async init(options = {}) {
+ const { forceSync = false } = options;
+
+ if (this._initialized || !this.enabled || !this.studiesEnabled) {
+ return;
+ }
+
+ this.setTimer();
+ lazy.CleanupManager.addCleanupHandler(() => this.uninit());
+ this._initialized = true;
+
+ await this.updateRecipes(undefined, { forceSync });
+ }
+
+ uninit() {
+ if (!this._initialized) {
+ return;
+ }
+ lazy.timerManager.unregisterTimer(TIMER_NAME);
+ this._initialized = false;
+ this._updating = false;
+ }
+
+ /**
+ * Get all recipes from remote settings
+ * @param {string} trigger What caused the update to occur?
+ * @param {Object} options additional options.
+ * @param {bool} options.forceSync force Remote Settings to sync recipe collection
+ * before updating recipes; throw if sync fails.
+ * @return {Promise} which resolves after recipes are updated.
+ */
+ async updateRecipes(trigger, options = {}) {
+ if (this._updating || !this._initialized) {
+ return;
+ }
+
+ const { forceSync = false } = options;
+
+ // Since this method is async, the enabled pref could change between await
+ // points. We don't want to half validate experiments, so we cache this to
+ // keep it consistent throughout updating.
+ const validationEnabled = this.validationEnabled;
+
+ this._updating = true;
+
+ lazy.log.debug(
+ "Updating recipes" + (trigger ? ` with trigger ${trigger}` : "")
+ );
+
+ let recipes;
+ let loadingError = false;
+
+ try {
+ recipes = await this.remoteSettingsClient.get({
+ forceSync,
+ // Throw instead of returning an empty list.
+ emptyListFallback: false,
+ });
+ lazy.log.debug(`Got ${recipes.length} recipes from Remote Settings`);
+ } catch (e) {
+ lazy.log.debug("Error getting recipes from remote settings.");
+ loadingError = true;
+ console.error(e);
+ }
+
+ recipes?.sort(
+ (a, b) => new Date(a.publishedDate ?? 0) - new Date(b.publishedDate ?? 0)
+ );
+
+ let recipeValidator;
+
+ if (validationEnabled) {
+ recipeValidator = new lazy.JsonSchema.Validator(
+ await SCHEMAS.NimbusExperiment
+ );
+ }
+
+ const enrollmentsCtx = new EnrollmentsContext(
+ this.manager,
+ recipeValidator,
+ { validationEnabled, shouldCheckTargeting: true }
+ );
+
+ if (recipes && !loadingError) {
+ for (const recipe of recipes) {
+ if (await enrollmentsCtx.checkRecipe(recipe)) {
+ await this.manager.onRecipe(recipe, "rs-loader");
+ }
+ }
+
+ lazy.log.debug(
+ `${enrollmentsCtx.matches} recipes matched. Finalizing ExperimentManager.`
+ );
+ this.manager.onFinalize("rs-loader", enrollmentsCtx.getResults());
+ }
+
+ if (trigger !== "timer") {
+ const lastUpdateTime = Math.round(Date.now() / 1000);
+ Services.prefs.setIntPref(TIMER_LAST_UPDATE_PREF, lastUpdateTime);
+ }
+
+ Services.obs.notifyObservers(null, "nimbus:enrollments-updated");
+
+ this._updating = false;
+
+ this.recordIsReady();
+ }
+
+ async optInToExperiment({
+ slug,
+ branch: branchSlug,
+ collection,
+ applyTargeting = false,
+ }) {
+ lazy.log.debug(`Attempting force enrollment with ${slug} / ${branchSlug}`);
+
+ if (!lazy.NIMBUS_DEBUG) {
+ lazy.log.debug(
+ `Force enrollment only works when '${NIMBUS_DEBUG_PREF}' is enabled.`
+ );
+ // More generic error if no debug preference is on.
+ throw new Error("Could not opt in.");
+ }
+
+ if (!this.studiesEnabled) {
+ lazy.log.debug(
+ "Force enrollment does not work when studies are disabled."
+ );
+ throw new Error("Could not opt in: studies are disabled.");
+ }
+
+ let recipes;
+ try {
+ recipes = await lazy
+ .RemoteSettings(collection || lazy.COLLECTION_ID)
+ .get({
+ // Throw instead of returning an empty list.
+ emptyListFallback: false,
+ });
+ } catch (e) {
+ console.error(e);
+ throw new Error("Error getting recipes from remote settings.");
+ }
+
+ const recipe = recipes.find(r => r.slug === slug);
+
+ if (!recipe) {
+ throw new Error(
+ `Could not find experiment slug ${slug} in collection ${
+ collection || lazy.COLLECTION_ID
+ }.`
+ );
+ }
+
+ const recipeValidator = new lazy.JsonSchema.Validator(
+ await SCHEMAS.NimbusExperiment
+ );
+ const enrollmentsCtx = new EnrollmentsContext(
+ this.manager,
+ recipeValidator,
+ {
+ validationEnabled: this.validationEnabled,
+ shouldCheckTargeting: applyTargeting,
+ }
+ );
+
+ if (!(await enrollmentsCtx.checkRecipe(recipe))) {
+ const results = enrollmentsCtx.getResults();
+
+ if (results.recipeMismatches.length) {
+ throw new Error(`Recipe ${recipe.slug} did not match targeting`);
+ } else if (results.invalidRecipes.length) {
+ console.error(`Recipe ${recipe.slug} did not match recipe schema`);
+ } else if (results.invalidBranches.size) {
+ // There will only be one entry becuase we only validated a single recipe.
+ for (const branches of results.invalidBranches.values()) {
+ for (const branch of branches) {
+ console.error(
+ `Recipe ${recipe.slug} failed feature validation for branch ${branch}`
+ );
+ }
+ }
+ } else if (results.invalidFeatures.length) {
+ for (const featureIds of results.invalidFeatures.values()) {
+ for (const featureId of featureIds) {
+ console.error(
+ `Recipe ${recipe.slug} references unknown feature ID ${featureId}`
+ );
+ }
+ }
+ }
+
+ throw new Error(
+ `Recipe ${recipe.slug} failed validation: ${JSON.stringify(results)}`
+ );
+ }
+
+ let branch = recipe.branches.find(b => b.slug === branchSlug);
+ if (!branch) {
+ throw new Error(`Could not find branch slug ${branchSlug} in ${slug}.`);
+ }
+
+ await this.manager.forceEnroll(recipe, branch);
+ }
+
+ /**
+ * Handles feature status based on feature pref and STUDIES_OPT_OUT_PREF.
+ * Changing any of them to false will turn off any recipe fetching and
+ * processing.
+ */
+ onEnabledPrefChange() {
+ if (this._initialized && !(this.enabled && this.studiesEnabled)) {
+ this.uninit();
+ } else if (!this._initialized && this.enabled && this.studiesEnabled) {
+ // If the feature pref is turned on then turn on recipe processing.
+ // If the opt in pref is turned on then turn on recipe processing only if
+ // the feature pref is also enabled.
+ this.init();
+ }
+ }
+
+ observe(aSubect, aTopic, aData) {
+ if (aTopic === STUDIES_ENABLED_CHANGED) {
+ this.onEnabledPrefChange();
+ }
+ }
+
+ /**
+ * Sets a timer to update recipes every this.intervalInSeconds
+ */
+ setTimer() {
+ if (this.intervalInSeconds === 0) {
+ // Used in tests where we want to turn this mechanism off
+ return;
+ }
+ // The callbacks will be called soon after the timer is registered
+ lazy.timerManager.registerTimer(
+ TIMER_NAME,
+ () => this.updateRecipes("timer"),
+ this.intervalInSeconds
+ );
+ lazy.log.debug("Registered update timer");
+ }
+
+ recordIsReady() {
+ const eventCount =
+ lazy.NimbusFeatures.nimbusIsReady.getVariable("eventCount") ?? 1;
+ for (let i = 0; i < eventCount; i++) {
+ Glean.nimbusEvents.isReady.record();
+ }
+ }
+}
+
+export class EnrollmentsContext {
+ constructor(
+ experimentManager,
+ recipeValidator,
+ { validationEnabled = true, shouldCheckTargeting = true } = {}
+ ) {
+ this.experimentManager = experimentManager;
+ this.recipeValidator = recipeValidator;
+
+ this.validationEnabled = validationEnabled;
+ this.shouldCheckTargeting = shouldCheckTargeting;
+ this.matches = 0;
+
+ this.recipeMismatches = [];
+ this.invalidRecipes = [];
+ this.invalidBranches = new Map();
+ this.invalidFeatures = new Map();
+ this.validatorCache = {};
+ this.missingLocale = [];
+ this.missingL10nIds = new Map();
+
+ this.locale = Services.locale.appLocaleAsBCP47;
+ }
+
+ getResults() {
+ return {
+ recipeMismatches: this.recipeMismatches,
+ invalidRecipes: this.invalidRecipes,
+ invalidBranches: this.invalidBranches,
+ invalidFeatures: this.invalidFeatures,
+ missingLocale: this.missingLocale,
+ missingL10nIds: this.missingL10nIds,
+ locale: this.locale,
+ validationEnabled: this.validationEnabled,
+ };
+ }
+
+ async checkRecipe(recipe) {
+ if (recipe.appId !== "firefox-desktop") {
+ // Skip over recipes not intended for desktop. Experimenter publishes
+ // recipes into a collection per application (desktop goes to
+ // `nimbus-desktop-experiments`) but all preview experiments share the
+ // same collection (`nimbus-preview`).
+ //
+ // This is *not* the same as `lazy.APP_ID` which is used to
+ // distinguish between desktop Firefox and the desktop background
+ // updater.
+ return false;
+ }
+
+ const validateFeatureSchemas =
+ this.validationEnabled && !recipe.featureValidationOptOut;
+
+ if (this.validationEnabled) {
+ let validation = this.recipeValidator.validate(recipe);
+ if (!validation.valid) {
+ console.error(
+ `Could not validate experiment recipe ${recipe.id}: ${JSON.stringify(
+ validation.errors,
+ null,
+ 2
+ )}`
+ );
+ if (recipe.slug) {
+ this.invalidRecipes.push(recipe.slug);
+ }
+ return false;
+ }
+ }
+
+ const featureIds =
+ recipe.featureIds ??
+ recipe.branches
+ .flatMap(branch => branch.features ?? [branch.feature])
+ .map(featureDef => featureDef.featureId);
+
+ let haveAllFeatures = true;
+
+ for (const featureId of featureIds) {
+ const feature = lazy.NimbusFeatures[featureId];
+
+ // If validation is enabled, we want to catch this later in
+ // _validateBranches to collect the correct stats for telemetry.
+ if (!feature) {
+ continue;
+ }
+
+ if (!feature.applications.includes(lazy.APP_ID)) {
+ lazy.log.debug(
+ `${recipe.id} uses feature ${featureId} which is not enabled for this application (${lazy.APP_ID}) -- skipping`
+ );
+ haveAllFeatures = false;
+ break;
+ }
+ }
+
+ if (!haveAllFeatures) {
+ return false;
+ }
+
+ if (this.shouldCheckTargeting) {
+ const match = await this.checkTargeting(recipe);
+
+ if (match) {
+ const type = recipe.isRollout ? "rollout" : "experiment";
+ lazy.log.debug(`[${type}] ${recipe.id} matched targeting`);
+ } else {
+ lazy.log.debug(`${recipe.id} did not match due to targeting`);
+ this.recipeMismatches.push(recipe.slug);
+ return false;
+ }
+ }
+
+ this.matches++;
+
+ if (
+ typeof recipe.localizations === "object" &&
+ recipe.localizations !== null
+ ) {
+ if (
+ typeof recipe.localizations[this.locale] !== "object" ||
+ recipe.localizations[this.locale] === null
+ ) {
+ this.missingLocale.push(recipe.slug);
+ lazy.log.debug(
+ `${recipe.id} is localized but missing locale ${this.locale}`
+ );
+ return false;
+ }
+ }
+
+ const result = await this._validateBranches(recipe, validateFeatureSchemas);
+ if (!result.valid) {
+ if (result.invalidBranchSlugs.length) {
+ this.invalidBranches.set(recipe.slug, result.invalidBranchSlugs);
+ }
+ if (result.invalidFeatureIds.length) {
+ this.invalidFeatures.set(recipe.slug, result.invalidFeatureIds);
+ }
+ if (result.missingL10nIds.length) {
+ this.missingL10nIds.set(recipe.slug, result.missingL10nIds);
+ }
+ lazy.log.debug(`${recipe.id} did not validate`);
+ return false;
+ }
+
+ return true;
+ }
+
+ async evaluateJexl(jexlString, customContext) {
+ if (customContext && !customContext.experiment) {
+ throw new Error(
+ "Expected an .experiment property in second param of this function"
+ );
+ }
+
+ if (!customContext.source) {
+ throw new Error(
+ "Expected a .source property that identifies which targeting expression is being evaluated."
+ );
+ }
+
+ const context = lazy.TargetingContext.combineContexts(
+ customContext,
+ this.experimentManager.createTargetingContext(),
+ lazy.ASRouterTargeting.Environment
+ );
+
+ lazy.log.debug("Testing targeting expression:", jexlString);
+ const targetingContext = new lazy.TargetingContext(context, {
+ source: customContext.source,
+ });
+
+ let result = null;
+ try {
+ result = await targetingContext.evalWithDefault(jexlString);
+ } catch (e) {
+ lazy.log.debug("Targeting failed because of an error", e);
+ console.error(e);
+ }
+ return result;
+ }
+
+ /**
+ * Checks targeting of a recipe if it is defined
+ * @param {Recipe} recipe
+ * @param {{[key: string]: any}} customContext A custom filter context
+ * @returns {Promise<boolean>} Should we process the recipe?
+ */
+ async checkTargeting(recipe) {
+ if (!recipe.targeting) {
+ lazy.log.debug("No targeting for recipe, so it matches automatically");
+ return true;
+ }
+
+ const result = await this.evaluateJexl(recipe.targeting, {
+ experiment: recipe,
+ source: recipe.slug,
+ });
+
+ return Boolean(result);
+ }
+
+ /**
+ * Validate the branches of an experiment.
+ *
+ * @param {object} recipe The recipe object.
+ * @param {boolean} validateSchema Whether to validate the feature values
+ * using JSON schemas.
+ *
+ * @returns {object} The lists of invalid branch slugs and invalid feature
+ * IDs.
+ */
+ async _validateBranches({ id, branches, localizations }, validateSchema) {
+ const invalidBranchSlugs = [];
+ const invalidFeatureIds = new Set();
+ const missingL10nIds = new Set();
+
+ if (validateSchema || typeof localizations !== "undefined") {
+ for (const [branchIdx, branch] of branches.entries()) {
+ const features = branch.features ?? [branch.feature];
+ for (const feature of features) {
+ const { featureId, value } = feature;
+ if (!lazy.NimbusFeatures[featureId]) {
+ console.error(
+ `Experiment ${id} has unknown featureId: ${featureId}`
+ );
+
+ invalidFeatureIds.add(featureId);
+ continue;
+ }
+
+ let substitutedValue = value;
+
+ if (localizations) {
+ // We already know that we have a localization table for this locale
+ // because we checked in `checkRecipe`.
+ try {
+ substitutedValue =
+ lazy._ExperimentFeature.substituteLocalizations(
+ value,
+ localizations[Services.locale.appLocaleAsBCP47],
+ missingL10nIds
+ );
+ } catch (e) {
+ if (e?.reason === "l10n-missing-entry") {
+ // Skip validation because it *will* fail.
+ continue;
+ }
+ throw e;
+ }
+ }
+
+ if (validateSchema) {
+ let validator;
+ if (this.validatorCache[featureId]) {
+ validator = this.validatorCache[featureId];
+ } else if (lazy.NimbusFeatures[featureId].manifest.schema?.uri) {
+ const uri = lazy.NimbusFeatures[featureId].manifest.schema.uri;
+ try {
+ const schema = await fetch(uri, {
+ credentials: "omit",
+ }).then(rsp => rsp.json());
+
+ validator = this.validatorCache[featureId] =
+ new lazy.JsonSchema.Validator(schema);
+ } catch (e) {
+ throw new Error(
+ `Could not fetch schema for feature ${featureId} at "${uri}": ${e}`
+ );
+ }
+ } else {
+ const schema = this._generateVariablesOnlySchema(
+ lazy.NimbusFeatures[featureId]
+ );
+ validator = this.validatorCache[featureId] =
+ new lazy.JsonSchema.Validator(schema);
+ }
+
+ const result = validator.validate(substitutedValue);
+ if (!result.valid) {
+ console.error(
+ `Experiment ${id} branch ${branchIdx} feature ${featureId} does not validate: ${JSON.stringify(
+ result.errors,
+ undefined,
+ 2
+ )}`
+ );
+ invalidBranchSlugs.push(branch.slug);
+ }
+ }
+ }
+ }
+ }
+
+ return {
+ invalidBranchSlugs,
+ invalidFeatureIds: Array.from(invalidFeatureIds),
+ missingL10nIds: Array.from(missingL10nIds),
+ valid:
+ invalidBranchSlugs.length === 0 &&
+ invalidFeatureIds.size === 0 &&
+ missingL10nIds.size === 0,
+ };
+ }
+
+ _generateVariablesOnlySchema({ featureId, manifest }) {
+ // See-also: https://github.com/mozilla/experimenter/blob/main/app/experimenter/features/__init__.py#L21-L64
+ const schema = {
+ $schema: "https://json-schema.org/draft/2019-09/schema",
+ title: featureId,
+ description: manifest.description,
+ type: "object",
+ properties: {},
+ additionalProperties: true,
+ };
+
+ for (const [varName, desc] of Object.entries(manifest.variables)) {
+ const prop = {};
+ switch (desc.type) {
+ case "boolean":
+ case "string":
+ prop.type = desc.type;
+ break;
+
+ case "int":
+ prop.type = "integer";
+ break;
+
+ case "json":
+ // NB: Don't set a type of json fields, since they can be of any type.
+ break;
+
+ default:
+ // NB: Experimenter doesn't outright reject invalid types either.
+ console.error(
+ `Feature ID ${featureId} has variable ${varName} with invalid FML type: ${prop.type}`
+ );
+ break;
+ }
+
+ if (prop.type === "string" && !!desc.enum) {
+ prop.enum = [...desc.enum];
+ }
+
+ schema.properties[varName] = prop;
+ }
+
+ return schema;
+ }
+}
+
+export const RemoteSettingsExperimentLoader =
+ new _RemoteSettingsExperimentLoader();
diff --git a/toolkit/components/nimbus/lib/SharedDataMap.sys.mjs b/toolkit/components/nimbus/lib/SharedDataMap.sys.mjs
new file mode 100644
index 0000000000..ccf1c8b145
--- /dev/null
+++ b/toolkit/components/nimbus/lib/SharedDataMap.sys.mjs
@@ -0,0 +1,177 @@
+/* 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 { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
+});
+
+const IS_MAIN_PROCESS =
+ Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT;
+
+export class SharedDataMap extends EventEmitter {
+ constructor(sharedDataKey, options = { isParent: IS_MAIN_PROCESS }) {
+ super();
+
+ this._sharedDataKey = sharedDataKey;
+ this._isParent = options.isParent;
+ this._isReady = false;
+ this._readyDeferred = Promise.withResolvers();
+ this._data = null;
+
+ if (this.isParent) {
+ // Lazy-load JSON file that backs Storage instances.
+ ChromeUtils.defineLazyGetter(this, "_store", () => {
+ let path = options.path;
+ let store = null;
+ if (!path) {
+ try {
+ const profileDir = Services.dirsvc.get("ProfD", Ci.nsIFile).path;
+ path = PathUtils.join(profileDir, `${sharedDataKey}.json`);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ try {
+ store = new lazy.JSONFile({ path });
+ } catch (e) {
+ console.error(e);
+ }
+ return store;
+ });
+ } else {
+ this._syncFromParent();
+ Services.cpmm.sharedData.addEventListener("change", this);
+ }
+ }
+
+ async init() {
+ if (!this._isReady && this.isParent) {
+ try {
+ await this._store.load();
+ this._data = this._store.data;
+ this._syncToChildren({ flush: true });
+ this._checkIfReady();
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+
+ get sharedDataKey() {
+ return this._sharedDataKey;
+ }
+
+ get isParent() {
+ return this._isParent;
+ }
+
+ ready() {
+ return this._readyDeferred.promise;
+ }
+
+ get(key) {
+ if (!this._data) {
+ return null;
+ }
+
+ let entry = this._data[key];
+
+ return entry;
+ }
+
+ set(key, value) {
+ if (!this.isParent) {
+ throw new Error(
+ "Setting values from within a content process is not allowed"
+ );
+ }
+ this._store.data[key] = value;
+ this._store.saveSoon();
+ this._syncToChildren();
+ this._notifyUpdate();
+ }
+
+ /**
+ * Replace the stored data with an updated filtered dataset for cleanup
+ * purposes. We don't notify of update because we're only filtering out
+ * old unused entries.
+ *
+ * @param {string[]} keysToRemove - list of keys to remove from the persistent store
+ */
+ _removeEntriesByKeys(keysToRemove) {
+ if (!keysToRemove.length) {
+ return;
+ }
+ for (let key of keysToRemove) {
+ try {
+ delete this._store.data[key];
+ } catch (e) {
+ // It's ok if this fails
+ }
+ }
+ this._store.saveSoon();
+ }
+
+ // Only used in tests
+ _deleteForTests(key) {
+ if (!this.isParent) {
+ throw new Error(
+ "Setting values from within a content process is not allowed"
+ );
+ }
+ if (this.has(key)) {
+ delete this._store.data[key];
+ this._store.saveSoon();
+ this._syncToChildren();
+ this._notifyUpdate();
+ }
+ }
+
+ has(key) {
+ return Boolean(this.get(key));
+ }
+
+ /**
+ * Notify store listeners of updates
+ * Called both from Main and Content process
+ */
+ _notifyUpdate(process = "parent") {
+ for (let key of Object.keys(this._data || {})) {
+ this.emit(`${process}-store-update:${key}`, this._data[key]);
+ }
+ }
+
+ _syncToChildren({ flush = false } = {}) {
+ Services.ppmm.sharedData.set(this.sharedDataKey, {
+ ...this._data,
+ });
+ if (flush) {
+ Services.ppmm.sharedData.flush();
+ }
+ }
+
+ _syncFromParent() {
+ this._data = Services.cpmm.sharedData.get(this.sharedDataKey);
+ this._checkIfReady();
+ this._notifyUpdate("child");
+ }
+
+ _checkIfReady() {
+ if (!this._isReady && this._data) {
+ this._isReady = true;
+ this._readyDeferred.resolve();
+ }
+ }
+
+ handleEvent(event) {
+ if (event.type === "change") {
+ if (event.changedKeys.includes(this.sharedDataKey)) {
+ this._syncFromParent();
+ }
+ }
+ }
+}