Bug 1193535 - Store Heartbeat Scores in Unified Telemetry. r=MattN draft
authorVladan Djeric <vdjeric@mozilla.com>
Tue, 02 Feb 2016 12:18:35 -0800
changeset 328303 28ef4fd20337c559acc532f35b076240b2021117
parent 328302 1b290ea60421e25cb0942e85c4975975815d2a27
child 513792 61b68d74e375608c4e8d72d86e5b41e3678ec2bf
push id10335
push usermozilla@noorenberghe.ca
push dateTue, 02 Feb 2016 20:18:47 +0000
reviewersMattN
bugs1193535
milestone47.0a1
Bug 1193535 - Store Heartbeat Scores in Unified Telemetry. r=MattN
browser/app/profile/firefox.js
browser/components/uitour/UITour.jsm
browser/components/uitour/test/browser_UITour_heartbeat.js
toolkit/components/telemetry/docs/heartbeat-ping.rst
toolkit/components/telemetry/docs/index.rst
toolkit/components/telemetry/docs/pings.rst
--- a/browser/app/profile/firefox.js
+++ b/browser/app/profile/firefox.js
@@ -207,16 +207,18 @@ pref("browser.eme.ui.enabled", false);
 // UI tour experience.
 pref("browser.uitour.enabled", true);
 pref("browser.uitour.loglevel", "Error");
 pref("browser.uitour.requireSecure", true);
 pref("browser.uitour.themeOrigin", "https://addons.mozilla.org/%LOCALE%/firefox/themes/");
 pref("browser.uitour.url", "https://www.mozilla.org/%LOCALE%/firefox/%VERSION%/tour/");
 // This is used as a regexp match against the page's URL.
 pref("browser.uitour.readerViewTrigger", "^https:\\/\\/www\\.mozilla\\.org\\/[^\\/]+\\/firefox\\/reading\\/start");
+// How long to show a Hearbeat survey (two hours, in seconds)
+pref("browser.uitour.surveyDuration", 7200);
 
 pref("browser.customizemode.tip0.shown", false);
 pref("browser.customizemode.tip0.learnMoreUrl", "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/customize");
 
 pref("keyword.enabled", true);
 pref("browser.fixup.domainwhitelist.localhost", true);
 
 pref("general.useragent.locale", "@AB_CD@");
