Back out 8 changesets (bug 1235345, bug 1234526, bug 1234522, bug 1237700) for OS X 10.10 debug xpcshell timeouts in test_ocsp_stapling.js and test_ocsp_stapling_expired.js
authorPhil Ringnalda <philringnalda@gmail.com>
Fri, 08 Jan 2016 20:25:27 -0800
changeset 314360 19a2342819e4a868d66e258b5811d7e34ff4b15d
parent 314359 77c75e5b4df1888b2ccbff01944ecec4bfad0032
child 314361 27279907f6f7068bbad54ff5f261671db1cb8dac
push id5703
push userraliiev@mozilla.com
push dateMon, 07 Mar 2016 14:18:41 +0000
treeherdermozilla-beta@31e373ad5b5f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
bugs1235345, 1234526, 1234522, 1237700
milestone46.0a1
backs outf26c050a39a17cf1f4a88d8cde00916abb3763b1
c7689b72d3fa71043aa8601134b7bdc4581deb86
3124025d114712f6717e6cf6a4ba930f0ab02ac3
096d46bdaf86112bc1bf350fec18d5d4063ab0c2
96e0326e798579fda3e4babf0a253b43e19e2635
c3b6bf176f86b0167f008fbf87e5a2aa39061584
3e7dc6d87325ce8f6d817e7f0bad096152cc8aef
f6447d37d113b86e2bf72356da1cb67dcd49ad97
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
Back out 8 changesets (bug 1235345, bug 1234526, bug 1234522, bug 1237700) for OS X 10.10 debug xpcshell timeouts in test_ocsp_stapling.js and test_ocsp_stapling_expired.js CLOSED TREE Backed out changeset f26c050a39a1 (bug 1235345) Backed out changeset c7689b72d3fa (bug 1234526) Backed out changeset 3124025d1147 (bug 1234526) Backed out changeset 096d46bdaf86 (bug 1234526) Backed out changeset 96e0326e7985 (bug 1234522) Backed out changeset c3b6bf176f86 (bug 1234522) Backed out changeset 3e7dc6d87325 (bug 1234522) Backed out changeset f6447d37d113 (bug 1237700)
b2g/confvars.sh
b2g/graphene/confvars.sh
b2g/installer/package-manifest.in
browser/base/content/abouthealthreport/abouthealth.js
browser/base/content/browser-data-submission-info-bar.js
browser/base/content/browser.js
browser/base/content/test/general/browser.ini
browser/base/content/test/general/browser_aboutHealthReport.js
browser/base/content/test/general/browser_aboutHome.js
browser/base/content/test/general/browser_contextSearchTabPosition.js
browser/base/content/test/general/browser_datachoices_notification.js
browser/base/content/test/general/browser_datareporting_notification.js
browser/base/content/test/general/browser_urlbarSearchTelemetry.js
browser/base/content/test/general/browser_urlbar_search_healthreport.js
browser/base/content/test/general/head.js
browser/base/content/test/general/healthreport_testRemoteCommands.html
browser/base/content/urlbarBindings.xml
browser/components/nsBrowserGlue.js
browser/components/preferences/in-content/advanced.js
browser/components/preferences/in-content/tests/browser.ini
browser/components/preferences/in-content/tests/browser_healthreport.js
browser/components/search/content/search.xml
browser/components/search/test/browser_healthreport.js
browser/components/selfsupport/SelfSupportService.js
browser/confvars.sh
browser/experiments/ExperimentsService.js
browser/installer/package-manifest.in
browser/modules/ContentSearch.jsm
browser/modules/SelfSupportBackend.jsm
dom/webidl/MozSelfSupport.webidl
embedding/ios/confvars.sh
mobile/android/b2gdroid/installer/package-manifest.in
modules/libpref/greprefs.js
services/datareporting/DataReporting.manifest
services/datareporting/DataReportingService.js
services/datareporting/datareporting-prefs.js
services/datareporting/modules-testing/mocks.jsm
services/datareporting/moz.build
services/datareporting/policy.jsm
services/datareporting/tests/xpcshell/head.js
services/datareporting/tests/xpcshell/test_policy.js
services/datareporting/tests/xpcshell/xpcshell.ini
services/docs/datareporting.rst
services/docs/index.rst
services/docs/metrics.rst
services/healthreport/HealthReport.jsm
services/healthreport/HealthReportComponents.manifest
services/healthreport/docs/architecture.rst
services/healthreport/docs/dataformat.rst
services/healthreport/docs/identifiers.rst
services/healthreport/docs/index.rst
services/healthreport/healthreport-prefs.js
services/healthreport/healthreporter.jsm
services/healthreport/modules-testing/utils.jsm
services/healthreport/moz.build
services/healthreport/profile.jsm
services/healthreport/providers.jsm
services/healthreport/tests/xpcshell/head.js
services/healthreport/tests/xpcshell/test_healthreporter.js
services/healthreport/tests/xpcshell/test_load_modules.js
services/healthreport/tests/xpcshell/test_profile.js
services/healthreport/tests/xpcshell/test_provider_addons.js
services/healthreport/tests/xpcshell/test_provider_appinfo.js
services/healthreport/tests/xpcshell/test_provider_crashes.js
services/healthreport/tests/xpcshell/test_provider_hotfix.js
services/healthreport/tests/xpcshell/test_provider_places.js
services/healthreport/tests/xpcshell/test_provider_searches.js
services/healthreport/tests/xpcshell/test_provider_sessions.js
services/healthreport/tests/xpcshell/test_provider_sysinfo.js
services/healthreport/tests/xpcshell/xpcshell.ini
services/metrics/Metrics.jsm
services/metrics/dataprovider.jsm
services/metrics/modules-testing/mocks.jsm
services/metrics/moz.build
services/metrics/providermanager.jsm
services/metrics/storage.jsm
services/metrics/tests/xpcshell/head.js
services/metrics/tests/xpcshell/test_load_modules.js
services/metrics/tests/xpcshell/test_metrics_provider.js
services/metrics/tests/xpcshell/test_metrics_provider_manager.js
services/metrics/tests/xpcshell/test_metrics_storage.js
services/metrics/tests/xpcshell/xpcshell.ini
services/moz.build
testing/marionette/driver/marionette_driver/geckoinstance.py
toolkit/components/telemetry/Telemetry.cpp
toolkit/components/telemetry/TelemetryController.jsm
toolkit/components/telemetry/TelemetrySession.jsm
toolkit/components/telemetry/datareporting-prefs.js
toolkit/components/telemetry/docs/fhr/architecture.rst
toolkit/components/telemetry/docs/fhr/dataformat.rst
toolkit/components/telemetry/docs/fhr/identifiers.rst
toolkit/components/telemetry/docs/fhr/index.rst
toolkit/components/telemetry/healthreport-prefs.js
toolkit/components/telemetry/moz.build
toolkit/components/telemetry/tests/unit/head.js
toolkit/components/telemetry/tests/unit/test_TelemetryController.js
toolkit/components/telemetry/tests/unit/test_TelemetryControllerBuildID.js
toolkit/components/telemetry/tests/unit/test_TelemetryControllerShutdown.js
toolkit/components/telemetry/tests/unit/test_TelemetryReportingPolicy.js
toolkit/components/telemetry/tests/unit/test_TelemetrySendOldPings.js
toolkit/components/telemetry/tests/unit/test_TelemetrySession.js
toolkit/components/telemetry/tests/unit/test_TelemetryTimestamps.js
toolkit/modules/SessionRecorder.jsm
xulrunner/confvars.sh
--- a/b2g/confvars.sh
+++ b/b2g/confvars.sh
@@ -14,16 +14,17 @@ MOZ_B2G_VERSION=2.6.0.0-prerelease
 MOZ_B2G_OS_NAME=Boot2Gecko
 
 MOZ_BRANDING_DIRECTORY=b2g/branding/unofficial
 MOZ_OFFICIAL_BRANDING_DIRECTORY=b2g/branding/official
 # MOZ_APP_DISPLAYNAME is set by branding/configure.sh
 
 MOZ_SAFE_BROWSING=1
 MOZ_SERVICES_COMMON=1
+MOZ_SERVICES_METRICS=1
 
 MOZ_WEBSMS_BACKEND=1
 MOZ_NO_SMART_CARDS=1
 MOZ_APP_STATIC_INI=1
 NSS_DISABLE_DBM=1
 MOZ_NO_EV_CERTS=1
 MOZ_DISABLE_EXPORT_JS=1
 
--- a/b2g/graphene/confvars.sh
+++ b/b2g/graphene/confvars.sh
@@ -21,16 +21,17 @@ MOZ_B2G_VERSION=2.6.0.0-prerelease
 MOZ_B2G_OS_NAME=Boot2Gecko
 
 MOZ_BRANDING_DIRECTORY=b2g/branding/unofficial
 MOZ_OFFICIAL_BRANDING_DIRECTORY=b2g/branding/official
 # MOZ_APP_DISPLAYNAME is set by branding/configure.sh
 
 MOZ_SAFE_BROWSING=1
 MOZ_SERVICES_COMMON=1
+MOZ_SERVICES_METRICS=1
 MOZ_CAPTIVEDETECT=1
 
 MOZ_WEBSMS_BACKEND=1
 MOZ_NO_SMART_CARDS=1
 MOZ_APP_STATIC_INI=1
 NSS_NO_LIBPKIX=1
 NSS_DISABLE_DBM=1
 MOZ_DISABLE_EXPORT_JS=1
--- a/b2g/installer/package-manifest.in
+++ b/b2g/installer/package-manifest.in
@@ -626,16 +626,20 @@
 #ifdef MOZ_SERVICES_SYNC
 @RESPATH@/components/SyncComponents.manifest
 @RESPATH@/components/Weave.js
 @RESPATH@/components/WeaveCrypto.manifest
 @RESPATH@/components/WeaveCrypto.js
 #endif
 @RESPATH@/components/servicesComponents.manifest
 @RESPATH@/components/cryptoComponents.manifest
+#ifdef MOZ_SERVICES_HEALTHREPORT
+@RESPATH@/components/HealthReportComponents.manifest
+@RESPATH@/components/HealthReportService.js
+#endif
 @RESPATH@/components/CaptivePortalDetectComponents.manifest
 @RESPATH@/components/captivedetect.js
 @RESPATH@/components/TelemetryStartup.js
 @RESPATH@/components/TelemetryStartup.manifest
 @RESPATH@/components/XULStore.js
 @RESPATH@/components/XULStore.manifest
 @RESPATH@/components/Webapps.js
 @RESPATH@/components/Webapps.manifest
