summaryrefslogtreecommitdiffstats
path: root/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.jsm')
-rw-r--r--toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.jsm593
1 files changed, 593 insertions, 0 deletions
diff --git a/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.jsm b/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.jsm
new file mode 100644
index 0000000000..300e82a72b
--- /dev/null
+++ b/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.jsm
@@ -0,0 +1,593 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = [
+ "_RemoteSettingsExperimentLoader",
+ "RemoteSettingsExperimentLoader",
+];
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ ASRouterTargeting: "resource://activity-stream/lib/ASRouterTargeting.jsm",
+ TargetingContext: "resource://messaging-system/targeting/Targeting.jsm",
+ ExperimentManager: "resource://nimbus/lib/ExperimentManager.jsm",
+ RemoteSettings: "resource://services-settings/remote-settings.js",
+ CleanupManager: "resource://normandy/lib/CleanupManager.jsm",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "log", () => {
+ const { Logger } = ChromeUtils.import(
+ "resource://messaging-system/lib/Logger.jsm"
+ );
+ 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);
+ },
+};
+
+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;
+
+ XPCOMUtils.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;
+ }
+
+ 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.manager.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);
+ Cu.reportError(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);
+ }
+
+ /**
+ * 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;
+ Cu.reportError(e);
+ }
+
+ let recipeValidator;
+
+ if (validationEnabled) {
+ recipeValidator = new lazy.JsonSchema.Validator(
+ await SCHEMAS.NimbusExperiment
+ );
+ }
+
+ let matches = 0;
+ let recipeMismatches = [];
+ let invalidRecipes = [];
+ let invalidBranches = new Map();
+ let invalidFeatures = new Map();
+ let validatorCache = {};
+
+ if (recipes && !loadingError) {
+ for (const r of recipes) {
+ if (r.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.
+ continue;
+ }
+
+ const validateFeatures =
+ validationEnabled && !r.featureValidationOptOut;
+
+ if (validationEnabled) {
+ let validation = recipeValidator.validate(r);
+ if (!validation.valid) {
+ Cu.reportError(
+ `Could not validate experiment recipe ${r.id}: ${JSON.stringify(
+ validation.errors,
+ undefined,
+ 2
+ )}`
+ );
+ if (r.slug) {
+ invalidRecipes.push(r.slug);
+ }
+ continue;
+ }
+ }
+
+ const featureIds =
+ r.featureIds ??
+ r.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(
+ `${r.id} uses feature ${featureId} which is not enabled for this application (${lazy.APP_ID}) -- skipping`
+ );
+ haveAllFeatures = false;
+ break;
+ }
+ }
+ if (!haveAllFeatures) {
+ continue;
+ }
+
+ if (!(await this.checkTargeting(r))) {
+ lazy.log.debug(`${r.id} did not match due to targeting`);
+ recipeMismatches.push(r.slug);
+ continue;
+ }
+
+ const type = r.isRollout ? "rollout" : "experiment";
+ lazy.log.debug(`[${type}] ${r.id} matched targeting`);
+ matches++;
+
+ if (validateFeatures) {
+ const result = await this._validateBranches(r, validatorCache);
+ if (!result.valid) {
+ if (result.invalidBranchSlugs.length) {
+ invalidBranches.set(r.slug, result.invalidBranchSlugs);
+ }
+ if (result.invalidFeatureIds.length) {
+ invalidFeatures.set(r.slug, result.invalidFeatureIds);
+ }
+ lazy.log.debug(`${r.id} did not validate`);
+ continue;
+ }
+ }
+
+ await this.manager.onRecipe(r, "rs-loader");
+ }
+
+ lazy.log.debug(
+ `${matches} recipes matched. Finalizing ExperimentManager.`
+ );
+ this.manager.onFinalize("rs-loader", {
+ recipeMismatches,
+ invalidRecipes,
+ invalidBranches,
+ invalidFeatures,
+ validationEnabled,
+ });
+ }
+
+ if (trigger !== "timer") {
+ const lastUpdateTime = Math.round(Date.now() / 1000);
+ Services.prefs.setIntPref(TIMER_LAST_UPDATE_PREF, lastUpdateTime);
+ }
+
+ this._updating = false;
+ }
+
+ async optInToExperiment({ slug, branch: branchSlug, collection }) {
+ 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) {
+ Cu.reportError(e);
+ throw new Error("Error getting recipes from remote settings.");
+ }
+
+ let recipe = recipes.find(r => r.slug === slug);
+
+ if (!recipe) {
+ throw new Error(
+ `Could not find experiment slug ${slug} in collection ${collection ||
+ lazy.COLLECTION_ID}.`
+ );
+ }
+
+ let branch = recipe.branches.find(b => b.slug === branchSlug);
+ if (!branch) {
+ throw new Error(`Could not find branch slug ${branchSlug} in ${slug}.`);
+ }
+
+ return lazy.ExperimentManager.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");
+ }
+
+ /**
+ * Validate the branches of an experiment using schemas
+ *
+ * @param {object} recipe The recipe object.
+ * @param {object} validatorCache A cache of JSON Schema validators keyed by feature
+ * ID.
+ *
+ * @returns {object} The lists of invalid branch slugs and invalid feature
+ * IDs.
+ */
+ async _validateBranches({ id, branches }, validatorCache = {}) {
+ const invalidBranchSlugs = [];
+ const invalidFeatureIds = new Set();
+
+ 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]) {
+ Cu.reportError(
+ `Experiment ${id} has unknown featureId: ${featureId}`
+ );
+
+ invalidFeatureIds.add(featureId);
+ continue;
+ }
+
+ let validator;
+ if (validatorCache[featureId]) {
+ validator = 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 = 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(
+ featureId,
+ lazy.NimbusFeatures[featureId].manifest
+ );
+ validator = validatorCache[featureId] = new lazy.JsonSchema.Validator(
+ schema
+ );
+ }
+
+ const result = validator.validate(value);
+ if (!result.valid) {
+ Cu.reportError(
+ `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),
+ valid: invalidBranchSlugs.length === 0 && invalidFeatureIds.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.
+ Cu.reportError(
+ `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;
+ }
+}
+
+const RemoteSettingsExperimentLoader = new _RemoteSettingsExperimentLoader();