--- a/browser/components/uitour/UITour.jsm
+++ b/browser/components/uitour/UITour.jsm
@@ -10,16 +10,17 @@ const {classes: Cc, interfaces: Ci, util
 
 Cu.import("resource://gre/modules/AppConstants.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Promise.jsm");
 Cu.import("resource:///modules/RecentWindow.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/TelemetryController.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
 
 Cu.importGlobalProperties(["URL"]);
 
 XPCOMUtils.defineLazyModuleGetter(this, "LightweightThemeManager",
   "resource://gre/modules/LightweightThemeManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ResetProfile",
   "resource://gre/modules/ResetProfile.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "CustomizableUI",
@@ -36,16 +37,17 @@ XPCOMUtils.defineLazyModuleGetter(this, 
   "resource://gre/modules/ReaderMode.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ReaderParent",
   "resource:///modules/ReaderParent.jsm");
 
 // See LOG_LEVELS in Console.jsm. Common examples: "All", "Info", "Warn", & "Error".
 const PREF_LOG_LEVEL      = "browser.uitour.loglevel";
 const PREF_SEENPAGEIDS    = "browser.uitour.seenPageIDs";
 const PREF_READERVIEW_TRIGGER = "browser.uitour.readerViewTrigger";
+const PREF_SURVEY_DURATION = "browser.uitour.surveyDuration";
 
 const BACKGROUND_PAGE_ACTIONS_ALLOWED = new Set([
   "forceShowReaderIcon",
   "getConfiguration",
   "getTreatmentTag",
   "hideHighlight",
   "hideInfo",
   "hideMenu",
@@ -1055,17 +1057,18 @@ this.UITour = {
   resetTheme: function() {
     LightweightThemeManager.resetPreview();
   },
 
   /**
    * Show the Heartbeat UI to request user feedback. This function reports back to the
    * caller using |notify|. The notification event name reflects the current status the UI
    * is in (either "Heartbeat:NotificationOffered", "Heartbeat:NotificationClosed",
-   * "Heartbeat:LearnMore", "Heartbeat:Engaged" or "Heartbeat:Voted").
+   * "Heartbeat:LearnMore", "Heartbeat:Engaged", "Heartbeat:Voted",
+   * "Heartbeat:SurveyExpired" or "Heartbeat:WindowClosed").
    * When a "Heartbeat:Voted" event is notified
    * the data payload contains a |score| field which holds the rating picked by the user.
    * Please note that input parameters are already validated by the caller.
    *
    * @param aChromeWindow
    *        The chrome window that the heartbeat notification is displayed in.
    * @param {Object} aOptions Options object.
    * @param {String} aOptions.message
@@ -1081,56 +1084,160 @@ this.UITour = {
    * @param {String} [aOptions.engagementURL=null]
    *        The engagement URL to open in a new tab once user has engaged. If this is null
    *        or invalid, no new tab is opened.
    * @param {String} [aOptions.learnMoreLabel=null]
    *        The label of the learn more link. No link will be shown if this is null.
    * @param {String} [aOptions.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} [aOptions.privateWindowsOnly=false]
+   * @param {boolean} [aOptions.privateWindowsOnly=false]
    *        Whether the heartbeat UI should only be targeted at a private window (if one exists).
    *        No notifications should be fired when this is true.
+   * @param {String} [aOptions.surveyId]
+   *        An ID for the survey, reflected in the Telemetry ping.
+   * @param {Number} [aOptions.surveyVersion]
+   *        Survey's version number, reflected in the Telemetry ping.
+   * @param {boolean} [aOptions.testing]
+   *        Whether this is a test survey, reflected in the Telemetry ping.
    */
   showHeartbeat(aChromeWindow, aOptions) {
-    let maybeNotifyHeartbeat = (...aParams) => {
+    // Initialize survey state
+    let pingSent = false;
+    let surveyResults = {};
+    let surveyEndTimer = null;
+
+    /**
+     * Accumulates survey events and submits to Telemetry after the survey ends.
+     *
+     * @param {String} aEventName
+     *        Heartbeat event name
+     * @param {Object} aParams
+     *        Additional parameters and their values
+     */
+    let maybeNotifyHeartbeat = (aEventName, aParams = {}) => {
+      // Return if event occurred after the ping was sent
+      if (pingSent) {
+        log.warn("maybeNotifyHeartbeat: event occurred after ping sent:", aEventName, aParams);
+        return;
+      }
+
+      // No Telemetry from private-window-only Heartbeats
       if (aOptions.privateWindowsOnly) {
         return;
       }
-      this.notify(...aParams);
+
+      let ts = Date.now();
+      let sendPing = false;
+      switch (aEventName) {
+        case "Heartbeat:NotificationOffered":
+          surveyResults.flowId = aOptions.flowId;
+          surveyResults.offeredTS = ts;
+          break;
+        case "Heartbeat:LearnMore":
+          // record only the first click
+          if (!surveyResults.learnMoreTS) {
+            surveyResults.learnMoreTS = ts;
+          }
+          break;
+        case "Heartbeat:Engaged":
+          surveyResults.engagedTS = ts;
+          break;
+        case "Heartbeat:Voted":
+          surveyResults.votedTS = ts;
+          surveyResults.score = aParams.score;
+          break;
+        case "Heartbeat:SurveyExpired":
+          surveyResults.expiredTS = ts;
+          break;
+        case "Heartbeat:NotificationClosed":
+          // this is the final event in most surveys
+          surveyResults.closedTS = ts;
+          sendPing = true;
+          break;
+        case "Heartbeat:WindowClosed":
+          surveyResults.windowClosedTS = ts;
+          sendPing = true;
+          break;
+        default:
+          log.error("maybeNotifyHeartbeat: unrecognized event:", aEventName);
+          break;
+      }
+
+      aParams.timestamp = ts;
+      aParams.flowId = aOptions.flowId;
+      this.notify(aEventName, aParams);
+
+      if (!sendPing) {
+        return;
+      }
+
+      // Send the ping to Telemetry
+      let payload = Object.assign({}, surveyResults);
+      payload.version = 1;
+      for (let meta of ["surveyId", "surveyVersion", "testing"]) {
+        if (aOptions.hasOwnProperty(meta)) {
+          payload[meta] = aOptions[meta];
+        }
+      }
+
+      log.debug("Sending payload to Telemetry: aEventName:", aEventName,
+                "payload:", payload);
+
+      TelemetryController.submitExternalPing("heartbeat", payload, {
+        addClientId: true,
+        addEnvironment: true,
+      });
+
+      // only for testing
+      this.notify("Heartbeat:TelemetrySent", payload);
+
+      // Survey is complete, clear out the expiry timer & survey configuration
+      if (surveyEndTimer) {
+        clearTimeout(surveyEndTimer);
+        surveyEndTimer = null;
+      }
+
+      pingSent = true;
+      surveyResults = {};
     };
 
     let nb = aChromeWindow.document.getElementById("high-priority-global-notificationbox");
     let buttons = null;
 
     if (aOptions.engagementButtonLabel) {
       buttons = [{
         label: aOptions.engagementButtonLabel,
         callback: () => {
           // Let the consumer know user engaged.
-          maybeNotifyHeartbeat("Heartbeat:Engaged", { flowId: aOptions.flowId, timestamp: Date.now() });
+          maybeNotifyHeartbeat("Heartbeat:Engaged");
 
           userEngaged(new Map([
             ["type", "button"],
             ["flowid", aOptions.flowId]
           ]));
 
           // Return true so that the notification bar doesn't close itself since
           // we have a thank you message to show.
           return true;
         },
       }];
     }
     // Create the notification. Prefix its ID to decrease the chances of collisions.
     let notice = nb.appendNotification(aOptions.message, "heartbeat-" + aOptions.flowId,
-      "chrome://browser/skin/heartbeat-icon.svg", nb.PRIORITY_INFO_HIGH, buttons, function() {
-        // Let the consumer know the notification bar was closed. This also happens
-        // after voting.
-        maybeNotifyHeartbeat("Heartbeat:NotificationClosed", { flowId: aOptions.flowId, timestamp: Date.now() });
-    }.bind(this));
+                                       "chrome://browser/skin/heartbeat-icon.svg",
+                                       nb.PRIORITY_INFO_HIGH, buttons,
+                                       (aEventType) => {
+                                         if (aEventType != "removed") {
+                                           return;
+                                         }
+                                         // Let the consumer know the notification bar was closed.
+                                         // This also happens after voting.
+                                         maybeNotifyHeartbeat("Heartbeat:NotificationClosed");
+                                       });
 
     // Get the elements we need to style.
     let messageImage =
       aChromeWindow.document.getAnonymousElementByAttribute(notice, "anonid", "messageImage");
     let messageText =
       aChromeWindow.document.getAnonymousElementByAttribute(notice, "anonid", "messageText");
 
     function userEngaged(aEngagementParams) {
@@ -1191,21 +1298,17 @@ this.UITour = {
       ratingElement.id = "star" + starIndex;
       ratingElement.setAttribute("data-score", starIndex);
 
       // Add the click handler.
       ratingElement.addEventListener("click", function (evt) {
         let rating = Number(evt.target.getAttribute("data-score"), 10);
 
         // Let the consumer know user voted.
-        maybeNotifyHeartbeat("Heartbeat:Voted", {
-          flowId: aOptions.flowId,
-          score: rating,
-          timestamp: Date.now(),
-        });
+        maybeNotifyHeartbeat("Heartbeat:Voted", { score: rating });
 
         // Append the score data to the engagement URL.
         userEngaged(new Map([
           ["type", "stars"],
           ["score", rating],
           ["flowid", aOptions.flowId]
         ]));
       }.bind(this));
@@ -1234,32 +1337,44 @@ this.UITour = {
     }
 
     // Add the learn more link.
     if (aOptions.learnMoreLabel && learnMoreURL) {
       let learnMore = aChromeWindow.document.createElement("label");
       learnMore.className = "text-link";
       learnMore.href = learnMoreURL.toString();
       learnMore.setAttribute("value", aOptions.learnMoreLabel);
-      learnMore.addEventListener("click", () => maybeNotifyHeartbeat("Heartbeat:LearnMore",
-        { flowId: aOptions.flowId, timestamp: Date.now() }));
+      learnMore.addEventListener("click", () => maybeNotifyHeartbeat("Heartbeat:LearnMore"));
       frag.appendChild(learnMore);
     }
 
     // Append the fragment and apply the styling.
     notice.appendChild(frag);
     notice.classList.add("heartbeat");
     messageImage.classList.add("heartbeat", "pulse-onshow");
     messageText.classList.add("heartbeat");
 
     // Let the consumer know the notification was shown.
-    maybeNotifyHeartbeat("Heartbeat:NotificationOffered", {
-      flowId: aOptions.flowId,
-      timestamp: Date.now(),
-    });
+    maybeNotifyHeartbeat("Heartbeat:NotificationOffered");
+
+    // End the survey if the user quits, closes the window, or
+    // hasn't responded before expiration.
+    if (!aOptions.privateWindowsOnly) {
+      function handleWindowClosed(aTopic) {
+        maybeNotifyHeartbeat("Heartbeat:WindowClosed");
+        aChromeWindow.removeEventListener("SSWindowClosing", handleWindowClosed);
+      }
+      aChromeWindow.addEventListener("SSWindowClosing", handleWindowClosed);
+
+      let surveyDuration = Services.prefs.getIntPref(PREF_SURVEY_DURATION) * 1000;
+      surveyEndTimer = setTimeout(() => {
+        maybeNotifyHeartbeat("Heartbeat:SurveyExpired");
+        nb.removeNotification(notice);
+      }, surveyDuration);
+    }
   },
 
   /**
    * The node to which a highlight or notification(-popup) is anchored is sometimes
    * obscured because it may be inside an overflow menu. This function should figure
    * that out and offer the overflow chevron as an alternative.
    *
    * @param {Node} aAnchor The element that's supposed to be the anchor
--- a/browser/components/uitour/test/browser_UITour_heartbeat.js
+++ b/browser/components/uitour/test/browser_UITour_heartbeat.js
@@ -5,16 +5,19 @@
 
 var gTestTab;
 var gContentAPI;
 var gContentWindow;
 
 function test() {
   UITourTest();
   requestLongerTimeout(2);
+  registerCleanupFunction(() => {
+    Services.prefs.clearUserPref("browser.uitour.surveyDuration");
+  });
 }
 
 function getHeartbeatNotification(aId, aChromeWindow = window) {
   let notificationBox = aChromeWindow.document.getElementById("high-priority-global-notificationbox");
   // UITour.jsm prefixes the notification box ID with "heartbeat-" to prevent collisions.
   return notificationBox.getNotificationWithValue("heartbeat-" + aId);
 }
 
@@ -61,16 +64,49 @@ function clickLearnMore(aId) {
  * @param [aChromeWindow=window]
  *        The chrome window the notification box is in.
  */
 function cleanUpNotification(aId, aChromeWindow = window) {
   let notification = getHeartbeatNotification(aId, aChromeWindow);
   notification.close();
 }
 
+/**
+ * Check telemetry payload for proper format and expected content.
+ *
+ * @param aPayload
+ *        The Telemetry payload to verify
+ * @param aFlowId
+ *        Expected value of the flowId field.
+ * @param aExpectedFields
+ *        Array of expected fields. No other fields are allowed.
+ */
+function checkTelemetry(aPayload, aFlowId, aExpectedFields) {
+  // Basic payload format
+  is(aPayload.version, 1, "Telemetry ping must have heartbeat version=1");
+  is(aPayload.flowId, aFlowId, "Flow ID in the Telemetry ping must match");
+
+  // Check for superfluous fields
+  let extraKeys = new Set(Object.keys(aPayload));
+  extraKeys.delete("version");
+  extraKeys.delete("flowId");
+
+  // Check for expected fields
+  for (let field of aExpectedFields) {
+    ok(field in aPayload, "The payload should have the field '" + field + "'");
+    if (field.endsWith("TS")) {
+      let ts = aPayload[field];
+      ok(Number.isInteger(ts) && ts > 0, "Timestamp '" + field + "' must be a natural number");
+    }
+    extraKeys.delete(field);
+  }
+
+  is(extraKeys.size, 0, "No unexpected fields in the Telemetry payload");
+}
+
 var tests = [
   /**
    * Check that the "stars" heartbeat UI correctly shows and closes.
    */
   function test_heartbeat_stars_show(done) {
     let flowId = "ui-ratefirefox-" + Math.random();
     let engagementURL = "http://example.com";
 
@@ -83,16 +119,21 @@ var tests = [
           break;
         }
         case "Heartbeat:NotificationClosed": {
           info("'Heartbeat:NotificationClosed' notification received (timestamp " + aData.timestamp.toString() + ").");
           ok(Number.isFinite(aData.timestamp), "Timestamp must be a number.");
           done();
           break;
         }
+        case "Heartbeat:TelemetrySent": {
+          info("'Heartbeat:TelemetrySent' notification received");
+          checkTelemetry(aData, flowId, ["offeredTS", "closedTS"]);
+          break;
+        }
         default:
           // We are not expecting other states for this test.
           ok(false, "Unexpected notification received: " + aEventName);
       }
     });
 
     gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!", flowId, engagementURL);
   },
@@ -120,16 +161,22 @@ var tests = [
         }
         case "Heartbeat:NotificationClosed": {
           info("'Heartbeat:NotificationClosed' notification received (timestamp " + aData.timestamp.toString() + ").");
           ok(Number.isFinite(aData.timestamp), "Timestamp must be a number.");
           is(gBrowser.tabs.length, originalTabCount, "No engagement tab should be opened.");
           done();
           break;
         }
+        case "Heartbeat:TelemetrySent": {
+          info("'Heartbeat:TelemetrySent' notification received.");
+          checkTelemetry(aData, flowId, ["offeredTS", "votedTS", "closedTS", "score"]);
+          is(aData.score, 2, "Checking Telemetry payload.score");
+          break;
+        }
         default:
           // We are not expecting other states for this test.
           ok(false, "Unexpected notification received: " + aEventName);
       }
     });
 
     gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!", flowId, null);
   },
@@ -158,16 +205,22 @@ var tests = [
         }
         case "Heartbeat:NotificationClosed": {
           info("'Heartbeat:NotificationClosed' notification received (timestamp " + aData.timestamp.toString() + ").");
           ok(Number.isFinite(aData.timestamp), "Timestamp must be a number.");
           is(gBrowser.tabs.length, originalTabCount, "No engagement tab should be opened.");
           done();
           break;
         }
+        case "Heartbeat:TelemetrySent": {
+          info("'Heartbeat:TelemetrySent' notification received.");
+          checkTelemetry(aData, flowId, ["offeredTS", "votedTS", "closedTS", "score"]);
+          is(aData.score, 2, "Checking Telemetry payload.score");
+          break;
+        }
         default:
           // We are not expecting other states for this test.
           ok(false, "Unexpected notification received: " + aEventName);
       }
     });
 
     gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!", flowId, invalidEngagementURL);
   },