--- a/browser/base/content/abouthealthreport/abouthealth.js
+++ b/browser/base/content/abouthealthreport/abouthealth.js
@@ -18,16 +18,20 @@ const PREF_UNIFIED_OPTIN = "toolkit.tele
 const IS_V4 = Preferences.get(PREF_UNIFIED, false) &&
               !Preferences.get(PREF_UNIFIED_OPTIN, false);
 
 var healthReportWrapper = {
   init: function () {
     let iframe = document.getElementById("remote-report");
     iframe.addEventListener("load", healthReportWrapper.initRemotePage, false);
     iframe.src = this._getReportURI().spec;
+    iframe.onload = () => {
+      MozSelfSupport.getHealthReportPayload().then(this.updatePayload,
+                                                   this.handleInitFailure);
+    };
     prefs.observe("uploadEnabled", this.updatePrefState, healthReportWrapper);
   },
 
   uninit: function () {
     prefs.ignore("uploadEnabled", this.updatePrefState, healthReportWrapper);
   },
 
   _getReportURI: function () {
@@ -94,16 +98,25 @@ var healthReportWrapper = {
     console.log("AboutHealthReport: Sending current Telemetry ping data.");
     MozSelfSupport.getCurrentTelemetrySubsessionPing().then((ping) => {
       this.injectData("telemetry-current-ping-data", ping);
     }).catch((ex) => {
       console.log("AboutHealthReport: Collecting current ping data failed: " + ex);
     });
   },
 
+  refreshPayload: function () {
+    MozSelfSupport.getHealthReportPayload().then(this.updatePayload,
+                                                 this.handlePayloadFailure);
+  },
+
+  updatePayload: function (payload) {
+    healthReportWrapper.injectData("payload", JSON.stringify(payload));
+  },
+
   injectData: function (type, content) {
     let report = this._getReportURI();
 
     // file URIs can't be used for targetOrigin, so we use "*" for this special case
     // in all other cases, pass in the URL to the report so we properly restrict the message dispatch
     let reportUrl = report.scheme == "file" ? "*" : report.spec;
 
     let data = {
@@ -121,16 +134,19 @@ var healthReportWrapper = {
         this.setDataSubmission(false);
         break;
       case "EnableDataSubmission":
         this.setDataSubmission(true);
         break;
       case "RequestCurrentPrefs":
         this.updatePrefState();
         break;
+      case "RequestCurrentPayload":
+        this.refreshPayload();
+        break;
       case "RequestTelemetryPingList":
         this.sendTelemetryPingList();
         break;
       case "RequestTelemetryPingData":
         this.sendTelemetryPingData(evt.detail.id);
         break;
       case "RequestCurrentEnvironment":
         this.sendCurrentEnvironment();
--- a/browser/base/content/browser-data-submission-info-bar.js
+++ b/browser/base/content/browser-data-submission-info-bar.js
@@ -1,15 +1,12 @@
 /* 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/. */
 
-const LOGGER_NAME = "Toolkit.Telemetry";
-const LOGGER_PREFIX = "DataNotificationInfoBar::";
-
 /**
  * Represents an info bar that shows a data submission notification.
  */
 var gDataNotificationInfoBar = {
   _OBSERVERS: [
     "datareporting:notify-data-policy:request",
     "datareporting:notify-data-policy:close",
   ],
@@ -19,17 +16,17 @@ var gDataNotificationInfoBar = {
   get _notificationBox() {
     delete this._notificationBox;
     return this._notificationBox = document.getElementById("global-notificationbox");
   },
 
   get _log() {
     let Log = Cu.import("resource://gre/modules/Log.jsm", {}).Log;
     delete this._log;
-    return this._log = Log.repository.getLoggerWithMessagePrefix(LOGGER_NAME, LOGGER_PREFIX);
+    return this._log = Log.repository.getLogger("Services.DataReporting.InfoBar");
   },
 
   init: function() {
     window.addEventListener("unload", () => {
       for (let o of this._OBSERVERS) {
         Services.obs.removeObserver(this, o);
       }
     }, false);
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -3628,17 +3628,17 @@ const BrowserSearch = {
    * Perform a search initiated from the context menu.
    *
    * This should only be called from the context menu. See
    * BrowserSearch.loadSearch for the preferred API.
    */
   loadSearchFromContext: function (terms) {
     let engine = BrowserSearch._loadSearch(terms, true, "contextmenu");
     if (engine) {
-      BrowserSearch.recordSearchInTelemetry(engine, "contextmenu");
+      BrowserSearch.recordSearchInHealthReport(engine, "contextmenu");
     }
   },
 
   pasteAndSearch: function (event) {
     BrowserSearch.searchBar.select();
     goDoCommand("cmd_paste");
     BrowserSearch.searchBar.handleSearchCommand(event);
   },
@@ -3652,58 +3652,78 @@ const BrowserSearch = {
 
   loadAddEngines: function BrowserSearch_loadAddEngines() {
     var newWindowPref = gPrefService.getIntPref("browser.link.open_newwindow");
     var where = newWindowPref == 3 ? "tab" : "window";
     var searchEnginesURL = formatURL("browser.search.searchEnginesURL", true);
     openUILinkIn(searchEnginesURL, where);
   },
 
-  _getSearchEngineId: function (engine) {
-    if (!engine) {
-      return "other";
-    }
-
-    if (engine.identifier) {
-      return engine.identifier;
-    }
-
-    if (!("name" in engine) || engine.name === undefined) {
-      return "other";
-    }
-
-    return "other-" + engine.name;
-  },
-
   /**
-   * Helper to record a search with Telemetry.
+   * Helper to record a search with Firefox Health Report.
    *
-   * Telemetry records only search counts and nothing pertaining to the search itself.
+   * FHR records only search counts and nothing pertaining to the search itself.
    *
    * @param engine
    *        (nsISearchEngine) The engine handling the search.
    * @param source
    *        (string) Where the search originated from. See the FHR
    *        SearchesProvider for allowed values.
    * @param selection [optional]
    *        ({index: The selected index, kind: "key" or "mouse"}) If
    *        the search was a suggested search, this indicates where the
    *        item was in the suggestion list and how the user selected it.
    */
-  recordSearchInTelemetry: function (engine, source, selection) {
+  recordSearchInHealthReport: function (engine, source, selection) {
+    BrowserUITelemetry.countSearchEvent(source, null, selection);
+    this.recordSearchInTelemetry(engine, source);
+
+    let reporter = AppConstants.MOZ_SERVICES_HEALTHREPORT
+                   ? Cc["@mozilla.org/datareporting/service;1"]
+                     .getService()
+                     .wrappedJSObject
+                     .healthReporter
+                   : null;
+
+    // This can happen if the FHR component of the data reporting service is
+    // disabled. This is controlled by a pref that most will never use.
+    if (!reporter) {
+      return;
+    }
+
+    reporter.onInit().then(function record() {
+      try {
+        reporter.getProvider("org.mozilla.searches").recordSearch(engine, source);
+      } catch (ex) {
+        Cu.reportError(ex);
+      }
+    });
+  },
+
+  _getSearchEngineId: function (engine) {
+    if (!engine) {
+      return "other";
+    }
+
+    if (engine.identifier) {
+      return engine.identifier;
+    }
+
+    return "other-" + engine.name;
+  },
+
+  recordSearchInTelemetry: function (engine, source) {
     const SOURCES = [
       "abouthome",
       "contextmenu",
       "newtab",
       "searchbar",
       "urlbar",
     ];
 
-    BrowserUITelemetry.countSearchEvent(source, null, selection);
-
     if (SOURCES.indexOf(source) == -1) {
       Cu.reportError("Unknown source for search: " + source);
       return;
     }
 
     let countId = this._getSearchEngineId(engine) + "." + source;
 
     let count = Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS");
--- a/browser/base/content/test/general/browser.ini
+++ b/browser/base/content/test/general/browser.ini
@@ -281,16 +281,18 @@ skip-if = os == 'win' || e10s # Bug 1159
 [browser_accesskeys.js]
 [browser_canonizeURL.js]
 skip-if = e10s # Bug 1094510 - test hits the network in e10s mode only
 [browser_clipboard.js]
 [browser_contentAreaClick.js]
 [browser_contextSearchTabPosition.js]
 skip-if = os == "mac" || e10s # bug 967013; e10s: bug 1094761 - test hits the network in e10s, causing next test to crash
 [browser_ctrlTab.js]
+[browser_datareporting_notification.js]
+skip-if = !datareporting
 [browser_datachoices_notification.js]
 skip-if = !datareporting
 [browser_devedition.js]
 [browser_devices_get_user_media.js]
 skip-if = buildapp == 'mulet' || (os == "linux" && debug) || e10s # linux: bug 976544; e10s: bug 1071623
 [browser_devices_get_user_media_about_urls.js]
 skip-if = e10s # Bug 1071623
 [browser_devices_get_user_media_in_frame.js]
@@ -473,16 +475,17 @@ skip-if = os == "linux" # Bug 1073339 - 
 [browser_urlbarRevert.js]
 [browser_urlbarSearchSingleWordNotification.js]
 [browser_urlbarSearchSuggestions.js]
 [browser_urlbarSearchSuggestionsNotification.js]
 [browser_urlbarSearchTelemetry.js]
 [browser_urlbarStop.js]
 [browser_urlbarTrimURLs.js]
 [browser_urlbar_autoFill_backspaced.js]
+[browser_urlbar_search_healthreport.js]
 [browser_urlbar_searchsettings.js]
 [browser_utilityOverlay.js]
 [browser_viewSourceInTabOnViewSource.js]
 [browser_visibleFindSelection.js]
 [browser_visibleLabel.js]
 [browser_visibleTabs.js]
 [browser_visibleTabs_bookmarkAllPages.js]
 skip-if = true # Bug 1005420 - fails intermittently. also with e10s enabled: bizarre problem with hidden tab having _mouseenter called, via _setPositionalAttributes, and tab not being found resulting in 'candidate is undefined'
--- a/browser/base/content/test/general/browser_aboutHealthReport.js
+++ b/browser/base/content/test/general/browser_aboutHealthReport.js
@@ -1,143 +1,154 @@
-/* Any copyright is dedicated to the Public Domain.
- * http://creativecommons.org/publicdomain/zero/1.0/
- */
-
-XPCOMUtils.defineLazyModuleGetter(this, "Promise",
-  "resource://gre/modules/Promise.jsm");
-XPCOMUtils.defineLazyModuleGetter(this, "Task",
-  "resource://gre/modules/Task.jsm");
-
-const CHROME_BASE = "chrome://mochitests/content/browser/browser/base/content/test/general/";
-const HTTPS_BASE = "https://example.com/browser/browser/base/content/test/general/";
-
-const TELEMETRY_LOG_PREF = "toolkit.telemetry.log.level";
-const telemetryOriginalLogPref = Preferences.get(TELEMETRY_LOG_PREF, null);
-
-const originalReportUrl = Services.prefs.getCharPref("datareporting.healthreport.about.reportUrl");
-const originalReportUrlUnified = Services.prefs.getCharPref("datareporting.healthreport.about.reportUrlUnified");
-
-registerCleanupFunction(function() {
-  // Ensure we don't pollute prefs for next tests.
-  if (telemetryOriginalLogPref) {
-    Preferences.set(TELEMETRY_LOG_PREF, telemetryOriginalLogPref);
-  } else {
-    Preferences.reset(TELEMETRY_LOG_PREF);
-  }
-
-  try {
-    Services.prefs.setCharPref("datareporting.healthreport.about.reportUrl", originalReportUrl);
-    Services.prefs.setCharPref("datareporting.healthreport.about.reportUrlUnified", originalReportUrlUnified);
-    Services.prefs.setBoolPref("datareporting.healthreport.uploadEnabled", true);
-  } catch (ex) {}
-});
-
-function fakeTelemetryNow(...args) {
-  let date = new Date(...args);
-  let scope = {};
-  const modules = [
-    Cu.import("resource://gre/modules/TelemetrySession.jsm", scope),
-    Cu.import("resource://gre/modules/TelemetryEnvironment.jsm", scope),
-    Cu.import("resource://gre/modules/TelemetryController.jsm", scope),
-  ];
-
-  for (let m of modules) {
-    m.Policy.now = () => new Date(date);
-  }
-
-  return date;
-}
-
-function setupPingArchive() {
-  let scope = {};
-  Cu.import("resource://gre/modules/TelemetryController.jsm", scope);
-  Cc["@mozilla.org/moz/jssubscript-loader;1"].getService(Ci.mozIJSSubScriptLoader)
-    .loadSubScript(CHROME_BASE + "healthreport_pingData.js", scope);
-
-  for (let p of scope.TEST_PINGS) {
-    fakeTelemetryNow(p.date);
-    p.id = yield scope.TelemetryController.submitExternalPing(p.type, p.payload);
-  }
-}
-
-var gTests = [
-
-{
-  desc: "Test the remote commands",
-  setup: Task.async(function*()
-  {
-    Preferences.set(TELEMETRY_LOG_PREF, "Trace");
-    yield setupPingArchive();
-    Preferences.set("datareporting.healthreport.about.reportUrl",
-                    HTTPS_BASE + "healthreport_testRemoteCommands.html");
-    Preferences.set("datareporting.healthreport.about.reportUrlUnified",
-                    HTTPS_BASE + "healthreport_testRemoteCommands.html");
-  }),
-  run: function (iframe)
-  {
-    let deferred = Promise.defer();
-    let results = 0;
-    try {
-      iframe.contentWindow.addEventListener("FirefoxHealthReportTestResponse", function evtHandler(event) {
-        let data = event.detail.data;
-        if (data.type == "testResult") {
-          ok(data.pass, data.info);
-          results++;
-        }
-        else if (data.type == "testsComplete") {
-          is(results, data.count, "Checking number of results received matches the number of tests that should have run");
-          iframe.contentWindow.removeEventListener("FirefoxHealthReportTestResponse", evtHandler, true);
-          deferred.resolve();
-        }
-      }, true);
-
-    } catch(e) {
-      ok(false, "Failed to get all commands");
-      deferred.reject();
-    }
-    return deferred.promise;
-  }
-},
-
-]; // gTests
-
-function test()
-{
-  waitForExplicitFinish();
-
-  // xxxmpc leaving this here until we resolve bug 854038 and bug 854060
-  requestLongerTimeout(10);
-
-  Task.spawn(function () {
-    for (let test of gTests) {
-      info(test.desc);
-      yield test.setup();
-
-      let iframe = yield promiseNewTabLoadEvent("about:healthreport");
-
-      yield test.run(iframe);
-
-      gBrowser.removeCurrentTab();
-    }
-
-    finish();
-  });
-}
-
-function promiseNewTabLoadEvent(aUrl, aEventType="load")
-{
-  let deferred = Promise.defer();
-  let tab = gBrowser.selectedTab = gBrowser.addTab(aUrl);
-  tab.linkedBrowser.addEventListener(aEventType, function load(event) {
-    tab.linkedBrowser.removeEventListener(aEventType, load, true);
-    let iframe = tab.linkedBrowser.contentDocument.getElementById("remote-report");
-      iframe.addEventListener("load", function frameLoad(e) {
-        if (iframe.contentWindow.location.href == "about:blank" ||
-            e.target != iframe) {
-          return;
-        }
-        iframe.removeEventListener("load", frameLoad, false);
-        deferred.resolve(iframe);
-      }, false);
-    }, true);
-  return deferred.promise;
-}
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+XPCOMUtils.defineLazyModuleGetter(this, "Promise",
+  "resource://gre/modules/Promise.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+  "resource://gre/modules/Task.jsm");
+
+const CHROME_BASE = "chrome://mochitests/content/browser/browser/base/content/test/general/";
+const HTTPS_BASE = "https://example.com/browser/browser/base/content/test/general/";
+
+const TELEMETRY_LOG_PREF = "toolkit.telemetry.log.level";
+const telemetryOriginalLogPref = Preferences.get(TELEMETRY_LOG_PREF, null);
+
+const originalReportUrl = Services.prefs.getCharPref("datareporting.healthreport.about.reportUrl");
+const originalReportUrlUnified = Services.prefs.getCharPref("datareporting.healthreport.about.reportUrlUnified");
+
+registerCleanupFunction(function() {
+  // Ensure we don't pollute prefs for next tests.
+  if (telemetryOriginalLogPref) {
+    Preferences.set(TELEMETRY_LOG_PREF, telemetryOriginalLogPref);
+  } else {
+    Preferences.reset(TELEMETRY_LOG_PREF);
+  }
+
+  try {
+    Services.prefs.setCharPref("datareporting.healthreport.about.reportUrl", originalReportUrl);
+    Services.prefs.setCharPref("datareporting.healthreport.about.reportUrlUnified", originalReportUrlUnified);
+    let policy = Cc["@mozilla.org/datareporting/service;1"]
+                 .getService(Ci.nsISupports)
+                 .wrappedJSObject
+                 .policy;
+    policy.recordHealthReportUploadEnabled(true,
+                                           "Resetting after tests.");
+  } catch (ex) {}
+});
+
+function fakeTelemetryNow(...args) {
+  let date = new Date(...args);
+  let scope = {};
+  const modules = [
+    Cu.import("resource://gre/modules/TelemetrySession.jsm", scope),
+    Cu.import("resource://gre/modules/TelemetryEnvironment.jsm", scope),
+    Cu.import("resource://gre/modules/TelemetryController.jsm", scope),
+  ];
+
+  for (let m of modules) {
+    m.Policy.now = () => new Date(date);
+  }
+
+  return date;
+}
+
+function setupPingArchive() {
+  let scope = {};
+  Cu.import("resource://gre/modules/TelemetryController.jsm", scope);
+  Cc["@mozilla.org/moz/jssubscript-loader;1"].getService(Ci.mozIJSSubScriptLoader)
+    .loadSubScript(CHROME_BASE + "healthreport_pingData.js", scope);
+
+  for (let p of scope.TEST_PINGS) {
+    fakeTelemetryNow(p.date);
+    p.id = yield scope.TelemetryController.submitExternalPing(p.type, p.payload);
+  }
+}
+
+var gTests = [
+
+{
+  desc: "Test the remote commands",
+  setup: Task.async(function*()
+  {
+    Preferences.set(TELEMETRY_LOG_PREF, "Trace");
+    yield setupPingArchive();
+    Preferences.set("datareporting.healthreport.about.reportUrl",
+                    HTTPS_BASE + "healthreport_testRemoteCommands.html");
+    Preferences.set("datareporting.healthreport.about.reportUrlUnified",
+                    HTTPS_BASE + "healthreport_testRemoteCommands.html");
+  }),
+  run: function (iframe)
+  {
+    let deferred = Promise.defer();
+
+    let policy = Cc["@mozilla.org/datareporting/service;1"]
+                 .getService(Ci.nsISupports)
+                 .wrappedJSObject
+                 .policy;
+
+    let results = 0;
+    try {
+      iframe.contentWindow.addEventListener("FirefoxHealthReportTestResponse", function evtHandler(event) {
+        let data = event.detail.data;
+        if (data.type == "testResult") {
+          ok(data.pass, data.info);
+          results++;
+        }
+        else if (data.type == "testsComplete") {
+          is(results, data.count, "Checking number of results received matches the number of tests that should have run");
+          iframe.contentWindow.removeEventListener("FirefoxHealthReportTestResponse", evtHandler, true);
+          deferred.resolve();
+        }
+      }, true);
+
+    } catch(e) {
+      ok(false, "Failed to get all commands");
+      deferred.reject();
+    }
+    return deferred.promise;
+  }
+},
+
+]; // gTests
+
+function test()
+{
+  waitForExplicitFinish();
+
+  // xxxmpc leaving this here until we resolve bug 854038 and bug 854060
+  requestLongerTimeout(10);
+
+  Task.spawn(function () {
+    for (let test of gTests) {
+      info(test.desc);
+      yield test.setup();
+
+      let iframe = yield promiseNewTabLoadEvent("about:healthreport");
+
+      yield test.run(iframe);
+
+      gBrowser.removeCurrentTab();
+    }
+
+    finish();
+  });
+}
+
+function promiseNewTabLoadEvent(aUrl, aEventType="load")
+{
+  let deferred = Promise.defer();
+  let tab = gBrowser.selectedTab = gBrowser.addTab(aUrl);
+  tab.linkedBrowser.addEventListener(aEventType, function load(event) {
+    tab.linkedBrowser.removeEventListener(aEventType, load, true);
+    let iframe = tab.linkedBrowser.contentDocument.getElementById("remote-report");
+      iframe.addEventListener("load", function frameLoad(e) {
+        if (iframe.contentWindow.location.href == "about:blank" ||
+            e.target != iframe) {
+          return;
+        }
+        iframe.removeEventListener("load", frameLoad, false);
+        deferred.resolve(iframe);
+      }, false);
+    }, true);
+  return deferred.promise;
+}
--- a/browser/base/content/test/general/browser_aboutHome.js
+++ b/browser/base/content/test/general/browser_aboutHome.js
@@ -73,21 +73,34 @@ var gTests = [
     ok(snippetsElt, "Found snippets element");
     is(snippetsElt.getElementsByTagName("span").length, 1,
        "A default snippet is present.");
 
     aSnippetsMap.delete("snippets");
   }
 },
 
+// Disabled on Linux for intermittent issues with FHR, see Bug 945667.
 {
   desc: "Check that performing a search fires a search event and records to " +
-        "Telemetry.",
+        "Firefox Health Report.",
   setup: function () { },
   run: function* () {
+    // Skip this test on Linux.
+    if (navigator.platform.indexOf("Linux") == 0) {
+      return Promise.resolve();
+    }
+
+    try {
+      let cm = Cc["@mozilla.org/categorymanager;1"].getService(Ci.nsICategoryManager);
+      cm.getCategoryEntry("healthreport-js-provider-default", "SearchesProvider");
+    } catch (ex) {
+      // Health Report disabled, or no SearchesProvider.
+      return Promise.resolve();
+    }
 
     let engine = yield promiseNewEngine("searchSuggestionEngine.xml");
     // Make this actually work in healthreport by giving it an ID:
     Object.defineProperty(engine.wrappedJSObject, "identifier",
                           {value: "org.mozilla.testsearchsuggestions"});
 
     let p = promiseContentSearchChange(engine.name);
     Services.search.currentEngine = engine;
@@ -95,42 +108,33 @@ var gTests = [
 
     let numSearchesBefore = 0;
     let searchEventDeferred = Promise.defer();
     let doc = gBrowser.contentDocument;
     let engineName = gBrowser.contentWindow.wrappedJSObject.gContentSearchController.defaultEngine.name;
     is(engine.name, engineName, "Engine name in DOM should match engine we just added");
 
     // Get the current number of recorded searches.
-    let histogramKey = engine.identifier + ".abouthome";
-    try {
-      let hs = Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS").snapshot();
-      if (histogramKey in hs) {
-        numSearchesBefore = hs[histogramKey].sum;
-      }
-    } catch (ex) {
-      // No searches performed yet, not a problem, |numSearchesBefore| is 0.
-    }
+    let searchStr = "a search";
+    getNumberOfSearchesInFHR(engineName, "abouthome").then(num => {
+      numSearchesBefore = num;
 
-    // Perform a search to increase the SEARCH_COUNT histogram.
-    let searchStr = "a search";
-    info("Perform a search.");
-    doc.getElementById("searchText").value = searchStr;
-    doc.getElementById("searchSubmit").click();
+      info("Perform a search.");
+      doc.getElementById("searchText").value = searchStr;
+      doc.getElementById("searchSubmit").click();
+    });
 
     let expectedURL = Services.search.currentEngine.
                       getSubmission(searchStr, null, "homepage").
                       uri.spec;
     let loadPromise = waitForDocLoadAndStopIt(expectedURL).then(() => {
-      // Make sure the SEARCH_COUNTS histogram has the right key and count.
-      let hs = Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS").snapshot();
-      Assert.ok(histogramKey in hs, "histogram with key should be recorded");
-      Assert.equal(hs[histogramKey].sum, numSearchesBefore + 1,
-                   "histogram sum should be incremented");
-      searchEventDeferred.resolve();
+      getNumberOfSearchesInFHR(engineName, "abouthome").then(num => {
+        is(num, numSearchesBefore + 1, "One more search recorded.");
+        searchEventDeferred.resolve();
+      });
     });
 
     try {
       yield Promise.all([searchEventDeferred.promise, loadPromise]);
     } catch (ex) {
       Cu.reportError(ex);
       ok(false, "An error occurred waiting for the search to be performed: " + ex);
     } finally {
--- a/browser/base/content/test/general/browser_contextSearchTabPosition.js
+++ b/browser/base/content/test/general/browser_contextSearchTabPosition.js
@@ -1,56 +1,70 @@
 /* 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/. */
 
-add_task(function* test() {
-
-  // Will need to be changed if Google isn't the default search engine.
-  // Note: geoSpecificDefaults are disabled for mochitests, so this is the
-  // non-US en-US default.
-  let histogramKey = "google.contextmenu";
-  let numSearchesBefore = 0;
-  try {
-    let hs = Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS").snapshot();
-    if (histogramKey in hs) {
-      numSearchesBefore = hs[histogramKey].sum;
-    }
-  } catch (ex) {
-    // No searches performed yet, not a problem, |numSearchesBefore| is 0.
-  }
-
-  let tabs = [];
-  let tabsLoadedDeferred = Promise.defer();
+function test() {
+  waitForExplicitFinish();
 
   function tabAdded(event) {
     let tab = event.target;
     tabs.push(tab);
+  }
 
-    // We wait for the blank tab and the two context searches tabs to open.
-    if (tabs.length == 3) {
-      tabsLoadedDeferred.resolve();
-    }
-  }
+  let tabs = [];
 
   let container = gBrowser.tabContainer;
   container.addEventListener("TabOpen", tabAdded, false);
 
   gBrowser.addTab("about:blank");
   BrowserSearch.loadSearchFromContext("mozilla");
   BrowserSearch.loadSearchFromContext("firefox");
 
-  // Wait for all the tabs to open.
-  yield tabsLoadedDeferred.promise;
-
   is(tabs[0], gBrowser.tabs[3], "blank tab has been pushed to the end");
   is(tabs[1], gBrowser.tabs[1], "first search tab opens next to the current tab");
   is(tabs[2], gBrowser.tabs[2], "second search tab opens next to the first search tab");
 
   container.removeEventListener("TabOpen", tabAdded, false);
   tabs.forEach(gBrowser.removeTab, gBrowser);
 
-  // Make sure that the context searches are correctly recorded.
-  let hs = Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS").snapshot();
-  Assert.ok(histogramKey in hs, "The histogram must contain the correct key");
-  Assert.equal(hs[histogramKey].sum, numSearchesBefore + 2,
-               "The histogram must contain the correct search count");
-});
+  try {
+    let cm = Components.classes["@mozilla.org/categorymanager;1"]
+                       .getService(Components.interfaces.nsICategoryManager);
+    cm.getCategoryEntry("healthreport-js-provider-default", "SearchesProvider");
+  } catch (ex) {
+    // Health Report disabled, or no SearchesProvider.
+    finish();
+    return;
+  }
+
+  let reporter = Components.classes["@mozilla.org/datareporting/service;1"]
+                                   .getService()
+                                   .wrappedJSObject
+                                   .healthReporter;
+
+  // reporter should always be available in automation.
+  ok(reporter, "Health Reporter available.");
+  reporter.onInit().then(function onInit() {
+    let provider = reporter.getProvider("org.mozilla.searches");
+    ok(provider, "Searches provider is available.");
+
+    let m = provider.getMeasurement("counts", 3);
+    m.getValues().then(function onValues(data) {
+      let now = new Date();
+      ok(data.days.hasDay(now), "Have data for today.");
+      let day = data.days.getDay(now);
+
+      // Will need to be changed if Google isn't the default search engine.
+      // Note: geoSpecificDefaults are disabled for mochitests, so this is the
+      // non-US en-US default.
+      let defaultProviderID = "google";
+      let field = defaultProviderID + ".contextmenu";
+      ok(day.has(field), "Have search recorded for context menu.");
+
+      // If any other mochitests perform a context menu search, this will fail.
+      // The solution will be to look up count at test start and ensure it is
+      // incremented by two.
+      is(day.get(field), 2, "2 searches recorded in FHR.");
+      finish();
+    });
+  });
+}
--- a/browser/base/content/test/general/browser_datachoices_notification.js
+++ b/browser/base/content/test/general/browser_datachoices_notification.js
@@ -5,17 +5,23 @@
 "use strict";
 
 // Pass an empty scope object to the import to prevent "leaked window property"
 // errors in tests.
 var Preferences = Cu.import("resource://gre/modules/Preferences.jsm", {}).Preferences;
 var TelemetryReportingPolicy =
   Cu.import("resource://gre/modules/TelemetryReportingPolicy.jsm", {}).TelemetryReportingPolicy;
 
+XPCOMUtils.defineLazyGetter(this, "gDatareportingService",
+  () => Cc["@mozilla.org/datareporting/service;1"]
+          .getService(Ci.nsISupports)
+          .wrappedJSObject);
+
 const PREF_BRANCH = "datareporting.policy.";
+const PREF_DRS_ENABLED = "datareporting.healthreport.service.enabled";
 const PREF_BYPASS_NOTIFICATION = PREF_BRANCH + "dataSubmissionPolicyBypassNotification";
 const PREF_CURRENT_POLICY_VERSION = PREF_BRANCH + "currentPolicyVersion";
 const PREF_ACCEPTED_POLICY_VERSION = PREF_BRANCH + "dataSubmissionPolicyAcceptedVersion";
 const PREF_ACCEPTED_POLICY_DATE = PREF_BRANCH + "dataSubmissionPolicyNotifiedTime";
 
 const TEST_POLICY_VERSION = 37;
 
 function fakeShowPolicyTimeout(set, clear) {
@@ -92,31 +98,41 @@ var checkInfobarButton = Task.async(func
   button.click();
 
   // Wait for the preferences panel to open.
   let preferenceWindow = yield paneLoadedPromise;
   yield promiseNextTick();
 });
 
 add_task(function* setup(){
+  const drsEnabled = Preferences.get(PREF_DRS_ENABLED, true);
   const bypassNotification = Preferences.get(PREF_BYPASS_NOTIFICATION, true);
   const currentPolicyVersion = Preferences.get(PREF_CURRENT_POLICY_VERSION, 1);
 
   // Register a cleanup function to reset our preferences.
   registerCleanupFunction(() => {
+    Preferences.set(PREF_DRS_ENABLED, drsEnabled);
     Preferences.set(PREF_BYPASS_NOTIFICATION, bypassNotification);
     Preferences.set(PREF_CURRENT_POLICY_VERSION, currentPolicyVersion);
 
+    // Start polling again.
+    gDatareportingService.policy.startPolling();
+
     return closeAllNotifications();
   });
 
+  // Disable Healthreport/Data reporting service.
+  Preferences.set(PREF_DRS_ENABLED, false);
   // Don't skip the infobar visualisation.
   Preferences.set(PREF_BYPASS_NOTIFICATION, false);
   // Set the current policy version.
   Preferences.set(PREF_CURRENT_POLICY_VERSION, TEST_POLICY_VERSION);
+
+  // Stop the polling to make sure no policy gets displayed by FHR.
+  gDatareportingService.policy.stopPolling();
 });
 
 function clearAcceptedPolicy() {
   // Reset the accepted policy.
   Preferences.reset(PREF_ACCEPTED_POLICY_VERSION);
   Preferences.reset(PREF_ACCEPTED_POLICY_DATE);
 }
 
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/browser_datareporting_notification.js
@@ -0,0 +1,213 @@
+/* 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/. */
+
+var originalPolicy = null;
+
+/**
+ * Display a datareporting notification to the user.
+ *
+ * @param  {String} name
+ */
+function sendNotifyRequest(name) {
+  let ns = {};
+  Cu.import("resource://gre/modules/services/datareporting/policy.jsm", ns);
+  Cu.import("resource://gre/modules/Preferences.jsm", ns);
+
+  let service = Cc["@mozilla.org/datareporting/service;1"]
+                  .getService(Ci.nsISupports)
+                  .wrappedJSObject;
+  ok(service.healthReporter, "Health Reporter instance is available.");
+
+  Cu.import("resource://gre/modules/Promise.jsm", ns);
+  let deferred = ns.Promise.defer();
+
+  if (!originalPolicy) {
+    originalPolicy = service.policy;
+  }
+
+  let policyPrefs = new ns.Preferences("testing." + name + ".");
+  ok(service._prefs, "Health Reporter prefs are available.");
+  let hrPrefs = service._prefs;
+
+  let policy = new ns.DataReportingPolicy(policyPrefs, hrPrefs, service);
+  policy.dataSubmissionPolicyBypassNotification = false;
+  service.policy = policy;
+  policy.firstRunDate = new Date(Date.now() - 24 * 60 * 60 * 1000);
+
+  service.healthReporter.onInit().then(function onSuccess () {
+    is(policy.ensureUserNotified(), false, "User not notified about data policy on init.");
+    ok(policy._userNotifyPromise, "_userNotifyPromise defined.");
+    policy._userNotifyPromise.then(
+      deferred.resolve.bind(deferred),
+      deferred.reject.bind(deferred)
+    );
+  }.bind(this), deferred.reject.bind(deferred));
+
+  return [policy, deferred.promise];
+}
+
+var dumpAppender, rootLogger;
+
+function test() {
+  registerCleanupFunction(cleanup);
+  waitForExplicitFinish();
+
+  let ns = {};
+  Components.utils.import("resource://gre/modules/Log.jsm", ns);
+  rootLogger = ns.Log.repository.rootLogger;
+  dumpAppender = new ns.Log.DumpAppender();
+  dumpAppender.level = ns.Log.Level.All;
+  rootLogger.addAppender(dumpAppender);
+
+  closeAllNotifications().then(function onSuccess () {
+    let notification = document.getElementById("global-notificationbox");
+
+    notification.addEventListener("AlertActive", function active() {
+      notification.removeEventListener("AlertActive", active, true);
+      is(notification.allNotifications.length, 1, "Notification Displayed.");
+
+      executeSoon(function afterNotification() {
+        waitForNotificationClose(notification.currentNotification, function onClose() {
+          is(notification.allNotifications.length, 0, "No notifications remain.");
+          is(policy.dataSubmissionPolicyAcceptedVersion, 1, "Version pref set.");
+          ok(policy.dataSubmissionPolicyNotifiedDate.getTime() > -1, "Date pref set.");
+          test_multiple_windows();
+        });
+        notification.currentNotification.close();
+      });
+    }, true);
+
+    let [policy, promise] = sendNotifyRequest("single_window_notified");
+
+    is(policy.dataSubmissionPolicyAcceptedVersion, 0, "No version should be set on init.");
+    is(policy.dataSubmissionPolicyNotifiedDate.getTime(), 0, "No date should be set on init.");
+    is(policy.userNotifiedOfCurrentPolicy, false, "User not notified about datareporting policy.");
+
+    promise.then(function () {
+      is(policy.dataSubmissionPolicyAcceptedVersion, 1, "Policy version set.");
+      is(policy.dataSubmissionPolicyNotifiedDate.getTime() > 0, true, "Policy date set.");
+      is(policy.userNotifiedOfCurrentPolicy, true, "User notified about datareporting policy.");
+    }.bind(this), function (err) {
+      throw err;
+    });
+
+  }.bind(this), function onError (err) {
+    throw err;
+  });
+}
+
+function test_multiple_windows() {
+  // Ensure we see the notification on all windows and that action on one window
+  // results in dismiss on every window.
+  let window2 = OpenBrowserWindow();
+  whenDelayedStartupFinished(window2, function onWindow() {
+    let notification1 = document.getElementById("global-notificationbox");
+    let notification2 = window2.document.getElementById("global-notificationbox");
+    ok(notification2, "2nd window has a global notification box.");
+
+    let [policy, promise] = sendNotifyRequest("multiple_window_behavior");
+    let displayCount = 0;
+    let prefWindowOpened = false;
+    let mutationObserversRemoved = false;
+
+    function onAlertDisplayed() {
+      displayCount++;
+
+      if (displayCount != 2) {
+        return;
+      }
+
+      ok(true, "Data reporting info bar displayed on all open windows.");
+
+      // We register two independent observers and we need both to clean up
+      // properly. This handles gating for test completion.
+      function maybeFinish() {
+        if (!prefWindowOpened) {
+          dump("Not finishing test yet because pref pane hasn't yet appeared.\n");
+          return;
+        }
+
+        if (!mutationObserversRemoved) {
+          dump("Not finishing test yet because mutation observers haven't been removed yet.\n");
+          return;
+        }
+
+        window2.close();
+
+        dump("Finishing multiple window test.\n");
+        rootLogger.removeAppender(dumpAppender);
+        dumpAppender = null;
+        rootLogger = null;
+        finish();
+      }
+      let closeCount = 0;
+
+      function onAlertClose() {
+        closeCount++;
+
+        if (closeCount != 2) {
+          return;
+        }
+
+        ok(true, "Closing info bar on one window closed them on all.");
+        is(policy.userNotifiedOfCurrentPolicy, true, "Data submission policy accepted.");
+
+        is(notification1.allNotifications.length, 0, "No notifications remain on main window.");
+        is(notification2.allNotifications.length, 0, "No notifications remain on 2nd window.");
+
+        mutationObserversRemoved = true;
+        maybeFinish();
+      }
+
+      waitForNotificationClose(notification1.currentNotification, onAlertClose);
+      waitForNotificationClose(notification2.currentNotification, onAlertClose);
+
+      // While we're here, we dual purpose this test to check that pressing the
+      // button does the right thing.
+      let buttons = notification2.currentNotification.getElementsByTagName("button");
+      is(buttons.length, 1, "There is 1 button in the data reporting notification.");
+      let button = buttons[0];
+
+      // Add an observer to ensure the "advanced" pane opened (but don't bother
+      // closing it - we close the entire window when done.)
+      Services.obs.addObserver(function observer(prefWin, topic, data) {
+        Services.obs.removeObserver(observer, "advanced-pane-loaded");
+
+        ok(true, "Advanced preferences opened on info bar button press.");
+        executeSoon(function soon() {
+          prefWindowOpened = true;
+          maybeFinish();
+        });
+      }, "advanced-pane-loaded", false);
+
+      button.click();
+    }
+
+    notification1.addEventListener("AlertActive", function active1() {
+      notification1.removeEventListener("AlertActive", active1, true);
+      executeSoon(onAlertDisplayed);
+    }, true);
+
+    notification2.addEventListener("AlertActive", function active2() {
+      notification2.removeEventListener("AlertActive", active2, true);
+      executeSoon(onAlertDisplayed);
+    }, true);
+
+    promise.then(null, function onError(err) {
+      throw err;
+    });
+  });
+}
+
+function cleanup () {
+  // In case some test fails.
+  if (originalPolicy) {
+    let service = Cc["@mozilla.org/datareporting/service;1"]
+                    .getService(Ci.nsISupports)
+                    .wrappedJSObject;
+    service.policy = originalPolicy;
+  }
+
+  return closeAllNotifications();
+}
--- a/browser/base/content/test/general/browser_urlbarSearchTelemetry.js
+++ b/browser/base/content/test/general/browser_urlbarSearchTelemetry.js
@@ -98,16 +98,17 @@ function* compareCounts(clickCallback) {
     if (histogramKey in snapshot) {
       histogramCount = snapshot[histogramKey].sum;
     }
   }
 
   // FHR -- first make sure the engine has an identifier so that FHR is happy.
   Object.defineProperty(engine.wrappedJSObject, "identifier",
                         { value: engineID });
+  let fhrCount = yield getNumberOfSearchesInFHR(engine.name, "urlbar");
 
   gURLBar.focus();
   yield clickCallback();
 
   // Now get the new counts and compare them to the old.
 
   // BrowserUITelemetry
   events = BrowserUITelemetry.getToolbarMeasures().countableEvents;
@@ -120,16 +121,20 @@ function* compareCounts(clickCallback) {
                "clicked suggestion should be recorded");
 
   // telemetry histogram SEARCH_COUNTS
   histogram = Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS");
   let snapshot = histogram.snapshot();
   Assert.ok(histogramKey in snapshot, "histogram with key should be recorded");
   Assert.equal(snapshot[histogramKey].sum, histogramCount + 1,
                "histogram sum should be incremented");
+
+  // FHR
+  let newFHRCount = yield getNumberOfSearchesInFHR(engine.name, "urlbar");
+  Assert.equal(newFHRCount, fhrCount + 1, "should be recorded in FHR");
 }
 
 /**
  * Returns the "action" object at the given index in the urlbar results:
  * { type, params: {}}
  *
  * @param index The index in the urlbar results.
  * @return An action object, or null if index >= number of results.
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/browser_urlbar_search_healthreport.js
@@ -0,0 +1,85 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(function* test_healthreport_search_recording() {
+  try {
+    let cm = Cc["@mozilla.org/categorymanager;1"].getService(Ci.nsICategoryManager);
+    cm.getCategoryEntry("healthreport-js-provider-default", "SearchesProvider");
+  } catch (ex) {
+    // Health Report disabled, or no SearchesProvider.
+    ok(true, "Firefox Health Report is not enabled.");
+    return;
+  }
+
+  let reporter = Cc["@mozilla.org/datareporting/service;1"]
+                   .getService()
+                   .wrappedJSObject
+                   .healthReporter;
+  ok(reporter, "Health Reporter available.");
+  yield reporter.onInit();
+  let provider = reporter.getProvider("org.mozilla.searches");
+  ok(provider, "Searches provider is available.");
+  let m = provider.getMeasurement("counts", 3);
+
+  let data = yield m.getValues();
+  let now = new Date();
+  let oldCount = 0;
+
+  // This will to be need changed if default search engine is not Google.
+  // Note: geoSpecificDefaults are disabled for mochitests, so this is the
+  // non-US en-US default.
+  let defaultEngineID = "google";
+
+  let field = defaultEngineID + ".urlbar";
+
+  if (data.days.hasDay(now)) {
+    let day = data.days.getDay(now);
+    if (day.has(field)) {
+      oldCount = day.get(field);
+    }
+  }
+
+  let tab = gBrowser.addTab("about:blank");
+  yield BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+  gBrowser.selectedTab = tab;
+
+  let searchStr = "firefox health report";
+  let expectedURL = Services.search.currentEngine.
+                    getSubmission(searchStr, "", "keyword").uri.spec;
+
+  // Expect the search URL to load but stop it as soon as it starts.
+  let docLoadPromise = waitForDocLoadAndStopIt(expectedURL);
+
+  // Trigger the search.
+  gURLBar.value = searchStr;
+  gURLBar.handleCommand();
+
+  yield docLoadPromise;
+
+  data = yield m.getValues();
+  ok(data.days.hasDay(now), "We have a search measurement for today.");
+  let day = data.days.getDay(now);
+  ok(day.has(field), "Have a search count for the urlbar.");
+  let newCount = day.get(field);
+  is(newCount, oldCount + 1, "We recorded one new search.");
+
+  // We should record the default search engine if Telemetry is enabled.
+  let oldTelemetry = Services.prefs.getBoolPref("toolkit.telemetry.enabled");
+  Services.prefs.setBoolPref("toolkit.telemetry.enabled", true);
+
+  m = provider.getMeasurement("engines", 2);
+  yield provider.collectDailyData();
+  data = yield m.getValues();
+
+  ok(data.days.hasDay(now), "Have engines data when Telemetry is enabled.");
+  day = data.days.getDay(now);
+  ok(day.has("default"), "We have default engine data.");
+  is(day.get("default"), defaultEngineID, "The default engine is reported properly.");
+
+  // Restore.
+  Services.prefs.setBoolPref("toolkit.telemetry.enabled", oldTelemetry);
+
+  gBrowser.removeTab(tab);
+});
--- a/browser/base/content/test/general/head.js
+++ b/browser/base/content/test/general/head.js
@@ -1197,8 +1197,66 @@ function promiseCrashReport(expectedExtr
       let value = extra.getPropertyAsAString(key);
       if (key in expectedExtra) {
         is(value, expectedExtra[key],
            `Crash report had the right extra value for ${key}`);
       }
     }
   });
 }
+
+/**
+ * Retrieves the number of searches recorded in FHR for the current day.
+ *
+ * @param aEngineName
+ *        name of the setup search engine.
+ * @param aSource
+ *        The FHR "source" name for the search, like "abouthome" or "urlbar".
+ *
+ * @return {Promise} Returns a promise resolving to the number of searches.
+ */
+function getNumberOfSearchesInFHR(aEngineName, aSource) {
+  let reporter = Components.classes["@mozilla.org/datareporting/service;1"]
+                                   .getService()
+                                   .wrappedJSObject
+                                   .healthReporter;
+  ok(reporter, "Health Reporter instance available.");
+
+  return reporter.onInit().then(function onInit() {
+    let provider = reporter.getProvider("org.mozilla.searches");
+    ok(provider, "Searches provider is available.");
+
+    let m = provider.getMeasurement("counts", 3);
+    return m.getValues().then(data => {
+      let now = new Date();
+      let yday = new Date(now);
+      yday.setDate(yday.getDate() - 1);
+
+      // Add the number of searches recorded yesterday to the number of searches
+      // recorded today. This makes the test not fail intermittently when it is
+      // run at midnight and we accidentally compare the number of searches from
+      // different days. Tests are always run with an empty profile so there
+      // are no searches from yesterday, normally. Should the test happen to run
+      // past midnight we make sure to count them in as well.
+      return getNumberOfSearchesInFHRByDate(aEngineName, aSource, data, now) +
+             getNumberOfSearchesInFHRByDate(aEngineName, aSource, data, yday);
+    });
+  });
+}
+
+/**
+ * Helper for getNumberOfSearchesInFHR.  You probably don't want to call this
+ * directly.
+ */
+function getNumberOfSearchesInFHRByDate(aEngineName, aSource, aData, aDate) {
+  if (aData.days.hasDay(aDate)) {
+    let id = Services.search.getEngineByName(aEngineName).identifier;
+
+    let day = aData.days.getDay(aDate);
+    let field = id + "." + aSource;
+
+    if (day.has(field)) {
+      return day.get(field) || 0;
+    }
+  }
+
+  return 0; // No records found.
+}
--- a/browser/base/content/test/general/healthreport_testRemoteCommands.html
+++ b/browser/base/content/test/general/healthreport_testRemoteCommands.html
@@ -2,24 +2,45 @@
   <head>
     <meta charset="utf-8">
 <script type="application/javascript;version=1.7"
             src="healthreport_pingData.js">
 </script>
 <script type="application/javascript;version=1.7">
 
 function init() {
-  window.addEventListener("message", doTest, false);
-  doTest();
+  window.addEventListener("message", function process(e) {
+    // The init function of abouthealth.js schedules an initial payload event,
+    // which will be sent after the payload data has been collected. This extra
+    // event can cause unexpected successes/failures in this test, so we wait
+    // for the extra event to arrive here before progressing with the actual
+    // test.
+    if (e.data.type == "payload") {
+      window.removeEventListener("message", process, false);
+
+      window.addEventListener("message", doTest, false);
+      doTest();
+    }
+  }, false);
 }
 
 function checkSubmissionValue(payload, expectedValue) {
   return payload.enabled == expectedValue;
 }
 
+function validatePayload(payload) {
+  payload = JSON.parse(payload);
+
+  // xxxmpc - this is some pretty low-bar validation, but we have plenty of tests of that API elsewhere
+  if (!payload.thisPingDate)
+    return false;
+
+  return true;
+}
+
 function isArray(arg) {
   return Object.prototype.toString.call(arg) === '[object Array]';
 }
 
 function writeDiagnostic(text) {
   let node = document.createTextNode(text);
   let br = document.createElement("br");
   document.body.appendChild(node);
@@ -115,21 +136,21 @@ var tests = [
   info: "Verifying we're still disabled",
   event: "RequestCurrentPrefs",
   payloadType: "prefs",
   validateResponse: function(payload) {
     return checkSubmissionValue(payload, false);
   },
 },
 {
-  info: "Verifying that we can get the current ping data while submission is disabled",
-  event: "RequestCurrentPingData",
-  payloadType: "telemetry-current-ping-data",
+  info: "Verifying we can get a payload while submission is disabled",
+  event: "RequestCurrentPayload",
+  payloadType: "payload",
   validateResponse: function(payload) {
-    return validateCurrentTelemetryPingData(payload);
+    return validatePayload(payload);
   },
 },
 {
   info: "Verifying enabling works",
   event: "EnableDataSubmission",
   payloadType: "prefs",
   validateResponse: function(payload) {
     return checkSubmissionValue(payload, true);
@@ -139,16 +160,24 @@ var tests = [
   info: "Verifying we're still re-enabled",
   event: "RequestCurrentPrefs",
   payloadType: "prefs",
   validateResponse: function(payload) {
     return checkSubmissionValue(payload, true);
   },
 },
 {
+  info: "Verifying we can get a payload after re-enabling",
+  event: "RequestCurrentPayload",
+  payloadType: "payload",
+  validateResponse: function(payload) {
+    return validatePayload(payload);
+  },
+},
+{
   info: "Verifying that we can get the current Telemetry environment data",
   event: "RequestCurrentEnvironment",
   payloadType: "telemetry-current-environment-data",
   validateResponse: function(payload) {
     return validateCurrentTelemetryEnvironment(payload);
   },
 },
 {
--- a/browser/base/content/urlbarBindings.xml
+++ b/browser/base/content/urlbarBindings.xml
@@ -449,17 +449,17 @@ file, You can obtain one at http://mozil
         ]]></body>
       </method>
 
       <method name="_parseAndRecordSearchEngineAction">
         <parameter name="action"/>
         <body><![CDATA[
           let engine =
             Services.search.getEngineByName(action.params.engineName);
-          BrowserSearch.recordSearchInTelemetry(engine, "urlbar");
+          BrowserSearch.recordSearchInHealthReport(engine, "urlbar");
           let query = action.params.searchSuggestion ||
                       action.params.searchQuery;
           let submission = engine.getSubmission(query, null, "keyword");
           return [submission.uri.spec, submission.postData];
         ]]></body>
       </method>
 
       <method name="_canonizeURL">
--- a/browser/components/nsBrowserGlue.js
+++ b/browser/components/nsBrowserGlue.js
@@ -426,17 +426,17 @@ BrowserGlue.prototype = {
         // URI that it's been asked to load into a keyword search.
         let engine = null;
         try {
           engine = subject.QueryInterface(Ci.nsISearchEngine);
         } catch (ex) {
           Cu.reportError(ex);
         }
         let win = RecentWindow.getMostRecentBrowserWindow();
-        win.BrowserSearch.recordSearchInTelemetry(engine, "urlbar");
+        win.BrowserSearch.recordSearchInHealthReport(engine, "urlbar");
         break;
       case "browser-search-engine-modified":
         // Ensure we cleanup the hiddenOneOffs pref when removing
         // an engine, and that newly added engines are visible.
         if (data == "engine-added" || data == "engine-removed") {
           let engineName = subject.QueryInterface(Ci.nsISearchEngine).name;
           let Preferences =
             Cu.import("resource://gre/modules/Preferences.jsm", {}).Preferences;
--- a/browser/components/preferences/in-content/advanced.js
+++ b/browser/components/preferences/in-content/advanced.js
@@ -2,18 +2,16 @@
  * 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/. */
 
 // Load DownloadUtils module for convertByteUnits
 Components.utils.import("resource://gre/modules/DownloadUtils.jsm");
 Components.utils.import("resource://gre/modules/LoadContextInfo.jsm");
 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
 
-const PREF_UPLOAD_ENABLED = "datareporting.healthreport.uploadEnabled";
-
 var gAdvancedPane = {
   _inited: false,
 
   /**
    * Brings the appropriate tab to the front and initializes various bits of UI.
    */
   init: function ()
   {
@@ -286,33 +284,48 @@ var gAdvancedPane = {
 
 #ifdef MOZ_SERVICES_HEALTHREPORT
   /**
    * Initialize the health report service reference and checkbox.
    */
   initSubmitHealthReport: function () {
     this._setupLearnMoreLink("datareporting.healthreport.infoURL", "FHRLearnMore");
 
+    let policy = Components.classes["@mozilla.org/datareporting/service;1"]
+                                   .getService(Components.interfaces.nsISupports)
+                                   .wrappedJSObject
+                                   .policy;
+
     let checkbox = document.getElementById("submitHealthReportBox");
 
-    if (Services.prefs.prefIsLocked(PREF_UPLOAD_ENABLED)) {
+    if (!policy || policy.healthReportUploadLocked) {
       checkbox.setAttribute("disabled", "true");
       return;
     }
 
-    checkbox.checked = Services.prefs.getBoolPref(PREF_UPLOAD_ENABLED);
+    checkbox.checked = policy.healthReportUploadEnabled;
     this.setTelemetrySectionEnabled(checkbox.checked);
   },
 
   /**
-   * Update the health report preference with state from checkbox.
+   * Update the health report policy acceptance with state from checkbox.
    */
   updateSubmitHealthReport: function () {
+    let policy = Components.classes["@mozilla.org/datareporting/service;1"]
+                                   .getService(Components.interfaces.nsISupports)
+                                   .wrappedJSObject
+                                   .policy;
+
+    if (!policy) {
+      return;
+    }
+
     let checkbox = document.getElementById("submitHealthReportBox");
-    Services.prefs.setBoolPref(PREF_UPLOAD_ENABLED, checkbox.checked);
+    policy.recordHealthReportUploadEnabled(checkbox.checked,
+                                           "Checkbox from preferences pane");
     this.setTelemetrySectionEnabled(checkbox.checked);
   },
 #endif
 
   // NETWORK TAB
 
   /*
    * Preferences:
--- a/browser/components/preferences/in-content/tests/browser.ini
+++ b/browser/components/preferences/in-content/tests/browser.ini
@@ -13,17 +13,17 @@ support-files =
 [browser_bug1018066_resetScrollPosition.js]
 [browser_bug1020245_openPreferences_to_paneContent.js]
 [browser_change_app_handler.js]
 skip-if = os != "win" # This test tests the windows-specific app selection dialog, so can't run on non-Windows
 [browser_connection.js]
 [browser_connection_bug388287.js]
 [browser_cookies_exceptions.js]
 [browser_healthreport.js]
-skip-if = true || !healthreport # Bug 1185403 for the "true"
+skip-if = true || !healthreport || (os == 'linux' && debug) # Bug 1185403 for the "true"
 [browser_homepages_filter_aboutpreferences.js]
 [browser_notifications_do_not_disturb.js]
 [browser_permissions_urlFieldHidden.js]
 [browser_proxy_backup.js]
 [browser_privacypane_1.js]
 [browser_privacypane_3.js]
 [browser_privacypane_4.js]
 [browser_privacypane_5.js]
--- a/browser/components/preferences/in-content/tests/browser_healthreport.js
+++ b/browser/components/preferences/in-content/tests/browser_healthreport.js
@@ -1,62 +1,64 @@
 /* Any copyright is dedicated to the Public Domain.
 * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
-const FHR_UPLOAD_ENABLED = "datareporting.healthreport.uploadEnabled";
-
 function runPaneTest(fn) {
   open_preferences((win) => {
     let doc = win.document;
     win.gotoPref("paneAdvanced");
     let advancedPrefs = doc.getElementById("advancedPrefs");
     let tab = doc.getElementById("dataChoicesTab");
     advancedPrefs.selectedTab = tab;
-    fn(win, doc);
+
+    let policy = Components.classes["@mozilla.org/datareporting/service;1"]
+                                   .getService(Components.interfaces.nsISupports)
+                                   .wrappedJSObject
+                                   .policy;
+
+    ok(policy, "Policy object is defined.");
+    fn(win, doc, policy);
   });
 }
 
 function test() {
   waitForExplicitFinish();
   resetPreferences();
   registerCleanupFunction(resetPreferences);
   runPaneTest(testBasic);
 }
 
-function testBasic(win, doc) {
-  is(Services.prefs.getBoolPref(FHR_UPLOAD_ENABLED), true,
-     "Health Report upload enabled on app first run.");
+function testBasic(win, doc, policy) {
+  is(policy.healthReportUploadEnabled, true, "Health Report upload enabled on app first run.");
 
   let checkbox = doc.getElementById("submitHealthReportBox");
   ok(checkbox);
   is(checkbox.checked, true, "Health Report checkbox is checked on app first run.");
 
   checkbox.checked = false;
   checkbox.doCommand();
-  is(Services.prefs.getBoolPref(FHR_UPLOAD_ENABLED), false,
-     "Unchecking checkbox opts out of FHR upload.");
+  is(policy.healthReportUploadEnabled, false, "Unchecking checkbox opts out of FHR upload.");
 
   checkbox.checked = true;
   checkbox.doCommand();
-  is(Services.prefs.getBoolPref(FHR_UPLOAD_ENABLED), true,
-     "Checking checkbox allows FHR upload.");
+  is(policy.healthReportUploadEnabled, true, "Checking checkbox allows FHR upload.");
 
   win.close();
-  Services.prefs.lockPref(FHR_UPLOAD_ENABLED);
+  Services.prefs.lockPref("datareporting.healthreport.uploadEnabled");
   runPaneTest(testUploadDisabled);
 }
 
-function testUploadDisabled(win, doc) {
-  ok(Services.prefs.prefIsLocked(FHR_UPLOAD_ENABLED), "Upload enabled flag is locked.");
+function testUploadDisabled(win, doc, policy) {
+  ok(policy.healthReportUploadLocked, "Upload enabled flag is locked.");
   let checkbox = doc.getElementById("submitHealthReportBox");
   is(checkbox.getAttribute("disabled"), "true", "Checkbox is disabled if upload flag is locked.");
-  Services.prefs.unlockPref(FHR_UPLOAD_ENABLED);
+  policy._healthReportPrefs.unlock("uploadEnabled");
 
   win.close();
   finish();
 }
 
 function resetPreferences() {
-  Services.prefs.clearUserPref(FHR_UPLOAD_ENABLED);
+  Services.prefs.clearUserPref("datareporting.healthreport.uploadEnabled");
 }
 
--- a/browser/components/search/content/search.xml
+++ b/browser/components/search/content/search.xml
@@ -415,17 +415,17 @@
 
           let engine = aEngine || this.currentEngine;
           var submission = engine.getSubmission(aData, null, "searchbar");
           let telemetrySearchDetails = this.telemetrySearchDetails;
           this.telemetrySearchDetails = null;
           if (telemetrySearchDetails && telemetrySearchDetails.index == -1) {
             telemetrySearchDetails = null;
           }
-          BrowserSearch.recordSearchInTelemetry(engine, "searchbar", telemetrySearchDetails);
+          BrowserSearch.recordSearchInHealthReport(engine, "searchbar", telemetrySearchDetails);
           // null parameter below specifies HTML response for search
           let params = {
             postData: submission.postData,
             inBackground: aWhere == "tab-background"
           };
           openUILinkIn(submission.uri.spec,
                        aWhere == "tab-background" ? "tab" : aWhere,
                        params);
--- a/browser/components/search/test/browser_healthreport.js
+++ b/browser/components/search/test/browser_healthreport.js
@@ -1,81 +1,109 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
-var Preferences = Cu.import("resource://gre/modules/Preferences.jsm", {}).Preferences;
-
 function test() {
+  requestLongerTimeout(2);
   waitForExplicitFinish();
   resetPreferences();
 
-  function testTelemetry() {
-    // Find the right bucket for the "Foo" engine.
-    let engine = Services.search.getEngineByName("Foo");
-    let histogramKey = (engine.identifier || "other-Foo") + ".searchbar";
-    let numSearchesBefore = 0;
-    try {
-      let hs = Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS").snapshot();
-      if (histogramKey in hs) {
-        numSearchesBefore = hs[histogramKey].sum;
-      }
-    } catch (ex) {
-      // No searches performed yet, not a problem, |numSearchesBefore| is 0.
-    }
+  try {
+    let cm = Components.classes["@mozilla.org/categorymanager;1"]
+                       .getService(Components.interfaces.nsICategoryManager);
+    cm.getCategoryEntry("healthreport-js-provider-default", "SearchesProvider");
+  } catch (ex) {
+    // Health Report disabled, or no SearchesProvider.
+    // We need a test or else we'll be marked as failure.
+    ok(true, "Firefox Health Report is not enabled.");
+    finish();
+    return;
+  }
 
-    // Now perform a search and ensure the count is incremented.
-    let tab = gBrowser.addTab();
-    gBrowser.selectedTab = tab;
-    let searchBar = BrowserSearch.searchBar;
+  function testFHR() {
+    let reporter = Components.classes["@mozilla.org/datareporting/service;1"]
+                                     .getService()
+                                     .wrappedJSObject
+                                     .healthReporter;
+    ok(reporter, "Health Reporter available.");
+    reporter.onInit().then(function onInit() {
+      let provider = reporter.getProvider("org.mozilla.searches");
+      let m = provider.getMeasurement("counts", 3);
+
+      m.getValues().then(function onData(data) {
+        let now = new Date();
+        let oldCount = 0;
+
+        // Find the right bucket for the "Foo" engine.
+        let engine = Services.search.getEngineByName("Foo");
+        let field = (engine.identifier || "other-Foo") + ".searchbar";
 
-    searchBar.value = "firefox health report";
-    searchBar.focus();
+        if (data.days.hasDay(now)) {
+          let day = data.days.getDay(now);
+          if (day.has(field)) {
+            oldCount = day.get(field);
+          }
+        }
 
-     function afterSearch() {
-        searchBar.value = "";
-        gBrowser.removeTab(tab);
+        // Now perform a search and ensure the count is incremented.
+        let tab = gBrowser.addTab();
+        gBrowser.selectedTab = tab;
+        let searchBar = BrowserSearch.searchBar;
+
+        searchBar.value = "firefox health report";
+        searchBar.focus();
 
-        // Make sure that the context searches are correctly recorded.
-        let hs = Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS").snapshot();
-        Assert.ok(histogramKey in hs, "The histogram must contain the correct key");
-        Assert.equal(hs[histogramKey].sum, numSearchesBefore + 1,
-                     "Performing a search increments the related SEARCH_COUNTS key by 1.");
+        function afterSearch() {
+          searchBar.value = "";
+          gBrowser.removeTab(tab);
+
+          m.getValues().then(function onData(data) {
+            ok(data.days.hasDay(now), "Have data for today.");
+            let day = data.days.getDay(now);
+
+            is(day.get(field), oldCount + 1, "Performing a search increments FHR count by 1.");
 
-        let engine = Services.search.getEngineByName("Foo");
-        Services.search.removeEngine(engine);
-      }
+            let engine = Services.search.getEngineByName("Foo");
+            Services.search.removeEngine(engine);
+          });
+        }
 
-      EventUtils.synthesizeKey("VK_RETURN", {});
-      executeSoon(() => executeSoon(afterSearch));
+        EventUtils.synthesizeKey("VK_RETURN", {});
+        executeSoon(() => executeSoon(afterSearch));
+      });
+    });
   }
 
   function observer(subject, topic, data) {
     switch (data) {
       case "engine-added":
         let engine = Services.search.getEngineByName("Foo");
         ok(engine, "Engine was added.");
         Services.search.currentEngine = engine;
         break;
 
       case "engine-current":
         is(Services.search.currentEngine.name, "Foo", "Current engine is Foo");
-        testTelemetry();
+        testFHR();
         break;
 
       case "engine-removed":
         Services.obs.removeObserver(observer, "browser-search-engine-modified");
         finish();
         break;
     }
   }
 
   Services.obs.addObserver(observer, "browser-search-engine-modified", false);
   Services.search.addEngine("http://mochi.test:8888/browser/browser/components/search/test/testEngine.xml",
                             null, "data:image/x-icon,%00", false);
 
 }
 
 function resetPreferences() {
-  Preferences.resetBranch("datareporting.policy.");
-  Preferences.set("datareporting.policy.dataSubmissionPolicyBypassNotification", true);
+  let service = Components.classes["@mozilla.org/datareporting/service;1"]
+                                  .getService(Components.interfaces.nsISupports)
+                                  .wrappedJSObject;
+  service.policy._prefs.resetBranch("datareporting.policy.");
+  service.policy.dataSubmissionPolicyBypassNotification = true;
 }
--- a/browser/components/selfsupport/SelfSupportService.js
+++ b/browser/components/selfsupport/SelfSupportService.js
@@ -7,16 +7,34 @@
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Preferences.jsm");
 
 const PREF_FHR_UPLOAD_ENABLED = "datareporting.healthreport.uploadEnabled";
 
+XPCOMUtils.defineLazyGetter(this, "gPolicy", () => {
+  try {
+    return Cc["@mozilla.org/datareporting/service;1"]
+             .getService(Ci.nsISupports)
+             .wrappedJSObject
+             .policy;
+  } catch (e) {
+    return undefined;
+  }
+});
+
+XPCOMUtils.defineLazyGetter(this, "reporter", () => {
+  return Cc["@mozilla.org/datareporting/service;1"]
+           .getService(Ci.nsISupports)
+           .wrappedJSObject
+           .healthReporter;
+});
+
 XPCOMUtils.defineLazyModuleGetter(this, "TelemetryArchive",
                                   "resource://gre/modules/TelemetryArchive.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "TelemetryEnvironment",
                                   "resource://gre/modules/TelemetryEnvironment.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "TelemetryController",
                                   "resource://gre/modules/TelemetryController.jsm");
 
 function MozSelfSupportInterface() {
@@ -30,23 +48,55 @@ MozSelfSupportInterface.prototype = {
 
   _window: null,
 
   init: function (window) {
     this._window = window;
   },
 
   get healthReportDataSubmissionEnabled() {
+    if (gPolicy) {
+      return gPolicy.healthReportUploadEnabled;
+    }
+
+    // The datareporting service is unavailable or disabled.
     return Preferences.get(PREF_FHR_UPLOAD_ENABLED, false);
   },
 
   set healthReportDataSubmissionEnabled(enabled) {
+    if (gPolicy) {
+      let reason = "Self-support interface sent " +
+                   (enabled ? "opt-in" : "opt-out") +
+                   " command.";
+      gPolicy.recordHealthReportUploadEnabled(enabled, reason);
+      return;
+    }
+
+    // The datareporting service is unavailable or disabled.
     Preferences.set(PREF_FHR_UPLOAD_ENABLED, enabled);
   },
 
+  getHealthReportPayload: function () {
+    return new this._window.Promise(function (aResolve, aReject) {
+      if (reporter) {
+        let resolvePayload = function () {
+          reporter.collectAndObtainJSONPayload(true).then(aResolve, aReject);
+        };
+
+        if (reporter.initialized) {
+          resolvePayload();
+        } else {
+          reporter.onInit().then(resolvePayload, aReject);
+        }
+      } else {
+        aReject(new Error("No reporter"));
+      }
+    }.bind(this));
+  },
+
   resetPref: function(name) {
     Services.prefs.clearUserPref(name);
   },
 
   resetSearchEngines: function() {
     Services.search.restoreDefaultEngines();
     Services.search.resetToOriginalDefaultEngine();
   },
--- a/browser/confvars.sh
+++ b/browser/confvars.sh
@@ -26,16 +26,17 @@ fi
 MOZ_ENABLE_SIGNMAR=1
 
 MOZ_CHROME_FILE_FORMAT=omni
 MOZ_DISABLE_EXPORT_JS=1
 MOZ_SAFE_BROWSING=1
 MOZ_SERVICES_COMMON=1
 MOZ_SERVICES_CRYPTO=1
 MOZ_SERVICES_HEALTHREPORT=1
+MOZ_SERVICES_METRICS=1
 MOZ_SERVICES_SYNC=1
 MOZ_SERVICES_CLOUDSYNC=1
 MOZ_APP_VERSION=$FIREFOX_VERSION
 MOZ_APP_VERSION_DISPLAY=$FIREFOX_VERSION_DISPLAY
 MOZ_EXTENSIONS_DEFAULT=" gio"
 # MOZ_APP_DISPLAYNAME will be set by branding/configure.sh
 # MOZ_BRANDING_DIRECTORY is the default branding directory used when none is
 # specified. It should never point to the "official" branding directory.
--- a/browser/experiments/ExperimentsService.js
+++ b/browser/experiments/ExperimentsService.js
@@ -14,16 +14,17 @@ XPCOMUtils.defineLazyModuleGetter(this, 
                                   "resource:///modules/experiments/Experiments.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                   "resource://gre/modules/osfile.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils",
                                   "resource://services-common/utils.js");
 
 const PREF_EXPERIMENTS_ENABLED  = "experiments.enabled";
 const PREF_ACTIVE_EXPERIMENT    = "experiments.activeExperiment"; // whether we have an active experiment
+const PREF_HEALTHREPORT_ENABLED = "datareporting.healthreport.service.enabled";
 const PREF_TELEMETRY_ENABLED    = "toolkit.telemetry.enabled";
 const PREF_TELEMETRY_UNIFIED    = "toolkit.telemetry.unified";
 const DELAY_INIT_MS             = 30 * 1000;
 
 // Whether the FHR/Telemetry unification features are enabled.
 // Changing this pref requires a restart.
 const IS_UNIFIED_TELEMETRY = Preferences.get(PREF_TELEMETRY_UNIFIED, false);
 
@@ -32,17 +33,18 @@ XPCOMUtils.defineLazyGetter(
     return new Preferences();
   });
 
 XPCOMUtils.defineLazyGetter(
   this, "gExperimentsEnabled", () => {
     // We can enable experiments if either unified Telemetry or FHR is on, and the user
     // has opted into Telemetry.
     return gPrefs.get(PREF_EXPERIMENTS_ENABLED, false) &&
-           IS_UNIFIED_TELEMETRY && gPrefs.get(PREF_TELEMETRY_ENABLED, false);
+           (gPrefs.get(PREF_HEALTHREPORT_ENABLED, false) || IS_UNIFIED_TELEMETRY) &&
+           gPrefs.get(PREF_TELEMETRY_ENABLED, false);
   });
 
 XPCOMUtils.defineLazyGetter(
   this, "gActiveExperiment", () => {
     return gPrefs.get(PREF_ACTIVE_EXPERIMENT);
   });
 
 function ExperimentsService() {
--- a/browser/installer/package-manifest.in
+++ b/browser/installer/package-manifest.in
@@ -496,17 +496,22 @@
 #endif
 #ifdef XP_MACOSX
 @RESPATH@/browser/components/SafariProfileMigrator.js
 #endif
 @RESPATH@/components/nsINIProcessor.manifest
 @RESPATH@/components/nsINIProcessor.js
 @RESPATH@/components/nsPrompter.manifest
 @RESPATH@/components/nsPrompter.js
+#ifdef MOZ_DATA_REPORTING
+@RESPATH@/components/DataReporting.manifest
+@RESPATH@/components/DataReportingService.js
+#endif
 #ifdef MOZ_SERVICES_HEALTHREPORT
+@RESPATH@/components/HealthReportComponents.manifest
 @RESPATH@/browser/components/SelfSupportService.manifest
 @RESPATH@/browser/components/SelfSupportService.js
 #endif
 @RESPATH@/components/SyncComponents.manifest
 @RESPATH@/components/Weave.js
 @RESPATH@/components/CaptivePortalDetectComponents.manifest
 @RESPATH@/components/captivedetect.js
 @RESPATH@/components/servicesComponents.manifest
--- a/browser/modules/ContentSearch.jsm
+++ b/browser/modules/ContentSearch.jsm
@@ -298,18 +298,18 @@ this.ContentSearch = {
                                submission.postData);
     } else {
       let params = {
         postData: submission.postData,
         inBackground: Services.prefs.getBoolPref("browser.tabs.loadInBackground"),
       };
       win.openUILinkIn(submission.uri.spec, where, params);
     }
-    win.BrowserSearch.recordSearchInTelemetry(engine, data.healthReportKey,
-                                              data.selection || null);
+    win.BrowserSearch.recordSearchInHealthReport(engine, data.healthReportKey,
+                                                 data.selection || null);
     return Promise.resolve();
   },
 
   _onMessageSetCurrentEngine: function (msg, data) {
     Services.search.currentEngine = Services.search.getEngineByName(data);
     return Promise.resolve();
   },
 
--- a/browser/modules/SelfSupportBackend.jsm
+++ b/browser/modules/SelfSupportBackend.jsm
@@ -18,16 +18,18 @@ Cu.import("resource://gre/modules/XPCOMU
 
 XPCOMUtils.defineLazyModuleGetter(this, "HiddenFrame",
   "resource:///modules/HiddenFrame.jsm");
 
 // Enables or disables the Self Support.
 const PREF_ENABLED = "browser.selfsupport.enabled";
 // Url to open in the Self Support browser, in the urlFormatter service format.
 const PREF_URL = "browser.selfsupport.url";
+// FHR status.
+const PREF_FHR_ENABLED = "datareporting.healthreport.service.enabled";
 // Unified Telemetry status.
 const PREF_TELEMETRY_UNIFIED = "toolkit.telemetry.unified";
 // UITour status.
 const PREF_UITOUR_ENABLED = "browser.uitour.enabled";
 
 // Controls the interval at which the self support page tries to reload in case of
 // errors.
 const RETRY_INTERVAL_MS = 30000;
@@ -77,18 +79,18 @@ var SelfSupportBackendInternal = {
    */
   init: function () {
     this._configureLogging();
 
     this._log.trace("init");
 
     Preferences.observe(PREF_BRANCH_LOG, this._configureLogging, this);
 
-    // Only allow to use SelfSupport if Unified Telemetry is enabled.
-    let reportingEnabled = IS_UNIFIED_TELEMETRY;
+    // Only allow to use SelfSupport if either FHR or Unified Telemetry is enabled.
+    let reportingEnabled = Preferences.get(PREF_FHR_ENABLED, false) || IS_UNIFIED_TELEMETRY;
     if (!reportingEnabled) {
       this._log.config("init - Disabling SelfSupport because FHR and Unified Telemetry are disabled.");
       return;
     }
 
     // Make sure UITour is enabled.
     let uiTourEnabled = Preferences.get(PREF_UITOUR_ENABLED, false);
     if (!uiTourEnabled) {
--- a/dom/webidl/MozSelfSupport.webidl
+++ b/dom/webidl/MozSelfSupport.webidl
@@ -16,16 +16,36 @@
 interface MozSelfSupport
 {
   /**
    * Controls whether uploading FHR data is allowed.
    */
   attribute boolean healthReportDataSubmissionEnabled;
 
   /**
+   * Retrieves the FHR payload object, which is of the form:
+   *
+   * {
+   *   version: Number,
+   *   clientID: String,
+   *   clientIDVersion: Number,
+   *   thisPingDate: String,
+   *   geckoAppInfo: Object,
+   *   data: Object
+   * }
+   *
+   * Refer to the getJSONPayload function in healthreporter.jsm for more
+   * information.
+   *
+   * @return Promise<Object>
+   *         Resolved when the FHR payload data has been collected.
+   */
+  Promise<object> getHealthReportPayload();
+
+  /**
    * Retrieve a list of the archived Telemetry pings.
    * This contains objects with ping info, which are of the form:
    * {
    *   type: <string>, // The pings type, e.g. "main", "environment-change", ...
    *   timestampCreated: <number>, // The time the ping was created (ms since unix epoch).
    *   id: <string>, // The pings UUID.
    * }
    *
--- a/embedding/ios/confvars.sh
+++ b/embedding/ios/confvars.sh
@@ -7,12 +7,13 @@ MOZ_APP_NAME=geckoembed
 MOZ_APP_DISPLAYNAME=GeckoEmbed
 MOZ_UPDATER=
 MOZ_CHROME_FILE_FORMAT=omni
 MOZ_APP_VERSION=$MOZILLA_VERSION
 MOZ_PLACES=1
 MOZ_EXTENSIONS_DEFAULT=" gio"
 MOZ_SERVICES_COMMON=1
 MOZ_SERVICES_CRYPTO=1
+MOZ_SERVICES_METRICS=1
 MOZ_SERVICES_SYNC=1
 MOZ_MEDIA_NAVIGATOR=1
 MOZ_SERVICES_HEALTHREPORT=1
 MOZ_DISABLE_EXPORT_JS=1
--- a/mobile/android/b2gdroid/installer/package-manifest.in
+++ b/mobile/android/b2gdroid/installer/package-manifest.in
@@ -427,16 +427,21 @@
 @BINPATH@/components/ActivityWrapper.js
 @BINPATH@/components/ActivityMessageConfigurator.js
 
 #ifdef MOZ_WEBRTC
 @BINPATH@/components/PeerConnection.js
 @BINPATH@/components/PeerConnection.manifest
 #endif
 
+#ifdef MOZ_SERVICES_HEALTHREPORT
+@BINPATH@/components/HealthReportComponents.manifest
+@BINPATH@/components/HealthReportService.js
+#endif
+
 @BINPATH@/components/CaptivePortalDetectComponents.manifest
 @BINPATH@/components/captivedetect.js
 
 #ifdef MOZ_WEBSPEECH
 @BINPATH@/components/dom_webspeechsynth.xpt
 #endif
 
 #ifdef MOZ_DEBUG
--- a/modules/libpref/greprefs.js
+++ b/modules/libpref/greprefs.js
@@ -1,12 +1,12 @@
 #include ../../netwerk/base/security-prefs.js
 #include init/all.js
 #ifdef MOZ_DATA_REPORTING
-#include ../../toolkit/components/telemetry/datareporting-prefs.js
+#include ../../services/datareporting/datareporting-prefs.js
 #endif
 #ifdef MOZ_SERVICES_HEALTHREPORT
 #if MOZ_WIDGET_TOOLKIT == android
 #include ../../mobile/android/chrome/content/healthreport-prefs.js
 #else
-#include ../../toolkit/components/telemetry/healthreport-prefs.js
+#include ../../services/healthreport/healthreport-prefs.js
 #endif
 #endif
new file mode 100644
--- /dev/null
+++ b/services/datareporting/DataReporting.manifest
@@ -0,0 +1,16 @@
+#   b2g:            {3c2e2abc-06d4-11e1-ac3b-374f68613e61}
+#   browser:        {ec8030f7-c20a-464f-9b0e-13a3a9e97384}
+#   mobile/android: {aa3c5121-dab2-40e2-81ca-7ea25febc110}
+#   mobile/xul:     {a23983c0-fd0e-11dc-95ff-0800200c9a66}
+#   suite (comm):   {92650c4d-4b8e-4d2a-b7eb-24ecf4f6b63a}
+#   graphene:       {d1bfe7d9-c01e-4237-998b-7b5f960a4314}
+
+# The Data Reporting Service drives collection and submission of metrics
+# and other useful data to Mozilla. It drives the display of the data
+# submission notification info bar and thus is required by Firefox Health
+# Report and Telemetry.
+
+component {41f6ae36-a79f-4613-9ac3-915e70f83789} DataReportingService.js
+contract @mozilla.org/datareporting/service;1 {41f6ae36-a79f-4613-9ac3-915e70f83789}
+category app-startup DataReportingService service,@mozilla.org/datareporting/service;1 application={3c2e2abc-06d4-11e1-ac3b-374f68613e61} application={ec8030f7-c20a-464f-9b0e-13a3a9e97384} application={aa3c5121-dab2-40e2-81ca-7ea25febc110} application={a23983c0-fd0e-11dc-95ff-0800200c9a66} application={d1bfe7d9-c01e-4237-998b-7b5f960a4314}
+
new file mode 100644
--- /dev/null
+++ b/services/datareporting/DataReportingService.js
@@ -0,0 +1,296 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/ClientID.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Log",
+                                  "resource://gre/modules/Log.jsm");
+
+const ROOT_BRANCH = "datareporting.";
+const POLICY_BRANCH = ROOT_BRANCH + "policy.";
+const HEALTHREPORT_BRANCH = ROOT_BRANCH + "healthreport.";
+const HEALTHREPORT_LOGGING_BRANCH = HEALTHREPORT_BRANCH + "logging.";
+const DEFAULT_LOAD_DELAY_MSEC = 10 * 1000;
+const DEFAULT_LOAD_DELAY_FIRST_RUN_MSEC = 60 * 1000;
+
+/**
+ * The Firefox Health Report XPCOM service.
+ *
+ * External consumers will be interested in the "reporter" property of this
+ * service. This property is a `HealthReporter` instance that powers the
+ * service. The property may be null if the Health Report service is not
+ * enabled.
+ *
+ * EXAMPLE USAGE
+ * =============
+ *
+ * let reporter = Cc["@mozilla.org/datareporting/service;1"]
+ *                  .getService(Ci.nsISupports)
+ *                  .wrappedJSObject
+ *                  .healthReporter;
+ *
+ * if (reporter.haveRemoteData) {
+ *   // ...
+ * }
+ *
+ * IMPLEMENTATION NOTES
+ * ====================
+ *
+ * In order to not adversely impact application start time, the `HealthReporter`
+ * instance is not initialized until a few seconds after "final-ui-startup."
+ * The exact delay is configurable via preferences so it can be adjusted with
+ * a hotfix extension if the default value is ever problematic. Because of the
+ * overhead with the initial creation of the database, the first run is delayed
+ * even more than subsequent runs. This does mean that the first moments of
+ * browser activity may be lost by FHR.
+ *
+ * Shutdown of the `HealthReporter` instance is handled completely within the
+ * instance (it registers observers on initialization). See the notes on that
+ * type for more.
+ */
+this.DataReportingService = function () {
+  this.wrappedJSObject = this;
+
+  this._quitting = false;
+
+  this._os = Cc["@mozilla.org/observer-service;1"]
+               .getService(Ci.nsIObserverService);
+}
+
+DataReportingService.prototype = Object.freeze({
+  classID: Components.ID("{41f6ae36-a79f-4613-9ac3-915e70f83789}"),
+
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
+                                         Ci.nsISupportsWeakReference]),
+
+  //---------------------------------------------
+  // Start of policy listeners.
+  //---------------------------------------------
+
+  /**
+   * Called when policy requests data upload.
+   */
+  onRequestDataUpload: function (request) {
+    if (!this.healthReporter) {
+      return;
+    }
+
+    this.healthReporter.requestDataUpload(request);
+  },
+
+  onNotifyDataPolicy: function (request) {
+    Observers.notify("datareporting:notify-data-policy:request", request);
+  },
+
+  onRequestRemoteDelete: function (request) {
+    if (!this.healthReporter) {
+      return;
+    }
+
+    this.healthReporter.deleteRemoteData(request);
+  },
+
+  //---------------------------------------------
+  // End of policy listeners.
+  //---------------------------------------------
+
+  observe: function observe(subject, topic, data) {
+    switch (topic) {
+      case "app-startup":
+        this._os.addObserver(this, "profile-after-change", true);
+        break;
+
+      case "profile-after-change":
+        this._os.removeObserver(this, "profile-after-change");
+
+        try {
+          this._prefs = new Preferences(HEALTHREPORT_BRANCH);
+
+          // We can't interact with prefs until after the profile is present.
+          let policyPrefs = new Preferences(POLICY_BRANCH);
+          this.policy = new DataReportingPolicy(policyPrefs, this._prefs, this);
+
+          this._os.addObserver(this, "sessionstore-windows-restored", true);
+        } catch (ex) {
+          Cu.reportError("Exception when initializing data reporting service: " +
+                         Log.exceptionStr(ex));
+        }
+        break;
+
+      case "sessionstore-windows-restored":
+        this._os.removeObserver(this, "sessionstore-windows-restored");
+        this._os.addObserver(this, "quit-application", false);
+
+        let policy = this.policy;
+        policy.startPolling();
+
+        // Don't initialize Firefox Health Reporter collection and submission
+        // service unless it is enabled.
+        if (!this._prefs.get("service.enabled", true)) {
+          return;
+        }
+
+        let haveFirstRun = this._prefs.get("service.firstRun", false);
+        let delayInterval;
+
+        if (haveFirstRun) {
+          delayInterval = this._prefs.get("service.loadDelayMsec") ||
+                          DEFAULT_LOAD_DELAY_MSEC;
+        } else {
+          delayInterval = this._prefs.get("service.loadDelayFirstRunMsec") ||
+                          DEFAULT_LOAD_DELAY_FIRST_RUN_MSEC;
+        }
+
+        // Delay service loading a little more so things have an opportunity
+        // to cool down first.
+        this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+        this.timer.initWithCallback({
+          notify: function notify() {
+            delete this.timer;
+
+            // There could be a race between "quit-application" firing and
+            // this callback being invoked. We close that door.
+            if (this._quitting) {
+              return;
+            }
+
+            // Side effect: instantiates the reporter instance if not already
+            // accessed.
+            //
+            // The instance installs its own shutdown observers. So, we just
+            // fire and forget: it will clean itself up.
+            let reporter = this.healthReporter;
+            policy.ensureUserNotified();
+          }.bind(this),
+        }, delayInterval, this.timer.TYPE_ONE_SHOT);
+
+        break;
+
+      case "quit-application":
+        this._os.removeObserver(this, "quit-application");
+        this._quitting = true;
+
+        // Shutdown doesn't clear pending timers. So, we need to explicitly
+        // cancel our health reporter initialization timer or else it will
+        // attempt initialization after shutdown has commenced. This would
+        // likely lead to stalls or crashes.
+        if (this.timer) {
+          this.timer.cancel();
+        }
+
+        if (this.policy) {
+          this.policy.stopPolling();
+        }
+        break;
+    }
+  },
+
+  /**
+   * The HealthReporter instance associated with this service.
+   *
+   * If the service is disabled, this will return null.
+   *
+   * The obtained instance may not be fully initialized.
+   */
+  get healthReporter() {
+    if (!this._prefs.get("service.enabled", true)) {
+      return null;
+    }
+
+    if ("_healthReporter" in this) {
+      return this._healthReporter;
+    }
+
+    try {
+      this._loadHealthReporter();
+    } catch (ex) {
+      this._healthReporter = null;
+      Cu.reportError("Exception when obtaining health reporter: " +
+                     Log.exceptionStr(ex));
+    }
+
+    return this._healthReporter;
+  },
+
+  _loadHealthReporter: function () {
+    // This should never happen. It was added to help trace down bug 924307.
+    if (!this.policy) {
+      throw new Error("this.policy not set.");
+    }
+
+    let ns = {};
+    // Lazy import so application startup isn't adversely affected.
+
+    Cu.import("resource://gre/modules/HealthReport.jsm", ns);
+
+    // How many times will we rewrite this code before rolling it up into a
+    // generic module? See also bug 451283.
+    const LOGGERS = [
+      "Services.DataReporting",
+      "Services.HealthReport",
+      "Services.Metrics",
+      "Services.BagheeraClient",
+      "Sqlite.Connection.healthreport",
+    ];
+
+    let loggingPrefs = new Preferences(HEALTHREPORT_LOGGING_BRANCH);
+    if (loggingPrefs.get("consoleEnabled", true)) {
+      let level = loggingPrefs.get("consoleLevel", "Warn");
+      let appender = new Log.ConsoleAppender();
+      appender.level = Log.Level[level] || Log.Level.Warn;
+
+      for (let name of LOGGERS) {
+        let logger = Log.repository.getLogger(name);
+        logger.addAppender(appender);
+      }
+    }
+
+    if (loggingPrefs.get("dumpEnabled", false)) {
+      let level = loggingPrefs.get("dumpLevel", "Debug");
+      let appender = new Log.DumpAppender();
+      appender.level = Log.Level[level] || Log.Level.Debug;
+
+      for (let name of LOGGERS) {
+        let logger = Log.repository.getLogger(name);
+        logger.addAppender(appender);
+      }
+    }
+
+    this._healthReporter = new ns.HealthReporter(HEALTHREPORT_BRANCH, this.policy);
+
+    // Wait for initialization to finish so if a shutdown occurs before init
+    // has finished we don't adversely affect app startup on next run.
+    this._healthReporter.init().then(function onInit() {
+      this._prefs.set("service.firstRun", true);
+    }.bind(this));
+  },
+
+  /**
+   * This returns a promise resolving to the the stable client ID we use for
+   * data reporting (FHR & Telemetry). Previously exising FHR client IDs are
+   * migrated to this.
+   *
+   * @return Promise<string> The stable client ID.
+   */
+  getClientID: function() {
+    return ClientID.getClientID();
+  },
+});
+
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory([DataReportingService]);
+
+#define MERGED_COMPARTMENT
+
+#include ../common/observers.js
+;
+#include policy.jsm
+;
+
rename from toolkit/components/telemetry/datareporting-prefs.js
rename to services/datareporting/datareporting-prefs.js
--- a/toolkit/components/telemetry/datareporting-prefs.js
+++ b/services/datareporting/datareporting-prefs.js
@@ -1,11 +1,13 @@
 /* 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/. */
 
 pref("datareporting.policy.dataSubmissionEnabled", true);
+pref("datareporting.policy.dataSubmissionEnabled.v2", false);
+pref("datareporting.policy.firstRunTime", "0");
 pref("datareporting.policy.dataSubmissionPolicyNotifiedTime", "0");
 pref("datareporting.policy.dataSubmissionPolicyAcceptedVersion", 0);
 pref("datareporting.policy.dataSubmissionPolicyBypassNotification", false);
 pref("datareporting.policy.currentPolicyVersion", 2);
 pref("datareporting.policy.minimumPolicyVersion", 1);
 pref("datareporting.policy.minimumPolicyVersion.channel-beta", 2);
new file mode 100644
--- /dev/null
+++ b/services/datareporting/modules-testing/mocks.jsm
@@ -0,0 +1,52 @@
+/* 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";
+
+this.EXPORTED_SYMBOLS = ["MockPolicyListener"];
+
+const {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Log.jsm");
+
+
+this.MockPolicyListener = function MockPolicyListener() {
+  this._log = Log.repository.getLogger("Services.DataReporting.Testing.MockPolicyListener");
+  this._log.level = Log.Level["Debug"];
+
+  this.requestDataUploadCount = 0;
+  this.lastDataRequest = null;
+
+  this.requestRemoteDeleteCount = 0;
+  this.lastRemoteDeleteRequest = null;
+
+  this.notifyUserCount = 0;
+  this.lastNotifyRequest = null;
+}
+
+MockPolicyListener.prototype = {
+  onRequestDataUpload: function (request) {
+    this._log.info("onRequestDataUpload invoked.");
+    this.requestDataUploadCount++;
+    this.lastDataRequest = request;
+  },
+
+  onRequestRemoteDelete: function (request) {
+    this._log.info("onRequestRemoteDelete invoked.");
+    this.requestRemoteDeleteCount++;
+    this.lastRemoteDeleteRequest = request;
+  },
+
+  onNotifyDataPolicy: function (request, rejectMessage=null) {
+    this._log.info("onNotifyDataPolicy invoked.");
+    this.notifyUserCount++;
+    this.lastNotifyRequest = request;
+    if (rejectMessage) {
+      request.onUserNotifyFailed(rejectMessage);
+    } else {
+      request.onUserNotifyComplete();
+    }
+  },
+};
+
new file mode 100644
--- /dev/null
+++ b/services/datareporting/moz.build
@@ -0,0 +1,23 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+XPCSHELL_TESTS_MANIFESTS += ['tests/xpcshell/xpcshell.ini']
+
+EXTRA_COMPONENTS += [
+    'DataReporting.manifest',
+]
+
+EXTRA_PP_COMPONENTS += [
+    'DataReportingService.js',
+]
+
+EXTRA_PP_JS_MODULES.services.datareporting += [
+    'policy.jsm',
+]
+
+TESTING_JS_MODULES.services.datareporting += [
+    'modules-testing/mocks.jsm',
+]
new file mode 100644
--- /dev/null
+++ b/services/datareporting/policy.jsm
@@ -0,0 +1,927 @@
+/* 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/. */
+
+/**
+ * This file is in transition. Most of its content needs to be moved under
+ * /services/healthreport.
+ */
+
+#ifndef MERGED_COMPARTMENT
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+  "DataSubmissionRequest", // For test use only.
+  "DataReportingPolicy",
+  "DATAREPORTING_POLICY_VERSION",
+];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+#endif
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://gre/modules/UpdateUtils.jsm");
+
+// The current policy version number. If the version number stored in the prefs
+// is smaller than this, data upload will be disabled until the user is re-notified
+// about the policy changes.
+const DATAREPORTING_POLICY_VERSION = 1;
+
+const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
+
+// Used as a sanity lower bound for dates stored in prefs. This module was
+// implemented in 2012, so any earlier dates indicate an incorrect clock.
+const OLDEST_ALLOWED_YEAR = 2012;
+
+/**
+ * Represents a request to display data policy.
+ *
+ * Receivers of these instances are expected to call one or more of the on*
+ * functions when events occur.
+ *
+ * When one of these requests is received, the first thing a callee should do
+ * is present notification to the user of the data policy. When the notice
+ * is displayed to the user, the callee should call `onUserNotifyComplete`.
+ *
+ * If for whatever reason the callee could not display a notice,
+ * it should call `onUserNotifyFailed`.
+ *
+ * @param policy
+ *        (DataReportingPolicy) The policy instance this request came from.
+ * @param deferred
+ *        (deferred) The promise that will be fulfilled when display occurs.
+ */
+function NotifyPolicyRequest(policy, deferred) {
+  this.policy = policy;
+  this.deferred = deferred;
+}
+NotifyPolicyRequest.prototype = Object.freeze({
+  /**
+   * Called when the user is notified of the policy.
+   */
+  onUserNotifyComplete: function () {
+    return this.deferred.resolve();
+   },
+
+  /**
+   * Called when there was an error notifying the user about the policy.
+   *
+   * @param error
+   *        (Error) Explains what went wrong.
+   */
+  onUserNotifyFailed: function (error) {
+    return this.deferred.reject(error);
+  },
+});
+
+/**
+ * Represents a request to submit data.
+ *
+ * Instances of this are created when the policy requests data upload or
+ * deletion.
+ *
+ * Receivers are expected to call one of the provided on* functions to signal
+ * completion of the request.
+ *
+ * Instances of this type should not be instantiated outside of this file.
+ * Receivers of instances of this type should not attempt to do anything with
+ * the instance except call one of the on* methods.
+ */
+this.DataSubmissionRequest = function (promise, expiresDate, isDelete) {
+  this.promise = promise;
+  this.expiresDate = expiresDate;
+  this.isDelete = isDelete;
+
+  this.state = null;
+  this.reason = null;
+}
+
+this.DataSubmissionRequest.prototype = Object.freeze({
+  NO_DATA_AVAILABLE: "no-data-available",
+  SUBMISSION_SUCCESS: "success",
+  SUBMISSION_FAILURE_SOFT: "failure-soft",
+  SUBMISSION_FAILURE_HARD: "failure-hard",
+  UPLOAD_IN_PROGRESS: "upload-in-progress",
+
+  /**
+   * No submission was attempted because no data was available.
+   *
+   * In the case of upload, this means there is no data to upload (perhaps
+   * it isn't available yet). In case of remote deletion, it means that there
+   * is no remote data to delete.
+   */
+  onNoDataAvailable: function onNoDataAvailable() {
+    this.state = this.NO_DATA_AVAILABLE;
+    this.promise.resolve(this);
+    return this.promise.promise;
+  },
+
+  /**
+   * Data submission has completed successfully.
+   *
+   * In case of upload, this means the upload completed successfully. In case
+   * of deletion, the data was deleted successfully.
+   *
+   * @param date
+   *        (Date) When data submission occurred.
+   */
+  onSubmissionSuccess: function onSubmissionSuccess(date) {
+    this.state = this.SUBMISSION_SUCCESS;
+    this.submissionDate = date;
+    this.promise.resolve(this);
+    return this.promise.promise;
+  },
+
+  /**
+   * There was a recoverable failure when submitting data.
+   *
+   * Perhaps the server was down. Perhaps the network wasn't available. The
+   * policy may request submission again after a short delay.
+   *
+   * @param reason
+   *        (string) Why the failure occurred. For logging purposes only.
+   */
+  onSubmissionFailureSoft: function onSubmissionFailureSoft(reason=null) {
+    this.state = this.SUBMISSION_FAILURE_SOFT;
+    this.reason = reason;
+    this.promise.resolve(this);
+    return this.promise.promise;
+  },
+
+  /**
+   * There was an unrecoverable failure when submitting data.
+   *
+   * Perhaps the client is misconfigured. Perhaps the server rejected the data.
+   * Attempts at performing submission again will yield the same result. So,
+   * the policy should not try again (until the next day).
+   *
+   * @param reason
+   *        (string) Why the failure occurred. For logging purposes only.
+   */
+  onSubmissionFailureHard: function onSubmissionFailureHard(reason=null) {
+    this.state = this.SUBMISSION_FAILURE_HARD;
+    this.reason = reason;
+    this.promise.resolve(this);
+    return this.promise.promise;
+  },
+
+  /**
+   * The request was aborted because an upload was already in progress.
+   */
+  onUploadInProgress: function (reason=null) {
+    this.state = this.UPLOAD_IN_PROGRESS;
+    this.reason = reason;
+    this.promise.resolve(this);
+    return this.promise.promise;
+  },
+});
+
+/**
+ * Manages scheduling of Firefox Health Report data submission.
+ *
+ * The rules of data submission are as follows:
+ *
+ *  1. Do not submit data more than once every 24 hours.
+ *  2. Try to submit as close to 24 hours apart as possible.
+ *  3. Do not submit too soon after application startup so as to not negatively
+ *     impact performance at startup.
+ *  4. Before first ever data submission, the user should be notified about
+ *     data collection practices.
+ *  5. User should have opportunity to react to this notification before
+ *     data submission.
+ *  6. If data submission fails, try at most 2 additional times before giving
+ *     up on that day's submission.
+ *
+ * The listener passed into the instance must have the following properties
+ * (which are callbacks that will be invoked at certain key events):
+ *
+ *   * onRequestDataUpload(request) - Called when the policy is requesting
+ *     data to be submitted. The function is passed a `DataSubmissionRequest`.
+ *     The listener should call one of the special resolving functions on that
+ *     instance (see the documentation for that type).
+ *
+ *   * onRequestRemoteDelete(request) - Called when the policy is requesting
+ *     deletion of remotely stored data. The function is passed a
+ *     `DataSubmissionRequest`. The listener should call one of the special
+ *     resolving functions on that instance (just like `onRequestDataUpload`).
+ *
+ *   * onNotifyDataPolicy(request) - Called when the policy is requesting the
+ *     user to be notified that data submission will occur. The function
+ *     receives a `NotifyPolicyRequest` instance. The callee should call one or
+ *     more of the functions on that instance when specific events occur. See
+ *     the documentation for that type for more.
+ *
+ * Note that the notification method is abstracted. Different applications
+ * can have different mechanisms by which they notify the user of data
+ * submission practices.
+ *
+ * @param policyPrefs
+ *        (Preferences) Handle on preferences branch on which state will be
+ *        queried and stored.
+ * @param healthReportPrefs
+ *        (Preferences) Handle on preferences branch holding Health Report state.
+ * @param listener
+ *        (object) Object with callbacks that will be invoked at certain key
+ *        events.
+ */
+this.DataReportingPolicy = function (prefs, healthReportPrefs, listener) {
+  this._log = Log.repository.getLogger("Services.DataReporting.Policy");
+  this._log.level = Log.Level["Debug"];
+
+  for (let handler of this.REQUIRED_LISTENERS) {
+    if (!listener[handler]) {
+      throw new Error("Passed listener does not contain required handler: " +
+                      handler);
+    }
+  }
+
+  this._prefs = prefs;
+  this._healthReportPrefs = healthReportPrefs;
+  this._listener = listener;
+  this._userNotifyPromise = null;
+
+  this._migratePrefs();
+
+  if (!this.firstRunDate.getTime()) {
+    // If we've never run before, record the current time.
+    this.firstRunDate = this.now();
+  }
+
+  // Install an observer so that we can act on changes from external
+  // code (such as Android UI).
+  // Use a function because this is the only place where the Preferences
+  // abstraction is way less usable than nsIPrefBranch.
+  //
+  // Hang on to the observer here so that tests can reach it.
+  this.uploadEnabledObserver = function onUploadEnabledChanged() {
+    if (this.pendingDeleteRemoteData || this.healthReportUploadEnabled) {
+      // Nothing to do: either we're already deleting because the caller
+      // came through the front door (rHRUE), or they set the flag to true.
+      return;
+    }
+    this._log.info("uploadEnabled pref changed. Scheduling deletion.");
+    this.deleteRemoteData();
+  }.bind(this);
+
+  healthReportPrefs.observe("uploadEnabled", this.uploadEnabledObserver);
+
+  // Ensure we are scheduled to submit.
+  if (!this.nextDataSubmissionDate.getTime()) {
+    this.nextDataSubmissionDate = this._futureDate(MILLISECONDS_PER_DAY);
+  }
+
+  // Record when we last requested for submitted data to be sent. This is
+  // to avoid having multiple outstanding requests.
+  this._inProgressSubmissionRequest = null;
+};
+
+this.DataReportingPolicy.prototype = Object.freeze({
+  /**
+   *  How often to poll to see if we need to do something.
+   *
+   * The interval needs to be short enough such that short-lived applications
+   * have an opportunity to submit data. But, it also needs to be long enough
+   * to not negatively impact performance.
+   *
+   * The random bit is to ensure that other systems scheduling around the same
+   * interval don't all get scheduled together.
+   */
+  POLL_INTERVAL_MSEC: (60 * 1000) + Math.floor(2.5 * 1000 * Math.random()),
+
+  /**
+   * How long individual data submission requests live before expiring.
+   *
+   * Data submission requests have this long to complete before we give up on
+   * them and try again.
+   *
+   * We want this to be short enough that we retry frequently enough but long
+   * enough to give slow networks and systems time to handle it.
+   */
+  SUBMISSION_REQUEST_EXPIRE_INTERVAL_MSEC: 10 * 60 * 1000,
+
+  /**
+   * Our backoff schedule in case of submission failure.
+   *
+   * This dictates both the number of times we retry a daily submission and
+   * when to retry after each failure.
+   *
+   * Each element represents how long to wait after each recoverable failure.
+   * After the first failure, we wait the time in element 0 before trying
+   * again. After the second failure, we wait the time in element 1. Once
+   * we run out of values in this array, we give up on that day's submission
+   * and schedule for a day out.
+   */
+  FAILURE_BACKOFF_INTERVALS: [
+    15 * 60 * 1000,
+    60 * 60 * 1000,
+  ],
+
+  REQUIRED_LISTENERS: [
+    "onRequestDataUpload",
+    "onRequestRemoteDelete",
+    "onNotifyDataPolicy",
+  ],
+
+  /**
+   * The first time the health report policy came into existence.
+   *
+   * This is used for scheduling of the initial submission.
+   */
+  get firstRunDate() {
+    return CommonUtils.getDatePref(this._prefs, "firstRunTime", 0, this._log,
+                                   OLDEST_ALLOWED_YEAR);
+  },
+
+  set firstRunDate(value) {
+    this._log.debug("Setting first-run date: " + value);
+    CommonUtils.setDatePref(this._prefs, "firstRunTime", value,
+                            OLDEST_ALLOWED_YEAR);
+  },
+
+  get dataSubmissionPolicyNotifiedDate() {
+    return CommonUtils.getDatePref(this._prefs,
+                                   "dataSubmissionPolicyNotifiedTime", 0,
+                                   this._log, OLDEST_ALLOWED_YEAR);
+  },
+
+  set dataSubmissionPolicyNotifiedDate(value) {
+    this._log.debug("Setting user notified date: " + value);
+    CommonUtils.setDatePref(this._prefs, "dataSubmissionPolicyNotifiedTime",
+                            value, OLDEST_ALLOWED_YEAR);
+  },
+
+  get dataSubmissionPolicyBypassNotification() {
+    return this._prefs.get("dataSubmissionPolicyBypassNotification", false);
+  },
+
+  set dataSubmissionPolicyBypassNotification(value) {
+    return this._prefs.set("dataSubmissionPolicyBypassNotification", !!value);
+  },
+
+  /**
+   * Whether submission of data is allowed.
+   *
+   * This is the master switch for remote server communication. If it is
+   * false, we never request upload or deletion.
+   */
+  get dataSubmissionEnabled() {
+    // Default is true because we are opt-out.
+    return this._prefs.get("dataSubmissionEnabled", true);
+  },
+
+  set dataSubmissionEnabled(value) {
+    this._prefs.set("dataSubmissionEnabled", !!value);
+  },
+
+  /**
+   * Whether submission of data is allowed for v2.
+   *
+   * This is used to gently turn off data submission for FHR v2 in Firefox 42+.
+   */
+  get dataSubmissionEnabledV2() {
+    // Default is true because we are opt-out.
+    return this._prefs.get("dataSubmissionEnabled.v2", true);
+  },
+
+  get currentPolicyVersion() {
+    return this._prefs.get("currentPolicyVersion", DATAREPORTING_POLICY_VERSION);
+  },
+
+  /**
+   * The minimum policy version which for dataSubmissionPolicyAccepted to
+   * to be valid.
+   */
+  get minimumPolicyVersion() {
+    // First check if the current channel has an ove
+    let channel = UpdateUtils.getUpdateChannel(false);
+    let channelPref = this._prefs.get("minimumPolicyVersion.channel-" + channel);
+    return channelPref !== undefined ?
+           channelPref : this._prefs.get("minimumPolicyVersion", 1);
+  },
+
+  get dataSubmissionPolicyAcceptedVersion() {
+    return this._prefs.get("dataSubmissionPolicyAcceptedVersion", 0);
+  },
+
+  set dataSubmissionPolicyAcceptedVersion(value) {
+    this._prefs.set("dataSubmissionPolicyAcceptedVersion", value);
+  },
+
+  /**
+   * Checks to see if the user has been notified about data submission
+   * @return {bool}
+   */
+  get userNotifiedOfCurrentPolicy() {
+    return  this.dataSubmissionPolicyNotifiedDate.getTime() > 0 &&
+            this.dataSubmissionPolicyAcceptedVersion >= this.currentPolicyVersion;
+  },
+
+  /**
+   * When this policy last requested data submission.
+   *
+   * This is used mainly for forensics purposes and should have no bearing
+   * on scheduling or run-time behavior.
+   */
+  get lastDataSubmissionRequestedDate() {
+    return CommonUtils.getDatePref(this._healthReportPrefs,
+                                   "lastDataSubmissionRequestedTime", 0,
+                                   this._log, OLDEST_ALLOWED_YEAR);
+  },
+
+  set lastDataSubmissionRequestedDate(value) {
+    CommonUtils.setDatePref(this._healthReportPrefs,
+                            "lastDataSubmissionRequestedTime",
+                            value, OLDEST_ALLOWED_YEAR);
+  },
+
+  /**
+   * When the last data submission actually occurred.
+   *
+   * This is used mainly for forensics purposes and should have no bearing on
+   * actual scheduling.
+   */
+  get lastDataSubmissionSuccessfulDate() {
+    return CommonUtils.getDatePref(this._healthReportPrefs,
+                                   "lastDataSubmissionSuccessfulTime", 0,
+                                   this._log, OLDEST_ALLOWED_YEAR);
+  },
+
+  set lastDataSubmissionSuccessfulDate(value) {
+    CommonUtils.setDatePref(this._healthReportPrefs,
+                            "lastDataSubmissionSuccessfulTime",
+                            value, OLDEST_ALLOWED_YEAR);
+  },
+
+  /**
+   * When we last encountered a submission failure.
+   *
+   * This is used for forensics purposes and should have no bearing on
+   * scheduling.
+   */
+  get lastDataSubmissionFailureDate() {
+    return CommonUtils.getDatePref(this._healthReportPrefs,
+                                   "lastDataSubmissionFailureTime",
+                                   0, this._log, OLDEST_ALLOWED_YEAR);
+  },
+
+  set lastDataSubmissionFailureDate(value) {
+    CommonUtils.setDatePref(this._healthReportPrefs,
+                            "lastDataSubmissionFailureTime",
+                            value, OLDEST_ALLOWED_YEAR);
+  },
+
+  /**
+   * When the next data submission is scheduled to occur.
+   *
+   * This is maintained internally by this type. External users should not
+   * mutate this value.
+   */
+  get nextDataSubmissionDate() {
+    return CommonUtils.getDatePref(this._healthReportPrefs,
+                                   "nextDataSubmissionTime", 0,
+                                   this._log, OLDEST_ALLOWED_YEAR);
+  },
+
+  set nextDataSubmissionDate(value) {
+    CommonUtils.setDatePref(this._healthReportPrefs,
+                            "nextDataSubmissionTime", value,
+                            OLDEST_ALLOWED_YEAR);
+  },
+
+  /**
+   * The number of submission failures for this day's upload.
+   *
+   * This is used to drive backoff and scheduling.
+   */
+  get currentDaySubmissionFailureCount() {
+    let v = this._healthReportPrefs.get("currentDaySubmissionFailureCount", 0);
+
+    if (!Number.isInteger(v)) {
+      v = 0;
+    }
+
+    return v;
+  },
+
+  set currentDaySubmissionFailureCount(value) {
+    if (!Number.isInteger(value)) {
+      throw new Error("Value must be integer: " + value);
+    }
+
+    this._healthReportPrefs.set("currentDaySubmissionFailureCount", value);
+  },
+
+  /**
+   * Whether a request to delete remote data is awaiting completion.
+   *
+   * If this is true, the policy will request that remote data be deleted.
+   * Furthermore, no new data will be uploaded (if it's even allowed) until
+   * the remote deletion is fulfilled.
+   */
+  get pendingDeleteRemoteData() {
+    return !!this._healthReportPrefs.get("pendingDeleteRemoteData", false);
+  },
+
+  set pendingDeleteRemoteData(value) {
+    this._healthReportPrefs.set("pendingDeleteRemoteData", !!value);
+  },
+
+  /**
+   * Whether upload of Firefox Health Report data is enabled.
+   */
+  get healthReportUploadEnabled() {
+    return !!this._healthReportPrefs.get("uploadEnabled", true);
+  },
+
+  // External callers should update this via `recordHealthReportUploadEnabled`
+  // to ensure appropriate side-effects are performed.
+  set healthReportUploadEnabled(value) {
+    this._healthReportPrefs.set("uploadEnabled", !!value);
+  },
+
+  /**
+   * Whether the FHR upload enabled setting is locked and can't be changed.
+   */
+  get healthReportUploadLocked() {
+    return this._healthReportPrefs.locked("uploadEnabled");
+  },
+
+  /**
+   * Record the user's intent for whether FHR should upload data.
+   *
+   * This is the preferred way for XUL applications to record a user's
+   * preference on whether Firefox Health Report should upload data to
+   * a server.
+   *
+   * If upload is disabled through this API, a request for remote data
+   * deletion is initiated automatically.
+   *
+   * If upload is being disabled and this operation is scheduled to
+   * occur immediately, a promise will be returned. This promise will be
+   * fulfilled when the deletion attempt finishes. If upload is being
+   * disabled and a promise is not returned, callers must poll
+   * `haveRemoteData` on the HealthReporter instance to see if remote
+   * data has been deleted.
+   *
+   * @param flag
+   *        (bool) Whether data submission is enabled or disabled.
+   * @param reason
+   *        (string) Why this value is being adjusted. For logging
+   *        purposes only.
+   */
+  recordHealthReportUploadEnabled: function (flag, reason="no-reason") {
+    let result = null;
+    if (!flag) {
+      result = this.deleteRemoteData(reason);
+    }
+
+    this.healthReportUploadEnabled = flag;
+    return result;
+  },
+
+  /**
+   * Request that remote data be deleted.
+   *
+   * This will record an intent that previously uploaded data is to be deleted.
+   * The policy will eventually issue a request to the listener for data
+   * deletion. It will keep asking for deletion until the listener acknowledges
+   * that data has been deleted.
+   */
+  deleteRemoteData: function deleteRemoteData(reason="no-reason") {
+    this._log.info("Remote data deletion requested: " + reason);
+
+    this.pendingDeleteRemoteData = true;
+
+    // We want delete deletion to occur as soon as possible. Move up any
+    // pending scheduled data submission and try to trigger.
+    this.nextDataSubmissionDate = this.now();
+    return this.checkStateAndTrigger();
+  },
+
+  /**
+   * Start background polling for activity.
+   *
+   * This will set up a recurring timer that will periodically check if
+   * activity is warranted.
+   *
+   * You typically call this function for each constructed instance.
+   */
+  startPolling: function startPolling() {
+    this.stopPolling();
+
+    this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+    this._timer.initWithCallback({
+      notify: function notify() {
+        this.checkStateAndTrigger();
+      }.bind(this)
+    }, this.POLL_INTERVAL_MSEC, this._timer.TYPE_REPEATING_SLACK);
+  },
+
+  /**
+   * Stop background polling for activity.
+   *
+   * This should be called when the instance is no longer needed.
+   */
+  stopPolling: function stopPolling() {
+    if (this._timer) {
+      this._timer.cancel();
+      this._timer = null;
+    }
+  },
+
+  /**
+   * Abstraction for obtaining current time.
+   *
+   * The purpose of this is to facilitate testing. Testing code can monkeypatch
+   * this on instances instead of modifying the singleton Date object.
+   */
+  now: function now() {
+    return new Date();
+  },
+
+  /**
+   * Check state and trigger actions, if necessary.
+   *
+   * This is what enforces the submission and notification policy detailed
+   * above. You can think of this as the driver for health report data
+   * submission.
+   *
+   * Typically this function is called automatically by the background polling.
+   * But, it can safely be called manually as needed.
+   */
+  checkStateAndTrigger: function checkStateAndTrigger() {
+    // If the master data submission kill switch is toggled, we have nothing
+    // to do. We don't notify about data policies because this would have
+    // no effect.
+    if (!this.dataSubmissionEnabled || !this.dataSubmissionEnabledV2) {
+      this._log.debug("Data submission is disabled. Doing nothing.");
+      return;
+    }
+
+    let now = this.now();
+    let nowT = now.getTime();
+    let nextSubmissionDate = this.nextDataSubmissionDate;
+
+    // If the system clock were ever set to a time in the distant future,
+    // it's possible our next schedule date is far out as well. We know
+    // we shouldn't schedule for more than a day out, so we reset the next
+    // scheduled date appropriately. 3 days was chosen arbitrarily.
+    if (nextSubmissionDate.getTime() >= nowT + 3 * MILLISECONDS_PER_DAY) {
+      this._log.warn("Next data submission time is far away. Was the system " +
+                     "clock recently readjusted? " + nextSubmissionDate);
+
+      // It shouldn't really matter what we set this to. 1 day in the future
+      // should be pretty safe.
+      this._moveScheduleForward24h();
+
+      // Fall through since we may have other actions.
+    }
+
+    // Tend to any in progress work.
+    if (this._processInProgressSubmission()) {
+      return;
+    }
+
+    // Requests to delete remote data take priority above everything else.
+    if (this.pendingDeleteRemoteData) {
+      if (nowT < nextSubmissionDate.getTime()) {
+        this._log.debug("Deletion request is scheduled for the future: " +
+                        nextSubmissionDate);
+        return;
+      }
+
+      return this._dispatchSubmissionRequest("onRequestRemoteDelete", true);
+    }
+
+    if (!this.healthReportUploadEnabled) {
+      this._log.debug("Data upload is disabled. Doing nothing.");
+      return;
+    }
+
+    if (!this.ensureUserNotified()) {
+      this._log.warn("The user has not been notified about the data submission " +
+                     "policy. Not attempting upload.");
+      return;
+    }
+
+    // Data submission is allowed to occur. Now comes the scheduling part.
+
+    if (nowT < nextSubmissionDate.getTime()) {
+      this._log.debug("Next data submission is scheduled in the future: " +
+                     nextSubmissionDate);
+      return;
+    }
+
+    return this._dispatchSubmissionRequest("onRequestDataUpload", false);
+  },
+
+  /**
+   * Ensure that the data policy notification has been displayed.
+   *
+   * This must be called before data submission. If the policy has not been
+   * displayed, data submission must not occur.
+   *
+   * @return bool Whether the notification has been displayed.
+   */
+  ensureUserNotified: function () {
+    if (this.userNotifiedOfCurrentPolicy || this.dataSubmissionPolicyBypassNotification) {
+      return true;
+    }
+
+    // The user has not been notified yet, but is in the process of being notified.
+    if (this._userNotifyPromise) {
+      return false;
+    }
+
+    let deferred = Promise.defer();
+    deferred.promise.then((function onSuccess() {
+      this._recordDataPolicyNotification(this.now(), this.currentPolicyVersion);
+      this._userNotifyPromise = null;
+    }).bind(this), ((error) => {
+      this._log.warn("Data policy notification presentation failed", error);
+      this._userNotifyPromise = null;
+    }).bind(this));
+
+    this._log.info("Requesting display of data policy.");
+    let request = new NotifyPolicyRequest(this, deferred);
+    try {
+      this._listener.onNotifyDataPolicy(request);
+    } catch (ex) {
+      this._log.warn("Exception when calling onNotifyDataPolicy", ex);
+    }
+
+    this._userNotifyPromise = deferred.promise;
+
+    return false;
+  },
+
+  _recordDataPolicyNotification: function (date, version) {
+    this._log.debug("Recording data policy notification to version " + version +
+                  " on date " + date);
+    this.dataSubmissionPolicyNotifiedDate = date;
+    this.dataSubmissionPolicyAcceptedVersion = version;
+  },
+
+  _migratePrefs: function () {
+    // Current prefs are mostly the same than the old ones, except for some deprecated ones.
+    this._prefs.reset([
+      "dataSubmissionPolicyAccepted",
+      "dataSubmissionPolicyBypassAcceptance",
+      "dataSubmissionPolicyResponseType",
+      "dataSubmissionPolicyResponseTime"
+    ]);
+  },
+
+  _processInProgressSubmission: function _processInProgressSubmission() {
+    if (!this._inProgressSubmissionRequest) {
+      return false;
+    }
+
+    let now = this.now().getTime();
+    if (this._inProgressSubmissionRequest.expiresDate.getTime() > now) {
+      this._log.info("Waiting on in-progress submission request to finish.");
+      return true;
+    }
+
+    this._log.warn("Old submission request has expired from no activity.");
+    this._inProgressSubmissionRequest.promise.reject(new Error("Request has expired."));
+    this._inProgressSubmissionRequest = null;
+    this._handleSubmissionFailure();
+
+    return false;
+  },
+
+  _dispatchSubmissionRequest: function _dispatchSubmissionRequest(handler, isDelete) {
+    let now = this.now();
+
+    // We're past our scheduled next data submission date, so let's do it!
+    this.lastDataSubmissionRequestedDate = now;
+    let deferred = Promise.defer();
+    let requestExpiresDate =
+      this._futureDate(this.SUBMISSION_REQUEST_EXPIRE_INTERVAL_MSEC);
+    this._inProgressSubmissionRequest = new DataSubmissionRequest(deferred,
+                                                                  requestExpiresDate,
+                                                                  isDelete);
+
+    let onSuccess = function onSuccess(result) {
+      this._inProgressSubmissionRequest = null;
+      this._handleSubmissionResult(result);
+    }.bind(this);
+
+    let onError = function onError(error) {
+      this._log.error("Error when handling data submission result", error);
+      this._inProgressSubmissionRequest = null;
+      this._handleSubmissionFailure();
+    }.bind(this);
+
+    let chained = deferred.promise.then(onSuccess, onError);
+
+    this._log.info("Requesting data submission. Will expire at " +
+                   requestExpiresDate);
+    try {
+      let promise = this._listener[handler](this._inProgressSubmissionRequest);
+      chained = chained.then(() => promise, null);
+    } catch (ex) {
+      this._log.warn("Exception when calling " + handler, ex);
+      this._inProgressSubmissionRequest = null;
+      this._handleSubmissionFailure();
+      return;
+    }
+
+    return chained;
+  },
+
+  _handleSubmissionResult: function _handleSubmissionResult(request) {
+    let state = request.state;
+    let reason = request.reason || "no reason";
+    this._log.info("Got submission request result: " + state);
+
+    if (state == request.SUBMISSION_SUCCESS) {
+      if (request.isDelete) {
+        this.pendingDeleteRemoteData = false;
+        this._log.info("Successful data delete reported.");
+      } else {
+        this._log.info("Successful data upload reported.");
+      }
+
+      this.lastDataSubmissionSuccessfulDate = request.submissionDate;
+
+      let nextSubmissionDate =
+        new Date(request.submissionDate.getTime() + MILLISECONDS_PER_DAY);
+
+      // Schedule pending deletes immediately. This has potential to overload
+      // the server. However, the frequency of delete requests across all
+      // clients should be low, so this shouldn't pose a problem.
+      if (this.pendingDeleteRemoteData) {
+        nextSubmissionDate = this.now();
+      }
+
+      this.nextDataSubmissionDate = nextSubmissionDate;
+      this.currentDaySubmissionFailureCount = 0;
+      return;
+    }
+
+    if (state == request.NO_DATA_AVAILABLE) {
+      if (request.isDelete) {
+        this._log.info("Remote data delete requested but no remote data was stored.");
+        this.pendingDeleteRemoteData = false;
+        return;
+      }
+
+      this._log.info("No data was available to submit. May try later.");
+      this._handleSubmissionFailure();
+      return;
+    }
+
+    // We don't special case request.isDelete for these failures because it
+    // likely means there was a server error.
+
+    if (state == request.SUBMISSION_FAILURE_SOFT) {
+      this._log.warn("Soft error submitting data: " + reason);
+      this.lastDataSubmissionFailureDate = this.now();
+      this._handleSubmissionFailure();
+      return;
+    }
+
+    if (state == request.SUBMISSION_FAILURE_HARD) {
+      this._log.warn("Hard error submitting data: " + reason);
+      this.lastDataSubmissionFailureDate = this.now();
+      this._moveScheduleForward24h();
+      return;
+    }
+
+    throw new Error("Unknown state on DataSubmissionRequest: " + request.state);
+  },
+
+  _handleSubmissionFailure: function _handleSubmissionFailure() {
+    if (this.currentDaySubmissionFailureCount >= this.FAILURE_BACKOFF_INTERVALS.length) {
+      this._log.warn("Reached the limit of daily submission attempts. " +
+                     "Rescheduling for tomorrow.");
+      this._moveScheduleForward24h();
+      return false;
+    }
+
+    let offset = this.FAILURE_BACKOFF_INTERVALS[this.currentDaySubmissionFailureCount];
+    this.nextDataSubmissionDate = this._futureDate(offset);
+    this.currentDaySubmissionFailureCount++;
+    return true;
+  },
+
+  _moveScheduleForward24h: function _moveScheduleForward24h() {
+    let d = this._futureDate(MILLISECONDS_PER_DAY);
+    this._log.info("Setting next scheduled data submission for " + d);
+
+    this.nextDataSubmissionDate = d;
+    this.currentDaySubmissionFailureCount = 0;
+  },
+
+  _futureDate: function _futureDate(offset) {
+    return new Date(this.now().getTime() + offset);
+  },
+});
+
new file mode 100644
--- /dev/null
+++ b/services/datareporting/tests/xpcshell/head.js
@@ -0,0 +1,16 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// We need to initialize the profile or OS.File may not work. See bug 810543.
+do_get_profile();
+
+(function initTestingInfrastructure() {
+  let ns = {};
+  Components.utils.import("resource://testing-common/services/common/logging.js",
+                          ns);
+
+  ns.initTestLogging();
+}).call(this);
+
new file mode 100644
--- /dev/null
+++ b/services/datareporting/tests/xpcshell/test_policy.js
@@ -0,0 +1,689 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/services/datareporting/policy.jsm");
+Cu.import("resource://testing-common/services/datareporting/mocks.jsm");
+Cu.import("resource://gre/modules/UpdateUtils.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+
+function getPolicy(name,
+                   aCurrentPolicyVersion = 1,
+                   aMinimumPolicyVersion = 1,
+                   aBranchMinimumVersionOverride) {
+  let branch = "testing.datareporting." + name;
+
+  // The version prefs should not be removed on reset, so set them in the
+  // default branch.
+  let defaultPolicyPrefs = new Preferences({ branch: branch + ".policy."
+                                           , defaultBranch: true });
+  defaultPolicyPrefs.set("currentPolicyVersion", aCurrentPolicyVersion);
+  defaultPolicyPrefs.set("minimumPolicyVersion", aMinimumPolicyVersion);
+  let branchOverridePrefName = "minimumPolicyVersion.channel-" + UpdateUtils.getUpdateChannel(false);
+  if (aBranchMinimumVersionOverride !== undefined)
+    defaultPolicyPrefs.set(branchOverridePrefName, aBranchMinimumVersionOverride);
+  else
+    defaultPolicyPrefs.reset(branchOverridePrefName);
+
+  let policyPrefs = new Preferences(branch + ".policy.");
+  let healthReportPrefs = new Preferences(branch + ".healthreport.");
+
+  let listener = new MockPolicyListener();
+  let policy = new DataReportingPolicy(policyPrefs, healthReportPrefs, listener);
+
+  return [policy, policyPrefs, healthReportPrefs, listener];
+}
+
+/**
+ * Ensure that the notification has been displayed to the user therefore having
+ * policy.ensureUserNotified() === true, which will allow for a successful
+ * data upload and afterwards does a call to policy.checkStateAndTrigger()
+ * @param  {Policy} policy
+ * @return {Promise}
+ */
+function ensureUserNotifiedAndTrigger(policy) {
+  return Task.spawn(function* ensureUserNotifiedAndTrigger () {
+    policy.ensureUserNotified();
+    yield policy._listener.lastNotifyRequest.deferred.promise;
+    do_check_true(policy.userNotifiedOfCurrentPolicy);
+    policy.checkStateAndTrigger();
+  });
+}
+
+function defineNow(policy, now) {
+  print("Adjusting fake system clock to " + now);
+  Object.defineProperty(policy, "now", {
+    value: function customNow() {
+      return now;
+    },
+    writable: true,
+  });
+}
+
+function run_test() {
+  run_next_test();
+}
+
+add_test(function test_constructor() {
+  let policyPrefs = new Preferences("foo.bar.policy.");
+  let hrPrefs = new Preferences("foo.bar.healthreport.");
+  let listener = {
+    onRequestDataUpload: function() {},
+    onRequestRemoteDelete: function() {},
+    onNotifyDataPolicy: function() {},
+  };
+
+  let policy = new DataReportingPolicy(policyPrefs, hrPrefs, listener);
+  do_check_true(Date.now() - policy.firstRunDate.getTime() < 1000);
+
+  let tomorrow = Date.now() + 24 * 60 * 60 * 1000;
+  do_check_true(tomorrow - policy.nextDataSubmissionDate.getTime() < 1000);
+
+  do_check_eq(policy.dataSubmissionPolicyAcceptedVersion, 0);
+  do_check_false(policy.userNotifiedOfCurrentPolicy);
+
+  run_next_test();
+});
+
+add_test(function test_prefs() {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("prefs");
+
+  let now = new Date();
+  let nowT = now.getTime();
+
+  policy.firstRunDate = now;
+  do_check_eq(policyPrefs.get("firstRunTime"), nowT);
+  do_check_eq(policy.firstRunDate.getTime(), nowT);
+
+  policy.dataSubmissionPolicyNotifiedDate = now;
+  do_check_eq(policyPrefs.get("dataSubmissionPolicyNotifiedTime"), nowT);
+  do_check_neq(policy.dataSubmissionPolicyNotifiedDate, null);
+  do_check_eq(policy.dataSubmissionPolicyNotifiedDate.getTime(), nowT);
+
+  policy.dataSubmissionEnabled = false;
+  do_check_false(policyPrefs.get("dataSubmissionEnabled", true));
+  do_check_false(policy.dataSubmissionEnabled);
+
+  let new_version = DATAREPORTING_POLICY_VERSION + 1;
+  policy.dataSubmissionPolicyAcceptedVersion = new_version;
+  do_check_eq(policyPrefs.get("dataSubmissionPolicyAcceptedVersion"), new_version);
+
+  do_check_false(policy.dataSubmissionPolicyBypassNotification);
+  policy.dataSubmissionPolicyBypassNotification = true;
+  do_check_true(policy.dataSubmissionPolicyBypassNotification);
+  do_check_true(policyPrefs.get("dataSubmissionPolicyBypassNotification"));
+
+  policy.lastDataSubmissionRequestedDate = now;
+  do_check_eq(hrPrefs.get("lastDataSubmissionRequestedTime"), nowT);
+  do_check_eq(policy.lastDataSubmissionRequestedDate.getTime(), nowT);
+
+  policy.lastDataSubmissionSuccessfulDate = now;
+  do_check_eq(hrPrefs.get("lastDataSubmissionSuccessfulTime"), nowT);
+  do_check_eq(policy.lastDataSubmissionSuccessfulDate.getTime(), nowT);
+
+  policy.lastDataSubmissionFailureDate = now;
+  do_check_eq(hrPrefs.get("lastDataSubmissionFailureTime"), nowT);
+  do_check_eq(policy.lastDataSubmissionFailureDate.getTime(), nowT);
+
+  policy.nextDataSubmissionDate = now;
+  do_check_eq(hrPrefs.get("nextDataSubmissionTime"), nowT);
+  do_check_eq(policy.nextDataSubmissionDate.getTime(), nowT);
+
+  policy.currentDaySubmissionFailureCount = 2;
+  do_check_eq(hrPrefs.get("currentDaySubmissionFailureCount", 0), 2);
+  do_check_eq(policy.currentDaySubmissionFailureCount, 2);
+
+  policy.pendingDeleteRemoteData = true;
+  do_check_true(hrPrefs.get("pendingDeleteRemoteData"));
+  do_check_true(policy.pendingDeleteRemoteData);
+
+  policy.healthReportUploadEnabled = false;
+  do_check_false(hrPrefs.get("uploadEnabled"));
+  do_check_false(policy.healthReportUploadEnabled);
+
+  do_check_false(policy.healthReportUploadLocked);
+  hrPrefs.lock("uploadEnabled");
+  do_check_true(policy.healthReportUploadLocked);
+  hrPrefs.unlock("uploadEnabled");
+  do_check_false(policy.healthReportUploadLocked);
+
+  run_next_test();
+});
+
+add_task(function test_migratePrefs () {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("migratePrefs");
+  let outdated_prefs = {
+    dataSubmissionPolicyAccepted: true,
+    dataSubmissionPolicyBypassAcceptance: true,
+    dataSubmissionPolicyResponseType: "something",
+    dataSubmissionPolicyResponseTime: Date.now() + "",
+  };
+
+  // Test removal of old prefs.
+  for (let name in outdated_prefs) {
+    policyPrefs.set(name, outdated_prefs[name]);
+  }
+  policy._migratePrefs();
+  for (let name in outdated_prefs) {
+    do_check_false(policyPrefs.has(name));
+  }
+});
+
+add_task(function test_userNotifiedOfCurrentPolicy () {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("initial_submission_notification");
+
+  do_check_false(policy.userNotifiedOfCurrentPolicy,
+                 "The initial state should be unnotified.");
+  do_check_eq(policy.dataSubmissionPolicyNotifiedDate.getTime(), 0);
+
+  policy.dataSubmissionPolicyAcceptedVersion = DATAREPORTING_POLICY_VERSION;
+  do_check_false(policy.userNotifiedOfCurrentPolicy,
+                 "The default state of the date should have a time of 0 and it should therefore fail");
+  do_check_eq(policy.dataSubmissionPolicyNotifiedDate.getTime(), 0,
+              "Updating the accepted version should not set a notified date.");
+
+  policy._recordDataPolicyNotification(new Date(), DATAREPORTING_POLICY_VERSION);
+  do_check_true(policy.userNotifiedOfCurrentPolicy,
+                "Using the proper API causes user notification to report as true.");
+
+  // It is assumed that later versions of the policy will incorporate previous
+  // ones, therefore this should also return true.
+  policy._recordDataPolicyNotification(new Date(), DATAREPORTING_POLICY_VERSION);
+  policy.dataSubmissionPolicyAcceptedVersion = DATAREPORTING_POLICY_VERSION + 1;
+  do_check_true(policy.userNotifiedOfCurrentPolicy, 'A future version of the policy should pass.');
+
+  policy._recordDataPolicyNotification(new Date(), DATAREPORTING_POLICY_VERSION);
+  policy.dataSubmissionPolicyAcceptedVersion = DATAREPORTING_POLICY_VERSION - 1;
+  do_check_false(policy.userNotifiedOfCurrentPolicy, 'A previous version of the policy should fail.');
+});
+
+add_task(function* test_notification_displayed () {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("notification_accept_displayed");
+
+  do_check_eq(listener.requestDataUploadCount, 0);
+  do_check_eq(listener.notifyUserCount, 0);
+  do_check_eq(policy.dataSubmissionPolicyNotifiedDate.getTime(), 0);
+
+  // Uploads will trigger user notifications as needed.
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.notifyUserCount, 1);
+  do_check_eq(listener.requestDataUploadCount, 0);
+
+  yield ensureUserNotifiedAndTrigger(policy);
+
+  do_check_eq(listener.notifyUserCount, 1);
+  do_check_true(policy.dataSubmissionPolicyNotifiedDate.getTime() > 0);
+  do_check_true(policy.userNotifiedOfCurrentPolicy);
+});
+
+add_task(function* test_submission_kill_switch() {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("submission_kill_switch");
+  policy.nextDataSubmissionDate = new Date(Date.now() - 24 * 60 * 60 * 1000);
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestDataUploadCount, 0);
+  yield ensureUserNotifiedAndTrigger(policy);
+  do_check_eq(listener.requestDataUploadCount, 1);
+
+  defineNow(policy,
+    new Date(Date.now() + policy.SUBMISSION_REQUEST_EXPIRE_INTERVAL_MSEC + 100));
+  policy.dataSubmissionEnabled = false;
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestDataUploadCount, 1);
+});
+
+add_task(function* test_upload_kill_switch() {
+   let [policy, policyPrefs, hrPrefs, listener] = getPolicy("upload_kill_switch");
+
+  yield ensureUserNotifiedAndTrigger(policy);
+  defineNow(policy, policy.nextDataSubmissionDate);
+
+  // So that we don't trigger deletions, which cause uploads to be delayed.
+  hrPrefs.ignore("uploadEnabled", policy.uploadEnabledObserver);
+
+  policy.healthReportUploadEnabled = false;
+  yield policy.checkStateAndTrigger();
+  do_check_eq(listener.requestDataUploadCount, 0);
+  policy.healthReportUploadEnabled = true;
+  yield ensureUserNotifiedAndTrigger(policy);
+  do_check_eq(listener.requestDataUploadCount, 1);
+});
+
+add_task(function* test_data_submission_no_data() {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("data_submission_no_data");
+
+  let now = new Date(policy.nextDataSubmissionDate.getTime() + 1);
+  defineNow(policy, now);
+  do_check_eq(listener.requestDataUploadCount, 0);
+  yield ensureUserNotifiedAndTrigger(policy);
+  do_check_eq(listener.requestDataUploadCount, 1);
+  listener.lastDataRequest.onNoDataAvailable();
+
+  // The next trigger should try again.
+  defineNow(policy, new Date(now.getTime() + 155 * 60 * 1000));
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestDataUploadCount, 2);
+ });
+
+add_task(function* test_data_submission_submit_failure_hard() {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("data_submission_submit_failure_hard");
+
+  let nextDataSubmissionDate = policy.nextDataSubmissionDate;
+  let now = new Date(policy.nextDataSubmissionDate.getTime() + 1);
+  defineNow(policy, now);
+
+  yield ensureUserNotifiedAndTrigger(policy);
+  do_check_eq(listener.requestDataUploadCount, 1);
+  yield listener.lastDataRequest.onSubmissionFailureHard();
+  do_check_eq(listener.lastDataRequest.state,
+              listener.lastDataRequest.SUBMISSION_FAILURE_HARD);
+
+  let expected = new Date(now.getTime() + 24 * 60 * 60 * 1000);
+  do_check_eq(policy.nextDataSubmissionDate.getTime(), expected.getTime());
+
+  defineNow(policy, new Date(now.getTime() + 10));
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestDataUploadCount, 1);
+});
+
+add_task(function* test_data_submission_submit_try_again() {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("data_submission_failure_soft");
+
+  let nextDataSubmissionDate = policy.nextDataSubmissionDate;
+  let now = new Date(policy.nextDataSubmissionDate.getTime());
+  defineNow(policy, now);
+  yield ensureUserNotifiedAndTrigger(policy);
+  yield listener.lastDataRequest.onSubmissionFailureSoft();
+  do_check_eq(policy.nextDataSubmissionDate.getTime(),
+              nextDataSubmissionDate.getTime() + 15 * 60 * 1000);
+});
+
+add_task(function* test_submission_daily_scheduling() {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("submission_daily_scheduling");
+
+  let nextDataSubmissionDate = policy.nextDataSubmissionDate;
+
+  // Skip ahead to next submission date. We should get a submission request.
+  let now = new Date(policy.nextDataSubmissionDate.getTime());
+  defineNow(policy, now);
+  yield ensureUserNotifiedAndTrigger(policy);
+  do_check_eq(listener.requestDataUploadCount, 1);
+  do_check_eq(policy.lastDataSubmissionRequestedDate.getTime(), now.getTime());
+
+  let finishedDate = new Date(now.getTime() + 250);
+  defineNow(policy, new Date(finishedDate.getTime() + 50));
+  yield listener.lastDataRequest.onSubmissionSuccess(finishedDate);
+  do_check_eq(policy.lastDataSubmissionSuccessfulDate.getTime(), finishedDate.getTime());
+
+  // Next scheduled submission should be exactly 1 day after the reported
+  // submission success.
+
+  let nextScheduled = new Date(finishedDate.getTime() + 24 * 60 * 60 * 1000);
+  do_check_eq(policy.nextDataSubmissionDate.getTime(), nextScheduled.getTime());
+
+  // Fast forward some arbitrary time. We shouldn't do any work yet.
+  defineNow(policy, new Date(now.getTime() + 40000));
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestDataUploadCount, 1);
+
+  defineNow(policy, nextScheduled);
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestDataUploadCount, 2);
+  yield listener.lastDataRequest.onSubmissionSuccess(new Date(nextScheduled.getTime() + 200));
+  do_check_eq(policy.nextDataSubmissionDate.getTime(),
+    new Date(nextScheduled.getTime() + 24 * 60 * 60 * 1000 + 200).getTime());
+});
+
+add_task(function* test_submission_far_future_scheduling() {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("submission_far_future_scheduling");
+
+  let now = new Date(Date.now() - 24 * 60 * 60 * 1000);
+  defineNow(policy, now);
+  yield ensureUserNotifiedAndTrigger(policy);
+
+  let nextDate = policy._futureDate(3 * 24 * 60 * 60 * 1000 - 1);
+  policy.nextDataSubmissionDate = nextDate;
+  policy.checkStateAndTrigger();
+  do_check_true(policy.dataSubmissionPolicyAcceptedVersion >= DATAREPORTING_POLICY_VERSION);
+  do_check_eq(listener.requestDataUploadCount, 0);
+  do_check_eq(policy.nextDataSubmissionDate.getTime(), nextDate.getTime());
+
+  policy.nextDataSubmissionDate = new Date(nextDate.getTime() + 1);
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestDataUploadCount, 0);
+  do_check_eq(policy.nextDataSubmissionDate.getTime(),
+              policy._futureDate(24 * 60 * 60 * 1000).getTime());
+});
+
+add_task(function* test_submission_backoff() {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("submission_backoff");
+
+  do_check_eq(policy.FAILURE_BACKOFF_INTERVALS.length, 2);
+
+
+  let now = new Date(policy.nextDataSubmissionDate.getTime());
+  defineNow(policy, now);
+  yield ensureUserNotifiedAndTrigger(policy);
+  do_check_eq(listener.requestDataUploadCount, 1);
+  do_check_eq(policy.currentDaySubmissionFailureCount, 0);
+
+  now = new Date(now.getTime() + 5000);
+  defineNow(policy, now);
+
+  // On first soft failure we should back off by scheduled interval.
+  yield listener.lastDataRequest.onSubmissionFailureSoft();
+  do_check_eq(policy.currentDaySubmissionFailureCount, 1);
+  do_check_eq(policy.nextDataSubmissionDate.getTime(),
+              new Date(now.getTime() + policy.FAILURE_BACKOFF_INTERVALS[0]).getTime());
+  do_check_eq(policy.lastDataSubmissionFailureDate.getTime(), now.getTime());
+
+  // Should not request submission until scheduled.
+  now = new Date(policy.nextDataSubmissionDate.getTime() - 1);
+  defineNow(policy, now);
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestDataUploadCount, 1);
+
+  // 2nd request for submission.
+  now = new Date(policy.nextDataSubmissionDate.getTime());
+  defineNow(policy, now);
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestDataUploadCount, 2);
+
+  now = new Date(now.getTime() + 5000);
+  defineNow(policy, now);
+
+  // On second failure we should back off by more.
+  yield listener.lastDataRequest.onSubmissionFailureSoft();
+  do_check_eq(policy.currentDaySubmissionFailureCount, 2);
+  do_check_eq(policy.nextDataSubmissionDate.getTime(),
+              new Date(now.getTime() + policy.FAILURE_BACKOFF_INTERVALS[1]).getTime());
+
+  now = new Date(policy.nextDataSubmissionDate.getTime());
+  defineNow(policy, now);
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestDataUploadCount, 3);
+
+  now = new Date(now.getTime() + 5000);
+  defineNow(policy, now);
+
+  // On 3rd failure we should back off by a whole day.
+  yield listener.lastDataRequest.onSubmissionFailureSoft();
+  do_check_eq(policy.currentDaySubmissionFailureCount, 0);
+  do_check_eq(policy.nextDataSubmissionDate.getTime(),
+              new Date(now.getTime() + 24 * 60 * 60 * 1000).getTime());
+});
+
+// Ensure that only one submission request can be active at a time.
+add_task(function* test_submission_expiring() {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("submission_expiring");
+
+  let nextDataSubmission = policy.nextDataSubmissionDate;
+  let now = new Date(policy.nextDataSubmissionDate.getTime());
+  defineNow(policy, now);
+  yield ensureUserNotifiedAndTrigger(policy);
+  do_check_eq(listener.requestDataUploadCount, 1);
+  defineNow(policy, new Date(now.getTime() + 500));
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestDataUploadCount, 1);
+
+  defineNow(policy, new Date(policy.now().getTime() +
+                             policy.SUBMISSION_REQUEST_EXPIRE_INTERVAL_MSEC));
+
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestDataUploadCount, 2);
+});
+
+add_task(function* test_delete_remote_data() {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("delete_remote_data");
+
+  do_check_false(policy.pendingDeleteRemoteData);
+  let nextSubmissionDate = policy.nextDataSubmissionDate;
+
+  let now = new Date();
+  defineNow(policy, now);
+
+  policy.deleteRemoteData();
+  do_check_true(policy.pendingDeleteRemoteData);
+  do_check_neq(nextSubmissionDate.getTime(),
+               policy.nextDataSubmissionDate.getTime());
+  do_check_eq(now.getTime(), policy.nextDataSubmissionDate.getTime());
+
+  do_check_eq(listener.requestRemoteDeleteCount, 1);
+  do_check_true(listener.lastRemoteDeleteRequest.isDelete);
+  defineNow(policy, policy._futureDate(1000));
+
+  yield listener.lastRemoteDeleteRequest.onSubmissionSuccess(policy.now());
+  do_check_false(policy.pendingDeleteRemoteData);
+});
+
+// Ensure that deletion requests take priority over regular data submission.
+add_task(function* test_delete_remote_data_priority() {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("delete_remote_data_priority");
+
+  let now = new Date();
+  defineNow(policy, new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000));
+
+  yield ensureUserNotifiedAndTrigger(policy);
+  do_check_eq(listener.requestDataUploadCount, 1);
+  policy._inProgressSubmissionRequest = null;
+
+  policy.deleteRemoteData();
+  policy.checkStateAndTrigger();
+
+  do_check_eq(listener.requestRemoteDeleteCount, 1);
+  do_check_eq(listener.requestDataUploadCount, 1);
+});
+
+add_test(function test_delete_remote_data_backoff() {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("delete_remote_data_backoff");
+
+  let now = new Date();
+  defineNow(policy, now);
+  policy.nextDataSubmissionDate = now;
+  policy.deleteRemoteData();
+
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestRemoteDeleteCount, 1);
+  defineNow(policy, policy._futureDate(1000));
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestDataUploadCount, 0);
+  do_check_eq(listener.requestRemoteDeleteCount, 1);
+
+  defineNow(policy, policy._futureDate(500));
+  listener.lastRemoteDeleteRequest.onSubmissionFailureSoft();
+  defineNow(policy, policy._futureDate(50));
+
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestRemoteDeleteCount, 1);
+
+  defineNow(policy, policy._futureDate(policy.FAILURE_BACKOFF_INTERVALS[0] - 50));
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestRemoteDeleteCount, 2);
+
+  run_next_test();
+});
+
+// If we request delete while an upload is in progress, delete should be
+// scheduled immediately after upload.
+add_task(function* test_delete_remote_data_in_progress_upload() {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("delete_remote_data_in_progress_upload");
+
+  defineNow(policy, policy.nextDataSubmissionDate);
+
+  yield ensureUserNotifiedAndTrigger(policy);
+  do_check_eq(listener.requestDataUploadCount, 1);
+  defineNow(policy, policy._futureDate(50 * 1000));
+
+  // If we request a delete during a pending request, nothing should be done.
+  policy.deleteRemoteData();
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestDataUploadCount, 1);
+  do_check_eq(listener.requestRemoteDeleteCount, 0);
+
+  // Now wait a little bit and finish the request.
+  defineNow(policy, policy._futureDate(10 * 1000));
+  yield listener.lastDataRequest.onSubmissionSuccess(policy._futureDate(1000));
+  defineNow(policy, policy._futureDate(5000));
+
+  policy.checkStateAndTrigger();
+  do_check_eq(listener.requestDataUploadCount, 1);
+  do_check_eq(listener.requestRemoteDeleteCount, 1);
+});
+
+add_test(function test_polling() {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("polling");
+  let intended = 500;
+  let acceptable = 250;     // Because nsITimer doesn't guarantee times.
+
+  // Ensure checkStateAndTrigger is called at a regular interval.
+  let then = Date.now();
+  print("Starting run: " + then);
+  Object.defineProperty(policy, "POLL_INTERVAL_MSEC", {
+    value: intended,
+  });
+  let count = 0;
+
+  Object.defineProperty(policy, "checkStateAndTrigger", {
+    value: function fakeCheckStateAndTrigger() {
+      let now = Date.now();
+      let after = now - then;
+      count++;
+
+      print("Polled at " + now + " after " + after + "ms, intended " + intended);
+      do_check_true(after >= acceptable);
+      DataReportingPolicy.prototype.checkStateAndTrigger.call(policy);
+
+      if (count >= 2) {
+        policy.stopPolling();
+
+        do_check_eq(listener.requestDataUploadCount, 0);
+
+        run_next_test();
+      }
+
+      // "Specified timer period will be at least the time between when
+      // processing for last firing the callback completes and when the next
+      // firing occurs."
+      //
+      // That means we should set 'then' at the *end* of our handler, not
+      // earlier.
+      then = Date.now();
+    }
+  });
+  policy.startPolling();
+});
+
+add_task(function* test_record_health_report_upload_enabled() {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("record_health_report_upload_enabled");
+
+  // Preconditions.
+  do_check_false(policy.pendingDeleteRemoteData);
+  do_check_true(policy.healthReportUploadEnabled);
+  do_check_eq(listener.requestRemoteDeleteCount, 0);
+
+  // User intent to disable should immediately result in a pending
+  // delete request.
+  policy.recordHealthReportUploadEnabled(false, "testing 1 2 3");
+  do_check_false(policy.healthReportUploadEnabled);
+  do_check_true(policy.pendingDeleteRemoteData);
+  do_check_eq(listener.requestRemoteDeleteCount, 1);
+
+  // Fulfilling it should make it go away.
+  yield listener.lastRemoteDeleteRequest.onNoDataAvailable();
+  do_check_false(policy.pendingDeleteRemoteData);
+
+  // User intent to enable should get us back to default state.
+  policy.recordHealthReportUploadEnabled(true, "testing 1 2 3");
+  do_check_false(policy.pendingDeleteRemoteData);
+  do_check_true(policy.healthReportUploadEnabled);
+});
+
+add_test(function test_pref_change_initiates_deletion() {
+  let [policy, policyPrefs, hrPrefs, listener] = getPolicy("record_health_report_upload_enabled");
+
+  // Preconditions.
+  do_check_false(policy.pendingDeleteRemoteData);
+  do_check_true(policy.healthReportUploadEnabled);
+  do_check_eq(listener.requestRemoteDeleteCount, 0);
+
+  // User intent to disable should indirectly result in a pending
+  // delete request, because the policy is watching for the pref
+  // to change.
+  Object.defineProperty(policy, "deleteRemoteData", {
+    value: function deleteRemoteDataProxy() {
+      do_check_false(policy.healthReportUploadEnabled);
+      do_check_false(policy.pendingDeleteRemoteData);     // Just called.
+
+      run_next_test();
+    },
+  });
+
+  hrPrefs.set("uploadEnabled", false);
+});
+
+add_task(function* test_policy_version() {
+  let policy, policyPrefs, hrPrefs, listener, now, firstRunTime;
+  function createPolicy(shouldBeNotified = false,
+                        currentPolicyVersion = 1, minimumPolicyVersion = 1,
+                        branchMinimumVersionOverride) {
+    [policy, policyPrefs, hrPrefs, listener] =
+      getPolicy("policy_version_test", currentPolicyVersion,
+                minimumPolicyVersion, branchMinimumVersionOverride);
+    let firstRun = now === undefined;
+    if (firstRun) {
+      firstRunTime = policy.firstRunDate.getTime();
+      do_check_true(firstRunTime > 0);
+      now = new Date(policy.firstRunDate.getTime());
+    }
+    else {
+      // The first-run time should not be reset even after policy-version
+      // upgrades.
+      do_check_eq(policy.firstRunDate.getTime(), firstRunTime);
+    }
+    defineNow(policy, now);
+    do_check_eq(policy.userNotifiedOfCurrentPolicy, shouldBeNotified);
+  }
+
+  function* triggerPolicyCheckAndEnsureNotified(notified = true) {
+    policy.checkStateAndTrigger();
+    do_check_eq(listener.notifyUserCount, Number(notified));
+    if (notified) {
+      policy.ensureUserNotified();
+      yield listener.lastNotifyRequest.deferred.promise;
+      do_check_true(policy.userNotifiedOfCurrentPolicy);
+      do_check_eq(policyPrefs.get("dataSubmissionPolicyAcceptedVersion"),
+                  policyPrefs.get("currentPolicyVersion"));
+    }
+  }
+
+  createPolicy();
+  yield triggerPolicyCheckAndEnsureNotified();
+
+  // We shouldn't be notified again if the current version is still valid;
+  createPolicy(true);
+  yield triggerPolicyCheckAndEnsureNotified(false);
+
+  // Just increasing the current version isn't enough. The minimum
+  // version must be changed.
+  let currentPolicyVersion = policyPrefs.get("currentPolicyVersion");
+  let minimumPolicyVersion = policyPrefs.get("minimumPolicyVersion");
+  createPolicy(false, ++currentPolicyVersion, minimumPolicyVersion);
+  yield triggerPolicyCheckAndEnsureNotified(true);
+  do_check_eq(policyPrefs.get("dataSubmissionPolicyAcceptedVersion"), currentPolicyVersion);
+
+  // Increase the minimum policy version and check if we're notified.
+
+  createPolicy(true, currentPolicyVersion, ++minimumPolicyVersion);
+  do_check_true(policyPrefs.has("dataSubmissionPolicyAcceptedVersion"));
+  yield triggerPolicyCheckAndEnsureNotified(false);
+
+
+  // Test increasing the minimum version just on the current channel.
+  createPolicy(true, currentPolicyVersion, minimumPolicyVersion);
+  yield triggerPolicyCheckAndEnsureNotified(false);
+  createPolicy(false, ++currentPolicyVersion, minimumPolicyVersion, minimumPolicyVersion + 1);
+  yield triggerPolicyCheckAndEnsureNotified(true);
+});
new file mode 100644
--- /dev/null
+++ b/services/datareporting/tests/xpcshell/xpcshell.ini
@@ -0,0 +1,6 @@
+[DEFAULT]
+head = head.js
+tail =
+skip-if = toolkit == 'android' || toolkit == 'gonk'
+
+[test_policy.js]
new file mode 100644
--- /dev/null
+++ b/services/docs/datareporting.rst
@@ -0,0 +1,28 @@
+.. _data_reporting_service:
+
+======================
+Data Reporting Service
+======================
+
+``/services/datareporting`` contains files related to an XPCOM service
+that collects and reports data within Gecko applications.
+
+The important files in this directory are:
+
+DataReportingService.js
+   An XPCOM service that coordinates collection and reporting of data.
+
+policy.jsm
+   A module containing the logic for coordinating and driving collection
+   and upload of data.
+
+sessions.jsm
+   Records Gecko application session history. This is loaded as part of
+   the XPCOM service because it needs to capture state from very early in
+   the application lifecycle. Bug 841561 tracks implementing this in C++.
+
+There is other code in the tree that collects and uploads data. The
+original intent of this directory and XPCOM service was to serve as a
+focal point for the coordination of all this activity so that it could
+all be done consistently and properly. This vision may or may not be fully
+realized.
new file mode 100644
--- /dev/null
+++ b/services/docs/index.rst
@@ -0,0 +1,17 @@
+=======================
+Firefox Services Module
+=======================
+
+The ``/services`` directory contains code for a variety of application
+features that communicate with external services - hence its name.
+
+It was originally created to hold code for Firefox Sync. Later, it
+became the location for code written by the Mozilla Services Client team
+and thus includes :ref:`healthreport`. This team no longer exists, but
+the directory remains.
+
+.. toctree::
+   :maxdepth: 1
+
+   metrics
+   datareporting
new file mode 100644
--- /dev/null
+++ b/services/docs/metrics.rst
@@ -0,0 +1,130 @@
+.. _services_metrics:
+
+============================
+Metrics Collection Framework
+============================
+
+The ``services/metrics`` directory contains a generic data metrics
+collecting and persisting framework for Gecko applications.
+
+Overview
+========
+
+The Metrics framework by itself doesn't do much: it simply provides a
+generic mechanism for collecting and persisting data. It is up to users
+of this framework to drive collection and do something with the obtained
+data. A consumer of this framework is :ref:`healthreport`.
+
+Relationship to Telemetry
+-------------------------
+
+Telemetry provides similar features to code in this directory. The two
+may be unified in the future.
+
+Usage
+=====
+
+To use the code in this directory, import Metrics.jsm. e.g.
+
+   Components.utils.import("resource://gre/modules/Metrics.jsm");
+
+This exports a *Metrics* object which holds references to the main JS
+types and functions provided by this feature. Read below for what those
+types are.
+
+Metrics Types
+=============
+
+``Metrics.jsm`` exports a number of types. They are documented in the
+sections below.
+
+Metrics.Provider
+----------------
+
+``Metrics.Provider`` is an entity that collects and manages data. Providers
+are typically domain-specific: if you need to collect a new type of data,
+you create a ``Metrics.Provider`` type that does this.
+
+Metrics.Measurement
+-------------------
+
+A ``Metrics.Measurement`` represents a collection of related pieces/fields
+of data.
+
+All data recorded by the metrics framework is modeled as
+``Metrics.Measurement`` instances. Instances of ``Metrics.Measurement``
+are essentially data structure descriptors.
+
+Each ``Metrics.Measurement`` consists of a name and version to identify
+itself (and its data) as well as a list of *fields* that this measurement
+holds. A *field* is effectively an entry in a data structure. It consists
+of a name and strongly enumerated type.
+
+Metrics.Storage
+---------------
+
+This entity is responsible for persisting collected data and state.
+
+It currently uses SQLite to store data, but this detail is abstracted away
+in order to facilitate swapping of storage backends.
+
+Metrics.ProviderManager
+-----------------------
+
+High-level entity coordinating activity among several ``Metrics.Provider``
+instances.
+
+Providers and Measurements
+==========================
+
+The most important types in this framework are ``Metrics.Provider`` and
+``Metrics.Measurement``, henceforth known as ``Provider`` and
+``Measurement``, respectively. As you will see, these two types go
+hand in hand.
+
+A ``Provider`` is an entity that *provides* data about a specific subsystem
+or feature. They do this by recording data to specific ``Measurement``
+types. Both ``Provider`` and ``Measurement`` are abstract base types.
+
+A ``Measurement`` implementation defines a name and version. More
+importantly, it also defines its storage requirements and how
+previously-stored values are serialized.
+
+Storage allocation is performed by communicating with the SQLite
+backend. There is a startup function that tells SQLite what fields the
+measurement is recording. The storage backend then registers these in
+the database. Internally, this is creating a new primary key for
+individual fields so later storage operations can directly reference
+these primary keys in order to retrieve data without having to perform
+complicated joins.
+
+A ``Provider`` can be thought of as a collection of ``Measurement``
+implementations. e.g. an Addons provider may consist of a measurement
+for all *current* add-ons as well as a separate measurement for
+historical counts of add-ons. A provider's primary role is to take
+metrics data and write it to various measurements. This effectively
+persists the data to SQLite.
+
+Data is emitted from providers in either a push or pull based mechanism.
+In push-based scenarios, the provider likely subscribes to external
+events (e.g. observer notifications). An event of interest can occur at
+any time. When it does, the provider immediately writes the event of
+interest to storage or buffers it for eventual writing. In pull-based
+scenarios, the provider is periodically queried and asked to populate
+data.
+
+SQLite Storage
+==============
+
+``Metrics.Storage`` provides an interface for persisting metrics data to a
+SQLite database.
+
+The storage API organizes values by fields. A field is a named member of
+a ``Measurement`` that has specific type and retention characteristics.
+Some example field types include:
+
+* Last text value
+* Last numeric value for a given day
+* Discrete text values for a given day
+
+See ``storage.jsm`` for more.
new file mode 100644
--- /dev/null
+++ b/services/healthreport/HealthReport.jsm
@@ -0,0 +1,43 @@
+/* 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";
+
+this.EXPORTED_SYMBOLS = [
+  "HealthReporter",
+  "AddonsProvider",
+  "AppInfoProvider",
+  "CrashesProvider",
+  "HealthReportProvider",
+  "HotfixProvider",
+  "Metrics",
+  "PlacesProvider",
+  "ProfileMetadataProvider",
+  "SearchesProvider",
+  "SessionsProvider",
+  "SysInfoProvider",
+];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
+
+// We concatenate the JSMs together to eliminate compartment overhead.
+// This is a giant hack until compartment overhead is no longer an
+// issue.
+#define MERGED_COMPARTMENT
+
+#include ../common/async.js
+;
+#include ../common/bagheeraclient.js
+;
+#include ../metrics/Metrics.jsm
+;
+#include healthreporter.jsm
+;
+#include profile.jsm
+;
+#include providers.jsm
+;
+
new file mode 100644
--- /dev/null
+++ b/services/healthreport/HealthReportComponents.manifest
@@ -0,0 +1,16 @@
+# Register Firefox Health Report providers.
+category healthreport-js-provider-default AddonsProvider resource://gre/modules/HealthReport.jsm
+category healthreport-js-provider-default AppInfoProvider resource://gre/modules/HealthReport.jsm
+#ifdef MOZ_CRASHREPORTER
+category healthreport-js-provider-default CrashesProvider resource://gre/modules/HealthReport.jsm
+#endif
+category healthreport-js-provider-default HealthReportProvider resource://gre/modules/HealthReport.jsm
+category healthreport-js-provider-default HotfixProvider resource://gre/modules/HealthReport.jsm
+category healthreport-js-provider-default PlacesProvider resource://gre/modules/HealthReport.jsm
+category healthreport-js-provider-default ProfileMetadataProvider resource://gre/modules/HealthReport.jsm
+category healthreport-js-provider-default SearchesProvider resource://gre/modules/HealthReport.jsm
+category healthreport-js-provider-default SessionsProvider resource://gre/modules/HealthReport.jsm
+category healthreport-js-provider-default SysInfoProvider resource://gre/modules/HealthReport.jsm
+
+# No Aurora or Beta providers yet; use the categories
+# "healthreport-js-provider-aurora", "healthreport-js-provider-beta".
rename from toolkit/components/telemetry/docs/fhr/architecture.rst
rename to services/healthreport/docs/architecture.rst
rename from toolkit/components/telemetry/docs/fhr/dataformat.rst
rename to services/healthreport/docs/dataformat.rst
rename from toolkit/components/telemetry/docs/fhr/identifiers.rst
rename to services/healthreport/docs/identifiers.rst
rename from toolkit/components/telemetry/docs/fhr/index.rst
rename to services/healthreport/docs/index.rst
--- a/toolkit/components/telemetry/docs/fhr/index.rst
+++ b/services/healthreport/docs/index.rst
@@ -1,16 +1,16 @@
 .. _healthreport:
 
-================================
-Firefox Health Report (Obsolete)
-================================
+=====================
+Firefox Health Report
+=====================
 
-**Firefox Health Report (FHR) is obsolete and no longer ships with Firefox.
-This documentation will live here for a few more cycles.**
+``/services/healthreport`` contains the implementation of the
+``Firefox Health Report`` (FHR).
 
 Firefox Health Report is a background service that collects application
 metrics and periodically submits them to a central server. The core
 parts of the service are implemented in this directory. However, the
 actual XPCOM service is implemented in the
 :ref:`data_reporting_service`.
 
 The core types can actually be instantiated multiple times and used to
rename from toolkit/components/telemetry/healthreport-prefs.js
rename to services/healthreport/healthreport-prefs.js
--- a/toolkit/components/telemetry/healthreport-prefs.js
+++ b/services/healthreport/healthreport-prefs.js
@@ -1,12 +1,38 @@
 #filter substitution
 /* 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/. */
 
+pref("datareporting.healthreport.currentDaySubmissionFailureCount", 0);
+pref("datareporting.healthreport.documentServerURI", "https://fhr.data.mozilla.com/");
+pref("datareporting.healthreport.documentServerNamespace", "metrics");
 pref("datareporting.healthreport.infoURL", "https://www.mozilla.org/legal/privacy/firefox.html#health-report");
+pref("datareporting.healthreport.logging.consoleEnabled", true);
+pref("datareporting.healthreport.logging.consoleLevel", "Warn");
+pref("datareporting.healthreport.logging.dumpEnabled", false);
+pref("datareporting.healthreport.logging.dumpLevel", "Debug");
+pref("datareporting.healthreport.lastDataSubmissionFailureTime", "0");
+pref("datareporting.healthreport.lastDataSubmissionRequestedTime", "0");
+pref("datareporting.healthreport.lastDataSubmissionSuccessfulTime", "0");
+pref("datareporting.healthreport.nextDataSubmissionTime", "0");
+pref("datareporting.healthreport.pendingDeleteRemoteData", false);
 
 // Health Report is enabled by default on all channels.
 pref("datareporting.healthreport.uploadEnabled", true);
 
+pref("datareporting.healthreport.service.enabled", true);
+pref("datareporting.healthreport.service.loadDelayMsec", 10000);
+pref("datareporting.healthreport.service.loadDelayFirstRunMsec", 60000);
+
+pref("datareporting.healthreport.service.providerCategories",
+#if MOZ_UPDATE_CHANNEL == release
+    "healthreport-js-provider-default"
+#elif MOZ_UPDATE_CHANNEL == default
+    "healthreport-js-provider-default"
+#else
+    "healthreport-js-provider-default,healthreport-js-provider-@MOZ_UPDATE_CHANNEL@"
+#endif
+    );
+
 pref("datareporting.healthreport.about.reportUrl", "https://fhr.cdn.mozilla.net/%LOCALE%/v4/");
 pref("datareporting.healthreport.about.reportUrlUnified", "https://fhr.cdn.mozilla.net/%LOCALE%/v4/");
new file mode 100644
--- /dev/null
+++ b/services/healthreport/healthreporter.jsm
@@ -0,0 +1,1507 @@
+/* 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/. */
+
+#ifndef MERGED_COMPARTMENT
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["HealthReporter"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
+
+Cu.import("resource://gre/modules/Metrics.jsm");
+Cu.import("resource://services-common/async.js");
+
+Cu.import("resource://services-common/bagheeraclient.js");
+#endif
+
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/TelemetryStopwatch.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "TelemetryController",
+                                  "resource://gre/modules/TelemetryController.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "UpdateUtils",
+                                  "resource://gre/modules/UpdateUtils.jsm");
+
+// Oldest year to allow in date preferences. This module was implemented in
+// 2012 and no dates older than that should be encountered.
+const OLDEST_ALLOWED_YEAR = 2012;
+
+const DAYS_IN_PAYLOAD = 180;
+
+const DEFAULT_DATABASE_NAME = "healthreport.sqlite";
+
+const TELEMETRY_INIT = "HEALTHREPORT_INIT_MS";
+const TELEMETRY_INIT_FIRSTRUN = "HEALTHREPORT_INIT_FIRSTRUN_MS";
+const TELEMETRY_DB_OPEN = "HEALTHREPORT_DB_OPEN_MS";
+const TELEMETRY_DB_OPEN_FIRSTRUN = "HEALTHREPORT_DB_OPEN_FIRSTRUN_MS";
+const TELEMETRY_GENERATE_PAYLOAD = "HEALTHREPORT_GENERATE_JSON_PAYLOAD_MS";
+const TELEMETRY_JSON_PAYLOAD_SERIALIZE = "HEALTHREPORT_JSON_PAYLOAD_SERIALIZE_MS";
+const TELEMETRY_PAYLOAD_SIZE_UNCOMPRESSED = "HEALTHREPORT_PAYLOAD_UNCOMPRESSED_BYTES";
+const TELEMETRY_PAYLOAD_SIZE_COMPRESSED = "HEALTHREPORT_PAYLOAD_COMPRESSED_BYTES";
+const TELEMETRY_UPLOAD = "HEALTHREPORT_UPLOAD_MS";
+const TELEMETRY_COLLECT_CONSTANT = "HEALTHREPORT_COLLECT_CONSTANT_DATA_MS";
+const TELEMETRY_COLLECT_DAILY = "HEALTHREPORT_COLLECT_DAILY_MS";
+const TELEMETRY_SHUTDOWN = "HEALTHREPORT_SHUTDOWN_MS";
+const TELEMETRY_COLLECT_CHECKPOINT = "HEALTHREPORT_POST_COLLECT_CHECKPOINT_MS";
+
+
+/**
+ * Helper type to assist with management of Health Reporter state.
+ *
+ * Instances are not meant to be created outside of a HealthReporter instance.
+ *
+ * There are two types of IDs associated with clients.
+ *
+ * Since the beginning of FHR, there has existed a per-upload ID: a UUID is
+ * generated at upload time and associated with the state before upload starts.
+ * That same upload includes a request to delete all other upload IDs known by
+ * the client.
+ *
+ * Per-upload IDs had the unintended side-effect of creating "orphaned"
+ * records/upload IDs on the server. So, a stable client identifer has been
+ * introduced. This client identifier is generated when it's missing and sent
+ * as part of every upload.
+ *
+ * There is a high chance we may remove upload IDs in the future.
+ */
+function HealthReporterState(reporter) {
+  this._reporter = reporter;
+
+  let profD = OS.Constants.Path.profileDir;
+
+  if (!profD || !profD.length) {
+    throw new Error("Could not obtain profile directory. OS.File not " +
+                    "initialized properly?");
+  }
+
+  this._log = reporter._log;
+
+  this._stateDir = OS.Path.join(profD, "healthreport");
+
+  // To facilitate testing.
+  let leaf = reporter._stateLeaf || "state.json";
+
+  this._filename = OS.Path.join(this._stateDir, leaf);
+  this._log.debug("Storing state in " + this._filename);
+  this._s = null;
+}
+
+HealthReporterState.prototype = Object.freeze({
+  /**
+   * Persistent string identifier associated with this client.
+   */
+  get clientID() {
+    return this._s.clientID;
+  },
+
+  /**
+   * The version associated with the client ID.
+   */
+  get clientIDVersion() {
+    return this._s.clientIDVersion;
+  },
+
+  get lastPingDate() {
+    return new Date(this._s.lastPingTime);
+  },
+
+  get lastSubmitID() {
+    return this._s.remoteIDs[0];
+  },
+
+  get remoteIDs() {
+    return this._s.remoteIDs;
+  },
+
+  get _lastPayloadPath() {
+    return OS.Path.join(this._stateDir, "lastpayload.json");
+  },
+
+  init: function () {
+    return Task.spawn(function* init() {
+      yield OS.File.makeDir(this._stateDir);
+
+      let drs = Cc["@mozilla.org/datareporting/service;1"]
+                  .getService(Ci.nsISupports)
+                  .wrappedJSObject;
+      let drsClientID = yield drs.getClientID();
+
+      let resetObjectState = function () {
+        this._s = {
+          // The payload version. This is bumped whenever there is a
+          // backwards-incompatible change.
+          v: 1,
+          // The persistent client identifier.
+          clientID: drsClientID,
+          // Denotes the mechanism used to generate the client identifier.
+          // 1: Random UUID.
+          clientIDVersion: 1,
+          // Upload IDs that might be on the server.
+          remoteIDs: [],
+          // When we last performed an uploaded.
+          lastPingTime: 0,
+          // Tracks whether we removed an outdated payload.
+          removedOutdatedLastpayload: false,
+        };
+      }.bind(this);
+
+      try {
+        this._s = yield CommonUtils.readJSON(this._filename);
+      } catch (ex if ex instanceof OS.File.Error &&
+               ex.becauseNoSuchFile) {
+        this._log.warn("Saved state file does not exist.");
+        resetObjectState();
+      } catch (ex) {
+        this._log.error("Exception when reading state from disk", ex);
+        resetObjectState();
+
+        // Don't save in case it goes away on next run.
+      }
+
+      if (typeof(this._s) != "object") {
+        this._log.warn("Read state is not an object. Resetting state.");
+        resetObjectState();
+        yield this.save();
+      }
+
+      if (this._s.v != 1) {
+        this._log.warn("Unknown version in state file: " + this._s.v);
+        resetObjectState();
+        // We explicitly don't save here in the hopes an application re-upgrade
+        // comes along and fixes us.
+      }
+
+      this._s.clientID = drsClientID;
+
+      // Always look for preferences. This ensures that downgrades followed
+      // by reupgrades don't result in excessive data loss.
+      for (let promise of this._migratePrefs()) {
+        yield promise;
+      }
+    }.bind(this));
+  },
+
+  save: function () {
+    this._log.info("Writing state file: " + this._filename);
+    return CommonUtils.writeJSON(this._s, this._filename);
+  },
+
+  addRemoteID: function (id) {
+    this._log.warn("Recording new remote ID: " + id);
+    this._s.remoteIDs.push(id);
+    return this.save();
+  },
+
+  removeRemoteID: function (id) {
+    return this.removeRemoteIDs(id ? [id] : []);
+  },
+
+  removeRemoteIDs: function (ids) {
+    if (!ids || !ids.length) {
+      this._log.warn("No IDs passed for removal.");
+      return Promise.resolve();
+    }
+
+    this._log.warn("Removing documents from remote ID list: " + ids);
+    let filtered = this._s.remoteIDs.filter((x) => ids.indexOf(x) === -1);
+
+    if (filtered.length == this._s.remoteIDs.length) {
+      return Promise.resolve();
+    }
+
+    this._s.remoteIDs = filtered;
+    return this.save();
+  },
+
+  setLastPingDate: function (date) {
+    this._s.lastPingTime = date.getTime();
+
+    return this.save();
+  },
+
+  updateLastPingAndRemoveRemoteID: function (date, id) {
+    return this.updateLastPingAndRemoveRemoteIDs(date, id ? [id] : []);
+  },
+
+  updateLastPingAndRemoveRemoteIDs: function (date, ids) {
+    if (!ids) {
+      return this.setLastPingDate(date);
+    }
+
+    this._log.info("Recording last ping time and deleted remote document.");
+    this._s.lastPingTime = date.getTime();
+    return this.removeRemoteIDs(ids);
+  },
+
+  _migratePrefs: function () {
+    let prefs = this._reporter._prefs;
+
+    let lastID = prefs.get("lastSubmitID", null);
+    let lastPingDate = CommonUtils.getDatePref(prefs, "lastPingTime",
+                                               0, this._log, OLDEST_ALLOWED_YEAR);
+
+    // If we have state from prefs, migrate and save it to a file then clear
+    // out old prefs.
+    if (lastID || (lastPingDate && lastPingDate.getTime() > 0)) {
+      this._log.warn("Migrating saved state from preferences.");
+
+      if (lastID) {
+        this._log.info("Migrating last saved ID: " + lastID);
+        this._s.remoteIDs.push(lastID);
+      }
+
+      let ourLast = this.lastPingDate;
+
+      if (lastPingDate && lastPingDate.getTime() > ourLast.getTime()) {
+        this._log.info("Migrating last ping time: " + lastPingDate);
+        this._s.lastPingTime = lastPingDate.getTime();
+      }
+
+      yield this.save();
+      prefs.reset(["lastSubmitID", "lastPingTime"]);
+    } else {
+      this._log.debug("No prefs data found.");
+    }
+  },
+});
+
+/**
+ * This is the abstract base class of `HealthReporter`. It exists so that
+ * we can sanely divide work on platforms where control of Firefox Health
+ * Report is outside of Gecko (e.g., Android).
+ */
+function AbstractHealthReporter(branch, policy, sessionRecorder) {
+  if (!branch.endsWith(".")) {
+    throw new Error("Branch must end with a period (.): " + branch);
+  }
+
+  if (!policy) {
+    throw new Error("Must provide policy to HealthReporter constructor.");
+  }
+
+  this._log = Log.repository.getLogger("Services.HealthReport.HealthReporter");
+  this._log.info("Initializing health reporter instance against " + branch);
+
+  this._branch = branch;
+  this._prefs = new Preferences(branch);
+
+  this._policy = policy;
+  this.sessionRecorder = sessionRecorder;
+
+  this._dbName = this._prefs.get("dbName") || DEFAULT_DATABASE_NAME;
+
+  this._storage = null;
+  this._storageInProgress = false;
+  this._providerManager = null;
+  this._providerManagerInProgress = false;
+  this._initializeStarted = false;
+  this._initialized = false;
+  this._initializeHadError = false;
+  this._initializedDeferred = Promise.defer();
+  this._shutdownRequested = false;
+  this._shutdownInitiated = false;
+  this._shutdownComplete = false;
+  this._deferredShutdown = Promise.defer();
+  this._promiseShutdown = this._deferredShutdown.promise;
+
+  this._errors = [];
+
+  this._lastDailyDate = null;
+
+  // Yes, this will probably run concurrently with remaining constructor work.
+  let hasFirstRun = this._prefs.get("service.firstRun", false);
+  this._initHistogram = hasFirstRun ? TELEMETRY_INIT : TELEMETRY_INIT_FIRSTRUN;
+  this._dbOpenHistogram = hasFirstRun ? TELEMETRY_DB_OPEN : TELEMETRY_DB_OPEN_FIRSTRUN;
+
+  // This is set to the name for the provider that we are currently initializing,
+  // shutting down or collecting data from, if any.
+  // This is used for AsyncShutdownTimeout diagnostics.
+  this._currentProviderInShutdown = null;
+  this._currentProviderInInit = null;
+  this._currentProviderInCollect = null;
+}
+
+AbstractHealthReporter.prototype = Object.freeze({
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]),
+
+  /**
+   * Whether the service is fully initialized and running.
+   *
+   * If this is false, it is not safe to call most functions.
+   */
+  get initialized() {
+    return this._initialized;
+  },
+
+  /**
+   * Initialize the instance.
+   *
+   * This must be called once after object construction or the instance is
+   * useless.
+   */
+  init: function () {
+    if (this._initializeStarted) {
+      throw new Error("We have already started initialization.");
+    }
+
+    this._initializeStarted = true;
+
+    return Task.spawn(function*() {
+      TelemetryStopwatch.start(this._initHistogram, this);
+
+      try {
+        yield this._state.init();
+
+        if (!this._state._s.removedOutdatedLastpayload) {
+          yield this._deleteOldLastPayload();
+          this._state._s.removedOutdatedLastpayload = true;
+          // Normally we should save this to a file but it directly conflicts with
+          // the "application re-upgrade" decision in HealthReporterState::init()
+          // which specifically does not save the state to a file.
+        }
+      } catch (ex) {
+        this._log.error("Error deleting last payload", ex);
+      }
+
+      // As soon as we have could have storage, we need to register cleanup or
+      // else bad things happen on shutdown.
+      Services.obs.addObserver(this, "quit-application", false);
+
+      // The database needs to be shut down by the end of shutdown
+      // phase profileBeforeChange.
+      Metrics.Storage.shutdown.addBlocker("FHR: Flushing storage shutdown",
+        () => {
+          // Workaround bug 1017706
+          // Apparently, in some cases, quit-application is not triggered
+          // (or is triggered after profile-before-change), so we need to
+          // make sure that `_initiateShutdown()` is triggered at least
+          // once.
+          this._initiateShutdown();
+          return this._promiseShutdown;
+        },
+        () => ({
+            shutdownInitiated: this._shutdownInitiated,
+            initialized: this._initialized,
+            shutdownRequested: this._shutdownRequested,
+            initializeHadError: this._initializeHadError,
+            providerManagerInProgress: this._providerManagerInProgress,
+            storageInProgress: this._storageInProgress,
+            hasProviderManager: !!this._providerManager,
+            hasStorage: !!this._storage,
+            shutdownComplete: this._shutdownComplete,
+            currentProviderInShutdown: this._currentProviderInShutdown,
+            currentProviderInInit: this._currentProviderInInit,
+            currentProviderInCollect: this._currentProviderInCollect,
+          }));
+
+      try {
+        this._storageInProgress = true;
+        TelemetryStopwatch.start(this._dbOpenHistogram, this);
+        let storage = yield Metrics.Storage(this._dbName);
+        TelemetryStopwatch.finish(this._dbOpenHistogram, this);
+        yield this._onStorageCreated();
+
+        delete this._dbOpenHistogram;
+        this._log.info("Storage initialized.");
+        this._storage = storage;
+        this._storageInProgress = false;
+
+        if (this._shutdownRequested) {
+          this._initiateShutdown();
+          return null;
+        }
+
+        yield this._initializeProviderManager();
+        yield this._onProviderManagerInitialized();
+        this._initializedDeferred.resolve();
+        return this.onInit();
+      } catch (ex) {
+        yield this._onInitError(ex);
+        this._initializedDeferred.reject(ex);
+      }
+    }.bind(this));
+  },
+
+  //----------------------------------------------------
+  // SERVICE CONTROL FUNCTIONS
+  //
+  // You shouldn't need to call any of these externally.
+  //----------------------------------------------------
+
+  _onInitError: function (error) {
+    TelemetryStopwatch.cancel(this._initHistogram, this);
+    TelemetryStopwatch.cancel(this._dbOpenHistogram, this);
+    delete this._initHistogram;
+    delete this._dbOpenHistogram;
+
+    this._recordError("Error during initialization", error);
+    this._initializeHadError = true;
+    this._initiateShutdown();
+    return Promise.reject(error);
+
+    // FUTURE consider poisoning prototype's functions so calls fail with a
+    // useful error message.
+  },
+
+
+  /**
+   * Removes the outdated lastpaylaod.json and lastpayload.json.tmp files
+   * @see Bug #867902
+   * @return a promise for when all the files have been deleted
+   */
+  _deleteOldLastPayload: function () {
+    let paths = [this._state._lastPayloadPath, this._state._lastPayloadPath + ".tmp"];
+    return Task.spawn(function removeAllFiles () {
+      for (let path of paths) {
+        try {
+          OS.File.remove(path);
+        } catch (ex) {
+          if (!ex.becauseNoSuchFile) {
+            this._log.error("Exception when removing outdated payload files", ex);
+          }
+        }
+      }
+    }.bind(this));
+  },
+
+  _initializeProviderManager: Task.async(function* _initializeProviderManager() {
+    if (this._collector) {
+      throw new Error("Provider manager has already been initialized.");
+    }
+
+    this._log.info("Initializing provider manager.");
+    this._providerManager = new Metrics.ProviderManager(this._storage);
+    this._providerManager.onProviderError = this._recordError.bind(this);
+    this._providerManager.onProviderInit = this._initProvider.bind(this);
+    this._providerManagerInProgress = true;
+
+    let catString = this._prefs.get("service.providerCategories") || "";
+    if (catString.length) {
+      for (let category of catString.split(",")) {
+        yield this._providerManager.registerProvidersFromCategoryManager(category,
+                     providerName => this._currentProviderInInit = providerName);
+      }
+      this._currentProviderInInit = null;
+    }
+  }),
+
+  _onProviderManagerInitialized: function () {
+    TelemetryStopwatch.finish(this._initHistogram, this);
+    delete this._initHistogram;
+    this._log.debug("Provider manager initialized.");
+    this._providerManagerInProgress = false;
+
+    if (this._shutdownRequested) {
+      this._initiateShutdown();
+      return;
+    }
+
+    this._log.info("HealthReporter started.");
+    this._initialized = true;
+    Services.obs.addObserver(this, "idle-daily", false);
+
+    // If upload is not enabled, ensure daily collection works. If upload
+    // is enabled, this will be performed as part of upload.
+    //
+    // This is important because it ensures about:healthreport contains
+    // longitudinal data even if upload is disabled. Having about:healthreport
+    // provide useful info even if upload is disabled was a core launch
+    // requirement.
+    //
+    // We do not catch changes to the backing pref. So, if the session lasts
+    // many days, we may fail to collect. However, most sessions are short and
+    // this code will likely be refactored as part of splitting up policy to
+    // serve Android. So, meh.
+    if (!this._policy.healthReportUploadEnabled) {
+      this._log.info("Upload not enabled. Scheduling daily collection.");
+      // Since the timer manager is a singleton and there could be multiple
+      // HealthReporter instances, we need to encode a unique identifier in
+      // the timer ID.
+      try {
+        let timerName = this._branch.replace(/\./g, "-") + "lastDailyCollection";
+        let tm = Cc["@mozilla.org/updates/timer-manager;1"]
+                   .getService(Ci.nsIUpdateTimerManager);
+        tm.registerTimer(timerName, this.collectMeasurements.bind(this),
+                         24 * 60 * 60);
+      } catch (ex) {
+        this._log.error("Error registering collection timer", ex);
+      }
+    }
+
+    // Clean up caches and reduce memory usage.
+    this._storage.compact();
+  },
+
+  // nsIObserver to handle shutdown.
+  observe: function (subject, topic, data) {
+    switch (topic) {
+      case "quit-application":
+        Services.obs.removeObserver(this, "quit-application");
+        this._initiateShutdown();
+        break;
+
+      case "idle-daily":
+        this._performDailyMaintenance();
+        break;
+    }
+  },
+
+  _initiateShutdown: function () {
+    // Ensure we only begin the main shutdown sequence once.
+    if (this._shutdownInitiated) {
+      this._log.warn("Shutdown has already been initiated. No-op.");
+      return;
+    }
+
+    this._log.info("Request to shut down.");
+
+    this._initialized = false;
+    this._shutdownRequested = true;
+
+    if (this._initializeHadError) {
+      this._log.warn("Initialization had error. Shutting down immediately.");
+    } else {
+      if (this._providerManagerInProgress) {
+        this._log.warn("Provider manager is in progress of initializing. " +
+                       "Waiting to finish.");
+        return;
+      }
+
+      // If storage is in the process of initializing, we need to wait for it
+      // to finish before continuing. The initialization process will call us
+      // again once storage has initialized.
+      if (this._storageInProgress) {
+        this._log.warn("Storage is in progress of initializing. Waiting to finish.");
+        return;
+      }
+    }
+
+    this._log.warn("Initiating main shutdown procedure.");
+
+    // Everything from here must only be performed once or else race conditions
+    // could occur.
+
+    TelemetryStopwatch.start(TELEMETRY_SHUTDOWN, this);
+    this._shutdownInitiated = true;
+
+    // We may not have registered the observer yet. If not, this will
+    // throw.
+    try {
+      Services.obs.removeObserver(this, "idle-daily");
+    } catch (ex) { }
+
+    Task.spawn(function*() {
+      try {
+        if (this._providerManager) {
+          this._log.info("Shutting down provider manager.");
+          for (let provider of this._providerManager.providers) {
+            try {
+              this._log.info("Shutting down provider: " + provider.name);
+              this._currentProviderInShutdown = provider.name;
+              yield provider.shutdown();
+            } catch (ex) {
+              this._log.warn("Error when shutting down provider", ex);
+            }
+          }
+          this._log.info("Provider manager shut down.");
+          this._providerManager = null;
+          this._currentProviderInShutdown = null;
+          this._onProviderManagerShutdown();
+        }
+        if (this._storage) {
+          this._log.info("Shutting down storage.");
+          try {
+            yield this._storage.close();
+            yield this._onStorageClose();
+          } catch (error) {
+            this._log.warn("Error when closing storage", error);
+          }
+          this._storage = null;
+        }
+
+        this._log.warn("Shutdown complete.");
+        this._shutdownComplete = true;
+      } finally {
+        this._deferredShutdown.resolve();
+        TelemetryStopwatch.finish(TELEMETRY_SHUTDOWN, this);
+      }
+    }.bind(this));
+  },
+
+  onInit: function() {
+    return this._initializedDeferred.promise;
+  },
+
+  _onStorageCreated: function() {
+    // Do nothing.
+    // This method provides a hook point for the test suite.
+  },
+
+  _onStorageClose: function() {
+    // Do nothing.
+    // This method provides a hook point for the test suite.
+  },
+
+  _onProviderManagerShutdown: function() {
+    // Do nothing.
+    // This method provides a hook point for the test suite.
+  },
+
+  /**
+   * Convenience method to shut down the instance.
+   *
+   * This should *not* be called outside of tests.
+   */
+  _shutdown: function () {
+    this._initiateShutdown();
+    return this._promiseShutdown;
+  },
+
+  _performDailyMaintenance: function () {
+    this._log.info("Request to perform daily maintenance.");
+
+    if (!this._initialized) {
+      return;
+    }
+
+    let now = new Date();
+    let cutoff = new Date(now.getTime() - MILLISECONDS_PER_DAY * (DAYS_IN_PAYLOAD - 1));
+
+    // The operation is enqueued and put in a transaction by the storage module.
+    this._storage.pruneDataBefore(cutoff);
+  },
+
+  //--------------------
+  // Provider Management
+  //--------------------
+
+  /**
+   * Obtain a provider from its name.
+   *
+   * This will only return providers that are currently initialized. If
+   * a provider is lazy initialized (like pull-only providers) this
+   * will likely not return anything.
+   */
+  getProvider: function (name) {
+    if (!this._providerManager) {
+      return null;
+    }
+
+    return this._providerManager.getProvider(name);
+  },
+
+  _initProvider: function (provider) {
+    provider.healthReporter = this;
+  },
+
+  /**
+   * Record an exception for reporting in the payload.
+   *
+   * A side effect is the exception is logged.
+   *
+   * Note that callers need to be extra sensitive about ensuring personal
+   * or otherwise private details do not leak into this. All of the user data
+   * on the stack in FHR code should be limited to data we were collecting with
+   * the intent to submit. So, it is covered under the user's consent to use
+   * the feature.
+   *
+   * @param message
+   *        (string) Human readable message describing error.
+   * @param ex
+   *        (Error) The error that should be captured.
+   */
+  _recordError: function (message, ex) {
+    let recordMessage = message;
+    let logMessage = message;
+
+    if (ex) {
+      recordMessage += ": " + Log.exceptionStr(ex);
+      logMessage += ": " + Log.exceptionStr(ex);
+    }
+
+    // Scrub out potentially identifying information from strings that could
+    // make the payload.
+    let appData = Services.dirsvc.get("UAppData", Ci.nsIFile);
+    let profile = Services.dirsvc.get("ProfD", Ci.nsIFile);
+
+    let appDataURI = Services.io.newFileURI(appData);
+    let profileURI = Services.io.newFileURI(profile);
+
+    // Order of operation is important here. We do the URI before the path version
+    // because the path may be a subset of the URI. We also have to check for the case
+    // where UAppData is underneath the profile directory (or vice-versa) so we
+    // don't substitute incomplete strings.
+
+    // Return a /g regex that matches the provided string exactly.
+    function regexify(s) {
+      return new RegExp(s.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&"), "g");
+    }
+
+    function replace(uri, path, thing) {
+      // Try is because .spec can throw on invalid URI.
+      try {
+        recordMessage = recordMessage.replace(regexify(uri.spec), "<" + thing + "URI>");
+      } catch (ex) { }
+
+      recordMessage = recordMessage.replace(regexify(path), "<" + thing + "Path>");
+    }
+
+    if (appData.path.includes(profile.path)) {
+      replace(appDataURI, appData.path, 'AppData');
+      replace(profileURI, profile.path, 'Profile');
+    } else {
+      replace(profileURI, profile.path, 'Profile');
+      replace(appDataURI, appData.path, 'AppData');
+    }
+
+    this._log.warn(logMessage);
+    this._errors.push(recordMessage);
+  },
+
+  /**
+   * Collect all measurements for all registered providers.
+   */
+  collectMeasurements: function () {
+    if (!this._initialized) {
+      return Promise.reject(new Error("Not initialized."));
+    }
+
+    return Task.spawn(function doCollection() {
+      yield this._providerManager.ensurePullOnlyProvidersRegistered();
+
+      try {
+        TelemetryStopwatch.start(TELEMETRY_COLLECT_CONSTANT, this);
+        yield this._providerManager.collectConstantData(name => this._currentProviderInCollect = name);
+        this._currentProviderInCollect = null;
+        TelemetryStopwatch.finish(TELEMETRY_COLLECT_CONSTANT, this);
+      } catch (ex) {
+        TelemetryStopwatch.cancel(TELEMETRY_COLLECT_CONSTANT, this);
+        this._log.warn("Error collecting constant data", ex);
+      }
+
+      // Daily data is collected if it hasn't yet been collected this
+      // application session or if it has been more than a day since the
+      // last collection. This means that providers could see many calls to
+      // collectDailyData per calendar day. However, this collection API
+      // makes no guarantees about limits. The alternative would involve
+      // recording state. The simpler implementation prevails for now.
+      if (!this._lastDailyDate ||
+          Date.now() - this._lastDailyDate > MILLISECONDS_PER_DAY) {
+
+        try {
+          TelemetryStopwatch.start(TELEMETRY_COLLECT_DAILY, this);
+          this._lastDailyDate = new Date();
+          yield this._providerManager.collectDailyData(name => this._currentProviderInCollect = name);
+          this._currentProviderInCollect = null;
+          TelemetryStopwatch.finish(TELEMETRY_COLLECT_DAILY, this);
+        } catch (ex) {
+          TelemetryStopwatch.cancel(TELEMETRY_COLLECT_DAILY, this);
+          this._log.warn("Error collecting daily data from providers", ex);
+        }
+      }
+
+      yield this._providerManager.ensurePullOnlyProvidersUnregistered();
+
+      // Flush gathered data to disk. This will incur an fsync. But, if
+      // there is ever a time we want to persist data to disk, it's
+      // after a massive collection.
+      try {
+        TelemetryStopwatch.start(TELEMETRY_COLLECT_CHECKPOINT, this);
+        yield this._storage.checkpoint();
+        TelemetryStopwatch.finish(TELEMETRY_COLLECT_CHECKPOINT, this);
+      } catch (ex) {
+        TelemetryStopwatch.cancel(TELEMETRY_COLLECT_CHECKPOINT, this);
+        throw ex;
+      }
+
+      throw new Task.Result();
+    }.bind(this));
+  },
+
+  /**
+   * Helper function to perform data collection and obtain the JSON payload.
+   *
+   * If you are looking for an up-to-date snapshot of FHR data that pulls in
+   * new data since the last upload, this is how you should obtain it.
+   *
+   * @param asObject
+   *        (bool) Whether to resolve an object or JSON-encoded string of that
+   *        object (the default).
+   *
+   * @return Promise<Object | string>
+   */
+  collectAndObtainJSONPayload: function (asObject=false) {
+    if (!this._initialized) {
+      return Promise.reject(new Error("Not initialized."));
+    }
+
+    return Task.spawn(function collectAndObtain() {
+      yield this._storage.setAutoCheckpoint(0);
+      yield this._providerManager.ensurePullOnlyProvidersRegistered();
+
+      let payload;
+      let error;
+
+      try {
+        yield this.collectMeasurements();
+        payload = yield this.getJSONPayload(asObject);
+      } catch (ex) {
+        error = ex;
+        this._collectException("Error collecting and/or retrieving JSON payload",
+                               ex);
+      } finally {
+        yield this._providerManager.ensurePullOnlyProvidersUnregistered();
+        yield this._storage.setAutoCheckpoint(1);
+
+        if (error) {
+          throw error;
+        }
+      }
+
+      // We hold off throwing to ensure that behavior between finally
+      // and generators and throwing is sane.
+      throw new Task.Result(payload);
+    }.bind(this));
+  },
+
+
+  /**
+   * Obtain the JSON payload for currently-collected data.
+   *
+   * The payload only contains data that has been recorded to FHR. Some
+   * providers may have newer data available. If you want to ensure you
+   * have all available data, call `collectAndObtainJSONPayload`
+   * instead.
+   *
+   * @param asObject
+   *        (bool) Whether to return an object or JSON encoding of that
+   *        object (the default).
+   *
+   * @return Promise<string|object>
+   */
+  getJSONPayload: function (asObject=false) {
+    TelemetryStopwatch.start(TELEMETRY_GENERATE_PAYLOAD, this);
+    let deferred = Promise.defer();
+
+    Task.spawn(this._getJSONPayload.bind(this, this._now(), asObject)).then(
+      function onResult(result) {
+        TelemetryStopwatch.finish(TELEMETRY_GENERATE_PAYLOAD, this);
+        deferred.resolve(result);
+      }.bind(this),
+      function onError(error) {
+        TelemetryStopwatch.cancel(TELEMETRY_GENERATE_PAYLOAD, this);
+        deferred.reject(error);
+      }.bind(this)
+    );
+
+    return deferred.promise;
+  },
+
+  _getJSONPayload: function (now, asObject=false) {
+    let pingDateString = this._formatDate(now);
+    this._log.info("Producing JSON payload for " + pingDateString);
+
+    // May not be present if we are generating as a result of init error.
+    if (this._providerManager) {
+      yield this._providerManager.ensurePullOnlyProvidersRegistered();
+    }
+
+    let o = {
+      version: 2,
+      clientID: this._state.clientID,
+      clientIDVersion: this._state.clientIDVersion,
+      thisPingDate: pingDateString,
+      geckoAppInfo: this.obtainAppInfo(this._log),
+      data: {last: {}, days: {}},
+    };
+
+    let outputDataDays = o.data.days;
+
+    // Guard here in case we don't track this (e.g., on Android).
+    let lastPingDate = this.lastPingDate;
+    if (lastPingDate && lastPingDate.getTime() > 0) {
+      o.lastPingDate = this._formatDate(lastPingDate);
+    }
+
+    // We can still generate a payload even if we're not initialized.
+    // This is to facilitate error upload on init failure.
+    if (this._initialized) {
+      for (let provider of this._providerManager.providers) {
+        let providerName = provider.name;
+
+        let providerEntry = {
+          measurements: {},
+        };
+
+        // Measurement name to recorded version.
+        let lastVersions = {};
+        // Day string to mapping of measurement name to recorded version.
+        let dayVersions = {};
+
+        for (let [measurementKey, measurement] of provider.measurements) {
+          let name = providerName + "." + measurement.name;
+          let version = measurement.version;
+
+          let serializer;
+          try {
+            // The measurement is responsible for returning a serializer which
+            // is aware of the measurement version.
+            serializer = measurement.serializer(measurement.SERIALIZE_JSON);
+          } catch (ex) {
+            this._recordError("Error obtaining serializer for measurement: " +
+                              name, ex);
+            continue;
+          }
+
+          let data;
+          try {
+            data = yield measurement.getValues();
+          } catch (ex) {
+            this._recordError("Error obtaining data for measurement: " + name,
+                              ex);
+            continue;
+          }
+
+          if (data.singular.size) {
+            try {
+              let serialized = serializer.singular(data.singular);
+              if (serialized) {
+                // Only replace the existing data if there is no data or if our
+                // version is newer than the old one.
+                if (!(name in o.data.last) || version > lastVersions[name]) {
+                  o.data.last[name] = serialized;
+                  lastVersions[name] = version;
+                }
+              }
+            } catch (ex) {
+              this._recordError("Error serializing singular data: " + name,
+                                ex);
+              continue;
+            }
+          }
+
+          let dataDays = data.days;
+          for (let i = 0; i < DAYS_IN_PAYLOAD; i++) {
+            let date = new Date(now.getTime() - i * MILLISECONDS_PER_DAY);
+            if (!dataDays.hasDay(date)) {
+              continue;
+            }
+            let dateFormatted = this._formatDate(date);
+
+            try {
+              let serialized = serializer.daily(dataDays.getDay(date));
+              if (!serialized) {
+                continue;
+              }
+
+              if (!(dateFormatted in outputDataDays)) {
+                outputDataDays[dateFormatted] = {};
+              }
+
+              // This needs to be separate because dayVersions is provider
+              // specific and gets blown away in a loop while outputDataDays
+              // is persistent.
+              if (!(dateFormatted in dayVersions)) {
+                dayVersions[dateFormatted] = {};
+              }
+
+              if (!(name in outputDataDays[dateFormatted]) ||
+                  version > dayVersions[dateFormatted][name]) {
+                outputDataDays[dateFormatted][name] = serialized;
+                dayVersions[dateFormatted][name] = version;
+              }
+            } catch (ex) {
+              this._recordError("Error populating data for day: " + name, ex);
+              continue;
+            }
+          }
+        }
+      }
+    } else {
+      o.notInitialized = 1;
+      this._log.warn("Not initialized. Sending report with only error info.");
+    }
+
+    if (this._errors.length) {
+      o.errors = this._errors.slice(0, 20);
+    }
+
+    if (this._initialized) {
+      this._storage.compact();
+    }
+
+    if (!asObject) {
+      TelemetryStopwatch.start(TELEMETRY_JSON_PAYLOAD_SERIALIZE, this);
+      o = JSON.stringify(o);
+      TelemetryStopwatch.finish(TELEMETRY_JSON_PAYLOAD_SERIALIZE, this);
+    }
+
+    if (this._providerManager) {
+      yield this._providerManager.ensurePullOnlyProvidersUnregistered();
+    }
+
+    throw new Task.Result(o);
+  },
+
+  _now: function _now() {
+    return new Date();
+  },
+
+  // These are stolen from AppInfoProvider.
+  appInfoVersion: 1,
+  appInfoFields: {
+    // From nsIXULAppInfo.
+    vendor: "vendor",
+    name: "name",
+    id: "ID",
+    version: "version",
+    appBuildID: "appBuildID",
+    platformVersion: "platformVersion",
+    platformBuildID: "platformBuildID",
+
+    // From nsIXULRuntime.
+    os: "OS",
+    xpcomabi: "XPCOMABI",
+  },
+
+  /**
+   * Statically return a bundle of app info data, a subset of that produced by
+   * AppInfoProvider._populateConstants. This allows us to more usefully handle
+   * payloads that, due to error, contain no data.
+   *
+   * Returns a very sparse object if Services.appinfo is unavailable.
+   */
+  obtainAppInfo: function () {
+    let out = {"_v": this.appInfoVersion};
+    try {
+      let ai = Services.appinfo;
+      for (let [k, v] in Iterator(this.appInfoFields)) {
+        out[k] = ai[v];
+      }
+    } catch (ex) {
+      this._log.warn("Could not obtain Services.appinfo", ex);
+    }
+
+    try {
+      out["updateChannel"] = UpdateUtils.UpdateChannel;
+    } catch (ex) {
+      this._log.warn("Could not obtain update channel", ex);
+    }
+
+    return out;
+  },
+});
+
+/**
+ * HealthReporter and its abstract superclass coordinate collection and
+ * submission of health report metrics.
+ *
+ * This is the main type for Firefox Health Report on desktop. It glues all the
+ * lower-level components (such as collection and submission) together.
+ *
+ * An instance of this type is created as an XPCOM service. See
+ * DataReportingService.js and
+ * DataReporting.manifest/HealthReportComponents.manifest.
+ *
+ * It is theoretically possible to have multiple instances of this running
+ * in the application. For example, this type may one day handle submission
+ * of telemetry data as well. However, there is some moderate coupling between
+ * this type and *the* Firefox Health Report (e.g., the policy). This could
+ * be abstracted if needed.
+ *
+ * Note that `AbstractHealthReporter` exists to allow for Firefox Health Report
+ * to be more easily implemented on platforms where a separate controlling
+ * layer is responsible for payload upload and deletion.
+ *
+ * IMPLEMENTATION NOTES
+ * ====================
+ *
+ * These notes apply to the combination of `HealthReporter` and
+ * `AbstractHealthReporter`.
+ *
+ * Initialization and shutdown are somewhat complicated and worth explaining
+ * in extra detail.
+ *
+ * The complexity is driven by the requirements of SQLite connection management.
+ * Once you have a SQLite connection, it isn't enough to just let the
+ * application shut down. If there is an open connection or if there are
+ * outstanding SQL statements come XPCOM shutdown time, Storage will assert.
+ * On debug builds you will crash. On release builds you will get a shutdown
+ * hang. This must be avoided!
+ *
+ * During initialization, the second we create a SQLite connection (via
+ * Metrics.Storage) we register observers for application shutdown. The
+ * "quit-application" notification initiates our shutdown procedure. The
+ * subsequent "profile-do-change" notification ensures it has completed.
+ *
+ * The handler for "profile-do-change" may result in event loop spinning. This
+ * is because of race conditions between our shutdown code and application
+ * shutdown.
+ *
+ * All of our shutdown routines are async. There is the potential that these
+ * async functions will not complete before XPCOM shutdown. If they don't
+ * finish in time, we could get assertions in Storage. Our solution is to
+ * initiate storage early in the shutdown cycle ("quit-application").
+ * Hopefully all the async operations have completed by the time we reach
+ * "profile-do-change." If so, great. If not, we spin the event loop until
+ * they have completed, avoiding potential race conditions.
+ *
+ * @param branch
+ *        (string) The preferences branch to use for state storage. The value
+ *        must end with a period (.).
+ *
+ * @param policy
+ *        (HealthReportPolicy) Policy driving execution of HealthReporter.
+ */
+this.HealthReporter = function (branch, policy, stateLeaf=null) {
+  this._stateLeaf = stateLeaf;
+  this._uploadInProgress = false;
+
+  AbstractHealthReporter.call(this, branch, policy, TelemetryController.getSessionRecorder());
+
+  if (!this.serverURI) {
+    throw new Error("No server URI defined. Did you forget to define the pref?");
+  }
+
+  if (!this.serverNamespace) {
+    throw new Error("No server namespace defined. Did you forget a pref?");
+  }
+
+  this._state = new HealthReporterState(this);
+}
+
+this.HealthReporter.prototype = Object.freeze({
+  __proto__: AbstractHealthReporter.prototype,
+
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]),
+
+  get lastSubmitID() {
+    return this._state.lastSubmitID;
+  },
+
+  /**
+   * When we last successfully submitted data to the server.
+   *
+   * This is sent as part of the upload. This is redundant with similar data
+   * in the policy because we like the modules to be loosely coupled and the
+   * similar data in the policy is only used for forensic purposes.
+   */
+  get lastPingDate() {
+    return this._state.lastPingDate;
+  },
+
+  /**
+   * The base URI of the document server to which to submit data.
+   *
+   * This is typically a Bagheera server instance. It is the URI up to but not
+   * including the version prefix. e.g. https://data.metrics.mozilla.com/
+   */
+  get serverURI() {
+    return this._prefs.get("documentServerURI", null);
+  },
+
+  set serverURI(value) {
+    if (!value) {
+      throw new Error("serverURI must have a value.");
+    }
+
+    if (typeof(value) != "string") {
+      throw new Error("serverURI must be a string: " + value);
+    }
+
+    this._prefs.set("documentServerURI", value);
+  },
+
+  /**
+   * The namespace on the document server to which we will be submitting data.
+   */
+  get serverNamespace() {
+    return this._prefs.get("documentServerNamespace", "metrics");
+  },
+
+  set serverNamespace(value) {
+    if (!value) {
+      throw new Error("serverNamespace must have a value.");
+    }
+
+    if (typeof(value) != "string") {
+      throw new Error("serverNamespace must be a string: " + value);
+    }
+
+    this._prefs.set("documentServerNamespace", value);
+  },
+
+  /**
+   * Whether this instance will upload data to a server.
+   */
+  get willUploadData() {
+    return  this._policy.userNotifiedOfCurrentPolicy &&
+            this._policy.healthReportUploadEnabled;
+  },
+
+  /**
+   * Whether remote data is currently stored.
+   *
+   * @return bool
+   */
+  haveRemoteData: function () {
+    return !!this._state.lastSubmitID;
+  },
+
+  /**
+   * Called to initiate a data upload.
+   *
+   * The passed argument is a `DataSubmissionRequest` from policy.jsm.
+   */
+  requestDataUpload: function (request) {
+    if (!this._initialized) {
+      return Promise.reject(new Error("Not initialized."));
+    }
+
+    return Task.spawn(function doUpload() {
+      yield this._providerManager.ensurePullOnlyProvidersRegistered();
+      try {
+        yield this.collectMeasurements();
+        try {
+          yield this._uploadData(request);
+        } catch (ex) {
+          this._onSubmitDataRequestFailure(ex);
+        }
+      } finally {
+        yield this._providerManager.ensurePullOnlyProvidersUnregistered();
+      }
+    }.bind(this));
+  },
+
+  /**
+   * Request that server data be deleted.
+   *
+   * If deletion is scheduled to occur immediately, a promise will be returned
+   * that will be fulfilled when the deletion attempt finishes. Otherwise,
+   * callers should poll haveRemoteData() to determine when remote data is
+   * deleted.
+   */
+  requestDeleteRemoteData: function (reason) {
+    if (!this.haveRemoteData()) {
+      return;
+    }
+
+    return this._policy.deleteRemoteData(reason);
+  },
+
+  /**
+   * Override default handler to incur an upload describing the error.
+   */
+  _onInitError: function (error) {
+    // Need to capture this before we call the parent else it's always
+    // set.
+    let inShutdown = this._shutdownRequested;
+    let result;
+
+    try {
+      result = AbstractHealthReporter.prototype._onInitError.call(this, error);
+    } catch (ex) {
+      this._log.error("Error when calling _onInitError", ex);
+    }
+
+    // This bypasses a lot of the checks in policy, such as respect for
+    // backoff. We should arguably not do this. However, reporting
+    // startup errors is important. And, they should not occur with much
+    // frequency in the wild. So, it shouldn't be too big of a deal.
+    if (!inShutdown &&
+        this._policy.healthReportUploadEnabled &&
+        this._policy.ensureUserNotified()) {
+      // We don't care about what happens to this request. It's best
+      // effort.
+      let request = {
+        onNoDataAvailable: function () {},
+        onSubmissionSuccess: function () {},
+        onSubmissionFailureSoft: function () {},
+        onSubmissionFailureHard: function () {},
+        onUploadInProgress: function () {},
+      };
+
+      this._uploadData(request);
+    }
+
+    return result;
+  },
+
+  _onBagheeraResult: function (request, isDelete, date, result) {
+    this._log.debug("Received Bagheera result.");
+
+    return Task.spawn(function onBagheeraResult() {
+      let hrProvider = this.getProvider("org.mozilla.healthreport");
+
+      if (!result.transportSuccess) {
+        // The built-in provider may not be initialized if this instance failed
+        // to initialize fully.
+        if (hrProvider && !isDelete) {
+          try {
+            hrProvider.recordEvent("uploadTransportFailure", date);
+          } catch (ex) {
+            this._log.error("Error recording upload transport failure", ex);
+          }
+        }
+
+        request.onSubmissionFailureSoft("Network transport error.");
+        throw new Task.Result(false);
+      }
+
+      if (!result.serverSuccess) {
+        if (hrProvider && !isDelete) {
+          try {
+            hrProvider.recordEvent("uploadServerFailure", date);
+          } catch (ex) {
+            this._log.error("Error recording server failure", ex);
+          }
+        }
+
+        request.onSubmissionFailureHard("Server failure.");
+        throw new Task.Result(false);
+      }
+
+      if (hrProvider && !isDelete) {
+        try {
+          hrProvider.recordEvent("uploadSuccess", date);
+        } catch (ex) {
+          this._log.error("Error recording upload success", ex);
+        }
+      }
+
+      if (isDelete) {
+        this._log.warn("Marking delete as successful.");
+        yield this._state.removeRemoteIDs([result.id]);
+      } else {
+        this._log.warn("Marking upload as successful.");
+        yield this._state.updateLastPingAndRemoveRemoteIDs(date, result.deleteIDs);
+      }
+
+      request.onSubmissionSuccess(this._now());
+
+      throw new Task.Result(true);
+    }.bind(this));
+  },
+
+  _onSubmitDataRequestFailure: function (error) {
+    this._log.error("Error processing request to submit data", error);
+  },
+
+  _formatDate: function (date) {
+    // Why, oh, why doesn't JS have a strftime() equivalent?
+    return date.toISOString().substr(0, 10);
+  },
+
+  _uploadData: function (request) {
+    // Under ideal circumstances, clients should never race to this
+    // function. However, server logs have observed behavior where
+    // racing to this function could be a cause. So, this lock was
+    // instituted.
+    if (this._uploadInProgress) {
+      this._log.warn("Upload requested but upload already in progress.");
+      let provider = this.getProvider("org.mozilla.healthreport");
+      let promise = provider.recordEvent("uploadAlreadyInProgress");
+      request.onUploadInProgress("Upload already in progress.");
+      return promise;
+    }
+
+    let id = CommonUtils.generateUUID();
+
+    this._log.info("Uploading data to server: " + this.serverURI + " " +
+                   this.serverNamespace + ":" + id);
+    let client = new BagheeraClient(this.serverURI);
+    let now = this._now();
+
+    return Task.spawn(function doUpload() {
+      try {
+        // The test for upload locking monkeypatches getJSONPayload.
+        // If the next two lines change, be sure to verify the test is
+        // accurate!
+        this._uploadInProgress = true;
+        let payload = yield this.getJSONPayload();
+
+        let histogram = Services.telemetry.getHistogramById(TELEMETRY_PAYLOAD_SIZE_UNCOMPRESSED);
+        histogram.add(payload.length);
+
+        let lastID = this.lastSubmitID;
+        yield this._state.addRemoteID(id);
+
+        let hrProvider = this.getProvider("org.mozilla.healthreport");
+        if (hrProvider) {
+          let event = lastID ? "continuationUploadAttempt"
+                             : "firstDocumentUploadAttempt";
+          try {
+            hrProvider.recordEvent(event, now);
+          } catch (ex) {
+            this._log.error("Error when recording upload attempt", ex);
+          }
+        }
+
+        TelemetryStopwatch.start(TELEMETRY_UPLOAD, this);
+        let result;
+        try {
+          let options = {
+            deleteIDs: this._state.remoteIDs.filter((x) => { return x != id; }),
+            telemetryCompressed: TELEMETRY_PAYLOAD_SIZE_COMPRESSED,
+          };
+          result = yield client.uploadJSON(this.serverNamespace, id, payload,
+                                           options);
+          TelemetryStopwatch.finish(TELEMETRY_UPLOAD, this);
+        } catch (ex) {
+          TelemetryStopwatch.cancel(TELEMETRY_UPLOAD, this);
+          if (hrProvider) {
+            try {
+              hrProvider.recordEvent("uploadClientFailure", now);
+            } catch (ex) {
+              this._log.error("Error when recording client failure", ex);
+            }
+          }
+          throw ex;
+        }
+
+        yield this._onBagheeraResult(request, false, now, result);
+      } finally {
+        this._uploadInProgress = false;
+      }
+    }.bind(this));
+  },
+
+  /**
+   * Request deletion of remote data.
+   *
+   * @param request
+   *        (DataSubmissionRequest) Tracks progress of this request.
+   */
+  deleteRemoteData: function (request) {
+    if (!this._state.lastSubmitID) {
+      this._log.info("Received request to delete remote data but no data stored.");
+      request.onNoDataAvailable();
+      return;
+    }
+
+    this._log.warn("Deleting remote data.");
+    let client = new BagheeraClient(this.serverURI);
+
+    return Task.spawn(function* doDelete() {
+      try {
+        let result = yield client.deleteDocument(this.serverNamespace,
+                                                 this.lastSubmitID);
+        yield this._onBagheeraResult(request, true, this._now(), result);
+      } catch (ex) {
+        this._log.error("Error processing request to delete data", ex);
+      }
+    }.bind(this));
+  },
+});
+
new file mode 100644
--- /dev/null
+++ b/services/healthreport/modules-testing/utils.jsm
@@ -0,0 +1,219 @@
+/* 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";
+
+this.EXPORTED_SYMBOLS = [
+  "getAppInfo",
+  "updateAppInfo",
+  "createFakeCrash",
+  "InspectedHealthReporter",
+  "getHealthReporter",
+];
+
+
+const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/FileUtils.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/services-common/utils.js");
+Cu.import("resource://gre/modules/services/datareporting/policy.jsm");
+Cu.import("resource://gre/modules/services/healthreport/healthreporter.jsm");
+Cu.import("resource://gre/modules/services/healthreport/providers.jsm");
+Cu.import("resource://testing-common/services/datareporting/mocks.jsm");
+
+
+var APP_INFO = {
+  vendor: "Mozilla",
+  name: "xpcshell",
+  ID: "xpcshell@tests.mozilla.org",
+  version: "1",
+  appBuildID: "20121107",
+  platformVersion: "p-ver",
+  platformBuildID: "20121106",
+  inSafeMode: false,
+  logConsoleErrors: true,
+  OS: "XPCShell",
+  XPCOMABI: "noarch-spidermonkey",
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsIXULAppInfo, Ci.nsIXULRuntime]),
+  invalidateCachesOnRestart: function() {},
+};
+
+
+/**
+ * Obtain a reference to the current object used to define XULAppInfo.
+ */
+this.getAppInfo = function () { return APP_INFO; }
+
+/**
+ * Update the current application info.
+ *
+ * If the argument is defined, it will be the object used. Else, APP_INFO is
+ * used.
+ *
+ * To change the current XULAppInfo, simply call this function. If there was
+ * a previously registered app info object, it will be unloaded and replaced.
+ */
+this.updateAppInfo = function (obj) {
+  obj = obj || APP_INFO;
+  APP_INFO = obj;
+
+  let id = Components.ID("{fbfae60b-64a4-44ef-a911-08ceb70b9f31}");
+  let cid = "@mozilla.org/xre/app-info;1";
+  let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+
+  // Unregister an existing factory if one exists.
+  try {
+    let existing = Components.manager.getClassObjectByContractID(cid, Ci.nsIFactory);
+    registrar.unregisterFactory(id, existing);
+  } catch (ex) {}
+
+  let factory = {
+    createInstance: function (outer, iid) {
+      if (outer != null) {
+        throw Cr.NS_ERROR_NO_AGGREGATION;
+      }
+
+      return obj.QueryInterface(iid);
+    },
+  };
+
+  registrar.registerFactory(id, "XULAppInfo", cid, factory);
+};
+
+/**
+ * Creates a fake crash in the Crash Reports directory.
+ *
+ * Currently, we just create a dummy file. A more robust implementation would
+ * create something that actually resembles a crash report file.
+ *
+ * This is very similar to code in crashreporter/tests/browser/head.js.
+ *
+ * FUTURE consolidate code in a shared JSM.
+ */
+this.createFakeCrash = function (submitted=false, date=new Date()) {
+  let id = CommonUtils.generateUUID();
+  let filename;
+
+  let paths = ["Crash Reports"];
+  let mode;
+
+  if (submitted) {
+    paths.push("submitted");
+    filename = "bp-" + id + ".txt";
+    mode = OS.Constants.libc.S_IRUSR | OS.Constants.libc.S_IWUSR |
+           OS.Constants.libc.S_IRGRP | OS.Constants.libc.S_IROTH;
+  } else {
+    paths.push("pending");
+    filename = id + ".dmp";
+    mode = OS.Constants.libc.S_IRUSR | OS.Constants.libc.S_IWUSR;
+  }
+
+  paths.push(filename);
+
+  let file = FileUtils.getFile("UAppData", paths, true);
+  file.create(file.NORMAL_FILE_TYPE, mode);
+  file.lastModifiedTime = date.getTime();
+  dump("Created fake crash: " + id + "\n");
+
+  return id;
+};
+
+
+/**
+ * A HealthReporter that is probed with various callbacks and counters.
+ *
+ * The purpose of this type is to aid testing of startup and shutdown.
+ */
+this.InspectedHealthReporter = function (branch, policy, stateLeaf) {
+  HealthReporter.call(this, branch, policy, stateLeaf);
+
+  this.onStorageCreated = null;
+  this.onProviderManagerInitialized = null;
+  this.providerManagerShutdownCount = 0;
+  this.storageCloseCount = 0;
+}
+
+InspectedHealthReporter.prototype = {
+  __proto__: HealthReporter.prototype,
+
+  _onStorageCreated: function (storage) {
+    if (this.onStorageCreated) {
+      this.onStorageCreated(storage);
+    }
+
+    return HealthReporter.prototype._onStorageCreated.call(this, storage);
+  },
+
+  _initializeProviderManager: Task.async(function* () {
+    yield HealthReporter.prototype._initializeProviderManager.call(this);
+
+    if (this.onInitializeProviderManagerFinished) {
+      this.onInitializeProviderManagerFinished();
+    }
+  }),
+
+  _onProviderManagerInitialized: function () {
+    if (this.onProviderManagerInitialized) {
+      this.onProviderManagerInitialized();
+    }
+
+    return HealthReporter.prototype._onProviderManagerInitialized.call(this);
+  },
+
+  _onProviderManagerShutdown: function () {
+    this.providerManagerShutdownCount++;
+
+    return HealthReporter.prototype._onProviderManagerShutdown.call(this);
+  },
+
+  _onStorageClose: function () {
+    this.storageCloseCount++;
+
+    return HealthReporter.prototype._onStorageClose.call(this);
+  },
+};
+
+const DUMMY_URI="http://localhost:62013/";
+
+this.getHealthReporter = function (name, uri=DUMMY_URI, inspected=false) {
+  // The healthreporters use the client id from the datareporting service,
+  // so we need to ensure it is initialized.
+  let drs = Cc["@mozilla.org/datareporting/service;1"]
+              .getService(Ci.nsISupports)
+              .wrappedJSObject;
+  drs.observe(null, "app-startup", null);
+  drs.observe(null, "profile-after-change", null);
+
+  let branch = "healthreport.testing." + name + ".";
+
+  let prefs = new Preferences(branch + "healthreport.");
+  prefs.set("documentServerURI", uri);
+  prefs.set("dbName", name);
+
+  let reporter;
+
+  let policyPrefs = new Preferences(branch + "policy.");
+  let listener = new MockPolicyListener();
+  listener.onRequestDataUpload = function (request) {
+    let promise = reporter.requestDataUpload(request);
+    MockPolicyListener.prototype.onRequestDataUpload.call(this, request);
+    return promise;
+  }
+  listener.onRequestRemoteDelete = function (request) {
+    let promise = reporter.deleteRemoteData(request);
+    MockPolicyListener.prototype.onRequestRemoteDelete.call(this, request);
+    return promise;
+  }
+  let policy = new DataReportingPolicy(policyPrefs, prefs, listener);
+  let type = inspected ? InspectedHealthReporter : HealthReporter;
+  reporter = new type(branch + "healthreport.", policy,
+                      "state-" + name + ".json");
+
+  return reporter;
+};
new file mode 100644
--- /dev/null
+++ b/services/healthreport/moz.build
@@ -0,0 +1,27 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+SPHINX_TREES['healthreport'] = 'docs'
+
+XPCSHELL_TESTS_MANIFESTS += ['tests/xpcshell/xpcshell.ini']
+
+EXTRA_PP_COMPONENTS += [
+    'HealthReportComponents.manifest',
+]
+
+EXTRA_PP_JS_MODULES += [
+    'HealthReport.jsm',
+]
+
+EXTRA_PP_JS_MODULES.services.healthreport += [
+    'healthreporter.jsm',
+    'profile.jsm',
+    'providers.jsm',
+]
+
+TESTING_JS_MODULES.services.healthreport += [
+    'modules-testing/utils.jsm',
+]
new file mode 100644
--- /dev/null
+++ b/services/healthreport/profile.jsm
@@ -0,0 +1,124 @@
+/* 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/. */
+
+#ifndef MERGED_COMPARTMENT
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["ProfileMetadataProvider"];
+
+const {utils: Cu, classes: Cc, interfaces: Ci} = Components;
+
+const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
+
+Cu.import("resource://gre/modules/Metrics.jsm");
+
+#endif
+
+const DEFAULT_PROFILE_MEASUREMENT_NAME = "age";
+const DEFAULT_PROFILE_MEASUREMENT_VERSION = 2;
+const REQUIRED_UINT32_TYPE = {type: "TYPE_UINT32"};
+
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/osfile.jsm")
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/ProfileAge.jsm");
+
+/**
+ * Measurements pertaining to the user's profile.
+ */
+// This is "version 1" of the metadata measurement - it must remain, but
+// it's currently unused - see bug 1063714 comment 12 for why.
+function ProfileMetadataMeasurement() {
+  Metrics.Measurement.call(this);
+}
+ProfileMetadataMeasurement.prototype = {
+  __proto__: Metrics.Measurement.prototype,
+
+  name: DEFAULT_PROFILE_MEASUREMENT_NAME,
+  version: 1,
+
+  fields: {
+    // Profile creation date. Number of days since Unix epoch.
+    profileCreation: {type: Metrics.Storage.FIELD_LAST_NUMERIC},
+  },
+};
+
+// This is the current measurement - it adds the profileReset value.
+function ProfileMetadataMeasurement2() {
+  Metrics.Measurement.call(this);
+}
+ProfileMetadataMeasurement2.prototype = {
+  __proto__: Metrics.Measurement.prototype,
+
+  name: DEFAULT_PROFILE_MEASUREMENT_NAME,
+  version: DEFAULT_PROFILE_MEASUREMENT_VERSION,
+
+  fields: {
+    // Profile creation date. Number of days since Unix epoch.
+    profileCreation: {type: Metrics.Storage.FIELD_LAST_NUMERIC},
+    // Profile reset date. Number of days since Unix epoch.
+    profileReset: {type: Metrics.Storage.FIELD_LAST_NUMERIC},
+  },
+};
+
+/**
+ * Turn a millisecond timestamp into a day timestamp.
+ *
+ * @param msec a number of milliseconds since epoch.
+ * @return the number of whole days denoted by the input.
+ */
+function truncate(msec) {
+  return Math.floor(msec / MILLISECONDS_PER_DAY);
+}
+
+/**
+ * A Metrics.Provider for profile metadata, such as profile creation and
+ * reset time.
+ */
+this.ProfileMetadataProvider = function() {
+  Metrics.Provider.call(this);
+}
+this.ProfileMetadataProvider.prototype = {
+  __proto__: Metrics.Provider.prototype,
+
+  name: "org.mozilla.profile",
+
+  measurementTypes: [ProfileMetadataMeasurement2],
+
+  pullOnly: true,
+
+  getProfileDays: Task.async(function* () {
+    let result = {};
+    let accessor = new ProfileAge(null, this._log);
+
+    let created = yield accessor.created;
+    result["profileCreation"] = truncate(created);
+    let reset = yield accessor.reset;
+    if (reset) {
+      result["profileReset"] = truncate(reset);
+    }
+    return result;
+  }),
+
+  collectConstantData: function () {
+    let m = this.getMeasurement(DEFAULT_PROFILE_MEASUREMENT_NAME,
+                                DEFAULT_PROFILE_MEASUREMENT_VERSION);
+
+    return Task.spawn(function* collectConstants() {
+      let days = yield this.getProfileDays();
+
+      yield this.enqueueStorageOperation(function storeDays() {
+        return Task.spawn(function* () {
+          yield m.setLastNumeric("profileCreation", days["profileCreation"]);
+          if (days["profileReset"]) {
+            yield m.setLastNumeric("profileReset", days["profileReset"]);
+          }
+        });
+      });
+    }.bind(this));
+  },
+};
+
new file mode 100644
--- /dev/null
+++ b/services/healthreport/providers.jsm
@@ -0,0 +1,1792 @@
+/* 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/. */
+
+/**
+ * This file contains metrics data providers for the Firefox Health
+ * Report. Ideally each provider in this file exists in separate modules
+ * and lives close to the code it is querying. However, because of the
+ * overhead of JS compartments (which are created for each module), we
+ * currently have all the code in one file. When the overhead of
+ * compartments reaches a reasonable level, this file should be split
+ * up.
+ */
+
+#ifndef MERGED_COMPARTMENT
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+  "AddonsProvider",
+  "AppInfoProvider",
+#ifdef MOZ_CRASHREPORTER
+  "CrashesProvider",
+#endif
+  "HealthReportProvider",
+  "HotfixProvider",
+  "PlacesProvider",
+  "SearchesProvider",
+  "SessionsProvider",
+  "SysInfoProvider",
+];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Metrics.jsm");
+
+#endif
+
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://services-common/utils.js");
+
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
+                                  "resource://gre/modules/AddonManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "UpdateUtils",
+                                  "resource://gre/modules/UpdateUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesDBUtils",
+                                  "resource://gre/modules/PlacesDBUtils.jsm");
+
+
+const LAST_NUMERIC_FIELD = {type: Metrics.Storage.FIELD_LAST_NUMERIC};
+const LAST_TEXT_FIELD = {type: Metrics.Storage.FIELD_LAST_TEXT};
+const DAILY_DISCRETE_NUMERIC_FIELD = {type: Metrics.Storage.FIELD_DAILY_DISCRETE_NUMERIC};
+const DAILY_LAST_NUMERIC_FIELD = {type: Metrics.Storage.FIELD_DAILY_LAST_NUMERIC};
+const DAILY_LAST_TEXT_FIELD = {type: Metrics.Storage.FIELD_DAILY_LAST_TEXT};
+const DAILY_COUNTER_FIELD = {type: Metrics.Storage.FIELD_DAILY_COUNTER};
+
+const TELEMETRY_PREF = "toolkit.telemetry.enabled";
+const SEARCH_COHORT_PREF = "browser.search.cohort";
+
+function isTelemetryEnabled(prefs) {
+  return prefs.get(TELEMETRY_PREF, false);
+}
+
+/**
+ * Represents basic application state.
+ *
+ * This is roughly a union of nsIXULAppInfo, nsIXULRuntime, with a few extra
+ * pieces thrown in.
+ */
+function AppInfoMeasurement() {
+  Metrics.Measurement.call(this);
+}
+
+AppInfoMeasurement.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "appinfo",
+  version: 2,
+
+  fields: {
+    vendor: LAST_TEXT_FIELD,
+    name: LAST_TEXT_FIELD,
+    id: LAST_TEXT_FIELD,
+    version: LAST_TEXT_FIELD,
+    appBuildID: LAST_TEXT_FIELD,
+    platformVersion: LAST_TEXT_FIELD,
+    platformBuildID: LAST_TEXT_FIELD,
+    os: LAST_TEXT_FIELD,
+    xpcomabi: LAST_TEXT_FIELD,
+    updateChannel: LAST_TEXT_FIELD,
+    distributionID: LAST_TEXT_FIELD,
+    distributionVersion: LAST_TEXT_FIELD,
+    hotfixVersion: LAST_TEXT_FIELD,
+    locale: LAST_TEXT_FIELD,
+    isDefaultBrowser: {type: Metrics.Storage.FIELD_DAILY_LAST_NUMERIC},
+    isTelemetryEnabled: {type: Metrics.Storage.FIELD_DAILY_LAST_NUMERIC},
+    isBlocklistEnabled: {type: Metrics.Storage.FIELD_DAILY_LAST_NUMERIC},
+  },
+});
+
+/**
+ * Legacy version of app info before Telemetry was added.
+ *
+ * The "last" fields have all been removed. We only report the longitudinal
+ * field.
+ */
+function AppInfoMeasurement1() {
+  Metrics.Measurement.call(this);
+}
+
+AppInfoMeasurement1.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "appinfo",
+  version: 1,
+
+  fields: {
+    isDefaultBrowser: {type: Metrics.Storage.FIELD_DAILY_LAST_NUMERIC},
+  },
+});
+
+
+function AppVersionMeasurement1() {
+  Metrics.Measurement.call(this);
+}
+
+AppVersionMeasurement1.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "versions",
+  version: 1,
+
+  fields: {
+    version: {type: Metrics.Storage.FIELD_DAILY_DISCRETE_TEXT},
+  },
+});
+
+// Version 2 added the build ID.
+function AppVersionMeasurement2() {
+  Metrics.Measurement.call(this);
+}
+
+AppVersionMeasurement2.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "versions",
+  version: 2,
+
+  fields: {
+    appVersion: {type: Metrics.Storage.FIELD_DAILY_DISCRETE_TEXT},
+    platformVersion: {type: Metrics.Storage.FIELD_DAILY_DISCRETE_TEXT},
+    appBuildID: {type: Metrics.Storage.FIELD_DAILY_DISCRETE_TEXT},
+    platformBuildID: {type: Metrics.Storage.FIELD_DAILY_DISCRETE_TEXT},
+  },
+});
+
+/**
+ * Holds data on the application update functionality.
+ */
+function AppUpdateMeasurement1() {
+  Metrics.Measurement.call(this);
+}
+
+AppUpdateMeasurement1.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "update",
+  version: 1,
+
+  fields: {
+    enabled: {type: Metrics.Storage.FIELD_DAILY_LAST_NUMERIC},
+    autoDownload: {type: Metrics.Storage.FIELD_DAILY_LAST_NUMERIC},
+  },
+});
+
+this.AppInfoProvider = function AppInfoProvider() {
+  Metrics.Provider.call(this);
+
+  this._prefs = new Preferences({defaultBranch: null});
+}
+AppInfoProvider.prototype = Object.freeze({
+  __proto__: Metrics.Provider.prototype,
+
+  name: "org.mozilla.appInfo",
+
+  measurementTypes: [
+    AppInfoMeasurement,
+    AppInfoMeasurement1,
+    AppUpdateMeasurement1,
+    AppVersionMeasurement1,
+    AppVersionMeasurement2,
+  ],
+
+  pullOnly: true,
+
+  appInfoFields: {
+    // From nsIXULAppInfo.
+    vendor: "vendor",
+    name: "name",
+    id: "ID",
+    version: "version",
+    appBuildID: "appBuildID",
+    platformVersion: "platformVersion",
+    platformBuildID: "platformBuildID",
+
+    // From nsIXULRuntime.
+    os: "OS",
+    xpcomabi: "XPCOMABI",
+  },
+
+  postInit: function () {
+    return Task.spawn(this._postInit.bind(this));
+  },
+
+  _postInit: function () {
+    let recordEmptyAppInfo = function () {
+      this._setCurrentAppVersion("");
+      this._setCurrentPlatformVersion("");
+      this._setCurrentAppBuildID("");
+      return this._setCurrentPlatformBuildID("");
+    }.bind(this);
+
+    // Services.appInfo should always be defined for any reasonably behaving
+    // Gecko app. If it isn't, we insert a empty string sentinel value.
+    let ai;
+    try {
+      ai = Services.appinfo;
+    } catch (ex) {
+      this._log.error("Could not obtain Services.appinfo", ex);
+      yield recordEmptyAppInfo();
+      return;
+    }
+
+    if (!ai) {
+      this._log.error("Services.appinfo is unavailable.");
+      yield recordEmptyAppInfo();
+      return;
+    }
+
+    let currentAppVersion = ai.version;
+    let currentPlatformVersion = ai.platformVersion;
+    let currentAppBuildID = ai.appBuildID;
+    let currentPlatformBuildID = ai.platformBuildID;
+
+    // State's name doesn't contain "app" for historical compatibility.
+    let lastAppVersion = yield this.getState("lastVersion");
+    let lastPlatformVersion = yield this.getState("lastPlatformVersion");
+    let lastAppBuildID = yield this.getState("lastAppBuildID");
+    let lastPlatformBuildID = yield this.getState("lastPlatformBuildID");
+
+    if (currentAppVersion != lastAppVersion) {
+      yield this._setCurrentAppVersion(currentAppVersion);
+    }
+
+    if (currentPlatformVersion != lastPlatformVersion) {
+      yield this._setCurrentPlatformVersion(currentPlatformVersion);
+    }
+
+    if (currentAppBuildID != lastAppBuildID) {
+      yield this._setCurrentAppBuildID(currentAppBuildID);
+    }
+
+    if (currentPlatformBuildID != lastPlatformBuildID) {
+      yield this._setCurrentPlatformBuildID(currentPlatformBuildID);
+    }
+  },
+
+  _setCurrentAppVersion: function (version) {
+    this._log.info("Recording new application version: " + version);
+    let m = this.getMeasurement("versions", 2);
+    m.addDailyDiscreteText("appVersion", version);
+
+    // "app" not encoded in key for historical compatibility.
+    return this.setState("lastVersion", version);
+  },
+
+  _setCurrentPlatformVersion: function (version) {
+    this._log.info("Recording new platform version: " + version);
+    let m = this.getMeasurement("versions", 2);
+    m.addDailyDiscreteText("platformVersion", version);
+    return this.setState("lastPlatformVersion", version);
+  },
+
+  _setCurrentAppBuildID: function (build) {
+    this._log.info("Recording new application build ID: " + build);
+    let m = this.getMeasurement("versions", 2);
+    m.addDailyDiscreteText("appBuildID", build);
+    return this.setState("lastAppBuildID", build);
+  },
+
+  _setCurrentPlatformBuildID: function (build) {
+    this._log.info("Recording new platform build ID: " + build);
+    let m = this.getMeasurement("versions", 2);
+    m.addDailyDiscreteText("platformBuildID", build);
+    return this.setState("lastPlatformBuildID", build);
+  },
+
+
+  collectConstantData: function () {
+    return this.storage.enqueueTransaction(this._populateConstants.bind(this));
+  },
+
+  _populateConstants: function () {
+    let m = this.getMeasurement(AppInfoMeasurement.prototype.name,
+                                AppInfoMeasurement.prototype.version);
+
+    let ai;
+    try {
+      ai = Services.appinfo;
+    } catch (ex) {
+      this._log.warn("Could not obtain Services.appinfo", ex);
+      throw ex;
+    }
+
+    if (!ai) {
+      this._log.warn("Services.appinfo is unavailable.");
+      throw ex;
+    }
+
+    for (let [k, v] in Iterator(this.appInfoFields)) {
+      try {
+        yield m.setLastText(k, ai[v]);
+      } catch (ex) {
+        this._log.warn("Error obtaining Services.appinfo." + v);
+      }
+    }
+
+    try {
+      yield m.setLastText("updateChannel", UpdateUtils.UpdateChannel);
+    } catch (ex) {
+      this._log.warn("Could not obtain update channel", ex);
+    }
+
+    yield m.setLastText("distributionID", this._prefs.get("distribution.id", ""));
+    yield m.setLastText("distributionVersion", this._prefs.get("distribution.version", ""));
+    yield m.setLastText("hotfixVersion", this._prefs.get("extensions.hotfix.lastVersion", ""));
+
+    try {
+      let locale = Cc["@mozilla.org/chrome/chrome-registry;1"]
+                     .getService(Ci.nsIXULChromeRegistry)
+                     .getSelectedLocale("global");
+      yield m.setLastText("locale", locale);
+    } catch (ex) {
+      this._log.warn("Could not obtain application locale", ex);
+    }
+
+    // FUTURE this should be retrieved periodically or at upload time.
+    yield this._recordIsTelemetryEnabled(m);
+    yield this._recordIsBlocklistEnabled(m);
+    yield this._recordDefaultBrowser(m);
+  },
+
+  _recordIsTelemetryEnabled: function (m) {
+    let enabled = isTelemetryEnabled(this._prefs);
+    this._log.debug("Recording telemetry enabled (" + TELEMETRY_PREF + "): " + enabled);
+    yield m.setDailyLastNumeric("isTelemetryEnabled", enabled ? 1 : 0);
+  },
+
+  _recordIsBlocklistEnabled: function (m) {
+    let enabled = this._prefs.get("extensions.blocklist.enabled", false);
+    this._log.debug("Recording blocklist enabled: " + enabled);
+    yield m.setDailyLastNumeric("isBlocklistEnabled", enabled ? 1 : 0);
+  },
+
+  _recordDefaultBrowser: function (m) {
+    let shellService;
+    try {
+      shellService = Cc["@mozilla.org/browser/shell-service;1"]
+                       .getService(Ci.nsIShellService);
+    } catch (ex) {
+      this._log.warn("Could not obtain shell service", ex);
+    }
+
+    let isDefault = -1;
+
+    if (shellService) {
+      try {
+        // This uses the same set of flags used by the pref pane.
+        isDefault = shellService.isDefaultBrowser(false, true) ? 1 : 0;
+      } catch (ex) {
+        this._log.warn("Could not determine if default browser", ex);
+      }
+    }
+
+    return m.setDailyLastNumeric("isDefaultBrowser", isDefault);
+  },
+
+  collectDailyData: function () {
+    return this.storage.enqueueTransaction(function getDaily() {
+      let m = this.getMeasurement(AppUpdateMeasurement1.prototype.name,
+                                  AppUpdateMeasurement1.prototype.version);
+
+      let enabled = this._prefs.get("app.update.enabled", false);
+      yield m.setDailyLastNumeric("enabled", enabled ? 1 : 0);
+
+      let auto = this._prefs.get("app.update.auto", false);
+      yield m.setDailyLastNumeric("autoDownload", auto ? 1 : 0);
+    }.bind(this));
+  },
+});
+
+
+function SysInfoMeasurement() {
+  Metrics.Measurement.call(this);
+}
+
+SysInfoMeasurement.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "sysinfo",
+  version: 2,
+
+  fields: {
+    cpuCount: {type: Metrics.Storage.FIELD_LAST_NUMERIC},
+    memoryMB: {type: Metrics.Storage.FIELD_LAST_NUMERIC},
+    manufacturer: LAST_TEXT_FIELD,
+    device: LAST_TEXT_FIELD,
+    hardware: LAST_TEXT_FIELD,
+    name: LAST_TEXT_FIELD,
+    version: LAST_TEXT_FIELD,
+    architecture: LAST_TEXT_FIELD,
+    isWow64: LAST_NUMERIC_FIELD,
+  },
+});
+
+
+this.SysInfoProvider = function SysInfoProvider() {
+  Metrics.Provider.call(this);
+};
+
+SysInfoProvider.prototype = Object.freeze({
+  __proto__: Metrics.Provider.prototype,
+
+  name: "org.mozilla.sysinfo",
+
+  measurementTypes: [SysInfoMeasurement],
+
+  pullOnly: true,
+
+  sysInfoFields: {
+    cpucount: "cpuCount",
+    memsize: "memoryMB",
+    manufacturer: "manufacturer",
+    device: "device",
+    hardware: "hardware",
+    name: "name",
+    version: "version",
+    arch: "architecture",
+    isWow64: "isWow64",
+  },
+
+  collectConstantData: function () {
+    return this.storage.enqueueTransaction(this._populateConstants.bind(this));
+  },
+
+  _populateConstants: function () {
+    let m = this.getMeasurement(SysInfoMeasurement.prototype.name,
+                                SysInfoMeasurement.prototype.version);
+
+    let si = Cc["@mozilla.org/system-info;1"]
+               .getService(Ci.nsIPropertyBag2);
+
+    for (let [k, v] in Iterator(this.sysInfoFields)) {
+      try {
+        if (!si.hasKey(k)) {
+          this._log.debug("Property not available: " + k);
+          continue;
+        }
+
+        let value = si.getProperty(k);
+        let method = "setLastText";
+
+        if (["cpucount", "memsize"].indexOf(k) != -1) {
+          let converted = parseInt(value, 10);
+          if (Number.isNaN(converted)) {
+            continue;
+          }
+
+          value = converted;
+          method = "setLastNumeric";
+        }
+
+        switch (k) {
+          case "memsize":
+            // Round memory to mebibytes.
+            value = Math.round(value / 1048576);
+            break;
+          case "isWow64":
+            // Property is only present on Windows. hasKey() skipping from
+            // above ensures undefined or null doesn't creep in here.
+            value = value ? 1 : 0;
+            method = "setLastNumeric";
+            break;
+        }
+
+        yield m[method](v, value);
+      } catch (ex) {
+        this._log.warn("Error obtaining system info field: " + k, ex);
+      }
+    }
+  },
+});
+
+
+/**
+ * Holds information about the current/active session.
+ *
+ * The fields within the current session are moved to daily session fields when
+ * the application is shut down.
+ *
+ * This measurement is backed by the SessionRecorder, not the database.
+ */
+function CurrentSessionMeasurement() {
+  Metrics.Measurement.call(this);
+}
+
+CurrentSessionMeasurement.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "current",
+  version: 3,
+
+  // Storage is in preferences.
+  fields: {},
+
+  /**
+   * All data is stored in prefs, so we have a custom implementation.
+   */
+  getValues: function () {
+    let sessions = this.provider.healthReporter.sessionRecorder;
+
+    let fields = new Map();
+    let now = new Date();
+    fields.set("startDay", [now, Metrics.dateToDays(sessions.startDate)]);
+    fields.set("activeTicks", [now, sessions.activeTicks]);
+    fields.set("totalTime", [now, sessions.totalTime]);
+    fields.set("main", [now, sessions.main]);
+    fields.set("firstPaint", [now, sessions.firstPaint]);
+    fields.set("sessionRestored", [now, sessions.sessionRestored]);
+
+    return CommonUtils.laterTickResolvingPromise({
+      days: new Metrics.DailyValues(),
+      singular: fields,
+    });
+  },
+
+  _serializeJSONSingular: function (data) {
+    let result = {"_v": this.version};
+
+    for (let [field, value] of data) {
+      result[field] = value[1];
+    }
+
+    return result;
+  },
+});
+
+/**
+ * Records a history of all application sessions.
+ */
+function PreviousSessionsMeasurement() {
+  Metrics.Measurement.call(this);
+}
+
+PreviousSessionsMeasurement.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "previous",
+  version: 3,
+
+  fields: {
+    // Milliseconds of sessions that were properly shut down.
+    cleanActiveTicks: DAILY_DISCRETE_NUMERIC_FIELD,
+    cleanTotalTime: DAILY_DISCRETE_NUMERIC_FIELD,
+
+    // Milliseconds of sessions that were not properly shut down.
+    abortedActiveTicks: DAILY_DISCRETE_NUMERIC_FIELD,
+    abortedTotalTime: DAILY_DISCRETE_NUMERIC_FIELD,
+
+    // Startup times in milliseconds.
+    main: DAILY_DISCRETE_NUMERIC_FIELD,
+    firstPaint: DAILY_DISCRETE_NUMERIC_FIELD,
+    sessionRestored: DAILY_DISCRETE_NUMERIC_FIELD,
+  },
+});
+
+
+/**
+ * Records information about the current browser session.
+ *
+ * A browser session is defined as an application/process lifetime. We
+ * start a new session when the application starts (essentially when
+ * this provider is instantiated) and end the session on shutdown.
+ *
+ * As the application runs, we record basic information about the
+ * "activity" of the session. Activity is defined by the presence of
+ * physical input into the browser (key press, mouse click, touch, etc).
+ *
+ * We differentiate between regular sessions and "aborted" sessions. An
+ * aborted session is one that does not end expectedly. This is often the
+ * result of a crash. We detect aborted sessions by storing the current
+ * session separate from completed sessions. We normally move the
+ * current session to completed sessions on application shutdown. If a
+ * current session is present on application startup, that means that
+ * the previous session was aborted.
+ */
+this.SessionsProvider = function () {
+  Metrics.Provider.call(this);
+};
+
+SessionsProvider.prototype = Object.freeze({
+  __proto__: Metrics.Provider.prototype,
+
+  name: "org.mozilla.appSessions",
+
+  measurementTypes: [CurrentSessionMeasurement, PreviousSessionsMeasurement],
+
+  pullOnly: true,
+
+  collectConstantData: function () {
+    let previous = this.getMeasurement("previous", 3);
+
+    return this.storage.enqueueTransaction(this._recordAndPruneSessions.bind(this));
+  },
+
+  _recordAndPruneSessions: function () {
+    this._log.info("Moving previous sessions from session recorder to storage.");
+    let recorder = this.healthReporter.sessionRecorder;
+    let sessions = recorder.getPreviousSessions();
+    this._log.debug("Found " + Object.keys(sessions).length + " previous sessions.");
+
+    let daily = this.getMeasurement("previous", 3);
+
+    // Please note the coupling here between the session recorder and our state.
+    // If the pruned index or the current index of the session recorder is ever
+    // deleted or reset to 0, our stored state of a later index would mean that
+    // new sessions would never be captured by this provider until the session
+    // recorder index catches up to our last session ID. This should not happen
+    // under normal circumstances, so we don't worry too much about it. We
+    // should, however, consider this as part of implementing bug 841561.
+    let lastRecordedSession = yield this.getState("lastSession");
+    if (lastRecordedSession === null) {
+      lastRecordedSession = -1;
+    }
+    this._log.debug("The last recorded session was #" + lastRecordedSession);
+
+    for (let [index, session] in Iterator(sessions)) {
+      if (index <= lastRecordedSession) {
+        this._log.warn("Already recorded session " + index + ". Did the last " +
+                       "session crash or have an issue saving the prefs file?");
+        continue;
+      }
+
+      let type = session.clean ? "clean" : "aborted";
+      let date = session.startDate;
+      yield daily.addDailyDiscreteNumeric(type + "ActiveTicks", session.activeTicks, date);
+      yield daily.addDailyDiscreteNumeric(type + "TotalTime", session.totalTime, date);
+
+      for (let field of ["main", "firstPaint", "sessionRestored"]) {
+        yield daily.addDailyDiscreteNumeric(field, session[field], date);
+      }
+
+      lastRecordedSession = index;
+    }
+
+    yield this.setState("lastSession", "" + lastRecordedSession);
+    recorder.pruneOldSessions(new Date());
+  },
+});
+
+/**
+ * Stores the set of active addons in storage.
+ *
+ * We do things a little differently than most other measurements. Because
+ * addons are difficult to shoehorn into distinct fields, we simply store a
+ * JSON blob in storage in a text field.
+ */
+function ActiveAddonsMeasurement() {
+  Metrics.Measurement.call(this);
+
+  this._serializers = {};
+  this._serializers[this.SERIALIZE_JSON] = {
+    singular: this._serializeJSONSingular.bind(this),
+    // We don't need a daily serializer because we have none of this data.
+  };
+}
+
+ActiveAddonsMeasurement.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "addons",
+  version: 2,
+
+  fields: {
+    addons: LAST_TEXT_FIELD,
+  },
+
+  _serializeJSONSingular: function (data) {
+    if (!data.has("addons")) {
+      this._log.warn("Don't have addons info. Weird.");
+      return null;
+    }
+
+    // Exceptions are caught in the caller.
+    let result = JSON.parse(data.get("addons")[1]);
+    result._v = this.version;
+    return result;
+  },
+});
+
+/**
+ * Stores the set of active plugins in storage.
+ *
+ * This stores the data in a JSON blob in a text field similar to the
+ * ActiveAddonsMeasurement.
+ */
+function ActivePluginsMeasurement() {
+  Metrics.Measurement.call(this);
+
+  this._serializers = {};
+  this._serializers[this.SERIALIZE_JSON] = {
+    singular: this._serializeJSONSingular.bind(this),
+    // We don't need a daily serializer because we have none of this data.
+  };
+}
+
+ActivePluginsMeasurement.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "plugins",
+  version: 1,
+
+  fields: {
+    plugins: LAST_TEXT_FIELD,
+  },
+
+  _serializeJSONSingular: function (data) {
+    if (!data.has("plugins")) {
+      this._log.warn("Don't have plugins info. Weird.");
+      return null;
+    }
+
+    // Exceptions are caught in the caller.
+    let result = JSON.parse(data.get("plugins")[1]);
+    result._v = this.version;
+    return result;
+  },
+});
+
+function ActiveGMPluginsMeasurement() {
+  Metrics.Measurement.call(this);
+
+  this._serializers = {};
+  this._serializers[this.SERIALIZE_JSON] = {
+    singular: this._serializeJSONSingular.bind(this),
+  };
+}
+
+ActiveGMPluginsMeasurement.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "gm-plugins",
+  version: 1,
+
+  fields: {
+    "gm-plugins": LAST_TEXT_FIELD,
+  },
+
+  _serializeJSONSingular: function (data) {
+    if (!data.has("gm-plugins")) {
+      this._log.warn("Don't have GM plugins info. Weird.");
+      return null;
+    }
+
+    let result = JSON.parse(data.get("gm-plugins")[1]);
+    result._v = this.version;
+    return result;
+  },
+});
+
+function AddonCountsMeasurement() {
+  Metrics.Measurement.call(this);
+}
+
+AddonCountsMeasurement.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "counts",
+  version: 2,
+
+  fields: {
+    theme: DAILY_LAST_NUMERIC_FIELD,
+    lwtheme: DAILY_LAST_NUMERIC_FIELD,
+    plugin: DAILY_LAST_NUMERIC_FIELD,
+    extension: DAILY_LAST_NUMERIC_FIELD,
+    service: DAILY_LAST_NUMERIC_FIELD,
+  },
+});
+
+
+/**
+ * Legacy version of addons counts before services was added.
+ */
+function AddonCountsMeasurement1() {
+  Metrics.Measurement.call(this);
+}
+
+AddonCountsMeasurement1.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "counts",
+  version: 1,
+
+  fields: {
+    theme: DAILY_LAST_NUMERIC_FIELD,
+    lwtheme: DAILY_LAST_NUMERIC_FIELD,
+    plugin: DAILY_LAST_NUMERIC_FIELD,
+    extension: DAILY_LAST_NUMERIC_FIELD,
+  },
+});
+
+
+this.AddonsProvider = function () {
+  Metrics.Provider.call(this);
+
+  this._prefs = new Preferences({defaultBranch: null});
+};
+
+AddonsProvider.prototype = Object.freeze({
+  __proto__: Metrics.Provider.prototype,
+
+  // Whenever these AddonListener callbacks are called, we repopulate
+  // and store the set of addons. Note that these events will only fire
+  // for restartless add-ons. For actions that require a restart, we
+  // will catch the change after restart. The alternative is a lot of
+  // state tracking here, which isn't desirable.
+  ADDON_LISTENER_CALLBACKS: [
+    "onEnabled",
+    "onDisabled",
+    "onInstalled",
+    "onUninstalled",
+  ],
+
+  // Add-on types for which full details are uploaded in the
+  // ActiveAddonsMeasurement. All other types are ignored.
+  FULL_DETAIL_TYPES: [
+    "extension",
+    "service",
+  ],
+
+  name: "org.mozilla.addons",
+
+  measurementTypes: [
+    ActiveAddonsMeasurement,
+    ActivePluginsMeasurement,
+    ActiveGMPluginsMeasurement,
+    AddonCountsMeasurement1,
+    AddonCountsMeasurement,
+  ],
+
+  postInit: function () {
+    let listener = {};
+
+    for (let method of this.ADDON_LISTENER_CALLBACKS) {
+      listener[method] = this._collectAndStoreAddons.bind(this);
+    }
+
+    this._listener = listener;
+    AddonManager.addAddonListener(this._listener);
+
+    return CommonUtils.laterTickResolvingPromise();
+  },
+
+  onShutdown: function () {
+    AddonManager.removeAddonListener(this._listener);
+    this._listener = null;
+
+    return CommonUtils.laterTickResolvingPromise();
+  },
+
+  collectConstantData: function () {
+    return this._collectAndStoreAddons();
+  },
+
+  _collectAndStoreAddons: function () {
+    let deferred = Promise.defer();
+
+    AddonManager.getAllAddons(function onAllAddons(allAddons) {
+      let data;
+      let addonsField;
+      let pluginsField;
+      let gmPluginsField;
+      try {
+        data = this._createDataStructure(allAddons);
+        addonsField = JSON.stringify(data.addons);
+        pluginsField = JSON.stringify(data.plugins);
+        gmPluginsField = JSON.stringify(data.gmPlugins);
+      } catch (ex) {
+        this._log.warn("Exception when populating add-ons data structure", ex);
+        deferred.reject(ex);
+        return;
+      }
+
+      let now = new Date();
+      let addons = this.getMeasurement("addons", 2);
+      let plugins = this.getMeasurement("plugins", 1);
+      let gmPlugins = this.getMeasurement("gm-plugins", 1);
+      let counts = this.getMeasurement(AddonCountsMeasurement.prototype.name,
+                                       AddonCountsMeasurement.prototype.version);
+
+      this.enqueueStorageOperation(function storageAddons() {
+        for (let type in data.counts) {
+          try {
+            counts.fieldID(type);
+          } catch (ex) {
+            this._log.warn("Add-on type without field: " + type);
+            continue;
+          }
+
+          counts.setDailyLastNumeric(type, data.counts[type], now);
+        }
+
+        return addons.setLastText("addons", addonsField).then(
+          function onSuccess() {
+            return plugins.setLastText("plugins", pluginsField).then(
+              function onSuccess() {
+                return gmPlugins.setLastText("gm-plugins", gmPluginsField).then(
+                  function onSuccess() {
+                    deferred.resolve();
+                  },
+                  function onError(error) {
+                    deferred.reject(error);
+                  });
+              },
+              function onError(error) { deferred.reject(error); }
+            );
+          },
+          function onError(error) { deferred.reject(error); }
+        );
+      }.bind(this));
+    }.bind(this));
+
+    return deferred.promise;
+  },
+
+  COPY_ADDON_FIELDS: [
+    "userDisabled",
+    "appDisabled",
+    "name",
+    "version",
+    "type",
+    "scope",
+    "description",
+    "foreignInstall",
+    "hasBinaryComponents",
+  ],
+
+  COPY_PLUGIN_FIELDS: [
+    "name",
+    "version",
+    "description",
+    "blocklisted",
+    "disabled",
+    "clicktoplay",
+  ],
+
+  _createDataStructure: function (addons) {
+    let data = {
+      addons: {},
+      plugins: {},
+      gmPlugins: {},
+      counts: {}
+    };
+
+    for (let addon of addons) {
+      let type = addon.type;
+
+      // We count plugins separately below.
+      if (addon.type == "plugin") {
+        if (addon.isGMPlugin) {
+          data.gmPlugins[addon.id] = {
+            version: addon.version,
+            userDisabled: addon.userDisabled,
+            applyBackgroundUpdates: addon.applyBackgroundUpdates,
+          };
+        }
+        continue;
+      }
+
+      data.counts[type] = (data.counts[type] || 0) + 1;
+
+      if (this.FULL_DETAIL_TYPES.indexOf(addon.type) == -1) {
+        continue;
+      }
+
+      let obj = {};
+      for (let field of this.COPY_ADDON_FIELDS) {
+        obj[field] = addon[field];
+      }
+
+      if (addon.installDate) {
+        obj.installDay = this._dateToDays(addon.installDate);
+      }
+
+      if (addon.updateDate) {
+        obj.updateDay = this._dateToDays(addon.updateDate);
+      }
+
+      data.addons[addon.id] = obj;
+    }
+
+    let pluginTags = Cc["@mozilla.org/plugin/host;1"].
+                       getService(Ci.nsIPluginHost).
+                       getPluginTags({});
+
+    for (let tag of pluginTags) {
+      let obj = {
+        mimeTypes: tag.getMimeTypes({}),
+      };
+
+      for (let field of this.COPY_PLUGIN_FIELDS) {
+        obj[field] = tag[field];
+      }
+
+      // Plugins need to have a filename and a name, so this can't be empty.
+      let id = tag.filename + ":" + tag.name + ":" + tag.version + ":"
+               + tag.description;
+      data.plugins[id] = obj;
+    }
+
+    data.counts["plugin"] = pluginTags.length;
+
+    return data;
+  },
+});
+
+#ifdef MOZ_CRASHREPORTER
+
+function DailyCrashesMeasurement1() {
+  Metrics.Measurement.call(this);
+}
+
+DailyCrashesMeasurement1.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "crashes",
+  version: 1,
+
+  fields: {
+    pending: DAILY_COUNTER_FIELD,
+    submitted: DAILY_COUNTER_FIELD,
+  },
+});
+
+function DailyCrashesMeasurement2() {
+  Metrics.Measurement.call(this);
+}
+
+DailyCrashesMeasurement2.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "crashes",
+  version: 2,
+
+  fields: {
+    mainCrash: DAILY_LAST_NUMERIC_FIELD,
+  },
+});
+
+function DailyCrashesMeasurement3() {
+  Metrics.Measurement.call(this);
+}
+
+DailyCrashesMeasurement3.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "crashes",
+  version: 3,
+
+  fields: {
+    "main-crash": DAILY_LAST_NUMERIC_FIELD,
+    "main-hang": DAILY_LAST_NUMERIC_FIELD,
+    "content-crash": DAILY_LAST_NUMERIC_FIELD,
+    "content-hang": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-crash": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-hang": DAILY_LAST_NUMERIC_FIELD,
+  },
+});
+
+function DailyCrashesMeasurement4() {
+  Metrics.Measurement.call(this);
+}
+
+DailyCrashesMeasurement4.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "crashes",
+  version: 4,
+
+  fields: {
+    "main-crash": DAILY_LAST_NUMERIC_FIELD,
+    "main-crash-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "main-crash-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+    "main-hang": DAILY_LAST_NUMERIC_FIELD,
+    "main-hang-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "main-hang-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+    "content-crash": DAILY_LAST_NUMERIC_FIELD,
+    "content-crash-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "content-crash-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+    "content-hang": DAILY_LAST_NUMERIC_FIELD,
+    "content-hang-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "content-hang-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-crash": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-crash-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-crash-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-hang": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-hang-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-hang-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+  },
+});
+
+function DailyCrashesMeasurement5() {
+  Metrics.Measurement.call(this);
+}
+
+DailyCrashesMeasurement5.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "crashes",
+  version: 5,
+
+  fields: {
+    "main-crash": DAILY_LAST_NUMERIC_FIELD,
+    "main-crash-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "main-crash-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+    "main-hang": DAILY_LAST_NUMERIC_FIELD,
+    "main-hang-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "main-hang-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+    "content-crash": DAILY_LAST_NUMERIC_FIELD,
+    "content-crash-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "content-crash-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+    "content-hang": DAILY_LAST_NUMERIC_FIELD,
+    "content-hang-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "content-hang-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-crash": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-crash-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-crash-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-hang": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-hang-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-hang-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+    "gmplugin-crash": DAILY_LAST_NUMERIC_FIELD,
+    "gmplugin-crash-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "gmplugin-crash-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+  },
+});
+
+function DailyCrashesMeasurement6() {
+  Metrics.Measurement.call(this);
+}
+
+DailyCrashesMeasurement6.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "crashes",
+  version: 6,
+
+  fields: {
+    "main-crash": DAILY_LAST_NUMERIC_FIELD,
+    "main-crash-oom": DAILY_LAST_NUMERIC_FIELD,
+    "main-crash-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "main-crash-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+    "main-hang": DAILY_LAST_NUMERIC_FIELD,
+    "main-hang-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "main-hang-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+    "content-crash": DAILY_LAST_NUMERIC_FIELD,
+    "content-crash-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "content-crash-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+    "content-hang": DAILY_LAST_NUMERIC_FIELD,
+    "content-hang-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "content-hang-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-crash": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-crash-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-crash-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-hang": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-hang-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "plugin-hang-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+    "gmplugin-crash": DAILY_LAST_NUMERIC_FIELD,
+    "gmplugin-crash-submission-succeeded": DAILY_LAST_NUMERIC_FIELD,
+    "gmplugin-crash-submission-failed": DAILY_LAST_NUMERIC_FIELD,
+  },
+});
+
+this.CrashesProvider = function () {
+  Metrics.Provider.call(this);
+
+  // So we can unit test.
+  this._manager = Services.crashmanager;
+};
+
+CrashesProvider.prototype = Object.freeze({
+  __proto__: Metrics.Provider.prototype,
+
+  name: "org.mozilla.crashes",
+
+  measurementTypes: [
+    DailyCrashesMeasurement1,
+    DailyCrashesMeasurement2,
+    DailyCrashesMeasurement3,
+    DailyCrashesMeasurement4,
+    DailyCrashesMeasurement5,
+    DailyCrashesMeasurement6,
+  ],
+
+  pullOnly: true,
+
+  collectDailyData: function () {
+    return this.storage.enqueueTransaction(this._populateCrashCounts.bind(this));
+  },
+
+  _populateCrashCounts: function () {
+    this._log.info("Grabbing crash counts from crash manager.");
+    let crashCounts = yield this._manager.getCrashCountsByDay();
+
+    // TODO: CrashManager no longer stores submissions as crashes, but we still
+    // want to send the submission data to FHR. As a temporary workaround, we
+    // populate |crashCounts| with the submission data to match past behaviour.
+    // See bug 1056160.
+    let crashes = yield this._manager.getCrashes();
+    for (let crash of crashes) {
+      for (let [submissionID, submission] of crash.submissions) {
+        if (!submission.responseDate) {
+          continue;
+        }
+
+        let day = Metrics.dateToDays(submission.responseDate);
+        if (!crashCounts.has(day)) {
+          crashCounts.set(day, new Map());
+        }
+
+        let succeeded =
+          submission.result == this._manager.SUBMISSION_RESULT_OK;
+        let type = crash.type + "-submission-" + (succeeded ? "succeeded" :
+                                                              "failed");
+
+        let count = (crashCounts.get(day).get(type) || 0) + 1;
+        crashCounts.get(day).set(type, count);
+      }
+    }
+
+    let m = this.getMeasurement("crashes", 6);
+    let fields = DailyCrashesMeasurement6.prototype.fields;
+
+    for (let [day, types] of crashCounts) {
+      let date = Metrics.daysToDate(day);
+      for (let [type, count] of types) {
+        if (!(type in fields)) {
+          this._log.warn("Unknown crash type encountered: " + type);
+          continue;
+        }
+
+        yield m.setDailyLastNumeric(type, count, date);
+      }
+    }
+  },
+});
+
+#endif
+
+/**
+ * Records data from update hotfixes.
+ *
+ * This measurement has dynamic fields. Field names are of the form
+ * <version>.<thing> where <version> is the hotfix version that produced
+ * the data. e.g. "v20140527". The sub-version of the hotfix is omitted
+ * because hotfixes can go through multiple minor versions during development
+ * and we don't want to introduce more fields than necessary. Furthermore,
+ * the subsequent dots make parsing field names slightly harder. By stripping,
+ * we can just split on the first dot.
+ */
+function UpdateHotfixMeasurement1() {
+  Metrics.Measurement.call(this);
+}
+
+UpdateHotfixMeasurement1.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "update",
+  version: 1,
+
+  hotfixFieldTypes: {
+    "upgradedFrom": Metrics.Storage.FIELD_LAST_TEXT,
+    "uninstallReason": Metrics.Storage.FIELD_LAST_TEXT,
+    "downloadAttempts": Metrics.Storage.FIELD_LAST_NUMERIC,
+    "downloadFailures": Metrics.Storage.FIELD_LAST_NUMERIC,
+    "installAttempts": Metrics.Storage.FIELD_LAST_NUMERIC,
+    "installFailures": Metrics.Storage.FIELD_LAST_NUMERIC,
+    "notificationsShown": Metrics.Storage.FIELD_LAST_NUMERIC,
+  },
+
+  fields: { },
+
+  // Our fields have dynamic names from the hotfix version that supplied them.
+  // We need to override the default behavior to deal with unknown fields.
+  shouldIncludeField: function (name) {
+    return name.includes(".");
+  },
+
+  fieldType: function (name) {
+    for (let known in this.hotfixFieldTypes) {
+      if (name.endsWith(known)) {
+        return this.hotfixFieldTypes[known];
+      }
+    }
+
+    return Metrics.Measurement.prototype.fieldType.call(this, name);
+  },
+});
+
+this.HotfixProvider = function () {
+  Metrics.Provider.call(this);
+};
+
+HotfixProvider.prototype = Object.freeze({
+  __proto__: Metrics.Provider.prototype,
+
+  name: "org.mozilla.hotfix",
+  measurementTypes: [
+    UpdateHotfixMeasurement1,
+  ],
+
+  pullOnly: true,
+
+  collectDailyData: function () {
+    return this.storage.enqueueTransaction(this._populateHotfixData.bind(this));
+  },
+
+  _populateHotfixData: function* () {
+    let m = this.getMeasurement("update", 1);
+
+    // The update hotfix retains its JSON state file after uninstall.
+    // The initial update hotfix had a hard-coded filename. We treat it
+    // specially. Subsequent update hotfixes named their files in a
+    // recognizeable pattern so we don't need to update this probe code to
+    // know about them.
+    let files = [
+        ["v20140527", OS.Path.join(OS.Constants.Path.profileDir,
+                                   "hotfix.v20140527.01.json")],
+    ];
+
+    let it = new OS.File.DirectoryIterator(OS.Constants.Path.profileDir);
+    try {
+      yield it.forEach((e, index, it) => {
+        let m = e.name.match(/^updateHotfix\.([a-zA-Z0-9]+)\.json$/);
+        if (m) {
+          files.push([m[1], e.path]);
+        }
+      });
+    } finally {
+      it.close();
+    }
+
+    let decoder = new TextDecoder();
+    for (let e of files) {
+      let [version, path] = e;
+      let p;
+      try {
+        let data = yield OS.File.read(path);
+        p = JSON.parse(decoder.decode(data));
+      } catch (ex if ex instanceof OS.File.Error && ex.becauseNoSuchFile) {
+        continue;
+      } catch (ex) {
+        this._log.warn("Error loading update hotfix payload: " + ex.message);
+      }
+
+      // Wrap just in case.
+      try {
+        for (let k in m.hotfixFieldTypes) {
+          if (!(k in p)) {
+            continue;
+          }
+
+          let value = p[k];
+          if (value === null && k == "uninstallReason") {
+            value = "STILL_INSTALLED";
+          }
+
+          let field = version + "." + k;
+          let fieldType;
+          let storageOp;
+          switch (typeof(value)) {
+            case "string":
+              fieldType = this.storage.FIELD_LAST_TEXT;
+              storageOp = "setLastTextFromFieldID";
+              break;
+            case "number":
+              fieldType = this.storage.FIELD_LAST_NUMERIC;
+              storageOp = "setLastNumericFromFieldID";
+              break;
+            default:
+              this._log.warn("Unknown value in hotfix state: " + k + "=" + value);
+              continue;
+          }
+
+          if (this.storage.hasFieldFromMeasurement(m.id, field, fieldType)) {
+            let fieldID = this.storage.fieldIDFromMeasurement(m.id, field);
+            yield this.storage[storageOp](fieldID, value);
+          } else {
+            let fieldID = yield this.storage.registerField(m.id, field,
+                                                           fieldType);
+            yield this.storage[storageOp](fieldID, value);
+          }
+        }
+
+      } catch (ex) {
+        this._log.warn("Error processing update hotfix data: " + ex);
+      }
+    }
+  },
+});
+
+/**
+ * Holds basic statistics about the Places database.
+ */
+function PlacesMeasurement() {
+  Metrics.Measurement.call(this);
+}
+
+PlacesMeasurement.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "places",
+  version: 1,
+
+  fields: {
+    pages: DAILY_LAST_NUMERIC_FIELD,
+    bookmarks: DAILY_LAST_NUMERIC_FIELD,
+  },
+});
+
+
+/**
+ * Collects information about Places.
+ */
+this.PlacesProvider = function () {
+  Metrics.Provider.call(this);
+};
+
+PlacesProvider.prototype = Object.freeze({
+  __proto__: Metrics.Provider.prototype,
+
+  name: "org.mozilla.places",
+
+  measurementTypes: [PlacesMeasurement],
+
+  collectDailyData: function () {
+    return this.storage.enqueueTransaction(this._collectData.bind(this));
+  },
+
+  _collectData: function () {
+    let now = new Date();
+    let data = yield this._getDailyValues();
+
+    let m = this.getMeasurement("places", 1);
+
+    yield m.setDailyLastNumeric("pages", data.PLACES_PAGES_COUNT);
+    yield m.setDailyLastNumeric("bookmarks", data.PLACES_BOOKMARKS_COUNT);
+  },
+
+  _getDailyValues: function () {
+    let deferred = Promise.defer();
+
+    PlacesDBUtils.telemetry(null, function onResult(data) {
+      deferred.resolve(data);
+    });
+
+    return deferred.promise;
+  },
+});
+
+function SearchCountMeasurement1() {
+  Metrics.Measurement.call(this);
+}
+
+SearchCountMeasurement1.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "counts",
+  version: 1,
+
+  // We only record searches for search engines that have partner agreements
+  // with Mozilla.
+  fields: {
+    "amazon.com.abouthome": DAILY_COUNTER_FIELD,
+    "amazon.com.contextmenu": DAILY_COUNTER_FIELD,
+    "amazon.com.searchbar": DAILY_COUNTER_FIELD,
+    "amazon.com.urlbar": DAILY_COUNTER_FIELD,
+    "bing.abouthome": DAILY_COUNTER_FIELD,
+    "bing.contextmenu": DAILY_COUNTER_FIELD,
+    "bing.searchbar": DAILY_COUNTER_FIELD,
+    "bing.urlbar": DAILY_COUNTER_FIELD,
+    "google.abouthome": DAILY_COUNTER_FIELD,
+    "google.contextmenu": DAILY_COUNTER_FIELD,
+    "google.searchbar": DAILY_COUNTER_FIELD,
+    "google.urlbar": DAILY_COUNTER_FIELD,
+    "yahoo.abouthome": DAILY_COUNTER_FIELD,
+    "yahoo.contextmenu": DAILY_COUNTER_FIELD,
+    "yahoo.searchbar": DAILY_COUNTER_FIELD,
+    "yahoo.urlbar": DAILY_COUNTER_FIELD,
+    "other.abouthome": DAILY_COUNTER_FIELD,
+    "other.contextmenu": DAILY_COUNTER_FIELD,
+    "other.searchbar": DAILY_COUNTER_FIELD,
+    "other.urlbar": DAILY_COUNTER_FIELD,
+  },
+});
+
+/**
+ * Records search counts per day per engine and where search initiated.
+ *
+ * We want to record granular details for individual locale-specific search
+ * providers, but only if they're Mozilla partners. In order to do this, we
+ * track the nsISearchEngine identifier, which denotes shipped search engines,
+ * and intersect those with our partner list.
+ *
+ * We don't use the search engine name directly, because it is shared across
+ * locales; e.g., eBay-de and eBay both share the name "eBay".
+ */
+function SearchCountMeasurementBase() {
+  this._fieldSpecs = {};
+  Metrics.Measurement.call(this);
+}
+
+SearchCountMeasurementBase.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+
+  // Our fields are dynamic.
+  get fields() {
+    return this._fieldSpecs;
+  },
+
+  /**
+   * Override the default behavior: serializers should include every counter
+   * field from the DB, even if we don't currently have it registered.
+   *
+   * Do this so we don't have to register several hundred fields to match
+   * various Firefox locales.
+   *
+   * We use the "provider.type" syntax as a rudimentary check for validity.
+   *
+   * We trust that measurement versioning is sufficient to exclude old provider
+   * data.
+   */
+  shouldIncludeField: function (name) {
+    return name.includes(".");
+  },
+
+  /**
+   * The measurement type mechanism doesn't introspect the DB. Override it
+   * so that we can assume all unknown fields are counters.
+   */
+  fieldType: function (name) {
+    if (name in this.fields) {
+      return this.fields[name].type;
+    }
+
+    // Default to a counter.
+    return Metrics.Storage.FIELD_DAILY_COUNTER;
+  },
+
+  SOURCES: [
+    "abouthome",
+    "contextmenu",
+    "newtab",
+    "searchbar",
+    "urlbar",
+  ],
+});
+
+function SearchCountMeasurement2() {
+  SearchCountMeasurementBase.call(this);
+}
+
+SearchCountMeasurement2.prototype = Object.freeze({
+  __proto__: SearchCountMeasurementBase.prototype,
+  name: "counts",
+  version: 2,
+});
+
+function SearchCountMeasurement3() {
+  SearchCountMeasurementBase.call(this);
+}
+
+SearchCountMeasurement3.prototype = Object.freeze({
+  __proto__: SearchCountMeasurementBase.prototype,
+  name: "counts",
+  version: 3,
+
+  getEngines: function () {
+    return Services.search.getEngines();
+  },
+
+  getEngineID: function (engine) {
+    if (!engine) {
+      return "other";
+    }
+    if (engine.identifier) {
+      return engine.identifier;
+    }
+    return "other-" + engine.name;
+  },
+});
+
+function SearchEnginesMeasurement1() {
+  Metrics.Measurement.call(this);
+}
+
+SearchEnginesMeasurement1.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "engines",
+  version: 2,
+
+  fields: {
+    default: DAILY_LAST_TEXT_FIELD,
+    cohort: DAILY_LAST_TEXT_FIELD,
+  },
+});
+
+this.SearchesProvider = function () {
+  Metrics.Provider.call(this);
+
+  this._prefs = new Preferences({defaultBranch: null});
+};
+
+this.SearchesProvider.prototype = Object.freeze({
+  __proto__: Metrics.Provider.prototype,
+
+  name: "org.mozilla.searches",
+  measurementTypes: [
+    SearchCountMeasurement1,
+    SearchCountMeasurement2,
+    SearchCountMeasurement3,
+    SearchEnginesMeasurement1,
+  ],
+
+  /**
+   * Initialize the search service before our measurements are touched.
+   */
+  preInit: function (storage) {
+    // Initialize search service.
+    let deferred = Promise.defer();
+    Services.search.init(function onInitComplete () {
+      deferred.resolve();
+    });
+    return deferred.promise;
+  },
+
+  collectDailyData: function () {
+    return this.storage.enqueueTransaction(function getDaily() {
+      let m = this.getMeasurement(SearchEnginesMeasurement1.prototype.name,
+                                  SearchEnginesMeasurement1.prototype.version);
+
+      let engine;
+      try {
+        engine = Services.search.defaultEngine;
+      } catch (e) {}
+      let name;
+
+      if (!engine) {
+        name = "NONE";
+      } else if (engine.identifier) {
+        name = engine.identifier;
+      } else if (engine.name) {
+        name = "other-" + engine.name;
+      } else {
+        name = "UNDEFINED";
+      }
+
+      yield m.setDailyLastText("default", name);
+
+      if (Services.prefs.prefHasUserValue(SEARCH_COHORT_PREF))
+        yield m.setDailyLastText("cohort", Services.prefs.getCharPref(SEARCH_COHORT_PREF));
+    }.bind(this));
+  },
+
+  /**
+   * Record that a search occurred.
+   *
+   * @param engine
+   *        (nsISearchEngine) The search engine used.
+   * @param source
+   *        (string) Where the search was initiated from. Must be one of the
+   *        SearchCountMeasurement2.SOURCES values.
+   *
+   * @return Promise<>
+   *         The promise is resolved when the storage operation completes.
+   */
+  recordSearch: function (engine, source) {
+    let m = this.getMeasurement("counts", 3);
+
+    if (m.SOURCES.indexOf(source) == -1) {
+      throw new Error("Unknown source for search: " + source);
+    }
+
+    let field = m.getEngineID(engine) + "." + source;
+    if (this.storage.hasFieldFromMeasurement(m.id, field,
+                                             this.storage.FIELD_DAILY_COUNTER)) {
+      let fieldID = this.storage.fieldIDFromMeasurement(m.id, field);
+      return this.enqueueStorageOperation(function recordSearchKnownField() {
+        return this.storage.incrementDailyCounterFromFieldID(fieldID);
+      }.bind(this));
+    }
+
+    // Otherwise, we first need to create the field.
+    return this.enqueueStorageOperation(function recordFieldAndSearch() {
+      // This function has to return a promise.
+      return Task.spawn(function () {
+        let fieldID = yield this.storage.registerField(m.id, field,
+                                                       this.storage.FIELD_DAILY_COUNTER);
+        yield this.storage.incrementDailyCounterFromFieldID(fieldID);
+      }.bind(this));
+    }.bind(this));
+  },
+});
+
+function HealthReportSubmissionMeasurement1() {
+  Metrics.Measurement.call(this);
+}
+
+HealthReportSubmissionMeasurement1.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "submissions",
+  version: 1,
+
+  fields: {
+    firstDocumentUploadAttempt: DAILY_COUNTER_FIELD,
+    continuationUploadAttempt: DAILY_COUNTER_FIELD,
+    uploadSuccess: DAILY_COUNTER_FIELD,
+    uploadTransportFailure: DAILY_COUNTER_FIELD,
+    uploadServerFailure: DAILY_COUNTER_FIELD,
+    uploadClientFailure: DAILY_COUNTER_FIELD,
+  },
+});
+
+function HealthReportSubmissionMeasurement2() {
+  Metrics.Measurement.call(this);
+}
+
+HealthReportSubmissionMeasurement2.prototype = Object.freeze({
+  __proto__: Metrics.Measurement.prototype,
+
+  name: "submissions",
+  version: 2,
+
+  fields: {
+    firstDocumentUploadAttempt: DAILY_COUNTER_FIELD,
+    continuationUploadAttempt: DAILY_COUNTER_FIELD,
+    uploadSuccess: DAILY_COUNTER_FIELD,
+    uploadTransportFailure: DAILY_COUNTER_FIELD,
+    uploadServerFailure: DAILY_COUNTER_FIELD,
+    uploadClientFailure: DAILY_COUNTER_FIELD,
+    uploadAlreadyInProgress: DAILY_COUNTER_FIELD,
+  },
+});
+
+this.HealthReportProvider = function () {
+  Metrics.Provider.call(this);
+}
+
+HealthReportProvider.prototype = Object.freeze({
+  __proto__: Metrics.Provider.prototype,
+
+  name: "org.mozilla.healthreport",
+
+  measurementTypes: [
+    HealthReportSubmissionMeasurement1,
+    HealthReportSubmissionMeasurement2,
+  ],
+
+  recordEvent: function (event, date=new Date()) {
+    let m = this.getMeasurement("submissions", 2);
+    return this.enqueueStorageOperation(function recordCounter() {
+      return m.incrementDailyCounter(event, date);
+    });
+  },
+});
new file mode 100644
--- /dev/null
+++ b/services/healthreport/tests/xpcshell/head.js
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// We need to initialize the profile or OS.File may not work. See bug 810543.
+do_get_profile();
+
+(function initMetricsTestingInfrastructure() {
+  let ns = {};
+  Components.utils.import("resource://testing-common/services/common/logging.js",
+                          ns);
+
+  ns.initTestLogging();
+}).call(this);
+
+(function createAppInfo() {
+  let ns = {};
+  Components.utils.import("resource://testing-common/services/healthreport/utils.jsm", ns);
+  ns.updateAppInfo();
+}).call(this);
new file mode 100644
--- /dev/null
+++ b/services/healthreport/tests/xpcshell/test_healthreporter.js
@@ -0,0 +1,1284 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://services-common/observers.js");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Metrics.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+var bsp = Cu.import("resource://gre/modules/services/healthreport/healthreporter.jsm");
+Cu.import("resource://gre/modules/services/healthreport/providers.jsm");
+Cu.import("resource://gre/modules/services/datareporting/policy.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://testing-common/httpd.js");
+Cu.import("resource://testing-common/services/common/bagheeraserver.js");
+Cu.import("resource://testing-common/services/metrics/mocks.jsm");
+Cu.import("resource://testing-common/services/healthreport/utils.jsm");
+Cu.import("resource://testing-common/AppData.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "gDatareportingService",
+  () => Cc["@mozilla.org/datareporting/service;1"]
+          .getService(Ci.nsISupports)
+          .wrappedJSObject);
+
+
+const DUMMY_URI = "http://localhost:62013/";
+const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
+
+const HealthReporterState = bsp.HealthReporterState;
+
+
+function defineNow(policy, now) {
+  print("Adjusting fake system clock to " + now);
+  Object.defineProperty(policy, "now", {
+    value: function customNow() {
+      return now;
+    },
+    writable: true,
+  });
+}
+
+function getReporter(name, uri, inspected) {
+  return Task.spawn(function init() {
+    let reporter = getHealthReporter(name, uri, inspected);
+    yield reporter.init();
+
+    yield reporter._providerManager.registerProviderFromType(
+      HealthReportProvider);
+
+    throw new Task.Result(reporter);
+  });
+}
+
+function getReporterAndServer(name, namespace="test") {
+  return Task.spawn(function get() {
+    let server = new BagheeraServer();
+    server.createNamespace(namespace);
+    server.start();
+
+    let reporter = yield getReporter(name, server.serverURI);
+    reporter.serverNamespace = namespace;
+
+    throw new Task.Result([reporter, server]);
+  });
+}
+
+function shutdownServer(server) {
+  let deferred = Promise.defer();
+  server.stop(deferred.resolve.bind(deferred));
+
+  return deferred.promise;
+}
+
+function getHealthReportProviderValues(reporter, day=null) {
+  return Task.spawn(function getValues() {
+    let p = reporter.getProvider("org.mozilla.healthreport");
+    do_check_neq(p, null);
+    let m = p.getMeasurement("submissions", 2);
+    do_check_neq(m, null);
+
+    let data = yield reporter._storage.getMeasurementValues(m.id);
+    if (!day) {
+      throw new Task.Result(data);
+    }
+
+    do_check_true(data.days.hasDay(day));
+    let serializer = m.serializer(m.SERIALIZE_JSON)
+    let json = serializer.daily(data.days.getDay(day));
+    do_check_eq(json._v, 2);
+
+    throw new Task.Result(json);
+  });
+}
+
+/*
+ * Ensure that the notification has been displayed to the user therefore having
+ * reporter._policy.userNotifiedOfCurrentPolicy === true, which will allow for a
+ * successful data upload.
+ * @param  {HealthReporter} reporter
+ * @return {Promise}
+ */
+function ensureUserNotified (reporter) {
+  return Task.spawn(function* ensureUserNotified () {
+    reporter._policy.ensureUserNotified();
+    yield reporter._policy._listener.lastNotifyRequest.deferred.promise;
+    do_check_true(reporter._policy.userNotifiedOfCurrentPolicy);
+  });
+}
+
+function run_test() {
+  do_get_profile();
+
+  // Send the needed startup notifications to the datareporting service
+  // to ensure that it has been initialized.
+  gDatareportingService.observe(null, "app-startup", null);
+  gDatareportingService.observe(null, "profile-after-change", null);
+
+  run_next_test();
+}
+
+// run_test() needs to finish synchronously, so we do async init here.
+add_task(function test_init() {
+  yield makeFakeAppDir();
+});
+
+add_task(function test_constructor() {
+  let reporter = yield getReporter("constructor");
+  try {
+    do_check_eq(reporter.lastPingDate.getTime(), 0);
+    do_check_null(reporter.lastSubmitID);
+    do_check_eq(typeof(reporter._state), "object");
+    do_check_eq(reporter._state.lastPingDate.getTime(), 0);
+    do_check_eq(reporter._state.remoteIDs.length, 0);
+    do_check_eq(reporter._state.clientIDVersion, 1);
+    do_check_neq(reporter._state.clientID, null);
+
+    let failed = false;
+    try {
+      new HealthReporter("foo.bar");
+    } catch (ex) {
+      failed = true;
+      do_check_true(ex.message.startsWith("Branch must end"));
+    } finally {
+      do_check_true(failed);
+      failed = false;
+    }
+  } finally {
+    yield reporter._shutdown();
+  }
+});
+
+add_task(function test_shutdown_normal() {
+  let reporter = yield getReporter("shutdown_normal");
+
+  // We can't send "quit-application" notification because the xpcshell runner
+  // will shut down!
+  reporter._initiateShutdown();
+  yield reporter._promiseShutdown;
+});
+
+add_task(function test_shutdown_storage_in_progress() {
+  let reporter = yield getHealthReporter("shutdown_storage_in_progress", DUMMY_URI, true);
+
+  reporter.onStorageCreated = function () {
+    print("Faking shutdown during storage initialization.");
+    reporter._initiateShutdown();
+  };
+
+  reporter.init();
+
+  yield reporter._promiseShutdown;
+  do_check_eq(reporter.providerManagerShutdownCount, 0);
+  do_check_eq(reporter.storageCloseCount, 1);
+});
+
+// Ensure that a shutdown triggered while provider manager is initializing
+// results in shutdown and storage closure.
+add_task(function test_shutdown_provider_manager_in_progress() {
+  let reporter = yield getHealthReporter("shutdown_provider_manager_in_progress",
+                                         DUMMY_URI, true);
+
+  reporter.onProviderManagerInitialized = function () {
+    print("Faking shutdown during provider manager initialization.");
+    reporter._initiateShutdown();
+  };
+
+  reporter.init();
+
+  // This will hang if shutdown logic is busted.
+  yield reporter._promiseShutdown;
+  do_check_eq(reporter.providerManagerShutdownCount, 1);
+  do_check_eq(reporter.storageCloseCount, 1);
+});
+
+// Simulates an error during provider manager initialization and verifies we shut down.
+add_task(function test_shutdown_when_provider_manager_errors() {
+  let reporter = yield getHealthReporter("shutdown_when_provider_manager_errors",
+                                       DUMMY_URI, true);
+
+  let error = new Error("Fake error during provider manager initialization.");
+  reporter.onInitializeProviderManagerFinished = function () {
+    print("Throwing fake error.");
+    throw error;
+  };
+
+  try {
+    yield reporter.init();
+    do_throw("The error was not reported by init()");
+  } catch (ex if ex == error) {
+    do_print("The error was reported by init()");
+  }
+
+  // This will hang if shutdown logic is busted.
+  yield reporter._promiseShutdown;
+  do_check_eq(reporter.providerManagerShutdownCount, 1);
+  do_check_eq(reporter.storageCloseCount, 1);
+});
+
+// Pull-only providers are only initialized at collect time.
+add_task(function test_pull_only_providers() {
+  const category = "healthreporter-constant-only";
+
+  let cm = Cc["@mozilla.org/categorymanager;1"]
+             .getService(Ci.nsICategoryManager);
+  cm.addCategoryEntry(category, "DummyProvider",
+                      "resource://testing-common/services/metrics/mocks.jsm",
+                      false, true);
+  cm.addCategoryEntry(category, "DummyConstantProvider",
+                      "resource://testing-common/services/metrics/mocks.jsm",
+                      false, true);
+
+  let reporter = yield getReporter("constant_only_providers");
+  try {
+    let initCount = reporter._providerManager.providers.length;
+    yield reporter._providerManager.registerProvidersFromCategoryManager(category);
+    do_check_eq(reporter._providerManager._providers.size, initCount + 1);
+    do_check_true(reporter._storage.hasProvider("DummyProvider"));
+    do_check_false(reporter._storage.hasProvider("DummyConstantProvider"));
+    do_check_neq(reporter.getProvider("DummyProvider"), null);
+    do_check_null(reporter.getProvider("DummyConstantProvider"));
+
+    yield reporter.collectMeasurements();
+
+    do_check_eq(reporter._providerManager._providers.size, initCount + 1);
+    do_check_true(reporter._storage.hasProvider("DummyConstantProvider"));
+
+    let mID = reporter._storage.measurementID("DummyConstantProvider", "DummyMeasurement", 1);
+    let values = yield reporter._storage.getMeasurementValues(mID);
+    do_check_true(values.singular.size > 0);
+  } finally {
+    yield reporter._shutdown();
+  }
+});
+
+add_task(function test_collect_daily() {
+  let reporter = yield getReporter("collect_daily");
+
+  try {
+    let now = new Date();
+    let provider = new DummyProvider();
+    yield reporter._providerManager.registerProvider(provider);
+    yield reporter.collectMeasurements();
+
+    do_check_eq(provider.collectConstantCount, 1);
+    do_check_eq(provider.collectDailyCount, 1);
+
+    yield reporter.collectMeasurements();
+    do_check_eq(provider.collectConstantCount, 1);
+    do_check_eq(provider.collectDailyCount, 1);
+
+    yield reporter.collectMeasurements();
+    do_check_eq(provider.collectDailyCount, 1); // Too soon.
+
+    reporter._lastDailyDate = now.getTime() - MILLISECONDS_PER_DAY - 1;
+    yield reporter.collectMeasurements();
+    do_check_eq(provider.collectDailyCount, 2);
+
+    reporter._lastDailyDate = null;
+    yield reporter.collectMeasurements();
+    do_check_eq(provider.collectDailyCount, 3);
+  } finally {
+    yield reporter._shutdown();
+  }
+});
+
+add_task(function test_remove_old_lastpayload() {
+  let reporter = getHealthReporter("remove-old-lastpayload");
+  let lastPayloadPath = reporter._state._lastPayloadPath;
+  let paths = [lastPayloadPath, lastPayloadPath + ".tmp"];
+  let createFiles = function () {
+    return Task.spawn(function createFiles() {
+      for (let path of paths) {
+        yield OS.File.writeAtomic(path, "delete-me", {tmpPath: path + ".tmp"});
+        do_check_true(yield OS.File.exists(path));
+      }
+    });
+  };
+  try {
+    do_check_true(!reporter._state.removedOutdatedLastpayload);
+    yield createFiles();
+    yield reporter.init();
+    for (let path of paths) {
+      do_check_false(yield OS.File.exists(path));
+    }
+    yield reporter._state.save();
+    yield reporter._shutdown();
+
+    let o = yield CommonUtils.readJSON(reporter._state._filename);
+    do_check_true(o.removedOutdatedLastpayload);
+
+    yield createFiles();
+    reporter = getHealthReporter("remove-old-lastpayload");
+    yield reporter.init();
+    for (let path of paths) {
+      do_check_true(yield OS.File.exists(path));
+    }
+  } finally {
+    yield reporter._shutdown();
+  }
+});
+
+add_task(function test_json_payload_simple() {
+  let reporter = yield getReporter("json_payload_simple");
+
+  let clientID = reporter._state.clientID;
+  do_check_neq(clientID, null);
+
+  try {
+    let now = new Date();
+    let payload = yield reporter.getJSONPayload();
+    do_check_eq(typeof payload, "string");
+    let original = JSON.parse(payload);
+
+    do_check_eq(original.version, 2);
+    do_check_eq(original.thisPingDate, reporter._formatDate(now));
+    do_check_eq(original.clientID, clientID);
+    do_check_eq(original.clientIDVersion, reporter._state.clientIDVersion);
+    do_check_eq(original.clientIDVersion, 1);
+    do_check_eq(Object.keys(original.data.last).length, 0);
+    do_check_eq(Object.keys(original.data.days).length, 0);
+    do_check_false("notInitialized" in original);
+
+    yield reporter._state.setLastPingDate(
+      new Date(now.getTime() - 24 * 60 * 60 * 1000 - 10));
+
+    original = JSON.parse(yield reporter.getJSONPayload());
+    do_check_eq(original.lastPingDate, reporter._formatDate(reporter.lastPingDate));
+    do_check_eq(original.clientID, clientID);
+
+    // This could fail if we cross UTC day boundaries at the exact instance the
+    // test is executed. Let's tempt fate.
+    do_check_eq(original.thisPingDate, reporter._formatDate(now));
+
+    payload = yield reporter.getJSONPayload(true);
+    do_check_eq(typeof payload, "object");
+  } finally {
+    yield reporter._shutdown();
+  }
+});
+
+add_task(function test_json_payload_dummy_provider() {
+  let reporter = yield getReporter("json_payload_dummy_provider");
+
+  try {
+    yield reporter._providerManager.registerProvider(new DummyProvider());
+    yield reporter.collectMeasurements();
+    let payload = yield reporter.getJSONPayload();
+    print(payload);
+    let o = JSON.parse(payload);
+
+    let name = "DummyProvider.DummyMeasurement";
+    do_check_eq(Object.keys(o.data.last).length, 1);
+    do_check_true(name in o.data.last);
+    do_check_eq(o.data.last[name]._v, 1);
+  } finally {
+    yield reporter._shutdown();
+  }
+});
+
+add_task(function test_collect_and_obtain_json_payload() {
+  let reporter = yield getReporter("collect_and_obtain_json_payload");
+
+  try {
+    yield reporter._providerManager.registerProvider(new DummyProvider());
+    let payload = yield reporter.collectAndObtainJSONPayload();
+    do_check_eq(typeof payload, "string");
+
+    let o = JSON.parse(payload);
+    do_check_true("DummyProvider.DummyMeasurement" in o.data.last);
+
+    payload = yield reporter.collectAndObtainJSONPayload(true);
+    do_check_eq(typeof payload, "object");
+  } finally {
+    yield reporter._shutdown();
+  }
+});
+
+// Ensure constant-only providers make their way into the JSON payload.
+add_task(function test_constant_only_providers_in_json_payload() {
+  const category = "healthreporter-constant-only-in-payload";
+
+  let cm = Cc["@mozilla.org/categorymanager;1"]
+             .getService(Ci.nsICategoryManager);
+  cm.addCategoryEntry(category, "DummyProvider",
+                      "resource://testing-common/services/metrics/mocks.jsm",
+                      false, true);
+  cm.addCategoryEntry(category, "DummyConstantProvider",
+                      "resource://testing-common/services/metrics/mocks.jsm",
+                      false, true);
+
+  let reporter = yield getReporter("constant_only_providers_in_json_payload");
+  try {
+    let initCount = reporter._providerManager.providers.length;
+    yield reporter._providerManager.registerProvidersFromCategoryManager(category);
+
+    let payload = yield reporter.collectAndObtainJSONPayload();
+    let o = JSON.parse(payload);
+    do_check_true("DummyProvider.DummyMeasurement" in o.data.last);
+    do_check_true("DummyConstantProvider.DummyMeasurement" in o.data.last);
+
+    let providers = reporter._providerManager.providers;
+    do_check_eq(providers.length, initCount + 1);
+
+    // Do it again for good measure.
+    payload = yield reporter.collectAndObtainJSONPayload();
+    o = JSON.parse(payload);
+    do_check_true("DummyProvider.DummyMeasurement" in o.data.last);
+    do_check_true("DummyConstantProvider.DummyMeasurement" in o.data.last);
+
+    providers = reporter._providerManager.providers;
+    do_check_eq(providers.length, initCount + 1);
+
+    // Ensure throwing getJSONPayload is handled properly.
+    Object.defineProperty(reporter, "_getJSONPayload", {
+      value: function () {
+        throw new Error("Silly error.");
+      },
+    });
+
+    let deferred = Promise.defer();
+
+    reporter.collectAndObtainJSONPayload().then(do_throw, function onError() {
+      providers = reporter._providerManager.providers;
+      do_check_eq(providers.length, initCount + 1);
+      deferred.resolve();
+    });
+
+    yield deferred.promise;
+  } finally {
+    yield reporter._shutdown();
+  }
+});
+
+add_task(function test_json_payload_multiple_days() {
+  let reporter = yield getReporter("json_payload_multiple_days");
+
+  try {
+    let provider = new DummyProvider();
+    yield reporter._providerManager.registerProvider(provider);
+
+    let now = new Date();
+
+    let m = provider.getMeasurement("DummyMeasurement", 1);
+    for (let i = 0; i < 200; i++) {
+      let date = new Date(now.getTime() - i * MILLISECONDS_PER_DAY);
+      yield m.incrementDailyCounter("daily-counter", date);
+    }
+
+    // This test could fail if we cross a UTC day boundary when running. So,
+    // we ensure this doesn't occur.
+    Object.defineProperty(reporter, "_now", {
+      value: function () {
+        return now;
+      },
+    });
+
+    let payload = yield reporter.getJSONPayload();
+    print(payload);
+    let o = JSON.parse(payload);
+
+    do_check_eq(Object.keys(o.data.days).length, 180);
+    let today = reporter._formatDate(now);
+    do_check_true(today in o.data.days);
+  } finally {
+    yield reporter._shutdown();
+  }
+});
+
+add_task(function test_json_payload_newer_version_overwrites() {
+  let reporter = yield getReporter("json_payload_newer_version_overwrites");
+
+  try {
+    let now = new Date();
+    // Instead of hacking up the internals to ensure consistent order in Map
+    // iteration (which would be difficult), we instead opt to generate a lot
+    // of measurements of different versions and verify their iterable order
+    // is not increasing.
+    let versions = [1, 6, 3, 9, 2, 3, 7, 4, 10, 8];
+    let protos = [];
+    for (let version of versions) {
+      let m = function () {
+        Metrics.Measurement.call(this);
+      };
+      m.prototype = {
+        __proto__: DummyMeasurement.prototype,
+        name: "DummyMeasurement",
+        version: version,
+      };
+
+      protos.push(m);
+    }
+
+    let ctor = function () {
+      Metrics.Provider.call(this);
+    };
+    ctor.prototype = {
+      __proto__: DummyProvider.prototype,
+
+      name: "MultiMeasurementProvider",
+      measurementTypes: protos,
+    };
+
+    let provider = new ctor();
+
+    yield reporter._providerManager.registerProvider(provider);
+
+    let haveUnordered = false;
+    let last = -1;
+    let highestVersion = -1;
+    for (let [key, measurement] of provider.measurements) {
+      yield measurement.setDailyLastNumeric("daily-last-numeric",
+                                            measurement.version, now);
+      yield measurement.setLastNumeric("last-numeric",
+                                       measurement.version, now);
+
+      if (measurement.version > highestVersion) {
+        highestVersion = measurement.version;
+      }
+
+      if (measurement.version < last) {
+        haveUnordered = true;
+      }
+
+      last = measurement.version;
+    }
+
+    // Ensure Map traversal isn't ordered. If this ever fails, then we'll need
+    // to monkeypatch.
+    do_check_true(haveUnordered);
+
+    let payload = yield reporter.getJSONPayload();
+    let o = JSON.parse(payload);
+    do_check_true("MultiMeasurementProvider.DummyMeasurement" in o.data.last);
+    do_check_eq(o.data.last["MultiMeasurementProvider.DummyMeasurement"]._v, highestVersion);
+
+    let day = reporter._formatDate(now);
+    do_check_true(day in o.data.days);
+    do_check_true("MultiMeasurementProvider.DummyMeasurement" in o.data.days[day]);
+    do_check_eq(o.data.days[day]["MultiMeasurementProvider.DummyMeasurement"]._v, highestVersion);
+
+  } finally {
+    yield reporter._shutdown();
+  }
+});
+
+add_task(function test_idle_daily() {
+  let reporter = yield getReporter("idle_daily");
+  try {
+    let provider = new DummyProvider();
+    yield reporter._providerManager.registerProvider(provider);
+
+    let now = new Date();
+    let m = provider.getMeasurement("DummyMeasurement", 1);
+    for (let i = 0; i < 200; i++) {
+      let date = new Date(now.getTime() - i * MILLISECONDS_PER_DAY);
+      yield m.incrementDailyCounter("daily-counter", date);
+    }
+
+    let values = yield m.getValues();
+    do_check_eq(values.days.size, 200);
+
+    Services.obs.notifyObservers(null, "idle-daily", null);
+
+    values = yield m.getValues();
+    do_check_eq(values.days.size, 180);
+  } finally {
+    yield reporter._shutdown();
+  }
+});
+
+add_task(function test_data_submission_transport_failure() {
+  let reporter = yield getReporter("data_submission_transport_failure");
+  try {
+    reporter.serverURI = DUMMY_URI;
+    reporter.serverNamespace = "test00";
+
+    let deferred = Promise.defer();
+    let request = new DataSubmissionRequest(deferred, new Date(Date.now + 30000));
+    reporter.requestDataUpload(request);
+
+    yield deferred.promise;
+    do_check_eq(request.state, request.SUBMISSION_FAILURE_SOFT);
+
+    let data = yield getHealthReportProviderValues(reporter, new Date());
+    do_check_eq(data.firstDocumentUploadAttempt, 1);
+    do_check_eq(data.uploadTransportFailure, 1);
+    do_check_eq(Object.keys(data).length, 3);
+  } finally {
+    reporter._shutdown();
+  }
+});
+
+add_task(function test_data_submission_server_failure() {
+  let [reporter, server] = yield getReporterAndServer("data_submission_server_failure");
+  try {
+    Object.defineProperty(server, "_handleNamespaceSubmitPost", {
+      value: function (ns, id, request, response) {
+        throw HTTP_500;
+      },
+      writable: true,
+    });
+
+    let deferred = Promise.defer();
+    let now = new Date();
+    let request = new DataSubmissionRequest(deferred, now);
+    reporter.requestDataUpload(request);
+    yield deferred.promise;
+    do_check_eq(request.state, request.SUBMISSION_FAILURE_HARD);
+
+    let data = yield getHealthReportProviderValues(reporter, now);
+    do_check_eq(data.firstDocumentUploadAttempt, 1);
+    do_check_eq(data.uploadServerFailure, 1);
+    do_check_eq(Object.keys(data).length, 3);
+  } finally {
+    yield shutdownServer(server);
+    reporter._shutdown();
+  }
+});
+
+add_task(function test_data_submission_success() {
+  let [reporter, server] = yield getReporterAndServer("data_submission_success");
+  try {
+    yield reporter._providerManager.registerProviderFromType(DummyProvider);
+    yield reporter._providerManager.registerProviderFromType(DummyConstantProvider);
+
+    do_check_eq(reporter.lastPingDate.getTime(), 0);
+    do_check_false(reporter.haveRemoteData());
+
+    let deferred = Promise.defer();
+
+    let now = new Date();
+    let request = new DataSubmissionRequest(deferred, now);
+    reporter._state.addRemoteID("foo");
+    reporter.requestDataUpload(request);
+    yield deferred.promise;
+    do_check_eq(request.state, request.SUBMISSION_SUCCESS);
+    do_check_true(reporter.lastPingDate.getTime() > 0);
+    do_check_true(reporter.haveRemoteData());
+    for (let remoteID of reporter._state.remoteIDs) {
+      do_check_neq(remoteID, "foo");
+    }
+
+    // Ensure data from providers made it to payload.
+    let o = yield reporter.getJSONPayload(true);
+    do_check_true("DummyProvider.DummyMeasurement" in o.data.last);
+    do_check_true("DummyConstantProvider.DummyMeasurement" in o.data.last);
+
+    let data = yield getHealthReportProviderValues(reporter, now);
+    do_check_eq(data.continuationUploadAttempt, 1);
+    do_check_eq(data.uploadSuccess, 1);
+    do_check_eq(Object.keys(data).length, 3);
+
+    let d = reporter.lastPingDate;
+    let id = reporter.lastSubmitID;
+    let clientID = reporter._state.clientID;
+
+    yield reporter._shutdown();
+
+    // Ensure reloading state works.
+    reporter = yield getReporter("data_submission_success");
+    do_check_eq(reporter.lastSubmitID, id);
+    do_check_eq(reporter.lastPingDate.getTime(), d.getTime());
+    do_check_eq(reporter._state.clientID, clientID);
+
+    yield reporter._shutdown();
+  } finally {
+    yield shutdownServer(server);
+  }
+});
+
+add_task(function test_recurring_daily_pings() {
+  let [reporter, server] = yield getReporterAndServer("recurring_daily_pings");
+  try {
+    reporter._providerManager.registerProvider(new DummyProvider());
+
+    let policy = reporter._policy;
+
+    defineNow(policy, policy.nextDataSubmissionDate);
+    yield ensureUserNotified(reporter);
+    let promise = policy.checkStateAndTrigger();
+    do_check_neq(promise, null);
+    yield promise;
+
+    let lastID = reporter.lastSubmitID;
+    do_check_neq(lastID, null);
+    do_check_true(server.hasDocument(reporter.serverNamespace, lastID));
+
+    // Skip forward to next scheduled submission time.
+    defineNow(policy, policy.nextDataSubmissionDate);
+    promise = policy.checkStateAndTrigger();
+    do_check_neq(promise, null);
+    yield promise;
+    do_check_neq(reporter.lastSubmitID, lastID);
+    do_check_true(server.hasDocument(reporter.serverNamespace, reporter.lastSubmitID));
+    do_check_false(server.hasDocument(reporter.serverNamespace, lastID));
+
+    // now() on the health reporter instance wasn't munged. So, we should see
+    // both requests attributed to the same day.
+    let data = yield getHealthReportProviderValues(reporter, new Date());
+    do_check_eq(data.firstDocumentUploadAttempt, 1);
+    do_check_eq(data.continuationUploadAttempt, 1);
+    do_check_eq(data.uploadSuccess, 2);
+    do_check_eq(Object.keys(data).length, 4);
+  } finally {
+    reporter._shutdown();
+    yield shu