Bug 808219 - Firefox Health Reporter service; r=rnewman
authorGregory Szorc <gps@mozilla.com>
Tue, 13 Nov 2012 20:22:09 -0800
changeset 113829 6f544baffff0389ece855da3fa9cdc45eea922e6
parent 113828 525e8539150a138f18783e461b22cfebc2bb8582
child 113830 213ad3540ebc91dffbde6b9b47753fea9bd19a08
push id23890
push userryanvm@gmail.com
push dateWed, 21 Nov 2012 02:43:32 +0000
treeherdermozilla-central@4f19e7fd8bea [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman
bugs808219
milestone20.0a1
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
Bug 808219 - Firefox Health Reporter service; r=rnewman
b2g/installer/package-manifest.in
browser/installer/package-manifest.in
mobile/android/installer/package-manifest.in
mobile/xul/installer/package-manifest.in
services/healthreport/HealthReportComponents.manifest
services/healthreport/HealthReportService.js
services/healthreport/Makefile.in
services/healthreport/README.rst
services/healthreport/healthreport-prefs.js
services/healthreport/healthreporter.jsm
services/healthreport/policy.jsm
services/healthreport/tests/xpcshell/test_healthreporter.js
services/healthreport/tests/xpcshell/test_load_modules.js
services/healthreport/tests/xpcshell/xpcshell.ini
--- a/b2g/installer/package-manifest.in
+++ b/b2g/installer/package-manifest.in
@@ -478,16 +478,20 @@
 @BINPATH@/components/nsPrompter.manifest
 @BINPATH@/components/nsPrompter.js
 #ifdef MOZ_SERVICES_SYNC
 @BINPATH@/components/SyncComponents.manifest
 @BINPATH@/components/Weave.js
 @BINPATH@/components/WeaveCrypto.manifest
 @BINPATH@/components/WeaveCrypto.js
 #endif
+#ifdef MOZ_SERVICES_HEALTHREPORT
+@BINPATH@/components/HealthReportComponents.manifest
+@BINPATH@/components/HealthReportService.js
+#endif
 @BINPATH@/components/TelemetryPing.js
 @BINPATH@/components/TelemetryPing.manifest
 @BINPATH@/components/Webapps.js
 @BINPATH@/components/Webapps.manifest
 @BINPATH@/components/AppsService.js
 @BINPATH@/components/AppsService.manifest
 
 @BINPATH@/components/nsDOMIdentity.js
@@ -569,16 +573,19 @@
 
 ; [Default Preferences]
 ; All the pref files must be part of base to prevent migration bugs
 @BINPATH@/@PREF_DIR@/b2g.js
 @BINPATH@/@PREF_DIR@/channel-prefs.js
 #ifdef MOZ_SERVICES_SYNC
 @BINPATH@/@PREF_DIR@/services-sync.js
 #endif
+#ifdef MOZ_SERVICES_HEALTHREPORT
+@BINPATH@/@PREF_DIR@/healthreport-prefs.js
+#endif
 @BINPATH@/greprefs.js
 @BINPATH@/defaults/autoconfig/platform.js
 @BINPATH@/defaults/autoconfig/prefcalls.js
 @BINPATH@/defaults/profile/prefs.js
 
 ; [Layout Engine Resources]
 ; Style Sheets, Graphics and other Resources used by the layout engine. 
 @BINPATH@/res/EditorOverride.css
--- a/browser/installer/package-manifest.in
+++ b/browser/installer/package-manifest.in
@@ -458,16 +458,20 @@
 @BINPATH@/components/nsINIProcessor.manifest
 @BINPATH@/components/nsINIProcessor.js
 @BINPATH@/components/nsPrompter.manifest
 @BINPATH@/components/nsPrompter.js
 #ifdef MOZ_SERVICES_AITC
 @BINPATH@/components/AitcComponents.manifest
 @BINPATH@/components/Aitc.js
 #endif
+#ifdef MOZ_SERVICES_HEALTHREPORT
+@BINPATH@/components/HealthReportComponents.manifest
+@BINPATH@/components/HealthReportService.js
+#endif
 #ifdef MOZ_SERVICES_NOTIFICATIONS
 @BINPATH@/components/NotificationsComponents.manifest
 #endif
 #ifdef MOZ_SERVICES_SYNC
 @BINPATH@/components/SyncComponents.manifest
 @BINPATH@/components/Weave.js
 #endif
 @BINPATH@/components/TelemetryPing.js
@@ -565,16 +569,19 @@
 @BINPATH@/@PREF_DIR@/services-aitc.js
 #endif
 #ifdef MOZ_SERVICES_NOTIFICATIONS
 @BINPATH@/@PREF_DIR@/services-notifications.js
 #endif
 #ifdef MOZ_SERVICES_SYNC
 @BINPATH@/@PREF_DIR@/services-sync.js
 #endif
+#ifdef MOZ_SERVICES_HEALTHREPORT
+@BINPATH@/@PREF_DIR@/healthreport-prefs.js
+#endif
 @BINPATH@/greprefs.js
 @BINPATH@/defaults/autoconfig/platform.js
 @BINPATH@/defaults/autoconfig/prefcalls.js
 #ifndef LIBXUL_SDK
 ; Warning: changing the path to channel-prefs.js can cause bugs (Bug 756325)
 @BINPATH@/defaults/pref/channel-prefs.js
 #else
 @BINPATH@/@PREF_DIR@/channel-prefs.js
--- a/mobile/android/installer/package-manifest.in
+++ b/mobile/android/installer/package-manifest.in
@@ -352,16 +352,21 @@
 @BINPATH@/components/Webapps.manifest
 @BINPATH@/components/AppsService.js
 @BINPATH@/components/AppsService.manifest
 
 @BINPATH@/components/TCPSocket.js
 @BINPATH@/components/TCPSocketParentIntermediary.js
 @BINPATH@/components/TCPSocket.manifest
 
+#ifdef MOZ_SERVICES_HEALTHREPORT
+@BINPATH@/components/HealthReportComponents.manifest
+@BINPATH@/components/HealthReportService.js
+#endif
+
 ; Modules
 @BINPATH@/modules/*
 
 #ifdef MOZ_SAFE_BROWSING
 ; Safe Browsing
 @BINPATH@/components/nsURLClassifier.manifest
 @BINPATH@/components/nsUrlClassifierHashCompleter.js
 @BINPATH@/components/nsUrlClassifierListManager.js
@@ -398,16 +403,19 @@
 @BINPATH@/icons/*.png
 #endif
 
 ; [Default Preferences]
 ; All the pref files must be part of base to prevent migration bugs
 @BINPATH@/@PREF_DIR@/mobile.js
 @BINPATH@/@PREF_DIR@/mobile-branding.js
 @BINPATH@/@PREF_DIR@/channel-prefs.js
+#ifdef MOZ_SERVICES_HEALTHREPORT
+@BINPATH@/@PREF_DIR@/healthreport-prefs.js
+#endif
 @BINPATH@/greprefs.js
 @BINPATH@/defaults/autoconfig/platform.js
 @BINPATH@/defaults/autoconfig/prefcalls.js
 @BINPATH@/defaults/profile/prefs.js
 
 ; [Layout Engine Resources]
 ; Style Sheets, Graphics and other Resources used by the layout engine. 
 @BINPATH@/res/EditorOverride.css
--- a/mobile/xul/installer/package-manifest.in
+++ b/mobile/xul/installer/package-manifest.in
@@ -430,16 +430,20 @@
 @BINPATH@/components/nsPrompter.manifest
 @BINPATH@/components/nsPrompter.js
 #ifdef MOZ_SERVICES_SYNC
 @BINPATH@/components/SyncComponents.manifest
 @BINPATH@/components/Weave.js
 @BINPATH@/components/WeaveCrypto.manifest
 @BINPATH@/components/WeaveCrypto.js
 #endif
+#ifdef MOZ_SERVICES_HEALTHREPORT
+@BINPATH@/components/HealthReportComponents.manifest
+@BINPATH@/components/HealthReportService.js
+#endif
 @BINPATH@/components/TelemetryPing.js
 @BINPATH@/components/TelemetryPing.manifest
 
 @BINPATH@/components/TCPSocket.js
 @BINPATH@/components/TCPSocketParentIntermediary.js
 @BINPATH@/components/TCPSocket.manifest
 
 ; Modules
@@ -496,16 +500,19 @@
 ; [Default Preferences]
 ; All the pref files must be part of base to prevent migration bugs
 @BINPATH@/@PREF_DIR@/mobile.js
 @BINPATH@/@PREF_DIR@/mobile-branding.js
 @BINPATH@/@PREF_DIR@/channel-prefs.js
 #ifdef MOZ_SERVICES_SYNC
 @BINPATH@/@PREF_DIR@/services-sync.js
 #endif
+#ifdef MOZ_SERVICES_HEALTHREPORT
+@BINPATH@/@PREF_DIR@/healthreport-prefs.js
+#endif
 @BINPATH@/greprefs.js
 @BINPATH@/defaults/autoconfig/platform.js
 @BINPATH@/defaults/autoconfig/prefcalls.js
 @BINPATH@/defaults/profile/prefs.js
 
 ; [Layout Engine Resources]
 ; Style Sheets, Graphics and other Resources used by the layout engine. 
 @BINPATH@/res/EditorOverride.css
new file mode 100644
--- /dev/null
+++ b/services/healthreport/HealthReportComponents.manifest
@@ -0,0 +1,11 @@
+#   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}
+#   metro browser:  {99bceaaa-e3c6-48c1-b981-ef9b46b67d60}
+
+component {e354c59b-b252-4040-b6dd-b71864e3e35c} HealthReportService.js
+contract @mozilla.org/healthreport/service;1 {e354c59b-b252-4040-b6dd-b71864e3e35c}
+category app-startup HealthReportService service,@mozilla.org/healthreport/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}
+
new file mode 100644
--- /dev/null
+++ b/services/healthreport/HealthReportService.js
@@ -0,0 +1,132 @@
+/* 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/XPCOMUtils.jsm");
+Cu.import("resource://services-common/preferences.js");
+
+
+const INITIAL_STARTUP_DELAY_MSEC = 10 * 1000;
+const BRANCH = "healthreport.";
+const JS_PROVIDERS_CATEGORY = "healthreport-js-provider";
+
+
+/**
+ * The Firefox Health Report XPCOM service.
+ *
+ * This instantiates an instance of HealthReporter (assuming it is enabled)
+ * and starts it upon application startup.
+ *
+ * One can obtain a reference to the underlying HealthReporter instance by
+ * accessing .reporter. If this property is null, the reporter isn't running
+ * yet or has been disabled.
+ */
+this.HealthReportService = function HealthReportService() {
+  this.wrappedJSObject = this;
+
+  this.prefs = new Preferences(BRANCH);
+  this._reporter = null;
+}
+
+HealthReportService.prototype = {
+  classID: Components.ID("{e354c59b-b252-4040-b6dd-b71864e3e35c}"),
+
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
+                                         Ci.nsISupportsWeakReference]),
+
+  observe: function observe(subject, topic, data) {
+    // If the background service is disabled, don't do anything.
+    if (!this.prefs.get("serviceEnabled", true)) {
+      return;
+    }
+
+    let os = Cc["@mozilla.org/observer-service;1"]
+               .getService(Ci.nsIObserverService);
+
+    switch (topic) {
+      case "app-startup":
+        os.addObserver(this, "final-ui-startup", true);
+        break;
+
+      case "final-ui-startup":
+        os.removeObserver(this, "final-ui-startup");
+        os.addObserver(this, "quit-application", true);
+
+        // 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() {
+            // Side effect: instantiates the reporter instance if not already
+            // accessed.
+            let reporter = this.reporter;
+            delete this.timer;
+          }.bind(this),
+        }, INITIAL_STARTUP_DELAY_MSEC, this.timer.TYPE_ONE_SHOT);
+
+        break;
+
+      case "quit-application-granted":
+        if (this.reporter) {
+          this.reporter.stop();
+        }
+
+        os.removeObserver(this, "quit-application");
+        break;
+    }
+  },
+
+  /**
+   * The HealthReporter instance associated with this service.
+   */
+  get reporter() {
+    if (!this.prefs.get("serviceEnabled", true)) {
+      return null;
+    }
+
+    if (this._reporter) {
+      return this._reporter;
+    }
+
+    // Lazy import so application startup isn't adversely affected.
+    let ns = {};
+    Cu.import("resource://services-common/log4moz.js", ns);
+    Cu.import("resource://gre/modules/services/healthreport/healthreporter.jsm", ns);
+
+    // How many times will we rewrite this code before rolling it up into a
+    // generic module? See also bug 451283.
+    const LOGGERS = [
+      "Metrics",
+      "Services.HealthReport",
+      "Services.Metrics",
+      "Services.BagheeraClient",
+    ];
+
+    let prefs = new Preferences(BRANCH + "logging.");
+    if (prefs.get("consoleEnabled", true)) {
+      let level = prefs.get("consoleLevel", "Warn");
+      let appender = new ns.Log4Moz.ConsoleAppender();
+      appender.level = ns.Log4Moz.Level[level] || ns.Log4Moz.Level.Warn;
+
+      for (let name of LOGGERS) {
+        let logger = ns.Log4Moz.repository.getLogger(name);
+        logger.addAppender(appender);
+      }
+    }
+
+    this._reporter = new ns.HealthReporter(BRANCH);
+    this._reporter.registerProvidersFromCategoryManager(JS_PROVIDERS_CATEGORY);
+    this._reporter.start();
+
+    return this._reporter;
+  },
+};
+
+Object.freeze(HealthReportService.prototype);
+
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory([HealthReportService]);
+
--- a/services/healthreport/Makefile.in
+++ b/services/healthreport/Makefile.in
@@ -5,25 +5,33 @@
 DEPTH     = @DEPTH@
 topsrcdir = @top_srcdir@
 srcdir    = @srcdir@
 VPATH     = @srcdir@
 
 include $(DEPTH)/config/autoconf.mk
 
 modules := \
