summaryrefslogtreecommitdiffstats
path: root/toolkit/components/messaging-system/lib/RemoteSettingsExperimentLoader.jsm
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--toolkit/components/messaging-system/lib/RemoteSettingsExperimentLoader.jsm229
1 files changed, 229 insertions, 0 deletions
diff --git a/toolkit/components/messaging-system/lib/RemoteSettingsExperimentLoader.jsm b/toolkit/components/messaging-system/lib/RemoteSettingsExperimentLoader.jsm
new file mode 100644
index 0000000000..8a6ff55911
--- /dev/null
+++ b/toolkit/components/messaging-system/lib/RemoteSettingsExperimentLoader.jsm
@@ -0,0 +1,229 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * @typedef {import("../experiments/@types/ExperimentManager").Recipe} Recipe
+ */
+
+const EXPORTED_SYMBOLS = [
+ "_RemoteSettingsExperimentLoader",
+ "RemoteSettingsExperimentLoader",
+];
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ASRouterTargeting: "resource://activity-stream/lib/ASRouterTargeting.jsm",
+ TargetingContext: "resource://messaging-system/targeting/Targeting.jsm",
+ ExperimentManager:
+ "resource://messaging-system/experiments/ExperimentManager.jsm",
+ RemoteSettings: "resource://services-settings/remote-settings.js",
+ CleanupManager: "resource://normandy/lib/CleanupManager.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(this, "log", () => {
+ const { Logger } = ChromeUtils.import(
+ "resource://messaging-system/lib/Logger.jsm"
+ );
+ return new Logger("RSLoader");
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "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 STUDIES_OPT_OUT_PREF = "app.shield.optoutstudies.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";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "COLLECTION_ID",
+ COLLECTION_ID_PREF,
+ COLLECTION_ID_FALLBACK
+);
+
+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 = ExperimentManager;
+
+ XPCOMUtils.defineLazyGetter(this, "remoteSettingsClient", () => {
+ return RemoteSettings(COLLECTION_ID);
+ });
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "enabled",
+ ENABLED_PREF,
+ false,
+ this.onEnabledPrefChange.bind(this)
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "studiesEnabled",
+ STUDIES_OPT_OUT_PREF,
+ false,
+ this.onEnabledPrefChange.bind(this)
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "intervalInSeconds",
+ RUN_INTERVAL_PREF,
+ 21600,
+ () => this.setTimer()
+ );
+ }
+
+ async init() {
+ if (this._initialized || !this.enabled || !this.studiesEnabled) {
+ return;
+ }
+
+ this.setTimer();
+ CleanupManager.addCleanupHandler(() => this.uninit());
+ this._initialized = true;
+
+ await this.updateRecipes();
+ }
+
+ uninit() {
+ if (!this._initialized) {
+ return;
+ }
+ timerManager.unregisterTimer(TIMER_NAME);
+ this._initialized = false;
+ }
+
+ /**
+ * 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, customContext = {}) {
+ const context = TargetingContext.combineContexts(
+ { experiment: recipe },
+ customContext,
+ ASRouterTargeting.Environment
+ );
+ const { targeting } = recipe;
+ if (!targeting) {
+ log.debug("No targeting for recipe, so it matches automatically");
+ return true;
+ }
+ log.debug("Testing targeting expression:", targeting);
+ const targetingContext = new TargetingContext(context);
+ let result = false;
+ try {
+ result = await targetingContext.evalWithDefault(targeting);
+ } catch (e) {
+ log.debug("Targeting failed because of an error");
+ Cu.reportError(e);
+ }
+ return Boolean(result);
+ }
+
+ /**
+ * Get all recipes from remote settings
+ * @param {string} trigger What caused the update to occur?
+ */
+ async updateRecipes(trigger) {
+ if (this._updating || !this._initialized) {
+ return;
+ }
+ this._updating = true;
+
+ log.debug("Updating recipes" + (trigger ? ` with trigger ${trigger}` : ""));
+
+ let recipes;
+ let loadingError = false;
+
+ try {
+ recipes = await this.remoteSettingsClient.get();
+ log.debug(`Got ${recipes.length} recipes from Remote Settings`);
+ } catch (e) {
+ log.debug("Error getting recipes from remote settings.");
+ loadingError = true;
+ Cu.reportError(e);
+ }
+
+ let matches = 0;
+ if (recipes && !loadingError) {
+ const context = this.manager.createTargetingContext();
+
+ for (const r of recipes) {
+ if (await this.checkTargeting(r, context)) {
+ matches++;
+ log.debug(`${r.id} matched`);
+ await this.manager.onRecipe(r, "rs-loader");
+ } else {
+ log.debug(`${r.id} did not match due to targeting`);
+ }
+ }
+
+ log.debug(`${matches} recipes matched. Finalizing ExperimentManager.`);
+ this.manager.onFinalize("rs-loader");
+ }
+
+ if (trigger !== "timer") {
+ const lastUpdateTime = Math.round(Date.now() / 1000);
+ Services.prefs.setIntPref(TIMER_LAST_UPDATE_PREF, lastUpdateTime);
+ }
+
+ this._updating = false;
+ }
+
+ /**
+ * 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(prefName, oldValue, newValue) {
+ if (this._initialized && !newValue) {
+ this.uninit();
+ } else if (!this._initialized && newValue && this.enabled) {
+ // 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();
+ }
+ }
+
+ /**
+ * Sets a timer to update recipes every this.intervalInSeconds
+ */
+ setTimer() {
+ // When this function is called, updateRecipes is also called immediately
+ timerManager.registerTimer(
+ TIMER_NAME,
+ () => this.updateRecipes("timer"),
+ this.intervalInSeconds
+ );
+ log.debug("Registered update timer");
+ }
+}
+
+const RemoteSettingsExperimentLoader = new _RemoteSettingsExperimentLoader();