@@ -195,16 +248,22 @@ var tests = [
           break;
         }
         case "Heartbeat:NotificationClosed": {
           info("'Heartbeat:NotificationClosed' notification received (timestamp " + aData.timestamp.toString() + ").");
           ok(Number.isFinite(aData.timestamp), "Timestamp must be a number.");
           done();
           break;
         }
+        case "Heartbeat:TelemetrySent": {
+          info("'Heartbeat:TelemetrySent' notification received.");
+          checkTelemetry(aData, flowId, ["offeredTS", "votedTS", "closedTS", "score"]);
+          is(aData.score, expectedScore, "Checking Telemetry payload.score");
+          break;
+        }
         default:
           // We are not expecting other states for this test.
           ok(false, "Unexpected notification received: " + aEventName);
       }
     });
 
     gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!", flowId, null);
   },
@@ -238,16 +297,22 @@ var tests = [
           ok(heartbeatVoteSeen, "Heartbeat vote should have been received");
           info("'Heartbeat:NotificationClosed' notification received (timestamp " + aData.timestamp.toString() + ").");
           ok(Number.isFinite(aData.timestamp), "Timestamp must be a number.");
           is(gBrowser.tabs.length, expectedTabCount, "Engagement URL should open in a new tab.");
           gBrowser.removeCurrentTab();
           done();
           break;
         }
