Backed out changeset 24657145bfd4 (bug 1440778) on request by dev mythmon CLOSED TREE
authorBogdan Tara <btara@mozilla.com>
Wed, 28 Nov 2018 23:22:07 +0200
changeset 505006 5ee131dbcffba34ea868e7a26f443970520986d9
parent 505005 1855e3f4af3458c82f6e6b87e50fa2fc0cb05fce
child 505007 90734740c6634a0d277c6ed0e9db84cfd39aa8c9
push id10290
push userffxbld-merge
push dateMon, 03 Dec 2018 16:23:23 +0000
treeherdermozilla-beta@700bed2445e6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
bugs1440778
milestone65.0a1
backs out24657145bfd4b354d1c2336f496e1f0a9eecd30a
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Backed out changeset 24657145bfd4 (bug 1440778) on request by dev mythmon CLOSED TREE
toolkit/components/normandy/actions/ShowHeartbeatAction.jsm
toolkit/components/normandy/actions/schemas/index.js
toolkit/components/normandy/actions/schemas/package.json
toolkit/components/normandy/lib/ActionsManager.jsm
toolkit/components/normandy/lib/EventEmitter.jsm
toolkit/components/normandy/lib/Heartbeat.jsm
toolkit/components/normandy/lib/NormandyDriver.jsm
toolkit/components/normandy/lib/Storage.jsm
toolkit/components/normandy/test/browser/browser.ini
toolkit/components/normandy/test/browser/browser_EventEmitter.js
toolkit/components/normandy/test/browser/browser_Heartbeat.js
toolkit/components/normandy/test/browser/browser_actions_ShowHeartbeatAction.js
deleted file mode 100644
--- a/toolkit/components/normandy/actions/ShowHeartbeatAction.jsm
+++ /dev/null
@@ -1,209 +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://gre/modules/Services.jsm");
-ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
-ChromeUtils.import("resource://normandy/actions/BaseAction.jsm");
-ChromeUtils.defineModuleGetter(this, "ActionSchemas", "resource://normandy/actions/schemas/index.js");
-ChromeUtils.defineModuleGetter(this, "BrowserWindowTracker", "resource:///modules/BrowserWindowTracker.jsm");
-ChromeUtils.defineModuleGetter(this, "ClientEnvironment", "resource://normandy/lib/ClientEnvironment.jsm");
-ChromeUtils.defineModuleGetter(this, "Heartbeat", "resource://normandy/lib/Heartbeat.jsm");
-ChromeUtils.defineModuleGetter(this, "ShellService", "resource:///modules/ShellService.jsm");
-ChromeUtils.defineModuleGetter(this, "Storage", "resource://normandy/lib/Storage.jsm");
-ChromeUtils.defineModuleGetter(this, "UpdateUtils", "resource://gre/modules/UpdateUtils.jsm");
-
-XPCOMUtils.defineLazyServiceGetter(this, "uuidGenerator", "@mozilla.org/uuid-generator;1", "nsIUUIDGenerator");
-
-var EXPORTED_SYMBOLS = ["ShowHeartbeatAction"];
-
-XPCOMUtils.defineLazyGetter(this, "gAllRecipeStorage", function() {
-  return new Storage("normandy-heartbeat");
-});
-
-const DAY_IN_MS = 24 * 60 * 60 * 1000;
-const HEARTBEAT_THROTTLE = 1 * DAY_IN_MS;
-
-class ShowHeartbeatAction extends BaseAction {
-  get schema() {
-    return ActionSchemas["show-heartbeat"];
-  }
-
-  async _run(recipe) {
-    const {
-      message,
-      engagementButtonLabel,
-      thanksMessage,
-      learnMoreMessage,
-      learnMoreUrl,
-    } = recipe.arguments;
-
-    const recipeStorage = new Storage(recipe.id);
-
-    if (!await this.shouldShow(recipeStorage, recipe)) {
-      return;
-    }
-
-    this.log.debug(`Heartbeat for recipe ${recipe.id} showing prompt "${message}"`);
-    const targetWindow = BrowserWindowTracker.getTopWindow();
-
-    if (!targetWindow) {
-      throw new Error("No window to show heartbeat in");
-    }
-
-    const heartbeat = new Heartbeat(targetWindow, {
-      surveyId: this.generateSurveyId(recipe),
-      message,
-      engagementButtonLabel,
-      thanksMessage,
-      learnMoreMessage,
-      learnMoreUrl,
-      postAnswerUrl: await this.generatePostAnswerURL(recipe),
-      flowId: this.uuid(),
-      surveyVersion: recipe.revision_id,
-    });
-
-    heartbeat.eventEmitter.once("Voted", this.updateLastInteraction.bind(this, recipeStorage));
-    heartbeat.eventEmitter.once("Engaged", this.updateLastInteraction.bind(this, recipeStorage));
-
-    let now = Date.now();
-    await Promise.all([
-      gAllRecipeStorage.setItem("lastShown", now),
-      recipeStorage.setItem("lastShown", now),
-    ]);
-  }
-
-  async shouldShow(recipeStorage, recipe) {
-    const { repeatOption, repeatEvery } = recipe.arguments;
-    // Don't show any heartbeats to a user more than once per throttle period
-    let lastShown = await gAllRecipeStorage.getItem("lastShown");
-    if (lastShown) {
-      const duration = new Date() - lastShown;
-      if (duration < HEARTBEAT_THROTTLE) {
-        // show the number of hours since the last heartbeat, with at most 1 decimal point.
-        const hoursAgo = Math.floor(duration / 1000 / 60 / 6) / 10;
-        this.log.debug(`A heartbeat was shown too recently (${hoursAgo} hours), skipping recipe ${recipe.id}.`);
-        return false;
-      }
-    }
-
-    switch (repeatOption) {
-      case "once": {
-        // Don't show if we've ever shown before
-        if (await recipeStorage.getItem("lastShown")) {
-          this.log.debug(`Heartbeat for "once" recipe ${recipe.id} has been shown before, skipping.`);
-          return false;
-        }
-      }
-
-      case "nag": {
-        // Show a heartbeat again only if the user has not interacted with it before
-        if (await recipeStorage.getItem("lastInteraction")) {
-          this.log.debug(`Heartbeat for "nag" recipe ${recipe.id} has already been interacted with, skipping.`);
-          return false;
-        }
-      }
-
-      case "xdays": {
-        // Show this heartbeat again if it  has been at least `repeatEvery` days since the last time it was shown.
-        let lastShown = await gAllRecipeStorage.getItem("lastShown");
-        if (lastShown) {
-          lastShown = new Date(lastShown);
-          const duration  = new Date() - lastShown;
-          if (duration < repeatEvery * DAY_IN_MS) {
-            // show the number of hours since the last time this hearbeat was shown, with at most 1 decimal point.
-            const hoursAgo = Math.floor(duration / 1000 / 60 / 6) / 10;
-            this.log.debug(
-              `Heartbeat for "xdays" recipe ${recipe.id} ran in the last ${repeatEvery} days, skipping. (${hoursAgo} hours ago)`
-            );
-            return false;
-          }
-        }
-      }
-    }
-
-    return true;
-  }
-
-  /**
-   * Returns a surveyId value. If recipe calls to include the Normandy client
-   * ID, then the client ID is attached to the surveyId in the format
-   * `${surveyId}::${userId}`.
-   *
-   * @return {String} Survey ID, possibly with user UUID
-   */
-  generateSurveyId(recipe) {
-    const { includeTelemetryUUID, surveyId } = recipe.arguments;
-    if (includeTelemetryUUID) {
-      return `${surveyId}::${ClientEnvironment.userId}`;
-    }
-    return surveyId;
-  }
-
-  /*
-   * Generate a UUID without surrounding brackets, as expected by Heartbeat
-   * telemetry.
-   */
-  uuid() {
-    let rv = uuidGenerator.generateUUID().toString();
-    return rv.slice(1, rv.length - 1);
-  }
-
-  /**
-   * Generate the appropriate post-answer URL for a recipe.
-   * @param  recipe
-   * @return {String} URL with post-answer query params
-   */
-  async generatePostAnswerURL(recipe) {
-    const { postAnswerUrl, message, includeTelemetryUUID } = recipe.arguments;
-
-    // Don`t bother with empty URLs.
-    if (!postAnswerUrl) {
-      return postAnswerUrl;
-    }
-
-    const userId = ClientEnvironment.userId;
-    const searchEngine = await new Promise(resolve => {
-      Services.search.init(rv => {
-        if (Components.isSuccessCode(rv)) {
-          resolve(Services.search.defaultEngine.identifier);
-        }
-      });
-    });
-
-    const args = {
-      fxVersion: Services.appinfo.version,
-      isDefaultBrowser: ShellService.isDefaultBrowser() ? 1 : 0,
-      searchEngine,
-      source: "heartbeat",
-      // `surveyversion` used to be the version of the heartbeat action when it
-      // was hosted on a server. Keeping it around for compatibility.
-      surveyversion: Services.appinfo.version,
-      syncSetup: Services.prefs.prefHasUserValue("services.sync.username") ? 1 : 0,
-      updateChannel: UpdateUtils.getUpdateChannel(false),
-      utm_campaign: encodeURIComponent(message.replace(/\s+/g, "")),
-      utm_medium: recipe.action,
-      utm_source: "firefox",
-    };
-    if (includeTelemetryUUID) {
-      args.userId = userId;
-    }
-
-    let url = new URL(postAnswerUrl);
-    // create a URL object to append arguments to
-    for (const [key, val] of Object.entries(args)) {
-      if (!url.searchParams.has(key)) {
-        url.searchParams.set(key, val);
-      }
-    }
-
-    // return the address with encoded queries
-    return url.toString();
-  }
-
-  updateLastInteraction(recipeStorage) {
-    recipeStorage.setItem("lastInteraction", Date.now());
-  }
-}
--- a/toolkit/components/normandy/actions/schemas/index.js
+++ b/toolkit/components/normandy/actions/schemas/index.js
@@ -90,82 +90,16 @@ const ActionSchemas = {
       },
       isEnrollmentPaused: {
         description: "If true, new users will not be enrolled in the study.",
         type: "boolean",
         default: false,
       },
     },
   },
