diff --git a/b2g/installer/package-manifest.in b/b2g/installer/package-manifest.in index 11637ab7f447..dad20310e996 100644 --- a/b2g/installer/package-manifest.in +++ b/b2g/installer/package-manifest.in @@ -483,6 +483,10 @@ @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 @@ -574,6 +578,9 @@ #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 diff --git a/browser/installer/package-manifest.in b/browser/installer/package-manifest.in index 1e2d188a248b..aa25bbdd962b 100644 --- a/browser/installer/package-manifest.in +++ b/browser/installer/package-manifest.in @@ -463,6 +463,10 @@ @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 @@ -570,6 +574,9 @@ #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 diff --git a/mobile/android/installer/package-manifest.in b/mobile/android/installer/package-manifest.in index 4f0a72ed88fd..557f78452a10 100644 --- a/mobile/android/installer/package-manifest.in +++ b/mobile/android/installer/package-manifest.in @@ -357,6 +357,11 @@ @BINPATH@/components/TCPSocketParentIntermediary.js @BINPATH@/components/TCPSocket.manifest +#ifdef MOZ_SERVICES_HEALTHREPORT +@BINPATH@/components/HealthReportComponents.manifest +@BINPATH@/components/HealthReportService.js +#endif + ; Modules @BINPATH@/modules/* @@ -403,6 +408,9 @@ @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 diff --git a/mobile/xul/installer/package-manifest.in b/mobile/xul/installer/package-manifest.in index d1037db0c838..c4d01f0dc5ec 100644 --- a/mobile/xul/installer/package-manifest.in +++ b/mobile/xul/installer/package-manifest.in @@ -435,6 +435,10 @@ @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 @@ -501,6 +505,9 @@ #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 diff --git a/services/healthreport/HealthReportComponents.manifest b/services/healthreport/HealthReportComponents.manifest new file mode 100644 index 000000000000..f750c51d33b9 --- /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} + diff --git a/services/healthreport/HealthReportService.js b/services/healthreport/HealthReportService.js new file mode 100644 index 000000000000..1d8eba914309 --- /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]); + diff --git a/services/healthreport/Makefile.in b/services/healthreport/Makefile.in index 835c6f4a720e..3e203021a4c8 100644 --- a/services/healthreport/Makefile.in +++ b/services/healthreport/Makefile.in @@ -10,6 +10,7 @@ VPATH = @srcdir@ include $(DEPTH)/config/autoconf.mk modules := \ + healthreporter.jsm \ policy.jsm \ $(NULL) @@ -26,4 +27,11 @@ 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 diff --git a/services/healthreport/README.rst b/services/healthreport/README.rst new file mode 100644 index 000000000000..a07948bf9fc4 --- /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. + diff --git a/services/healthreport/healthreport-prefs.js b/services/healthreport/healthreport-prefs.js new file mode 100644 index 000000000000..01b5cd454cf8 --- /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"); + diff --git a/services/healthreport/healthreporter.jsm b/services/healthreport/healthreporter.jsm new file mode 100644 index 000000000000..7c73e2482266 --- /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); + diff --git a/services/healthreport/policy.jsm b/services/healthreport/policy.jsm index ff181c213dd5..2e9b56fa4a36 100644 --- a/services/healthreport/policy.jsm +++ b/services/healthreport/policy.jsm @@ -5,6 +5,7 @@ "use strict"; this.EXPORTED_SYMBOLS = [ + "DataSubmissionRequest", // For test use only. "HealthReportPolicy", ]; @@ -242,7 +243,7 @@ Object.freeze(DataSubmissionRequest.prototype); * 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) { @@ -644,7 +645,7 @@ HealthReportPolicy.prototype = { // 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(); }, /** @@ -739,8 +740,7 @@ HealthReportPolicy.prototype = { return; } - this._dispatchSubmissionRequest("onRequestRemoteDelete", true); - return; + return this._dispatchSubmissionRequest("onRequestRemoteDelete", true); } if (!this.dataUploadEnabled) { @@ -768,7 +768,7 @@ HealthReportPolicy.prototype = { return; } - this._dispatchSubmissionRequest("onRequestDataUpload", false); + return this._dispatchSubmissionRequest("onRequestDataUpload", false); }, /** @@ -885,7 +885,7 @@ HealthReportPolicy.prototype = { 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); @@ -898,6 +898,8 @@ HealthReportPolicy.prototype = { this._handleSubmissionFailure(); return; } + + return chained; }, _handleSubmissionResult: function _handleSubmissionResult(request) { diff --git a/services/healthreport/tests/xpcshell/test_healthreporter.js b/services/healthreport/tests/xpcshell/test_healthreporter.js new file mode 100644 index 000000000000..275870b719db --- /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); +}); + diff --git a/services/healthreport/tests/xpcshell/test_load_modules.js b/services/healthreport/tests/xpcshell/test_load_modules.js index af9b4dc20d3a..384355e46181 100644 --- a/services/healthreport/tests/xpcshell/test_load_modules.js +++ b/services/healthreport/tests/xpcshell/test_load_modules.js @@ -4,6 +4,7 @@ "use strict"; const modules = [ + "healthreporter.jsm", "policy.jsm", ]; diff --git a/services/healthreport/tests/xpcshell/xpcshell.ini b/services/healthreport/tests/xpcshell/xpcshell.ini index 57854778d3e1..77cf67abad47 100644 --- a/services/healthreport/tests/xpcshell/xpcshell.ini +++ b/services/healthreport/tests/xpcshell/xpcshell.ini @@ -4,3 +4,4 @@ tail = [test_load_modules.js] [test_policy.js] +[test_healthreporter.js]