+        case "Heartbeat:TelemetrySent": {
+          info("'Heartbeat:TelemetrySent' notification received.");
+          checkTelemetry(aData, flowId, ["offeredTS", "votedTS", "closedTS", "score"]);
+          is(aData.score, 1, "Checking Telemetry payload.score");
+          break;
+        }
         default:
           // We are not expecting other states for this test.
           ok(false, "Unexpected notification received: " + aEventName);
       }
     });
 
     gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!", flowId, engagementURL);
   },
@@ -285,16 +350,21 @@ var tests = [
           info("'Heartbeat:NotificationClosed' notification received (timestamp " + aData.timestamp.toString() + ").");
           ok(heartbeatEngagedSeen, "Heartbeat:Engaged should have been received");
           ok(Number.isFinite(aData.timestamp), "Timestamp must be a number.");
           is(gBrowser.tabs.length, expectedTabCount, "Engagement URL should open in a new tab.");
           gBrowser.removeCurrentTab();
           executeSoon(done);
           break;
         }
+        case "Heartbeat:TelemetrySent": {
+          info("'Heartbeat:TelemetrySent' notification received.");
+          checkTelemetry(aData, flowId, ["offeredTS", "engagedTS", "closedTS"]);
+          break;
+        }
         default: {
           // We are not expecting other states for this test.
           ok(false, "Unexpected notification received: " + aEventName);
         }
       }
     });
 
     gContentAPI.showHeartbeat("Do you want to engage with us?", "Thank you!", flowId, engagementURL, null, null, {
@@ -330,16 +400,21 @@ var tests = [
         case "Heartbeat:NotificationClosed": {
           info("'Heartbeat:NotificationClosed' notification received (timestamp " + aData.timestamp.toString() + ").");
           ok(Number.isFinite(aData.timestamp), "Timestamp must be a number.");
           is(gBrowser.tabs.length, expectedTabCount, "Learn more URL should open in a new tab.");
           gBrowser.removeCurrentTab();
           done();
           break;
         }
+        case "Heartbeat:TelemetrySent": {
+          info("'Heartbeat:TelemetrySent' notification received.");
+          checkTelemetry(aData, flowId, ["offeredTS", "learnMoreTS", "closedTS"]);
+          break;
+        }
         default:
           // We are not expecting other states for this test.
           ok(false, "Unexpected notification received: " + aEventName);
       }
     });
 
     gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!", flowId, dummyURL,
                               "What is this?", dummyURL);
