Bug 1451033 - Extract Normandy ClientEnvironment into reusable module r?Gijs draft
authorMathieu Leplatre <mathieu@mozilla.com>
Wed, 04 Apr 2018 12:02:09 +0200
changeset 780710 c3013b94961d32b38c447e57e4ff1289fe1a679b
parent 779723 a8061a09cd7064a8783ca9e67979d77fb52e001e
push id106097
push usermleplatre@mozilla.com
push dateWed, 11 Apr 2018 20:42:35 +0000
reviewersGijs
bugs1451033
milestone61.0a1
Bug 1451033 - Extract Normandy ClientEnvironment into reusable module r?Gijs MozReview-Commit-ID: G6Awri9tDyK
toolkit/components/normandy/lib/Addons.jsm
toolkit/components/normandy/lib/ClientEnvironment.jsm
toolkit/components/normandy/lib/NormandyDriver.jsm
toolkit/components/normandy/lib/RecipeRunner.jsm
toolkit/components/normandy/lib/Utils.jsm
toolkit/components/normandy/test/browser/browser_ClientEnvironment.js
toolkit/components/normandy/test/browser/browser_FilterExpressions.js
toolkit/components/normandy/test/browser/browser_RecipeRunner.js
toolkit/components/normandy/test/browser/head.js
toolkit/components/normandy/test/unit/test_Utils.js
toolkit/components/normandy/test/unit/xpcshell.ini
toolkit/components/utils/ClientEnvironment.jsm
toolkit/components/utils/moz.build
--- a/toolkit/components/normandy/lib/Addons.jsm
+++ b/toolkit/components/normandy/lib/Addons.jsm
@@ -42,26 +42,16 @@ var Addons = {
     const addon = await AddonManager.getAddonByID(addonId);
     if (!addon) {
       return null;
     }
     return this.serializeForSandbox(addon);
   },
 
   /**
-   * Get information about all installed add-ons.
-   * @async
-   * @returns {Array<SafeAddon>}
-   */
-  async getAll(addonId) {
-    const addons = await AddonManager.getAllAddons();
-    return addons.map(this.serializeForSandbox.bind(this));
-  },
-
-  /**
    * Installs an add-on
    *
    * @param {string} addonUrl
    *   Url to download the .xpi for the add-on from.
    * @param {object} options
    * @param {boolean} options.update=false
    *   If true, will update an existing installed add-on with the same ID.
    * @async
--- a/toolkit/components/normandy/lib/ClientEnvironment.jsm
+++ b/toolkit/components/normandy/lib/ClientEnvironment.jsm
@@ -2,211 +2,107 @@
  * 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";
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 
-ChromeUtils.defineModuleGetter(this, "ShellService", "resource:///modules/ShellService.jsm");
-ChromeUtils.defineModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm");
-ChromeUtils.defineModuleGetter(this, "TelemetryArchive", "resource://gre/modules/TelemetryArchive.jsm");
-ChromeUtils.defineModuleGetter(this, "UpdateUtils", "resource://gre/modules/UpdateUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "NormandyApi", "resource://normandy/lib/NormandyApi.jsm");
 ChromeUtils.defineModuleGetter(
+  this,
+  "ClientEnvironmentBase",
+  "resource://gre/modules/components-utils/ClientEnvironment.jsm"
+);
+ChromeUtils.defineModuleGetter(
     this,
     "PreferenceExperiments",
     "resource://normandy/lib/PreferenceExperiments.jsm"
 );
-ChromeUtils.defineModuleGetter(this, "Utils", "resource://normandy/lib/Utils.jsm");
-ChromeUtils.defineModuleGetter(this, "Addons", "resource://normandy/lib/Addons.jsm");
-ChromeUtils.defineModuleGetter(this, "AppConstants", "resource://gre/modules/AppConstants.jsm");
 
 const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
 
 var EXPORTED_SYMBOLS = ["ClientEnvironment"];
 
 // Cached API request for client attributes that are determined by the Normandy
 // service.
 let _classifyRequest = null;
 
-var ClientEnvironment = {
+
+class ClientEnvironment extends ClientEnvironmentBase {
   /**
    * Fetches information about the client that is calculated on the server,
    * like geolocation and the current time.
    *
    * The server request is made lazily and is cached for the entire browser
    * session.
    */