+  healthreporter.jsm \
   policy.jsm \
   $(NULL)
 
 testing_modules := \
   mocks.jsm \
   $(NULL)
 
 TEST_DIRS += tests
 
 MODULES_FILES := $(modules)
 MODULES_DEST = $(FINAL_TARGET)/modules/services/healthreport
 INSTALL_TARGETS += MODULES
 
 TESTING_JS_MODULES := $(addprefix modules-testing/,$(testing_modules))
 TESTING_JS_MODULE_DIR := services/healthreport
 
+EXTRA_COMPONENTS := \
+  HealthReportComponents.manifest \
+  HealthReportService.js \
+  $(NULL)
+
+PREF_JS_EXPORTS := healthreport-prefs.js
+
 include $(topsrcdir)/config/rules.mk
new file mode 100644
--- /dev/null
+++ b/services/healthreport/README.rst
@@ -0,0 +1,45 @@
+=====================
+Firefox Health Report
+=====================
+
+This directory 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.
+
+Implementation Notes
+====================
+
+The XPCOM service powering FHR is defined in HealthReportService.js. It
+simply instantiates an instance of HealthReporter from healthreporter.jsm.
+
+All the logic for enforcing the privacy policy and for scheduling data
+submissions lives in policy.jsm.
+
+Preferences
+===========
+
+Preferences controlling behavior of Firefox Health Report live in the
+*healthreport.* branch.
+
+Some important preferences are:
+
+* **healthreport.serviceEnabled** - Controls whether the entire health report
+  service runs. The overall service performs data collection, storing, and
+  submission.
+
+* **healthreport.policy.dataSubmissionEnabled** - Controls whether data
+  submission is enabled. If this is *false*, data will still be collected
+  and stored - it just won't ever be submitted to a remote server.
+
+If the entire service is disabled, you lose data collection. This means that
+data analysis won't be available because there is no data to analyze!
+
+Other Notes
+===========
+
+There are many legal and privacy concerns with this code, especially
+around the data that is submitted. Changes to submitted data should be
+signed off by responsible parties.
+
new file mode 100644
--- /dev/null
+++ b/services/healthreport/healthreport-prefs.js
@@ -0,0 +1,21 @@
+/* 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("healthreport.documentServerURI", "https://data.mozilla.com/");
+pref("healthreport.documentServerNamespace", "metrics");
+pref("healthreport.serviceEnabled", true);
+pref("healthreport.logging.consoleEnabled", true);
+pref("healthreport.logging.consoleLevel", "Warn");
+pref("healthreport.policy.currentDaySubmissionFailureCount", 0);
+pref("healthreport.policy.dataSubmissionEnabled", true);
+pref("healthreport.policy.dataSubmissionPolicyAccepted", false);
+pref("healthreport.policy.dataSubmissionPolicyNotifiedTime", "0");
+pref("healthreport.policy.dataSubmissionPolicyResponseType", "");
+pref("healthreport.policy.dataSubmissionPolicyResponseTime", "0");
+pref("healthreport.policy.firstRunTime", "0");
+pref("healthreport.policy.lastDataSubmissionFailureTime", "0");
+pref("healthreport.policy.lastDataSubmissionRequestedTime", "0");
+pref("healthreport.policy.lastDataSubmissionSuccessfulTime", "0");
+pref("healthreport.policy.nextDataSubmissionTime", "0");
+
new file mode 100644
--- /dev/null
+++ b/services/healthreport/healthreporter.jsm
@@ -0,0 +1,427 @@
+/* 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"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://services-common/bagheeraclient.js");
+Cu.import("resource://services-common/log4moz.js");
+Cu.import("resource://services-common/observers.js");
+Cu.import("resource://services-common/preferences.js");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://gre/modules/commonjs/promise/core.js");
+Cu.import("resource://gre/modules/services/healthreport/policy.jsm");
+Cu.import("resource://gre/modules/services/metrics/collector.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;
+
+
+/**
+ * Coordinates collection and submission of metrics.
+ *
+ * This is the main type for Firefox Health Report. 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
+ * HealthReportService.js and 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.
+ *
+ * @param branch
+ *        (string) The preferences branch to use for state storage. The value
+ *        must end with a period (.).
+ */
+this.HealthReporter = function HealthReporter(branch) {
+  if (!branch.endsWith(".")) {
+    throw new Error("Branch argument must end with a period (.): " + branch);
+  }
+
+  this._log = Log4Moz.repository.getLogger("Services.HealthReport.HealthReporter");
+
+  this._prefs = new Preferences(branch);
+
+  let policyBranch = new Preferences(branch + "policy.");
+  this._policy = new HealthReportPolicy(policyBranch, this);
+  this._collector = new MetricsCollector();
+
+  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?");
+  }
+}
+
+HealthReporter.prototype = {
+  /**
+   * 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 CommonUtils.getDatePref(this._prefs, "lastPingTime", 0, this._log,
+                                   OLDEST_ALLOWED_YEAR);
+  },
+
+  set lastPingDate(value) {
+    CommonUtils.setDatePref(this._prefs, "lastPingTime", value,
+                            OLDEST_ALLOWED_YEAR);
+  },
+
+  /**
+   * 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);
+  },
+
+  /**
+   * The document ID for data to be submitted to the server.
+   *
+   * This should be a UUID.
+   *
+   * We generate a new UUID when we upload data to the server. When we get a
+   * successful response for that upload, we record that UUID in this value.
+   * On the subsequent upload, this ID will be deleted from the server.
+   */
+  get lastSubmitID() {
+    return this._prefs.get("lastSubmitID", null) || null;
+  },
+
+  set lastSubmitID(value) {
+    this._prefs.set("lastSubmitID", value || "");
+  },
+
+  /**
+   * Whether remote data is currently stored.
+   *
+   * @return bool
+   */
+  haveRemoteData: function haveRemoteData() {
+    return !!this.lastSubmitID;
+  },
+
+  /**
+   * Start background functionality.
+   *
+   * If this isn't called, no data upload will occur.
+   */
+  start: function start() {
+    this._policy.startPolling();
+    this._log.info("HealthReporter started.");
+  },
+
+  /**
+   * Stop background functionality.
+   */
+  stop: function stop() {
+    this._policy.stopPolling();
+  },
+
+  /**
+   * Register a `MetricsProvider` with this instance.
+   *
+   * This needs to be called or no data will be collected. See also
+   * registerProvidersFromCategoryManager`.
+   *
+   * @param provider
+   *        (MetricsProvider) The provider to register for collection.
+   */
+  registerProvider: function registerProvider(provider) {
+    return this._collector.registerProvider(provider);
+  },
+
+  /**
+   * Registers providers from a category manager category.
+   *
+   * This examines the specified category entries and registers found
+   * providers.
+   *
+   * Category entries are essentially JS modules and the name of the symbol
+   * within that module that is a `MetricsProvider` instance.
+   *
+   * The category entry name is the name of the JS type for the provider. The
+   * value is the resource:// URI to import which makes this type available.
+   *
+   * Example entry:
+   *
+   *   FooProvider resource://gre/modules/foo.jsm
+   *
+   * One can register entries in the application's .manifest file. e.g.
+   *
+   *   category healthreport-js-provider FooProvider resource://gre/modules/foo.jsm
+   *
+   * Then to load them:
+   *
+   *   let reporter = new HealthReporter("healthreport.");
+   *   reporter.registerProvidersFromCategoryManager("healthreport-js-provider");
+   *
+   * @param category
+   *        (string) Name of category to query and load from.
+   */
+  registerProvidersFromCategoryManager:
+    function registerProvidersFromCategoryManager(category) {
+
+    let cm = Cc["@mozilla.org/categorymanager;1"]
+               .getService(Ci.nsICategoryManager);
+
+    let enumerator = cm.enumerateCategory(category);
+    while (enumerator.hasMoreElements()) {
+      let entry = enumerator.getNext()
+                            .QueryInterface(Ci.nsISupportsCString)
+                            .toString();
+
+      let uri = cm.getCategoryEntry(category, entry);
+      this._log.info("Attempting to load provider from category manager: " +
+                     entry + " from " + uri);
+
+      try {
+        let ns = {};
+        Cu.import(uri, ns);
+
+        let provider = new ns[entry]();
+        this.registerProvider(provider);
+      } catch (ex) {
+        this._log.warn("Error registering provider from category manager: " +
+                       entry + "; " + CommonUtils.exceptionStr(ex));
+        continue;
+      }
+    }
+  },
+
+  /**
+   * Collect all measurements for all registered providers.
+   */
+  collectMeasurements: function collectMeasurements() {
+    return this._collector.collectConstantMeasurements();
+  },
+
+  /**
+   * Record the user's rejection of the data submission policy.
+   *
+   * This should be what everything uses to disable data submission.
+   *
+   * @param reason
+   *        (string) Why data submission is being disabled.
+   */
+  recordPolicyRejection: function recordPolicyRejection(reason) {
+    this._policy.recordUserRejection(reason);
+  },
+
+  /**
+   * Record the user's acceptance of the data submission policy.
+   *
+   * This should be what everything uses to enable data submission.
+   *
+   * @param reason
+   *        (string) Why data submission is being enabled.
+   */
+  recordPolicyAcceptance: function recordPolicyAcceptance(reason) {
+    this._policy.recordUserAcceptance(reason);
+  },
+
+  /**
+   * Whether the data submission policy has been accepted.
+   *
+   * If this is true, health data will be submitted unless one of the kill
+   * switches is active.
+   */
+  get dataSubmissionPolicyAccepted() {
+    return this._policy.dataSubmissionPolicyAccepted;
+  },
+
+  /**
+   * Whether this health reporter will upload data to a server.
+   */
+  get willUploadData() {
+    return this._policy.dataSubmissionPolicyAccepted &&
+           this._policy.dataUploadEnabled;
+  },
+
+  /**
+   * 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 requestDeleteRemoteData(reason) {
+    if (!this.lastSubmitID) {
+      return;
+    }
+
+    return this._policy.deleteRemoteData(reason);
+  },
+
+  getJSONPayload: function getJSONPayload() {
+    let o = {
+      version: 1,
+      thisPingDate: this._formatDate(this._now()),
+      providers: {},
+    };
+
+    let lastPingDate = this.lastPingDate;
+    if (lastPingDate.getTime() > 0) {
+      o.lastPingDate = this._formatDate(lastPingDate);
+    }
+
+    for (let [name, provider] of this._collector.collectionResults) {
+      o.providers[name] = provider;
+    }
+
+    return JSON.stringify(o);
+  },
+
+  _onBagheeraResult: function _onBagheeraResult(request, isDelete, result) {
+    this._log.debug("Received Bagheera result.");
+
+    let promise = Promise.resolve(null);
+
+    if (!result.transportSuccess) {
+      request.onSubmissionFailureSoft("Network transport error.");
+      return promise;
+    }
+
+    if (!result.serverSuccess) {
+      request.onSubmissionFailureHard("Server failure.");
+      return promise;
+    }
+
+    let now = this._now();
+
+    if (isDelete) {
+      this.lastSubmitID = null;
+    } else {
+      this.lastSubmitID = result.id;
+      this.lastPingDate = now;
+    }
+
+    request.onSubmissionSuccess(now);
+
+    return promise;
+  },
+
+  _onSubmitDataRequestFailure: function _onSubmitDataRequestFailure(error) {
+    this._log.error("Error processing request to submit data: " +
+                    CommonUtils.exceptionStr(error));
+  },
+
+  _formatDate: function _formatDate(date) {
+    // Why, oh, why doesn't JS have a strftime() equivalent?
+    return date.toISOString().substr(0, 10);
+  },
+
+
+  _uploadData: function _uploadData(request) {
+    let id = CommonUtils.generateUUID();
+
+    this._log.info("Uploading data to server: " + this.serverURI + " " +
+                   this.serverNamespace + ":" + id);
+    let client = new BagheeraClient(this.serverURI);
+
+    let payload = this.getJSONPayload();
+
+    let promise = client.uploadJSON(this.serverNamespace,
+                                    id,
+                                    payload,
+                                    this.lastSubmitID);
+
+    return promise.then(this._onBagheeraResult.bind(this, request, false));
+  },
+
+  _deleteRemoteData: function _deleteRemoteData(request) {
+    if (!this.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 client.deleteDocument(this.serverNamespace, this.lastSubmitID)
+                 .then(this._onBagheeraResult.bind(this, request, true),
+                       this._onSubmitDataRequestFailure.bind(this));
+
+  },
+
+  _now: function _now() {
+    return new Date();
+  },
+
+  //-----------------------------
+  // HealthReportPolicy listeners
+  //-----------------------------
+
+  onRequestDataUpload: function onRequestDataSubmission(request) {
+    this.collectMeasurements()
+        .then(this._uploadData.bind(this, request),
+              this._onSubmitDataRequestFailure.bind(this));
+  },
+
+  onNotifyDataPolicy: function onNotifyDataPolicy(request) {
+    // This isn't very loosely coupled. We may want to have this call
+    // registered listeners instead.
+    Observers.notify("healthreport:notify-data-policy:request", request);
+  },
+
+  onRequestRemoteDelete: function onRequestRemoteDelete(request) {
+    this._deleteRemoteData(request);
+  },
+
+  //------------------------------------
+  // End of HealthReportPolicy listeners
+  //------------------------------------
+};
+
+Object.freeze(HealthReporter.prototype);
+
--- a/services/healthreport/policy.jsm
+++ b/services/healthreport/policy.jsm
@@ -1,15 +1,16 @@
 /* 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 = [
+  "DataSubmissionRequest", // For test use only.
   "HealthReportPolicy",
 ];
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/commonjs/promise/core.js");
 Cu.import("resource://gre/modules/services-common/log4moz.js");
 Cu.import("resource://gre/modules/services-common/utils.js");
@@ -237,17 +238,17 @@ Object.freeze(DataSubmissionRequest.prot
  * @param prefs
  *        (Preferences) Handle on preferences branch on which state will be
  *        queried and stored.
  * @param listener
  *        (object) Object with callbacks that will be invoked at certain key
  *        events.
  */
 this.HealthReportPolicy = function HealthReportPolicy(prefs, listener) {
-  this._log = Log4Moz.repository.getLogger("HealthReport.Policy");
+  this._log = Log4Moz.repository.getLogger("Services.HealthReport.Policy");
   this._log.level = Log4Moz.Level["Debug"];
 
   for (let handler of this.REQUIRED_LISTENERS) {
     if (!listener[handler]) {
       throw new Error("Passed listener does not contain required handler: " +
                       handler);
     }
   }
@@ -639,17 +640,17 @@ HealthReportPolicy.prototype = {
   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();
-    this.checkStateAndTrigger();
+    return this.checkStateAndTrigger();
   },
 
   /**
    * Start background polling for activity.
    *
    * This will set up a recurring timer that will periodically check if
    * activity is warranted.
    *
@@ -734,18 +735,17 @@ HealthReportPolicy.prototype = {
     // 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;
       }
 
-      this._dispatchSubmissionRequest("onRequestRemoteDelete", true);
-      return;
+      return this._dispatchSubmissionRequest("onRequestRemoteDelete", true);
     }
 
     if (!this.dataUploadEnabled) {
       this._log.debug("Data upload is disabled. Doing nothing.");
       return;
     }
 
     // If the user hasn't responded to the data policy, don't do anything.
@@ -763,17 +763,17 @@ HealthReportPolicy.prototype = {
     // comes the scheduling part.
 
     if (nowT < nextSubmissionDate.getTime()) {
       this._log.debug("Next data submission is scheduled in the future: " +
                      nextSubmissionDate);
       return;
     }
 
-    this._dispatchSubmissionRequest("onRequestDataUpload", false);
+    return this._dispatchSubmissionRequest("onRequestDataUpload", false);
   },
 
   /**
    * Ensure user has responded to data submission policy.
    *
    * This must be called before data submission. If the policy has not been
    * responded to, data submission must not occur.
    *
@@ -880,29 +880,31 @@ HealthReportPolicy.prototype = {
 
     let onError = function onError(error) {
       this._log.error("Error when handling data submission result: " +
                       CommonUtils.exceptionStr(result));
       this._inProgressSubmissionRequest = null;
       this._handleSubmissionFailure();
     }.bind(this);
 
-    deferred.promise.then(onSuccess, onError);
+    let chained = deferred.promise.then(onSuccess, onError);
 
     this._log.info("Requesting data submission. Will expire at " +
                    requestExpiresDate);
     try {
       this._listener[handler](this._inProgressSubmissionRequest);
     } catch (ex) {
       this._log.warn("Exception when calling " + handler + ": " +
                      CommonUtils.exceptionStr(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) {
new file mode 100644
--- /dev/null
+++ b/services/healthreport/tests/xpcshell/test_healthreporter.js
@@ -0,0 +1,262 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://services-common/observers.js");
+Cu.import("resource://services-common/preferences.js");
+Cu.import("resource://gre/modules/commonjs/promise/core.js");
+Cu.import("resource://gre/modules/services/healthreport/healthreporter.jsm");
+Cu.import("resource://gre/modules/services/healthreport/policy.jsm");
+Cu.import("resource://testing-common/services-common/bagheeraserver.js");
+Cu.import("resource://testing-common/services/metrics/mocks.jsm");
+
+
+const SERVER_HOSTNAME = "localhost";
+const SERVER_PORT = 8080;
+const SERVER_URI = "http://" + SERVER_HOSTNAME + ":" + SERVER_PORT;
+
+
+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=SERVER_URI) {
+  let branch = "healthreport.testing. " + name + ".";
+
+  let prefs = new Preferences(branch);
+  prefs.set("documentServerURI", uri);
+
+  return new HealthReporter(branch);
+}
+
+function getReporterAndServer(name, namespace="test") {
+  let reporter = getReporter(name, SERVER_URI);
+  reporter.serverNamespace = namespace;
+
+  let server = new BagheeraServer(SERVER_URI);
+  server.createNamespace(namespace);
+
+  server.start(SERVER_PORT);
+
+  return [reporter, server];
+}
+
+function run_test() {
+  run_next_test();
+}
+
+add_test(function test_constructor() {
+  let reporter = getReporter("constructor");
+
+  do_check_eq(reporter.lastPingDate.getTime(), 0);
+  do_check_null(reporter.lastSubmitID);
+
+  reporter.lastSubmitID = "foo";
+  do_check_eq(reporter.lastSubmitID, "foo");
+  reporter.lastSubmitID = null;
+  do_check_null(reporter.lastSubmitID);
+
+  let failed = false;
+  try {
+    new HealthReporter("foo.bar");
+  } catch (ex) {
+    failed = true;
+    do_check_true(ex.message.startsWith("Branch argument must end"));
+  } finally {
+    do_check_true(failed);
+    failed = false;
+  }
+
+  run_next_test();
+});
+
+add_test(function test_register_providers_from_category_manager() {
+  const category = "healthreporter-js-modules";
+
+  let cm = Cc["@mozilla.org/categorymanager;1"]
+             .getService(Ci.nsICategoryManager);
+  cm.addCategoryEntry(category, "DummyProvider",
+                      "resource://testing-common/services/metrics/mocks.jsm",
+                      false, true);
+
+  let reporter = getReporter("category_manager");
+  do_check_eq(reporter._collector._providers.length, 0);
+  reporter.registerProvidersFromCategoryManager(category);
+  do_check_eq(reporter._collector._providers.length, 1);
+
+  run_next_test();
+});
+
+add_test(function test_json_payload_simple() {
+  let reporter = getReporter("json_payload_simple");
+
+  let now = new Date();
+  let payload = reporter.getJSONPayload();
+  let original = JSON.parse(payload);
+
+  do_check_eq(original.version, 1);
+  do_check_eq(original.thisPingDate, reporter._formatDate(now));
+  do_check_eq(Object.keys(original.providers).length, 0);
+
+  reporter.lastPingDate = new Date(now.getTime() - 24 * 60 * 60 * 1000 - 10);
+
+  original = JSON.parse(reporter.getJSONPayload());
+  do_check_eq(original.lastPingDate, reporter._formatDate(reporter.lastPingDate));
+
+  // 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));
+
+  run_next_test();
+});
+
+add_test(function test_json_payload_dummy_provider() {
+  let reporter = getReporter("json_payload_dummy_provider");
+
+  reporter.registerProvider(new DummyProvider());
+  reporter.collectMeasurements().then(function onResult() {
+    let o = JSON.parse(reporter.getJSONPayload());
+
+    do_check_eq(Object.keys(o.providers).length, 1);
+    do_check_true("DummyProvider" in o.providers);
+    do_check_true("measurements" in o.providers.DummyProvider);
+    do_check_true("DummyMeasurement" in o.providers.DummyProvider.measurements);
+
+    run_next_test();
+  });
+});
+
+add_test(function test_notify_policy_observers() {
+  let reporter = getReporter("notify_policy_observers");
+
+  Observers.add("healthreport:notify-data-policy:request",
+                function onObserver(subject, data) {
+    Observers.remove("healthreport:notify-data-policy:request", onObserver);
+
+    do_check_true("foo" in subject);
+
+    run_next_test();
+  });
+
+  reporter.onNotifyDataPolicy({foo: "bar"});
+});
+
+add_test(function test_data_submission_transport_failure() {
+  let reporter = getReporter("data_submission_transport_failure");
+  reporter.serverURI = "http://localhost:8080/";
+  reporter.serverNamespace = "test00";
+
+  let deferred = Promise.defer();
+  deferred.promise.then(function onResult(request) {
+    do_check_eq(request.state, request.SUBMISSION_FAILURE_SOFT);
+
+    run_next_test();
+  });
+
+  let request = new DataSubmissionRequest(deferred, new Date(Date.now + 30000));
+  reporter.onRequestDataUpload(request);
+});
+
+add_test(function test_data_submission_success() {
+  let [reporter, server] = getReporterAndServer("data_submission_success");
+
+  do_check_eq(reporter.lastPingDate.getTime(), 0);
+  do_check_false(reporter.haveRemoteData());
+
+  let deferred = Promise.defer();
+  deferred.promise.then(function onResult(request) {
+    do_check_eq(request.state, request.SUBMISSION_SUCCESS);
+    do_check_neq(reporter.lastPingDate.getTime(), 0);
+    do_check_true(reporter.haveRemoteData());
+
+    server.stop(run_next_test);
+  });
+
+  let request = new DataSubmissionRequest(deferred, new Date());
+  reporter.onRequestDataUpload(request);
+});
+
+add_test(function test_recurring_daily_pings() {
+  let [reporter, server] = getReporterAndServer("recurring_daily_pings");
+  reporter.registerProvider(new DummyProvider());
+
+  let policy = reporter._policy;
+
+  defineNow(policy, policy._futureDate(-24 * 60 * 68 * 1000));
+  policy.recordUserAcceptance();
+  defineNow(policy, policy.nextDataSubmissionDate);
+  let promise = policy.checkStateAndTrigger();
+  do_check_neq(promise, null);
+
+  promise.then(function onUploadComplete() {
+    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);
+    let promise = policy.checkStateAndTrigger();
+    do_check_neq(promise, null);
+    promise.then(function onSecondUploadCOmplete() {
+      do_check_neq(reporter.lastSubmitID, lastID);
+      do_check_true(server.hasDocument(reporter.serverNamespace, reporter.lastSubmitID));
+      do_check_false(server.hasDocument(reporter.serverNamespace, lastID));
+
+      server.stop(run_next_test);
+    });
+  });
+});
+
+add_test(function test_request_remote_data_deletion() {
+  let [reporter, server] = getReporterAndServer("request_remote_data_deletion");
+
+  let policy = reporter._policy;
+  defineNow(policy, policy._futureDate(-24 * 60 * 60 * 1000));
+  policy.recordUserAcceptance();
+  defineNow(policy, policy.nextDataSubmissionDate);
+  policy.checkStateAndTrigger().then(function onUploadComplete() {
+    let id = reporter.lastSubmitID;
+    do_check_neq(id, null);
+    do_check_true(server.hasDocument(reporter.serverNamespace, id));
+
+    defineNow(policy, policy._futureDate(10 * 1000));
+
+    let promise = reporter.requestDeleteRemoteData();
+    do_check_neq(promise, null);
+    promise.then(function onDeleteComplete() {
+      do_check_null(reporter.lastSubmitID);
+      do_check_false(reporter.haveRemoteData());
+      do_check_false(server.hasDocument(reporter.serverNamespace, id));
+
+      server.stop(run_next_test);
+    });
+  });
+});
+
+add_test(function test_policy_accept_reject() {
+  let [reporter, server] = getReporterAndServer("policy_accept_reject");
+
+  do_check_false(reporter.dataSubmissionPolicyAccepted);
+  do_check_false(reporter.willUploadData);
+
+  reporter.recordPolicyAcceptance();
+  do_check_true(reporter.dataSubmissionPolicyAccepted);
+  do_check_true(reporter.willUploadData);
+
+  reporter.recordPolicyRejection();
+  do_check_false(reporter.dataSubmissionPolicyAccepted);
+  do_check_false(reporter.willUploadData);
+
+  server.stop(run_next_test);
+});
+
--- a/services/healthreport/tests/xpcshell/test_load_modules.js
+++ b/services/healthreport/tests/xpcshell/test_load_modules.js
@@ -1,14 +1,15 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 const modules = [
+  "healthreporter.jsm",
   "policy.jsm",
 ];
 
 const test_modules = [
   "mocks.jsm",
 ];
 
 function run_test() {
--- a/services/healthreport/tests/xpcshell/xpcshell.ini
+++ b/services/healthreport/tests/xpcshell/xpcshell.ini
@@ -1,6 +1,7 @@
 [DEFAULT]
 head = head.js
 tail =
 
 [test_load_modules.js]
 [test_policy.js]
+[test_healthreporter.js]