@@ -451,9 +526,95 @@ var tests = [
     engagementButton.doCommand();
     let engagementTab = yield engagementTabPromise;
     is(engagementTab.linkedBrowser.currentURI.host, "example.com", "Check enagement site opened");
     ok(PrivateBrowsingUtils.isBrowserPrivate(engagementTab.linkedBrowser), "Ensure the engagement tab is private");
     yield BrowserTestUtils.removeTab(engagementTab);
 
     yield BrowserTestUtils.closeWindow(privateWin);
   }),
+
+  /**
+   * Test that the survey closes itself after a while and submits Telemetry
+   */
+  taskify(function* test_telemetry_surveyExpired() {
+    let flowId = "survey-expired-" + Math.random();
+    let engagementURL = "http://example.com";
+    let surveyDuration = 1; // 1 second (pref is in seconds)
+    Services.prefs.setIntPref("browser.uitour.surveyDuration", surveyDuration);
+
+    let telemetryPromise = new Promise((resolve, reject) => {
+        gContentAPI.observe(function (aEventName, aData) {
+          switch (aEventName) {
+            case "Heartbeat:NotificationOffered":
+              info("'Heartbeat:NotificationOffered' notification received");
+              break;
+            case "Heartbeat:SurveyExpired":
+              info("'Heartbeat:SurveyExpired' notification received");
+              ok(true, "Survey should end on its own after a time out");
+            case "Heartbeat:NotificationClosed":
+              info("'Heartbeat:NotificationClosed' notification received");
+              break;
+            case "Heartbeat:TelemetrySent": {
+              info("'Heartbeat:TelemetrySent' notification received");
+              checkTelemetry(aData, flowId, ["offeredTS", "expiredTS", "closedTS"]);
+              resolve();
+              break;
+            }
+            default:
+              // not expecting other states for this test
+              ok(false, "Unexpected notification received: " + aEventName);
+              reject();
+          }
+        });
+    });
+
+    gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!", flowId, engagementURL);
+    yield telemetryPromise;
+    Services.prefs.clearUserPref("browser.uitour.surveyDuration");
+  }),
+
+  /**
+   * Check that certain whitelisted experiment parameters get reflected in the
+   * Telemetry ping
+   */
+  function test_telemetry_params(done) {
+    let flowId = "telemetry-params-" + Math.random();
+    let engagementURL = "http://example.com";
+    let extraParams = {
+      "surveyId": "foo",
+      "surveyVersion": 1.5,
+      "testing": true,
+      "notWhitelisted": 123,
+    };
+    let expectedFields = ["surveyId", "surveyVersion", "testing"];
+
+    gContentAPI.observe(function (aEventName, aData) {
+      switch (aEventName) {
+        case "Heartbeat:NotificationOffered": {
+          info("'Heartbeat:Offered' notification received (timestamp " + aData.timestamp.toString() + ").");
+          cleanUpNotification(flowId);
+          break;
+        }
+        case "Heartbeat:NotificationClosed": {
+          info("'Heartbeat:NotificationClosed' notification received (timestamp " + aData.timestamp.toString() + ").");
+          break;
+        }
+        case "Heartbeat:TelemetrySent": {
+          info("'Heartbeat:TelemetrySent' notification received");
+          checkTelemetry(aData, flowId, ["offeredTS", "closedTS"].concat(expectedFields));
+          for (let param of expectedFields) {
+            is(aData[param], extraParams[param],
+               "Whitelisted experiment configs should be copied into Telemetry pings");
+          }
+          done();
+          break;
+        }
+        default:
+          // We are not expecting other states for this test.
+          ok(false, "Unexpected notification received: " + aEventName);
+      }
+    });
+
+    gContentAPI.showHeartbeat("How would you rate Firefox?", "Thank you!",
+                              flowId, engagementURL, null, null, extraParams);
+  },
 ];