-  async getClientClassification() {
+  static async getClientClassification() {
     if (!_classifyRequest) {
       _classifyRequest = NormandyApi.classifyClient();
     }
     return _classifyRequest;
-  },
+  }
 
-  clearClassifyCache() {
+  static clearClassifyCache() {
     _classifyRequest = null;
-  },
+  }
 
   /**
    * Test wrapper that mocks the server request for classifying the client.
    * @param  {Object}   data          Fake server data to use
    * @param  {Function} testFunction  Test function to execute while mock data is in effect.
    */
-  withMockClassify(data, testFunction) {
+  static withMockClassify(data, testFunction) {
     return async function inner() {
       const oldRequest = _classifyRequest;
       _classifyRequest = Promise.resolve(data);
       await testFunction();
       _classifyRequest = oldRequest;
     };
-  },
-
-  /**
-   * Create an object that provides general information about the client application.
-   *
-   * RecipeRunner.jsm uses this as part of the context for filter expressions,
-   * so avoid adding non-getter functions as attributes, as filter expressions
-   * cannot execute functions.
-   *
-   * Also note that, because filter expressions implicitly resolve promises, you
-   * can add getter functions that return promises for async data.
-   * @return {Object}
-   */
-  getEnvironment() {
-    const environment = {};
-
-    XPCOMUtils.defineLazyGetter(environment, "userId", () => {
-      let id = Services.prefs.getCharPref("app.normandy.user_id", "");
-      if (!id) {
-        // generateUUID adds leading and trailing "{" and "}". strip them off.
-        id = generateUUID().toString().slice(1, -1);
-        Services.prefs.setCharPref("app.normandy.user_id", id);
-      }
-      return id;
-    });
+  }
 
-    XPCOMUtils.defineLazyGetter(environment, "country", () => {
-      return ClientEnvironment.getClientClassification()
-        .then(classification => classification.country);
-    });
-
-    XPCOMUtils.defineLazyGetter(environment, "request_time", () => {
-      return ClientEnvironment.getClientClassification()
-        .then(classification => classification.request_time);
-    });
-
-    XPCOMUtils.defineLazyGetter(environment, "distribution", () => {
-      return Services.prefs.getCharPref("distribution.id", "default");
-    });
-
-    XPCOMUtils.defineLazyGetter(environment, "telemetry", async function() {
-      const pings = await TelemetryArchive.promiseArchivedPingList();
-
-      // get most recent ping per type
-      const mostRecentPings = {};
-      for (const ping of pings) {
-        if (ping.type in mostRecentPings) {
-          if (mostRecentPings[ping.type].timestampCreated < ping.timestampCreated) {
-            mostRecentPings[ping.type] = ping;
-          }
-        } else {
-          mostRecentPings[ping.type] = ping;
-        }
-      }
+  static get userId() {
+    let id = Services.prefs.getCharPref("app.normandy.user_id", "");
+    if (!id) {
+      // generateUUID adds leading and trailing "{" and "}". strip them off.
+      id = generateUUID().toString().slice(1, -1);
+      Services.prefs.setCharPref("app.normandy.user_id", id);
+    }
+    return id;
+  }
 
-      const telemetry = {};
-      for (const key in mostRecentPings) {
-        const ping = mostRecentPings[key];
-        telemetry[ping.type] = await TelemetryArchive.promiseArchivedPingById(ping.id);
-      }
-      return telemetry;
-    });
-
-    XPCOMUtils.defineLazyGetter(environment, "version", () => {
-      return AppConstants.MOZ_APP_VERSION_DISPLAY;
-    });
-
-    XPCOMUtils.defineLazyGetter(environment, "channel", () => {
-      return UpdateUtils.getUpdateChannel(false);
-    });
-
-    XPCOMUtils.defineLazyGetter(environment, "isDefaultBrowser", () => {
-      return ShellService.isDefaultBrowser();
-    });
-
-    XPCOMUtils.defineLazyGetter(environment, "searchEngine", async function() {
-      const searchInitialized = await new Promise(resolve => Services.search.init(resolve));
-      if (Components.isSuccessCode(searchInitialized)) {
-        return Services.search.defaultEngine.identifier;
-      }
-      return null;
-    });
-
-    XPCOMUtils.defineLazyGetter(environment, "syncSetup", () => {
-      return Services.prefs.prefHasUserValue("services.sync.username");
-    });
+  static get country() {
+    return (async () => {
+      const { country } = await ClientEnvironment.getClientClassification();
+      return country;
+    })();
+  }
 
-    XPCOMUtils.defineLazyGetter(environment, "syncDesktopDevices", () => {
-      return Services.prefs.getIntPref("services.sync.clients.devices.desktop", 0);
-    });
-
-    XPCOMUtils.defineLazyGetter(environment, "syncMobileDevices", () => {
-      return Services.prefs.getIntPref("services.sync.clients.devices.mobile", 0);
-    });
-
-    XPCOMUtils.defineLazyGetter(environment, "syncTotalDevices", () => {
-      return environment.syncDesktopDevices + environment.syncMobileDevices;
-    });
+  static get request_time() {
+    return (async () => {
+      const { request_time } = await ClientEnvironment.getClientClassification();
+      return request_time;
+    })();
+  }
 
-    XPCOMUtils.defineLazyGetter(environment, "plugins", async function() {
-      let plugins = await AddonManager.getAddonsByTypes(["plugin"]);
-      plugins = plugins.map(plugin => ({
-        name: plugin.name,
-        description: plugin.description,
-        version: plugin.version,
-      }));
-      return Utils.keyBy(plugins, "name");
-    });
-
-    XPCOMUtils.defineLazyGetter(environment, "locale", () => {
-      if (Services.locale.getAppLocaleAsLangTag) {
-        return Services.locale.getAppLocaleAsLangTag();
-      }
-
-      return Cc["@mozilla.org/chrome/chrome-registry;1"]
-        .getService(Ci.nsIXULChromeRegistry)
-        .getSelectedLocale("global");
-    });
-
-    XPCOMUtils.defineLazyGetter(environment, "doNotTrack", () => {
-      return Services.prefs.getBoolPref("privacy.donottrackheader.enabled", false);
-    });
-
-    XPCOMUtils.defineLazyGetter(environment, "experiments", async () => {
+  static get experiments() {
+    return (async () => {
       const names = {all: [], active: [], expired: []};
 
       for (const experiment of await PreferenceExperiments.getAll()) {
         names.all.push(experiment.name);
         if (experiment.expired) {
           names.expired.push(experiment.name);
         } else {
           names.active.push(experiment.name);
         }
       }
 
       return names;
-    });
-
-    XPCOMUtils.defineLazyGetter(environment, "addons", async () => {
-      const addons = await Addons.getAll();
-      return Utils.keyBy(addons, "id");
-    });
+    })();
+  }
 
-    XPCOMUtils.defineLazyGetter(environment, "isFirstRun", () => {
-      return Services.prefs.getBoolPref("app.normandy.first_run", true);
-    });
-
-    return environment;
-  },
-};
+  static get isFirstRun() {
+    return Services.prefs.getBoolPref("app.normandy.first_run", true);
+  }
+}
--- a/toolkit/components/normandy/lib/NormandyDriver.jsm
+++ b/toolkit/components/normandy/lib/NormandyDriver.jsm
@@ -45,17 +45,17 @@ var NormandyDriver = function(sandboxMan
       }
 
       return Cc["@mozilla.org/chrome/chrome-registry;1"]
         .getService(Ci.nsIXULChromeRegistry)
         .getSelectedLocale("global");
     },
 
     get userId() {
-      return ClientEnvironment.getEnvironment().userId;
+      return ClientEnvironment.userId;
     },
 
     log(message, level = "debug") {
       const levels = ["debug", "info", "warn", "error"];
       if (!levels.includes(level)) {
         throw new Error(`Invalid log level "${level}"`);
       }
       actionLog[level](message);
--- a/toolkit/components/normandy/lib/RecipeRunner.jsm
+++ b/toolkit/components/normandy/lib/RecipeRunner.jsm
@@ -46,16 +46,31 @@ const LAZY_CLASSIFY_PREF = `${PREF_PREFI
 
 const PREFS_TO_WATCH = [
   RUN_INTERVAL_PREF,
   TELEMETRY_ENABLED_PREF,
   SHIELD_ENABLED_PREF,
   API_URL_PREF,
 ];
 
+/**
+ * cacheProxy returns an object Proxy that will memoize properties of the target.
+ */
+function cacheProxy(target) {
+  const cache = new Map();
+  return new Proxy(target, {
+    get(target, prop, receiver) {
+      if (!cache.has(prop)) {
+        cache.set(prop, target[prop]);
+      }
+      return cache.get(prop);
+    }
+  });
+}
+
 var RecipeRunner = {
   async init() {
     this.enabled = null;
     this.checkPrefs(); // sets this.enabled
     this.watchPrefs();
 
     // Run if enabled immediately on first run, or if dev mode is enabled.
     const firstRun = Services.prefs.getBoolPref(FIRST_RUN_PREF, true);
@@ -230,23 +245,23 @@ var RecipeRunner = {
 
     // Close storage connections
     await AddonStudies.close();
 
     Uptake.reportRunner(Uptake.RUNNER_SUCCESS);
   },
 
   getFilterContext(recipe) {
+    const environment = cacheProxy(ClientEnvironment);
+    environment.recipe = {
+      id: recipe.id,
+      arguments: recipe.arguments,
+    };
     return {
-      normandy: Object.assign(ClientEnvironment.getEnvironment(), {
-        recipe: {
-          id: recipe.id,
-          arguments: recipe.arguments,
-        },
-      }),
+      normandy: environment
     };
   },
 
   /**
    * Evaluate a recipe's filter expression against the environment.
    * @param {object} recipe
    * @param {string} recipe.filter The expression to evaluate against the environment.
    * @return {boolean} The result of evaluating the filter, cast to a bool, or false
deleted file mode 100644
--- a/toolkit/components/normandy/lib/Utils.jsm
+++ /dev/null
@@ -1,36 +0,0 @@
-/* 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";
-
-ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
-
-var EXPORTED_SYMBOLS = ["Utils"];
-
-const log = LogManager.getLogger("utils");
-
-var Utils = {
-  /**
-   * Convert an array of objects to an object. Each item is keyed by the value
-   * of the given key on the item.
-   *
-   * > list = [{foo: "bar"}, {foo: "baz"}]
-   * > keyBy(list, "foo") == {bar: {foo: "bar"}, baz: {foo: "baz"}}
-   *
-   * @param  {Array} list
-   * @param  {String} key
-   * @return {Object}
-   */
-  keyBy(list, key) {
-    return list.reduce((map, item) => {
-      if (!(key in item)) {
-        log.warn(`Skipping list due to missing key "${key}".`);
-        return map;
-      }
-
-      map[item[key]] = item;
-      return map;
-    }, {});
-  },
-};
--- a/toolkit/components/normandy/test/browser/browser_ClientEnvironment.js
+++ b/toolkit/components/normandy/test/browser/browser_ClientEnvironment.js
@@ -10,99 +10,84 @@ ChromeUtils.import("resource://normandy/
 
 add_task(async function testTelemetry() {
   // setup
   await SpecialPowers.pushPrefEnv({set: [["privacy.reduceTimerPrecision", true]]});
 
   await TelemetryController.submitExternalPing("testfoo", {foo: 1});
   await TelemetryController.submitExternalPing("testbar", {bar: 2});
   await TelemetryController.submitExternalPing("testfoo", {foo: 3});
-  const environment = ClientEnvironment.getEnvironment();
 
   // Test it can access telemetry
-  const telemetry = await environment.telemetry;
+  const telemetry = await ClientEnvironment.telemetry;
   is(typeof telemetry, "object", "Telemetry is accesible");
 
   // Test it reads different types of telemetry
   is(telemetry.testfoo.payload.foo, 3, "telemetry filters pull the latest ping from a type");
   is(telemetry.testbar.payload.bar, 2, "telemetry filters pull from submitted telemetry pings");
 });
 
 add_task(async function testUserId() {
-  let environment = ClientEnvironment.getEnvironment();
-
   // Test that userId is available
-  ok(UUID_REGEX.test(environment.userId), "userId available");
+  ok(UUID_REGEX.test(ClientEnvironment.userId), "userId available");
 
   // test that it pulls from the right preference
   await SpecialPowers.pushPrefEnv({set: [["app.normandy.user_id", "fake id"]]});
-  environment = ClientEnvironment.getEnvironment();
-  is(environment.userId, "fake id", "userId is pulled from preferences");
+  is(ClientEnvironment.userId, "fake id", "userId is pulled from preferences");
 });
 
 add_task(async function testDistribution() {
-  let environment = ClientEnvironment.getEnvironment();
-
   // distribution id defaults to "default"
-  is(environment.distribution, "default", "distribution has a default value");
+  is(ClientEnvironment.distribution, "default", "distribution has a default value");
 
   // distribution id is read from a preference
   await SpecialPowers.pushPrefEnv({set: [["distribution.id", "funnelcake"]]});
-  environment = ClientEnvironment.getEnvironment();
-  is(environment.distribution, "funnelcake", "distribution is read from preferences");
+  is(ClientEnvironment.distribution, "funnelcake", "distribution is read from preferences");
 });
 
 const mockClassify = {country: "FR", request_time: new Date(2017, 1, 1)};
 add_task(ClientEnvironment.withMockClassify(mockClassify, async function testCountryRequestTime() {
-  const environment = ClientEnvironment.getEnvironment();
-
   // Test that country and request_time pull their data from the server.
-  is(await environment.country, mockClassify.country, "country is read from the server API");
+  is(await ClientEnvironment.country, mockClassify.country, "country is read from the server API");
   is(
-    await environment.request_time, mockClassify.request_time,
+    await ClientEnvironment.request_time, mockClassify.request_time,
     "request_time is read from the server API"
   );
 }));
 
 add_task(async function testSync() {
-  let environment = ClientEnvironment.getEnvironment();
-  is(environment.syncMobileDevices, 0, "syncMobileDevices defaults to zero");
-  is(environment.syncDesktopDevices, 0, "syncDesktopDevices defaults to zero");
-  is(environment.syncTotalDevices, 0, "syncTotalDevices defaults to zero");
+  is(ClientEnvironment.syncMobileDevices, 0, "syncMobileDevices defaults to zero");
+  is(ClientEnvironment.syncDesktopDevices, 0, "syncDesktopDevices defaults to zero");
+  is(ClientEnvironment.syncTotalDevices, 0, "syncTotalDevices defaults to zero");
   await SpecialPowers.pushPrefEnv({
     set: [
       ["services.sync.clients.devices.mobile", 5],
       ["services.sync.clients.devices.desktop", 4],
     ],
   });
-  environment = ClientEnvironment.getEnvironment();
-  is(environment.syncMobileDevices, 5, "syncMobileDevices is read when set");
-  is(environment.syncDesktopDevices, 4, "syncDesktopDevices is read when set");
-  is(environment.syncTotalDevices, 9, "syncTotalDevices is read when set");
+  is(ClientEnvironment.syncMobileDevices, 5, "syncMobileDevices is read when set");
+  is(ClientEnvironment.syncDesktopDevices, 4, "syncDesktopDevices is read when set");
+  is(ClientEnvironment.syncTotalDevices, 9, "syncTotalDevices is read when set");
 });
 
 add_task(async function testDoNotTrack() {
-  let environment = ClientEnvironment.getEnvironment();
-
   // doNotTrack defaults to false
-  ok(!environment.doNotTrack, "doNotTrack has a default value");
+  ok(!ClientEnvironment.doNotTrack, "doNotTrack has a default value");
 
   // doNotTrack is read from a preference
   await SpecialPowers.pushPrefEnv({set: [["privacy.donottrackheader.enabled", true]]});
-  environment = ClientEnvironment.getEnvironment();
-  ok(environment.doNotTrack, "doNotTrack is read from preferences");
+  ok(ClientEnvironment.doNotTrack, "doNotTrack is read from preferences");
 });
 
 add_task(async function testExperiments() {
   const active = {name: "active", expired: false};
   const expired = {name: "expired", expired: true};
   const getAll = sinon.stub(PreferenceExperiments, "getAll", async () => [active, expired]);
 
-  const environment = ClientEnvironment.getEnvironment();
-  const experiments = await environment.experiments;
+  const experiments = await ClientEnvironment.experiments;
   Assert.deepEqual(
     experiments.all,
     ["active", "expired"],
     "experiments.all returns all stored experiment names",
   );
   Assert.deepEqual(
     experiments.active,
     ["active"],
@@ -118,27 +103,25 @@ add_task(async function testExperiments(
 });
 
 add_task(withDriver(Assert, async function testAddonsInContext(driver) {
   // Create before install so that the listener is added before startup completes.
   const startupPromise = AddonTestUtils.promiseWebExtensionStartup("normandydriver@example.com");
   const addonId = await driver.addons.install(TEST_XPI_URL);
   await startupPromise;
 
-  const environment = ClientEnvironment.getEnvironment();
-  const addons = await environment.addons;
+  const addons = await ClientEnvironment.addons;
   Assert.deepEqual(addons[addonId], {
     id: [addonId],
     name: "normandy_fixture",
     version: "1.0",
     installDate: addons[addonId].installDate,
     isActive: true,
     type: "extension",
   }, "addons should be available in context");
 
   await driver.addons.uninstall(addonId);
 }));
 
 add_task(async function isFirstRun() {
   await SpecialPowers.pushPrefEnv({set: [["app.normandy.first_run", true]]});
-  const environment = ClientEnvironment.getEnvironment();
-  ok(environment.isFirstRun, "isFirstRun is read from preferences");
+  ok(ClientEnvironment.isFirstRun, "isFirstRun is read from preferences");
 });
--- a/toolkit/components/normandy/test/browser/browser_FilterExpressions.js
+++ b/toolkit/components/normandy/test/browser/browser_FilterExpressions.js
@@ -136,16 +136,26 @@ add_task(async function testKeys() {
     undefined,
     "keys returns undefined for numbers",
   );
   is(
     await FilterExpressions.eval("ctxObject|keys", {ctxObject: null}),
     undefined,
     "keys returns undefined for null",
   );
+
+  // Object properties are not cached
+  let pong = 0;
+  context = {ctxObject: {
+    get ping() {
+      return ++pong;
+    }
+  }};
+  await FilterExpressions.eval("ctxObject.ping == 0 || ctxObject.ping == 1", context);
+  is(pong, 2, "Properties are not reifed");
 });
 
 // intersect tests
 add_task(async function testIntersect() {
   let val;
 
   val = await FilterExpressions.eval("[1, 2, 3] intersect [4, 2, 6, 7, 3]");
   Assert.deepEqual(
--- a/toolkit/components/normandy/test/browser/browser_RecipeRunner.js
+++ b/toolkit/components/normandy/test/browser/browser_RecipeRunner.js
@@ -44,16 +44,23 @@ add_task(async function getFilterContext
     "normandy.recipe is the recipe passed to getFilterContext",
   );
   delete recipe.unrelated;
   Assert.deepEqual(
     context.normandy.recipe,
     recipe,
     "normandy.recipe drops unrecognized attributes from the recipe",
   );
+
+  // Filter context attributes are cached.
+  await SpecialPowers.pushPrefEnv({set: [["app.normandy.user_id", "some id"]]});
+  is(context.normandy.userId, "some id", "User id is read from prefs when accessed");
+  await SpecialPowers.pushPrefEnv({set: [["app.normandy.user_id", "real id"]]});
+  is(context.normandy.userId, "some id", "userId was cached");
+
 });
 
 add_task(async function checkFilter() {
   const check = filter => RecipeRunner.checkFilter({filter_expression: filter});
 
   // Errors must result in a false return value.
   ok(!(await check("invalid ( + 5yntax")), "Invalid filter expressions return false");
 
--- a/toolkit/components/normandy/test/browser/head.js
+++ b/toolkit/components/normandy/test/browser/head.js
@@ -2,17 +2,16 @@ ChromeUtils.import("resource://gre/modul
 ChromeUtils.import("resource://testing-common/AddonTestUtils.jsm", this);
 ChromeUtils.import("resource://testing-common/TestUtils.jsm", this);
 ChromeUtils.import("resource://normandy-content/AboutPages.jsm", this);
 ChromeUtils.import("resource://normandy/lib/Addons.jsm", this);
 ChromeUtils.import("resource://normandy/lib/SandboxManager.jsm", this);
 ChromeUtils.import("resource://normandy/lib/NormandyDriver.jsm", this);
 ChromeUtils.import("resource://normandy/lib/NormandyApi.jsm", this);
 ChromeUtils.import("resource://normandy/lib/TelemetryEvents.jsm", this);
-ChromeUtils.import("resource://normandy/lib/Utils.jsm", this);
 
 // Load mocking/stubbing library, sinon
 // docs: http://sinonjs.org/docs/
 /* global sinon */
 Services.scriptloader.loadSubScript("resource://testing-common/sinon-2.3.2.js");
 
 // Make sinon assertions fail in a way that mochitest understands
 sinon.assert.fail = function(message) {
deleted file mode 100644
--- a/toolkit/components/normandy/test/unit/test_Utils.js
+++ /dev/null
@@ -1,20 +0,0 @@
-"use strict";
-
-ChromeUtils.import("resource://normandy/lib/Utils.jsm");
-
-add_task(async function testKeyBy() {
-  const list = [];
-  deepEqual(Utils.keyBy(list, "foo"), {});
-
-  const foo = {name: "foo", value: 1};
-  list.push(foo);
-  deepEqual(Utils.keyBy(list, "name"), {foo});
-
-  const bar = {name: "bar", value: 2};
-  list.push(bar);
-  deepEqual(Utils.keyBy(list, "name"), {foo, bar});
-
-  const missingKey = {value: 7};
-  list.push(missingKey);
-  deepEqual(Utils.keyBy(list, "name"), {foo, bar}, "keyBy skips items that are missing the key");
-});
--- a/toolkit/components/normandy/test/unit/xpcshell.ini
+++ b/toolkit/components/normandy/test/unit/xpcshell.ini
@@ -1,13 +1,13 @@
 [DEFAULT]
 head = head_xpc.js
 support-files =
   mock_api/**
   invalid_recipe_signature_api/**
   query_server.sjs
   echo_server.sjs
   utils.js
+tags = normandy
 
 [test_NormandyApi.js]
 [test_Sampling.js]
 [test_SandboxManager.js]
-[test_Utils.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/utils/ClientEnvironment.jsm
@@ -0,0 +1,126 @@
+/* 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";
+
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(this, "ShellService", "resource:///modules/ShellService.jsm");
+ChromeUtils.defineModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm");
+ChromeUtils.defineModuleGetter(this, "TelemetryArchive", "resource://gre/modules/TelemetryArchive.jsm");
+ChromeUtils.defineModuleGetter(this, "UpdateUtils", "resource://gre/modules/UpdateUtils.jsm");
+ChromeUtils.defineModuleGetter(this, "AppConstants", "resource://gre/modules/AppConstants.jsm");
+
+var EXPORTED_SYMBOLS = ["ClientEnvironmentBase"];
+
+
+/**
+ * Create an object that provides general information about the client application.
+ *
+ * Components like Normandy RecipeRunner use this as part of the context for filter expressions,
+ * so avoid adding non-getter functions as attributes, as filter expressions
+ * cannot execute functions.
+ *
+ * Also note that, because filter expressions implicitly resolve promises, you
+ * can add getter functions that return promises for async data.
+ */
+class ClientEnvironmentBase {
+  static get distribution() {
+    return Services.prefs.getCharPref("distribution.id", "default");
+  }
+
+  static get telemetry() {
+    return (async () => {
+      const pings = await TelemetryArchive.promiseArchivedPingList();
+
+      // get most recent ping per type
+      const mostRecentPings = {};
+      for (const ping of pings) {
+        if (ping.type in mostRecentPings) {
+          if (mostRecentPings[ping.type].timestampCreated < ping.timestampCreated) {
+            mostRecentPings[ping.type] = ping;
+          }
+        } else {
+          mostRecentPings[ping.type] = ping;
+        }
+      }
+
+      const telemetry = {};
+      for (const key in mostRecentPings) {
+        const ping = mostRecentPings[key];
+        telemetry[ping.type] = await TelemetryArchive.promiseArchivedPingById(ping.id);
+      }
+      return telemetry;
+    })();
+  }
+
+  static get version() {
+    return AppConstants.MOZ_APP_VERSION_DISPLAY;
+  }
+
+  static get channel() {
+    return UpdateUtils.getUpdateChannel(false);
+  }
+
+  static get isDefaultBrowser() {
+    return ShellService.isDefaultBrowser();
+  }
+
+  static get searchEngine() {
+    return (async () => {
+      const searchInitialized = await new Promise(resolve => Services.search.init(resolve));
+      if (Components.isSuccessCode(searchInitialized)) {
+        return Services.search.defaultEngine.identifier;
+      }
+      return null;
+    })();
+  }
+
+  static get syncSetup() {
+    return Services.prefs.prefHasUserValue("services.sync.username");
+  }
+
+  static get syncDesktopDevices() {
+    return Services.prefs.getIntPref("services.sync.clients.devices.desktop", 0);
+  }
+
+  static get syncMobileDevices() {
+    return Services.prefs.getIntPref("services.sync.clients.devices.mobile", 0);
+  }
+
+  static get syncTotalDevices() {
+    return this.syncDesktopDevices + this.syncMobileDevices;
+  }
+
+  static get addons() {
+    return (async () => {
+      const addons = await AddonManager.getAllAddons();
+      return addons.reduce((acc, addon) => {
+        const { id, isActive, name, type, version, installDate: installDateN } = addon;
+        const installDate = new Date(installDateN);
+        acc[id] = { id, isActive, name, type, version, installDate };
+        return acc;
+      }, {});
+    })();
+  }
+
+  static get plugins() {
+    return (async () => {
+      const plugins = await AddonManager.getAddonsByTypes(["plugin"]);
+      return plugins.reduce((acc, plugin) => {
+        const { name, description, version } = plugin;
+        acc[name] = { name, description, version };
+        return acc;
+      }, {});
+    })();
+  }
+
+  static get locale() {
+    return Services.locale.getAppLocaleAsLangTag();
+  }
+
+  static get doNotTrack() {
+    return Services.prefs.getBoolPref("privacy.donottrackheader.enabled", false);
+  }
+}
--- a/toolkit/components/utils/moz.build
+++ b/toolkit/components/utils/moz.build
@@ -6,8 +6,12 @@
 
 with Files('**'):
     BUG_COMPONENT = ('Toolkit', 'General')
 
 EXTRA_COMPONENTS += [
     'simpleServices.js',
     'utils.manifest',
 ]
+
+EXTRA_JS_MODULES['components-utils'] = [
+    'ClientEnvironment.jsm'
+]