-
-  "show-heartbeat": {
-    "$schema": "http://json-schema.org/draft-04/schema#",
-    "title": "Show a Heartbeat survey.",
-    "description": "This action shows a single survey.",
-
-    "type": "object",
-    "required": [
-      "surveyId",
-      "message",
-      "thanksMessage",
-      "postAnswerUrl",
-      "learnMoreMessage",
-      "learnMoreUrl",
-    ],
-    "properties": {
-      "repeatOption": {
-        "type": "string",
-        "enum": ["once", "xdays", "nag"],
-        "description": "Determines how often a prompt is shown executes.",
-        "default": "once",
-      },
-      "repeatEvery": {
-        "description": "For repeatOption=xdays, how often (in days) the prompt is displayed.",
-        "default": null,
-        "type": ["number", "null"],
-      },
-      "includeTelemetryUUID": {
-        "type": "boolean",
-        "description": "Include unique user ID in post-answer-url and Telemetry",
-        "default": false,
-      },
-      "surveyId": {
-        "type": "string",
-        "description": "Slug uniquely identifying this survey in telemetry",
-      },
-      "message": {
-        "description": "Message to show to the user",
-        "type": "string",
-      },
-      "engagementButtonLabel": {
-        "description": "Text for the engagement button. If specified, this button will be shown instead of rating stars.",
-        "default": null,
-        "type": ["string", "null"],
-      },
-      "thanksMessage": {
-        "description": "Thanks message to show to the user after they've rated Firefox",
-        "type": "string",
-      },
-      "postAnswerUrl": {
-        "description": "URL to redirect the user to after rating Firefox or clicking the engagement button",
-        "default": null,
-        "type": ["string", "null"],
-      },
-      "learnMoreMessage": {
-        "description": "Message to show to the user to learn more",
-        "default": null,
-        "type": ["string", "null"],
-      },
-      "learnMoreUrl": {
-        "description": "URL to show to the user when they click Learn More",
-        "default": null,
-        "type": ["string", "null"],
-      },
-    },
-  },
 };
 
 // Legacy name used on Normandy server
 ActionSchemas["opt-out-study"] = ActionSchemas["addon-study"];
 
 // If running in Node.js, export the schemas.
 if (typeof module !== "undefined") {
   /* globals module */
--- a/toolkit/components/normandy/actions/schemas/package.json
+++ b/toolkit/components/normandy/actions/schemas/package.json
@@ -1,11 +1,11 @@
 {
   "name": "@mozilla/normandy-action-argument-schemas",
-  "version": "0.6.0",
+  "version": "0.5.0",
   "description": "Schemas for Normandy action arguments",
   "main": "index.js",
   "author": "Michael Cooper <mcooper@mozilla.com>",
   "license": "MPL-2.0",
   "scripts": {
     "prepack": "node export_json.js"
   }
 }
--- a/toolkit/components/normandy/lib/ActionsManager.jsm
+++ b/toolkit/components/normandy/lib/ActionsManager.jsm
@@ -1,20 +1,19 @@
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   ActionSandboxManager: "resource://normandy/lib/ActionSandboxManager.jsm",
+  NormandyApi: "resource://normandy/lib/NormandyApi.jsm",
+  Uptake: "resource://normandy/lib/Uptake.jsm",
   AddonStudyAction: "resource://normandy/actions/AddonStudyAction.jsm",
   ConsoleLogAction: "resource://normandy/actions/ConsoleLogAction.jsm",
-  NormandyApi: "resource://normandy/lib/NormandyApi.jsm",
+  PreferenceRolloutAction: "resource://normandy/actions/PreferenceRolloutAction.jsm",
   PreferenceRollbackAction: "resource://normandy/actions/PreferenceRollbackAction.jsm",
-  PreferenceRolloutAction: "resource://normandy/actions/PreferenceRolloutAction.jsm",
-  ShowHeartbeatAction: "resource://normandy/actions/ShowHeartbeatAction.jsm",
-  Uptake: "resource://normandy/lib/Uptake.jsm",
 });
 
 var EXPORTED_SYMBOLS = ["ActionsManager"];
 
 const log = LogManager.getLogger("recipe-runner");
 
 /**
  * A class to manage the actions that recipes can use in Normandy.
@@ -30,20 +29,19 @@ class ActionsManager {
     this.finalized = false;
     this.remoteActionSandboxes = {};
 
     const addonStudyAction = new AddonStudyAction();
 
     this.localActions = {
       "addon-study": addonStudyAction,
       "console-log": new ConsoleLogAction(),
-      "opt-out-study": addonStudyAction, // Legacy name used for addon-study on Normandy server
+      "preference-rollout": new PreferenceRolloutAction(),
       "preference-rollback": new PreferenceRollbackAction(),
-      "preference-rollout": new PreferenceRolloutAction(),
-      "show-heartbeat": new ShowHeartbeatAction(),
+      "opt-out-study": addonStudyAction, // Legacy name used on Normandy server
     };
   }
 
   async fetchRemoteActions() {
     const actions = await NormandyApi.fetchActions();
 
     for (const action of actions) {
       // Skip actions with local implementations
--- a/toolkit/components/normandy/lib/EventEmitter.jsm
+++ b/toolkit/components/normandy/lib/EventEmitter.jsm
@@ -4,37 +4,40 @@
 "use strict";
 
 ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
 
 var EXPORTED_SYMBOLS = ["EventEmitter"];
 
 const log = LogManager.getLogger("event-emitter");
 
-var EventEmitter = function() {
+var EventEmitter = function(sandboxManager) {
   const listeners = {};
 
   return {
+    createSandboxedEmitter() {
+      return sandboxManager.cloneInto({
+        on: this.on.bind(this),
+        off: this.off.bind(this),
+        once: this.once.bind(this),
+      }, {cloneFunctions: true});
+    },
+
     emit(eventName, event) {
       // Fire events async
       Promise.resolve()
         .then(() => {
           if (!(eventName in listeners)) {
             log.debug(`EventEmitter: Event fired with no listeners: ${eventName}`);
             return;
           }
           // Clone callbacks array to avoid problems with mutation while iterating
           const callbacks = Array.from(listeners[eventName]);
           for (const cb of callbacks) {
-            // Clone event so it can't by modified by the handler
-            let eventToPass = event;
-            if (typeof event === "object") {
-              eventToPass = Object.assign({}, event);
-            }
-            cb(eventToPass);
+            cb(sandboxManager.cloneInto(event));
           }
         });
     },
 
     on(eventName, callback) {
       if (!(eventName in listeners)) {
         listeners[eventName] = [];
       }
--- a/toolkit/components/normandy/lib/Heartbeat.jsm
+++ b/toolkit/components/normandy/lib/Heartbeat.jsm
@@ -43,75 +43,83 @@ CleanupManager.addCleanupHandler(() => {
   }
 });
 
 /**
  * Show the Heartbeat UI to request user feedback.
  *
  * @param chromeWindow
  *        The chrome window that the heartbeat notification is displayed in.
+ * @param sandboxManager
+ *        The manager for the sandbox this was called from. Heartbeat will
+ *        increment the hold counter on the manager.
  * @param {Object} options Options object.
  * @param {String} options.message
  *        The message, or question, to display on the notification.
  * @param {String} options.thanksMessage
  *        The thank you message to display after user votes.
  * @param {String} options.flowId
  *        An identifier for this rating flow. Please note that this is only used to
  *        identify the notification box.
  * @param {String} [options.engagementButtonLabel=null]
- *        The text of the engagement button to use instead of stars. If this is null
+ *        The text of the engagement button to use instad of stars. If this is null
  *        or invalid, rating stars are used.
  * @param {String} [options.learnMoreMessage=null]
  *        The label of the learn more link. No link will be shown if this is null.
  * @param {String} [options.learnMoreUrl=null]
  *        The learn more URL to open when clicking on the learn more link. No learn more
  *        will be shown if this is an invalid URL.
  * @param {String} [options.surveyId]
  *        An ID for the survey, reflected in the Telemetry ping.
  * @param {Number} [options.surveyVersion]
  *        Survey's version number, reflected in the Telemetry ping.
  * @param {boolean} [options.testing]
  *        Whether this is a test survey, reflected in the Telemetry ping.
  * @param {String} [options.postAnswerURL=null]
  *        The url to visit after the user answers the question.
  */
 var Heartbeat = class {
-  constructor(chromeWindow, options) {
+  constructor(chromeWindow, sandboxManager, options) {
     if (typeof options.flowId !== "string") {
-      throw new Error(`flowId must be a string, but got ${JSON.stringify(options.flowId)}, a ${typeof options.flowId}`);
+      throw new Error("flowId must be a string");
     }
 
     if (!options.flowId) {
       throw new Error("flowId must not be an empty string");
     }
 
     if (typeof options.message !== "string") {
-      throw new Error(`message must be a string, but got ${JSON.stringify(options.message)}, a ${typeof options.message}`);
+      throw new Error("message must be a string");
     }
 
     if (!options.message) {
       throw new Error("message must not be an empty string");
     }
 
+    if (!sandboxManager) {
+      throw new Error("sandboxManager must be provided");
+    }
+
     if (options.postAnswerUrl) {
       options.postAnswerUrl = new URL(options.postAnswerUrl);
     } else {
       options.postAnswerUrl = null;
     }
 
     if (options.learnMoreUrl) {
       try {
         options.learnMoreUrl = new URL(options.learnMoreUrl);
       } catch (e) {
         options.learnMoreUrl = null;
       }
     }
 
     this.chromeWindow = chromeWindow;
-    this.eventEmitter = new EventEmitter();
+    this.eventEmitter = new EventEmitter(sandboxManager);
+    this.sandboxManager = sandboxManager;
     this.options = options;
     this.surveyResults = {};
     this.buttons = null;
 
     if (!windowsWithInjectedCss.has(chromeWindow)) {
       windowsWithInjectedCss.add(chromeWindow);
       const utils = chromeWindow.windowUtils;
       utils.loadSheet(HEARTBEAT_CSS_URI, chromeWindow.AGENT_SHEET);
@@ -224,22 +232,23 @@ var Heartbeat = class {
     this.chromeWindow.addEventListener("SSWindowClosing", this.handleWindowClosed);
 
     const surveyDuration = Preferences.get(PREF_SURVEY_DURATION, 300) * 1000;
     this.surveyEndTimer = setTimeout(() => {
       this.maybeNotifyHeartbeat("SurveyExpired");
       this.close();
     }, surveyDuration);
 
+    this.sandboxManager.addHold("heartbeat");
     CleanupManager.addCleanupHandler(this.close);
   }
 
   maybeNotifyHeartbeat(name, data = {}) {
     if (this.pingSent) {
-      log.warn("Heartbeat event received after Telemetry ping sent. name:", name, "data:", data);
+      log.warn("Heartbeat event recieved after Telemetry ping sent. name:", name, "data:", data);
       return;
     }
 
     const timestamp = Date.now();
     let sendPing = false;
     let cleanup = false;
 
     const phases = {
@@ -360,22 +369,25 @@ var Heartbeat = class {
   close() {
     this.notificationBox.removeNotification(this.notice);
   }
 
   cleanup() {
     // Kill the timers which might call things after we've cleaned up:
     this.endTimerIfPresent("surveyEndTimer");
     this.endTimerIfPresent("engagementCloseTimer");
+
+    this.sandboxManager.removeHold("heartbeat");
     // remove listeners
     this.chromeWindow.removeEventListener("SSWindowClosing", this.handleWindowClosed);
     // remove references for garbage collection
     this.chromeWindow = null;
     this.notificationBox = null;
     this.notice = null;
     this.ratingContainer = null;
     this.rightSpacer = null;
     this.learnMore = null;
     this.eventEmitter = null;
+    this.sandboxManager = null;
     // Ensure we don't re-enter and release the CleanupManager's reference to us:
     CleanupManager.removeCleanupHandler(this.close);
   }
 };
--- a/toolkit/components/normandy/lib/NormandyDriver.jsm
+++ b/toolkit/components/normandy/lib/NormandyDriver.jsm
@@ -6,27 +6,29 @@
 
 ChromeUtils.import("resource://gre/modules/Services.jsm");
 ChromeUtils.import("resource://gre/modules/Preferences.jsm");
 ChromeUtils.import("resource:///modules/ShellService.jsm");
 ChromeUtils.import("resource://gre/modules/AddonManager.jsm");
 ChromeUtils.import("resource://gre/modules/Timer.jsm");
 ChromeUtils.import("resource://normandy/lib/LogManager.jsm");
 ChromeUtils.import("resource://normandy/lib/Storage.jsm");
+ChromeUtils.import("resource://normandy/lib/Heartbeat.jsm");
 ChromeUtils.import("resource://normandy/lib/ClientEnvironment.jsm");
 ChromeUtils.import("resource://normandy/lib/PreferenceExperiments.jsm");
 
 ChromeUtils.defineModuleGetter(
   this, "Sampling", "resource://gre/modules/components-utils/Sampling.jsm");
 ChromeUtils.defineModuleGetter(this, "UpdateUtils", "resource://gre/modules/UpdateUtils.jsm");
 
 const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
 
 var EXPORTED_SYMBOLS = ["NormandyDriver"];
 
+const log = LogManager.getLogger("normandy-driver");
 const actionLog = LogManager.getLogger("normandy-driver.actions");
 
 var NormandyDriver = function(sandboxManager) {
   if (!sandboxManager) {
     throw new Error("sandboxManager is required");
   }
   const {sandbox} = sandboxManager;
 
@@ -50,16 +52,33 @@ var NormandyDriver = function(sandboxMan
     log(message, level = "debug") {
       const levels = ["debug", "info", "warn", "error"];
       if (!levels.includes(level)) {
         throw new Error(`Invalid log level "${level}"`);
       }
       actionLog[level](message);
     },
 
+    showHeartbeat(options) {
+      log.info(`Showing heartbeat prompt "${options.message}"`);
+      const aWindow = Services.wm.getMostRecentWindow("navigator:browser");
+
+      if (!aWindow) {
+        return sandbox.Promise.reject(new sandbox.Error("No window to show heartbeat in"));
+      }
+
+      const internalOptions = Object.assign({}, options, {testing: this.testing});
+      const heartbeat = new Heartbeat(aWindow, sandboxManager, internalOptions);
+      return sandbox.Promise.resolve(heartbeat.eventEmitter.createSandboxedEmitter());
+    },
+
+    saveHeartbeatFlow() {
+      // no-op required by spec
+    },
+
     client() {
       const appinfo = {
         version: Services.appinfo.version,
         channel: UpdateUtils.getUpdateChannel(false),
         isDefaultBrowser: ShellService.isDefaultBrowser() || null,
         searchEngine: null,
         syncSetup: Preferences.isSet("services.sync.username"),
         syncDesktopDevices: Preferences.get("services.sync.clients.devices.desktop", 0),
--- a/toolkit/components/normandy/lib/Storage.jsm
+++ b/toolkit/components/normandy/lib/Storage.jsm
@@ -43,46 +43,46 @@ var Storage = class {
     const store = await lazyStore;
     const namespace = store.data[this.prefix] || {};
     return namespace[name] || null;
   }
 
   /**
    * Sets an item in the prefixed storage.
    * @returns {Promise}
-   * @resolves When the operation is completed successfully
+   * @resolves When the operation is completed succesfully
    * @rejects Javascript exception.
    */
   async setItem(name, value) {
     const store = await lazyStore;
     if (!(this.prefix in store.data)) {
       store.data[this.prefix] = {};
     }
     store.data[this.prefix][name] = value;
     store.saveSoon();
   }
 
   /**
    * Removes a single item from the prefixed storage.
    * @returns {Promise}
-   * @resolves When the operation is completed successfully
+   * @resolves When the operation is completed succesfully
    * @rejects Javascript exception.
    */
   async removeItem(name) {
     const store = await lazyStore;
     if (this.prefix in store.data) {
       delete store.data[this.prefix][name];
       store.saveSoon();
     }
   }
 
   /**
    * Clears all storage for the prefix.
    * @returns {Promise}
-   * @resolves When the operation is completed successfully
+   * @resolves When the operation is completed succesfully
    * @rejects Javascript exception.
    */
   async clear() {
     const store = await lazyStore;
     store.data[this.prefix] = {};
     store.saveSoon();
   }
 };
--- a/toolkit/components/normandy/test/browser/browser.ini
+++ b/toolkit/components/normandy/test/browser/browser.ini
@@ -6,17 +6,16 @@ head = head.js
 [browser_about_preferences.js]
 # Skip this test when FHR/Telemetry aren't available.
 skip-if = !healthreport || !telemetry
 [browser_about_studies.js]
 [browser_actions_AddonStudyAction.js]
 [browser_actions_ConsoleLogAction.js]
 [browser_actions_PreferenceRolloutAction.js]
 [browser_actions_PreferenceRollbackAction.js]
-[browser_actions_ShowHeartbeatAction.js]
 [browser_ActionSandboxManager.js]
 [browser_ActionsManager.js]
 [browser_AddonStudies.js]
 skip-if = (verify && (os == 'linux'))
 [browser_BaseAction.js]
 [browser_CleanupManager.js]
 [browser_ClientEnvironment.js]
 [browser_EventEmitter.js]
--- a/toolkit/components/normandy/test/browser/browser_EventEmitter.js
+++ b/toolkit/components/normandy/test/browser/browser_EventEmitter.js
@@ -5,45 +5,46 @@ ChromeUtils.import("resource://normandy/
 
 const evidence = {
   a: 0,
   b: 0,
   c: 0,
   log: "",
 };
 
-function listenerA(x) {
+function listenerA(x = 1) {
   evidence.a += x;
   evidence.log += "a";
 }
 
-function listenerB(x) {
+function listenerB(x = 1) {
   evidence.b += x;
   evidence.log += "b";
 }
 
-function listenerC(x) {
+function listenerC(x = 1) {
   evidence.c += x;
   evidence.log += "c";
 }
 
 decorate_task(
-  async function() {
-    const eventEmitter = new EventEmitter();
+  withSandboxManager(Assert),
+  async function(sandboxManager) {
+    const eventEmitter = new EventEmitter(sandboxManager);
 
     // Fire an unrelated event, to make sure nothing goes wrong
     eventEmitter.on("nothing");
 
     // bind listeners
     eventEmitter.on("event", listenerA);
     eventEmitter.on("event", listenerB);
     eventEmitter.once("event", listenerC);
 
     // one event for all listeners
-    eventEmitter.emit("event", 1);
+    eventEmitter.emit("event");
     // another event for a and b, since c should have turned off already
     eventEmitter.emit("event", 10);
 
     // make sure events haven't actually fired yet, just queued
     Assert.deepEqual(evidence, {
       a: 0,
       b: 0,
       c: 0,
@@ -92,8 +93,40 @@ decorate_task(
     const data = {count: 0};
     eventEmitter.emit("mutationTest", data);
     await Promise.resolve();
 
     is(handlerRunCount, 2, "Mutation handler was executed twice.");
     is(data.count, 0, "Event data cannot be mutated by handlers.");
   }
 );
+
+decorate_task(
+  withSandboxManager(Assert),
+  async function sandboxedEmitter(sandboxManager) {
+    const eventEmitter = new EventEmitter(sandboxManager);
+
+    // Event handlers inside the sandbox should be run in response to
+    // events triggered outside the sandbox.
+    sandboxManager.addGlobal("emitter", eventEmitter.createSandboxedEmitter());
+    sandboxManager.evalInSandbox(`
+      this.eventCounts = {on: 0, once: 0};
+      emitter.on("event", value => {
+        this.eventCounts.on += value;
+      });
+      emitter.once("eventOnce", value => {
+        this.eventCounts.once += value;
+      });
+    `);
+
+    eventEmitter.emit("event", 5);
+    eventEmitter.emit("event", 10);
+    eventEmitter.emit("eventOnce", 5);
+    eventEmitter.emit("eventOnce", 10);
+    await Promise.resolve();
+
+    const eventCounts = sandboxManager.evalInSandbox("this.eventCounts");
+    Assert.deepEqual(eventCounts, {
+      on: 15,
+      once: 5,
+    }, "Events emitted outside a sandbox trigger handlers within a sandbox.");
+  }
+);
--- a/toolkit/components/normandy/test/browser/browser_Heartbeat.js
+++ b/toolkit/components/normandy/test/browser/browser_Heartbeat.js
@@ -77,17 +77,17 @@ sandboxManager.addHold("test running");
 // into three batches.
 
 /* Batch #1 - General UI, Stars, and telemetry data */
 add_task(async function() {
   const targetWindow = Services.wm.getMostRecentWindow("navigator:browser");
   const notificationBox = targetWindow.gHighPriorityNotificationBox;
 
   const preCount = notificationBox.allNotifications.length;
-  const hb = new Heartbeat(targetWindow, {
+  const hb = new Heartbeat(targetWindow, sandboxManager, {
     testing: true,
     flowId: "test",
     message: "test",
     engagementButtonLabel: undefined,
     learnMoreMessage: "Learn More",
     learnMoreUrl: "https://example.org/learnmore",
   });
 
@@ -123,17 +123,17 @@ add_task(async function() {
   BrowserTestUtils.removeTab(tab);
 });
 
 
 // Batch #2 - Engagement buttons
 add_task(async function() {
   const targetWindow = Services.wm.getMostRecentWindow("navigator:browser");
   const notificationBox = targetWindow.gHighPriorityNotificationBox;
-  const hb = new Heartbeat(targetWindow, {
+  const hb = new Heartbeat(targetWindow, sandboxManager, {
     testing: true,
     flowId: "test",
     message: "test",
     engagementButtonLabel: "Click me!",
     postAnswerUrl: "https://example.org/postAnswer",
     learnMoreMessage: "Learn More",
     learnMoreUrl: "https://example.org/learnMore",
   });
@@ -165,17 +165,17 @@ add_task(async function() {
   await telemetrySentPromise;
   BrowserTestUtils.removeTab(tab);
 });
 
 // Batch 3 - Closing the window while heartbeat is open
 add_task(async function() {
   const targetWindow = await BrowserTestUtils.openNewBrowserWindow();
 
-  const hb = new Heartbeat(targetWindow, {
+  const hb = new Heartbeat(targetWindow, sandboxManager, {
     testing: true,
     flowId: "test",
     message: "test",
   });
 
   const telemetrySentPromise = assertTelemetrySent(hb, ["offeredTS", "windowClosedTS"]);
   // triggers sending ping to normandy
   await BrowserTestUtils.closeWindow(targetWindow);
deleted file mode 100644
--- a/toolkit/components/normandy/test/browser/browser_actions_ShowHeartbeatAction.js
+++ /dev/null
@@ -1,298 +0,0 @@
-"use strict";
-
-ChromeUtils.import("resource://normandy/actions/ShowHeartbeatAction.jsm", this);
-ChromeUtils.import("resource://normandy/lib/ClientEnvironment.jsm", this);
-ChromeUtils.import("resource://normandy/lib/Heartbeat.jsm", this);
-ChromeUtils.import("resource://normandy/lib/Storage.jsm", this);
-ChromeUtils.import("resource://normandy/lib/Uptake.jsm", this);
-
-const HOUR_IN_MS = 60 * 60 * 1000;
-
-function heartbeatRecipeFactory(overrides = {}) {
-  const defaults = {
-    revision_id: 1,
-    name: "Test Recipe",
-    action: "show-heartbeat",
-    arguments: {
-      surveyId: "a survey",
-      message: "test message",
-      engagementButtonLabel: "",
-      thanksMessage: "thanks!",
-      postAnswerUrl: "http://example.com",
-      learnMoreMessage: "Learn More",
-      learnMoreUrl: "http://example.com",
-      repeatOption: "once",
-    },
-  };
-
-  if (overrides.arguments) {
-    defaults.arguments = Object.assign(defaults.arguments, overrides.arguments);
-    delete overrides.arguments;
-  }
-
-  return recipeFactory(Object.assign(defaults, overrides));
-}
-
-class MockHeartbeat {
-  constructor() {
-    this.eventEmitter = new MockEventEmitter();
-  }
-}
-
-class MockEventEmitter {
-  constructor() {
-    this.once = sinon.stub();
-  }
-}
-
-function withStubbedHeartbeat(testFunction) {
-  return async function wrappedTestFunction(...args) {
-    const backstage = ChromeUtils.import("resource://normandy/actions/ShowHeartbeatAction.jsm", {});
-    const originalHeartbeat = backstage.Heartbeat;
-    const heartbeatInstanceStub = new MockHeartbeat();
-    const heartbeatClassStub = sinon.stub();
-    heartbeatClassStub.returns(heartbeatInstanceStub);
-    backstage.Heartbeat = heartbeatClassStub;
-
-    try {
-      await testFunction({heartbeatClassStub, heartbeatInstanceStub}, ...args);
-    } finally {
-      backstage.Heartbeat = originalHeartbeat;
-    }
-  };
-}
-
-function withClearStorage(testFunction) {
-  return async function wrappedTestFunction(...args) {
-    Storage.clearAllStorage();
-    try {
-      await testFunction(...args);
-    } finally {
-      Storage.clearAllStorage();
-    }
-  };
-}
-
-// Test that a normal heartbeat works as expected
-decorate_task(
-  withStubbedHeartbeat,
-  withClearStorage,
-  async function testHappyPath({ heartbeatClassStub, heartbeatInstanceStub }) {
-    const recipe = heartbeatRecipeFactory();
-    const action = new ShowHeartbeatAction();
-    await action.runRecipe(recipe);
-    await action.finalize();
-    is(action.state, ShowHeartbeatAction.STATE_FINALIZED, "Action should be finalized");
-    is(action.lastError, null, "No errors should have been thrown");
-
-    const options = heartbeatClassStub.args[0][1];
-    Assert.deepEqual(
-      heartbeatClassStub.args,
-      [[
-        heartbeatClassStub.args[0][0], // target window
-        {
-          surveyId: options.surveyId,
-          message: recipe.arguments.message,
-          engagementButtonLabel: recipe.arguments.engagementButtonLabel,
-          thanksMessage: recipe.arguments.thanksMessage,
-          learnMoreMessage: recipe.arguments.learnMoreMessage,
-          learnMoreUrl: recipe.arguments.learnMoreUrl,
-          postAnswerUrl: options.postAnswerUrl,
-          flowId: options.flowId,
-          surveyVersion: recipe.revision_id,
-        },
-      ]],
-      "expected arguments were passed",
-    );
-
-    const uuidRegex = /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i;
-    ok(options.flowId.match(uuidRegex), "flowId should be a uuid");
-
-    // postAnswerUrl gains several query string parameters. Check that the prefix is right
-    ok(options.postAnswerUrl.startsWith(recipe.arguments.postAnswerUrl));
-
-    ok(heartbeatInstanceStub.eventEmitter.once.calledWith("Voted"), "Voted event handler should be registered");
-    ok(heartbeatInstanceStub.eventEmitter.once.calledWith("Engaged"), "Engaged event handler should be registered");
-  }
-);
-
-/* Test that heartbeat doesn't show if an unrelated heartbeat has shown recently. */
-decorate_task(
-  withStubbedHeartbeat,
-  withClearStorage,
-  async function testRepeatGeneral({ heartbeatClassStub }) {
-    const allHeartbeatStorage = new Storage("normandy-heartbeat");
-    await allHeartbeatStorage.setItem("lastShown", Date.now());
-    const recipe = heartbeatRecipeFactory();
-
-    const action = new ShowHeartbeatAction();
-    await action.runRecipe(recipe);
-    is(action.lastError, null, "No errors should have been thrown");
-
-    is(heartbeatClassStub.args.length, 0, "Heartbeat should not be called once");
-  },
-);
-
-/* Test that a heartbeat shows if an unrelated heartbeat showed more than 24 hours ago. */
-decorate_task(
-  withStubbedHeartbeat,
-  withClearStorage,
-  async function testRepeatUnrelated({ heartbeatClassStub }) {
-    const allHeartbeatStorage = new Storage("normandy-heartbeat");
-    await allHeartbeatStorage.setItem("lastShown", Date.now() - 25 * HOUR_IN_MS);
-    const recipe = heartbeatRecipeFactory();
-
-    const action = new ShowHeartbeatAction();
-    await action.runRecipe(recipe);
-    is(action.lastError, null, "No errors should have been thrown");
-
-    is(heartbeatClassStub.args.length, 1, "Heartbeat should be called once");
-  },
-);
-
-/* Test that a repeat=once recipe is not shown again, even more than 24 hours ago. */
-decorate_task(
-  withStubbedHeartbeat,
-  withClearStorage,
-  async function testRepeatTypeOnce({ heartbeatClassStub }) {
-    const recipe = heartbeatRecipeFactory({ arguments: { repeatOption: "once" }});
-    const recipeStorage = new Storage(recipe.id);
-    await recipeStorage.setItem("lastShown", Date.now() - 25 * HOUR_IN_MS);
-
-    const action = new ShowHeartbeatAction();
-    await action.runRecipe(recipe);
-    is(action.lastError, null, "No errors should have been thrown");
-
-    is(heartbeatClassStub.args.length, 0, "Heartbeat should not be called");
-  },
-);
-
-/* Test that a repeat=xdays recipe is shown again, only after the expected number of days. */
-decorate_task(
-  withStubbedHeartbeat,
-  withClearStorage,
-  async function testRepeatTypeXdays({ heartbeatClassStub }) {
-    const recipe = heartbeatRecipeFactory({ arguments: {
-      repeatOption: "xdays",
-      repeatEvery: 2,
-    }});
-    const recipeStorage = new Storage(recipe.id);
-    const allHeartbeatStorage = new Storage("normandy-heartbeat");
-
-    await recipeStorage.setItem("lastShown", Date.now() - 25 * HOUR_IN_MS);
-    await allHeartbeatStorage.setItem("lastShown", Date.now() - 25 * HOUR_IN_MS);
-    const action = new ShowHeartbeatAction();
-    await action.runRecipe(recipe);
-    is(action.lastError, null, "No errors should have been thrown");
-    is(heartbeatClassStub.args.length, 0, "Heartbeat should not be called");
-
-    await recipeStorage.setItem("lastShown", Date.now() - 50 * HOUR_IN_MS);
-    await allHeartbeatStorage.setItem("lastShown", Date.now() - 50 * HOUR_IN_MS);
-    await action.runRecipe(recipe);
-    is(action.lastError, null, "No errors should have been thrown");
-    is(heartbeatClassStub.args.length, 1, "Heartbeat should have been called once");
-  },
-);
-
-/* Test that a repeat=nag recipe is shown again until lastInteraction is set */
-decorate_task(
-  withStubbedHeartbeat,
-  withClearStorage,
-  async function testRepeatTypeNag({ heartbeatClassStub }) {
-    const recipe = heartbeatRecipeFactory({ arguments: { repeatOption: "nag" }});
-    const recipeStorage = new Storage(recipe.id);
-    const allHeartbeatStorage = new Storage("normandy-heartbeat");
-
-    await allHeartbeatStorage.setItem("lastShown", Date.now() - 25 * HOUR_IN_MS);
-    await recipeStorage.setItem("lastShown", Date.now() - 25 * HOUR_IN_MS);
-    const action = new ShowHeartbeatAction();
-    await action.runRecipe(recipe);
-    is(action.lastError, null, "No errors should have been thrown");
-    is(heartbeatClassStub.args.length, 1, "Heartbeat should be called");
-
-    await allHeartbeatStorage.setItem("lastShown", Date.now() - 50 * HOUR_IN_MS);
-    await recipeStorage.setItem("lastShown", Date.now() - 50 * HOUR_IN_MS);
-    await action.runRecipe(recipe);
-    is(action.lastError, null, "No errors should have been thrown");
-    is(heartbeatClassStub.args.length, 2, "Heartbeat should be called again");
-
-    await allHeartbeatStorage.setItem("lastShown", Date.now() - 75 * HOUR_IN_MS);
-    await recipeStorage.setItem("lastShown", Date.now() - 75 * HOUR_IN_MS);
-    await recipeStorage.setItem("lastInteraction", Date.now() - 50 * HOUR_IN_MS);
-    await action.runRecipe(recipe);
-    is(action.lastError, null, "No errors should have been thrown");
-    is(heartbeatClassStub.args.length, 2, "Heartbeat should not be called again");
-  },
-);
-
-/* generatePostAnswerURL shouldn't annotate empty strings */
-add_task(
-  async function postAnswerEmptyString() {
-    const recipe = heartbeatRecipeFactory({ arguments: { postAnswerUrl: "" }});
-    const action = new ShowHeartbeatAction();
-    is(await action.generatePostAnswerURL(recipe), "", "an empty string should not be annotated");
-  }
-);
-
-/* generatePostAnswerURL should include the right details */
-add_task(
-  async function postAnswerUrl() {
-    const recipe = heartbeatRecipeFactory({ arguments: {
-      postAnswerUrl: "https://example.com/survey?survey_id=42",
-      includeTelemetryUUID: false,
-      message: "Hello, World!",
-    }});
-    const action = new ShowHeartbeatAction();
-    const url = new URL(await action.generatePostAnswerURL(recipe));
-
-    is(url.searchParams.get("survey_id"), "42", "Pre-existing search parameters should be preserved");
-    is(url.searchParams.get("fxVersion"), Services.appinfo.version, "Firefox version should be included");
-    is(url.searchParams.get("surveyversion"), Services.appinfo.version, "Survey version should also be the Firefox version");
-    ok(["0", "1"].includes(url.searchParams.get("syncSetup")), `syncSetup should be 0 or 1, got ${url.searchParams.get("syncSetup")}`);
-    is(url.searchParams.get("updateChannel"), UpdateUtils.getUpdateChannel("false"), "Update channel should be included");
-    ok(!url.searchParams.has("userId"), "no user id should be included");
-    is(url.searchParams.get("utm_campaign"), "Hello%2CWorld!", "utm_campaign should be an encoded version of the message");
-    is(url.searchParams.get("utm_medium"), "show-heartbeat", "utm_medium should be the action name");
-    is(url.searchParams.get("utm_source"), "firefox", "utm_source should be firefox");
-  }
-);
-
-/* generatePostAnswerURL shouldn't override existing values in the url */
-add_task(
-  async function postAnswerUrlNoOverwite() {
-    const recipe = heartbeatRecipeFactory({ arguments: {
-      postAnswerUrl: "https://example.com/survey?utm_source=shady_tims_firey_fox",
-    }});
-    const action = new ShowHeartbeatAction();
-    const url = new URL(await action.generatePostAnswerURL(recipe));
-    is(url.searchParams.get("utm_source"), "shady_tims_firey_fox", "utm_source should not be overwritten");
-  }
-);
-
-/* generatePostAnswerURL should only include userId if requested */
-add_task(
-  async function postAnswerUrlUserIdIfRequested() {
-    const recipeWithId = heartbeatRecipeFactory({ arguments: { includeTelemetryUUID: true }});
-    const recipeWithoutId = heartbeatRecipeFactory({ arguments: { includeTelemetryUUID: false }});
-    const action = new ShowHeartbeatAction();
-
-    const urlWithId = new URL(await action.generatePostAnswerURL(recipeWithId));
-    is(urlWithId.searchParams.get("userId"), ClientEnvironment.userId, "clientId should be included");
-
-    const urlWithoutId = new URL(await action.generatePostAnswerURL(recipeWithoutId));
-    ok(!urlWithoutId.searchParams.has("userId"), "userId should not be included");
-  }
-);
-
-/* generateSurveyId should include userId only if requested */
-decorate_task(
-  withStubbedHeartbeat,
-  withClearStorage,
-  async function testGenerateSurveyId({ heartbeatClassStub }) {
-    const recipeWithoutId = heartbeatRecipeFactory({ arguments: { surveyId: "test-id", includeTelemetryUUID: false }});
-    const recipeWithId = heartbeatRecipeFactory({ arguments: { surveyId: "test-id", includeTelemetryUUID: true }});
-    const action = new ShowHeartbeatAction();
-    is(action.generateSurveyId(recipeWithoutId), "test-id", "userId should not be included if not requested");
-    is(action.generateSurveyId(recipeWithId), `test-id::${ClientEnvironment.userId}`, "userId should be included if requested");
-  }
-);