new file mode 100644
--- /dev/null
+++ b/toolkit/components/telemetry/docs/heartbeat-ping.rst
@@ -0,0 +1,61 @@
+
+"heartbeat" ping
+=================
+
+This ping is submitted after a Firefox Heartbeat survey. Even if the user exits
+the browser, closes the survey window, or ignores the survey, Heartbeat will
+submit a ping to Telemetry during the same session.
+
+The payload contains the user's survey response (if any) as well as timestamps
+of various Heartbeat events (survey shown, survey closed, link clicked, etc).
+
+The ping will also report the "surveyId", "surveyVersion" and "testing"
+Heartbeat survey parameters (if they are present in the survey config).
+These "meta fields" will be repeated verbatim in the payload section.
+
+The environment block and client ID are submitted with this ping.
+
+Structure::
+
+    {
+      type: "heartbeat",
+      version: 4,
+      clientId: <UUID>,
+      environment: { ... }
+      ... common ping data ...
+      payload: {
+        version: 1,
+        flowId: <string>,
+        ... timestamps below ...
+        offeredTS: <integer epoch timestamp>,
+        learnMoreTS: <integer epoch timestamp>,
+        votedTS: <integer epoch timestamp>,
+        engagedTS: <integer epoch timestamp>,
+        closedTS: <integer epoch timestamp>,
+        expiredTS: <integer epoch timestamp>,
+        windowClosedTS: <integer epoch timestamp>,
+        ... user's rating below ...
+        score: <integer>,
+        ... survey meta fields below ...
+        surveyId: <string>,
+        surveyVersion: <integer>,
+        testing: <boolean>
+      }
+    }
+
+Notes:
+
+* Pings will **NOT** have all possible timestamps, timestamps are only reported for events that actually occurred.
+* Timestamp meanings:
+   * offeredTS: when the survey was shown to the user
+   * learnMoreTS: when the user clicked on the "Learn More" link
+   * votedTS: when the user voted
+   * engagedTS: when the user clicked on the survey-provided button (alternative to voting feature)
+   * closedTS: when the Heartbeat notification bar was closed
+   * expiredTS: indicates that the survey expired after 2 hours of no interaction (threshold regulated by "browser.uitour.surveyDuration" pref)
+   * windowClosedTS: the user closed the entire Firefox window containing the survey, thus ending the survey. This timestamp will also be reported when the survey is ended by the browser being shut down.
+* The surveyId/surveyVersion fields identify a specific survey (like a "1040EZ" tax paper form). The flowID is a UUID that uniquely identifies a single user's interaction with the survey. Think of it as a session token.
+* The self-support page cannot include additional data in this payload. Only the the 4 flowId/surveyId/surveyVersion/testing fields are under the self-support page's control.
+
+See also: :doc:`common ping fields <common-ping>`
+
--- a/toolkit/components/telemetry/docs/index.rst
+++ b/toolkit/components/telemetry/docs/index.rst
@@ -18,10 +18,11 @@ Client-side, this consists of:
    pings
    common-ping
    environment
    main-ping
    core-ping
    deletion-ping
    crash-ping
    uitour-ping
+   heartbeat-ping
    preferences
    crashes
--- a/toolkit/components/telemetry/docs/pings.rst
+++ b/toolkit/components/telemetry/docs/pings.rst
@@ -43,16 +43,17 @@ Ping types
 ==========
 
 * :doc:`main <main-ping>` - contains the information collected by Telemetry (Histograms, hang stacks, ...)
 * :doc:`saved-session <main-ping>` - has the same format as a main ping, but it contains the *"classic"* Telemetry payload with measurements covering the whole browser session. This is only a separate type to make storage of saved-session easier server-side. This is temporary and will be removed soon.
 * :doc:`crash <crash-ping>` - a ping that is captured and sent after Firefox crashes.
 * :doc:`uitour-ping` - a ping submitted via the UITour API
 * ``activation`` - *planned* - sent right after installation or profile creation
 * ``upgrade`` - *planned* - sent right after an upgrade
+* :doc:`heartbeat-ping` - contains information on Heartbeat surveys
 * :doc:`deletion <deletion-ping>` - sent when FHR upload is disabled, requesting deletion of the data associated with this user
 
 Archiving
 =========
 
 When archiving is enabled through the relative preference, pings submitted to ``TelemetryController`` are also stored locally in the user profile directory, in `<profile-dir>/datareporting/archived`.
 
 To allow for cheaper lookup of archived pings, storage follows a specific naming scheme for both the directory and the ping file name: `<YYYY-MM>/<timestamp>.<UUID>.<type>.json`.