michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: "use strict"; michael@0: michael@0: #ifndef MERGED_COMPARTMENT michael@0: michael@0: this.EXPORTED_SYMBOLS = ["HealthReporter"]; michael@0: michael@0: const {classes: Cc, interfaces: Ci, utils: Cu} = Components; michael@0: michael@0: const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; michael@0: michael@0: Cu.import("resource://gre/modules/Metrics.jsm"); michael@0: Cu.import("resource://services-common/async.js"); michael@0: michael@0: Cu.import("resource://services-common/bagheeraclient.js"); michael@0: #endif michael@0: michael@0: Cu.import("resource://gre/modules/Log.jsm"); michael@0: Cu.import("resource://services-common/utils.js"); michael@0: Cu.import("resource://gre/modules/Promise.jsm"); michael@0: Cu.import("resource://gre/modules/osfile.jsm"); michael@0: Cu.import("resource://gre/modules/Preferences.jsm"); michael@0: Cu.import("resource://gre/modules/Services.jsm"); michael@0: Cu.import("resource://gre/modules/Task.jsm"); michael@0: Cu.import("resource://gre/modules/TelemetryStopwatch.jsm"); michael@0: Cu.import("resource://gre/modules/XPCOMUtils.jsm"); michael@0: michael@0: XPCOMUtils.defineLazyModuleGetter(this, "UpdateChannel", michael@0: "resource://gre/modules/UpdateChannel.jsm"); michael@0: michael@0: // Oldest year to allow in date preferences. This module was implemented in michael@0: // 2012 and no dates older than that should be encountered. michael@0: const OLDEST_ALLOWED_YEAR = 2012; michael@0: michael@0: const DAYS_IN_PAYLOAD = 180; michael@0: michael@0: const DEFAULT_DATABASE_NAME = "healthreport.sqlite"; michael@0: michael@0: const TELEMETRY_INIT = "HEALTHREPORT_INIT_MS"; michael@0: const TELEMETRY_INIT_FIRSTRUN = "HEALTHREPORT_INIT_FIRSTRUN_MS"; michael@0: const TELEMETRY_DB_OPEN = "HEALTHREPORT_DB_OPEN_MS"; michael@0: const TELEMETRY_DB_OPEN_FIRSTRUN = "HEALTHREPORT_DB_OPEN_FIRSTRUN_MS"; michael@0: const TELEMETRY_GENERATE_PAYLOAD = "HEALTHREPORT_GENERATE_JSON_PAYLOAD_MS"; michael@0: const TELEMETRY_JSON_PAYLOAD_SERIALIZE = "HEALTHREPORT_JSON_PAYLOAD_SERIALIZE_MS"; michael@0: const TELEMETRY_PAYLOAD_SIZE_UNCOMPRESSED = "HEALTHREPORT_PAYLOAD_UNCOMPRESSED_BYTES"; michael@0: const TELEMETRY_PAYLOAD_SIZE_COMPRESSED = "HEALTHREPORT_PAYLOAD_COMPRESSED_BYTES"; michael@0: const TELEMETRY_UPLOAD = "HEALTHREPORT_UPLOAD_MS"; michael@0: const TELEMETRY_SHUTDOWN_DELAY = "HEALTHREPORT_SHUTDOWN_DELAY_MS"; michael@0: const TELEMETRY_COLLECT_CONSTANT = "HEALTHREPORT_COLLECT_CONSTANT_DATA_MS"; michael@0: const TELEMETRY_COLLECT_DAILY = "HEALTHREPORT_COLLECT_DAILY_MS"; michael@0: const TELEMETRY_SHUTDOWN = "HEALTHREPORT_SHUTDOWN_MS"; michael@0: const TELEMETRY_COLLECT_CHECKPOINT = "HEALTHREPORT_POST_COLLECT_CHECKPOINT_MS"; michael@0: michael@0: michael@0: /** michael@0: * Helper type to assist with management of Health Reporter state. michael@0: * michael@0: * Instances are not meant to be created outside of a HealthReporter instance. michael@0: * michael@0: * There are two types of IDs associated with clients. michael@0: * michael@0: * Since the beginning of FHR, there has existed a per-upload ID: a UUID is michael@0: * generated at upload time and associated with the state before upload starts. michael@0: * That same upload includes a request to delete all other upload IDs known by michael@0: * the client. michael@0: * michael@0: * Per-upload IDs had the unintended side-effect of creating "orphaned" michael@0: * records/upload IDs on the server. So, a stable client identifer has been michael@0: * introduced. This client identifier is generated when it's missing and sent michael@0: * as part of every upload. michael@0: * michael@0: * There is a high chance we may remove upload IDs in the future. michael@0: */ michael@0: function HealthReporterState(reporter) { michael@0: this._reporter = reporter; michael@0: michael@0: let profD = OS.Constants.Path.profileDir; michael@0: michael@0: if (!profD || !profD.length) { michael@0: throw new Error("Could not obtain profile directory. OS.File not " + michael@0: "initialized properly?"); michael@0: } michael@0: michael@0: this._log = reporter._log; michael@0: michael@0: this._stateDir = OS.Path.join(profD, "healthreport"); michael@0: michael@0: // To facilitate testing. michael@0: let leaf = reporter._stateLeaf || "state.json"; michael@0: michael@0: this._filename = OS.Path.join(this._stateDir, leaf); michael@0: this._log.debug("Storing state in " + this._filename); michael@0: this._s = null; michael@0: } michael@0: michael@0: HealthReporterState.prototype = Object.freeze({ michael@0: /** michael@0: * Persistent string identifier associated with this client. michael@0: */ michael@0: get clientID() { michael@0: return this._s.clientID; michael@0: }, michael@0: michael@0: /** michael@0: * The version associated with the client ID. michael@0: */ michael@0: get clientIDVersion() { michael@0: return this._s.clientIDVersion; michael@0: }, michael@0: michael@0: get lastPingDate() { michael@0: return new Date(this._s.lastPingTime); michael@0: }, michael@0: michael@0: get lastSubmitID() { michael@0: return this._s.remoteIDs[0]; michael@0: }, michael@0: michael@0: get remoteIDs() { michael@0: return this._s.remoteIDs; michael@0: }, michael@0: michael@0: get _lastPayloadPath() { michael@0: return OS.Path.join(this._stateDir, "lastpayload.json"); michael@0: }, michael@0: michael@0: init: function () { michael@0: return Task.spawn(function init() { michael@0: try { michael@0: OS.File.makeDir(this._stateDir); michael@0: } catch (ex if ex instanceof OS.FileError) { michael@0: if (!ex.becauseExists) { michael@0: throw ex; michael@0: } michael@0: } michael@0: michael@0: let resetObjectState = function () { michael@0: this._s = { michael@0: // The payload version. This is bumped whenever there is a michael@0: // backwards-incompatible change. michael@0: v: 1, michael@0: // The persistent client identifier. michael@0: clientID: CommonUtils.generateUUID(), michael@0: // Denotes the mechanism used to generate the client identifier. michael@0: // 1: Random UUID. michael@0: clientIDVersion: 1, michael@0: // Upload IDs that might be on the server. michael@0: remoteIDs: [], michael@0: // When we last performed an uploaded. michael@0: lastPingTime: 0, michael@0: // Tracks whether we removed an outdated payload. michael@0: removedOutdatedLastpayload: false, michael@0: }; michael@0: }.bind(this); michael@0: michael@0: try { michael@0: this._s = yield CommonUtils.readJSON(this._filename); michael@0: } catch (ex if ex instanceof OS.File.Error) { michael@0: if (!ex.becauseNoSuchFile) { michael@0: throw ex; michael@0: } michael@0: michael@0: this._log.warn("Saved state file does not exist."); michael@0: resetObjectState(); michael@0: } catch (ex) { michael@0: this._log.error("Exception when reading state from disk: " + michael@0: CommonUtils.exceptionStr(ex)); michael@0: resetObjectState(); michael@0: michael@0: // Don't save in case it goes away on next run. michael@0: } michael@0: michael@0: if (typeof(this._s) != "object") { michael@0: this._log.warn("Read state is not an object. Resetting state."); michael@0: resetObjectState(); michael@0: yield this.save(); michael@0: } michael@0: michael@0: if (this._s.v != 1) { michael@0: this._log.warn("Unknown version in state file: " + this._s.v); michael@0: resetObjectState(); michael@0: // We explicitly don't save here in the hopes an application re-upgrade michael@0: // comes along and fixes us. michael@0: } michael@0: michael@0: let regen = false; michael@0: if (!this._s.clientID) { michael@0: this._log.warn("No client ID stored. Generating random ID."); michael@0: regen = true; michael@0: } michael@0: michael@0: if (typeof(this._s.clientID) != "string") { michael@0: this._log.warn("Client ID is not a string. Regenerating."); michael@0: regen = true; michael@0: } michael@0: michael@0: if (regen) { michael@0: this._s.clientID = CommonUtils.generateUUID(); michael@0: this._s.clientIDVersion = 1; michael@0: yield this.save(); michael@0: } michael@0: michael@0: // Always look for preferences. This ensures that downgrades followed michael@0: // by reupgrades don't result in excessive data loss. michael@0: for (let promise of this._migratePrefs()) { michael@0: yield promise; michael@0: } michael@0: }.bind(this)); michael@0: }, michael@0: michael@0: save: function () { michael@0: this._log.info("Writing state file: " + this._filename); michael@0: return CommonUtils.writeJSON(this._s, this._filename); michael@0: }, michael@0: michael@0: addRemoteID: function (id) { michael@0: this._log.warn("Recording new remote ID: " + id); michael@0: this._s.remoteIDs.push(id); michael@0: return this.save(); michael@0: }, michael@0: michael@0: removeRemoteID: function (id) { michael@0: return this.removeRemoteIDs(id ? [id] : []); michael@0: }, michael@0: michael@0: removeRemoteIDs: function (ids) { michael@0: if (!ids || !ids.length) { michael@0: this._log.warn("No IDs passed for removal."); michael@0: return Promise.resolve(); michael@0: } michael@0: michael@0: this._log.warn("Removing documents from remote ID list: " + ids); michael@0: let filtered = this._s.remoteIDs.filter((x) => ids.indexOf(x) === -1); michael@0: michael@0: if (filtered.length == this._s.remoteIDs.length) { michael@0: return Promise.resolve(); michael@0: } michael@0: michael@0: this._s.remoteIDs = filtered; michael@0: return this.save(); michael@0: }, michael@0: michael@0: setLastPingDate: function (date) { michael@0: this._s.lastPingTime = date.getTime(); michael@0: michael@0: return this.save(); michael@0: }, michael@0: michael@0: updateLastPingAndRemoveRemoteID: function (date, id) { michael@0: return this.updateLastPingAndRemoveRemoteIDs(date, id ? [id] : []); michael@0: }, michael@0: michael@0: updateLastPingAndRemoveRemoteIDs: function (date, ids) { michael@0: if (!ids) { michael@0: return this.setLastPingDate(date); michael@0: } michael@0: michael@0: this._log.info("Recording last ping time and deleted remote document."); michael@0: this._s.lastPingTime = date.getTime(); michael@0: return this.removeRemoteIDs(ids); michael@0: }, michael@0: michael@0: /** michael@0: * Reset the client ID to something else. michael@0: * michael@0: * This fails if remote IDs are stored because changing the client ID michael@0: * while there is remote data will create orphaned records. michael@0: */ michael@0: resetClientID: function () { michael@0: if (this.remoteIDs.length) { michael@0: throw new Error("Cannot reset client ID while remote IDs are stored."); michael@0: } michael@0: michael@0: this._log.warn("Resetting client ID."); michael@0: this._s.clientID = CommonUtils.generateUUID(); michael@0: this._s.clientIDVersion = 1; michael@0: michael@0: return this.save(); michael@0: }, michael@0: michael@0: _migratePrefs: function () { michael@0: let prefs = this._reporter._prefs; michael@0: michael@0: let lastID = prefs.get("lastSubmitID", null); michael@0: let lastPingDate = CommonUtils.getDatePref(prefs, "lastPingTime", michael@0: 0, this._log, OLDEST_ALLOWED_YEAR); michael@0: michael@0: // If we have state from prefs, migrate and save it to a file then clear michael@0: // out old prefs. michael@0: if (lastID || (lastPingDate && lastPingDate.getTime() > 0)) { michael@0: this._log.warn("Migrating saved state from preferences."); michael@0: michael@0: if (lastID) { michael@0: this._log.info("Migrating last saved ID: " + lastID); michael@0: this._s.remoteIDs.push(lastID); michael@0: } michael@0: michael@0: let ourLast = this.lastPingDate; michael@0: michael@0: if (lastPingDate && lastPingDate.getTime() > ourLast.getTime()) { michael@0: this._log.info("Migrating last ping time: " + lastPingDate); michael@0: this._s.lastPingTime = lastPingDate.getTime(); michael@0: } michael@0: michael@0: yield this.save(); michael@0: prefs.reset(["lastSubmitID", "lastPingTime"]); michael@0: } else { michael@0: this._log.warn("No prefs data found."); michael@0: } michael@0: }, michael@0: }); michael@0: michael@0: /** michael@0: * This is the abstract base class of `HealthReporter`. It exists so that michael@0: * we can sanely divide work on platforms where control of Firefox Health michael@0: * Report is outside of Gecko (e.g., Android). michael@0: */ michael@0: function AbstractHealthReporter(branch, policy, sessionRecorder) { michael@0: if (!branch.endsWith(".")) { michael@0: throw new Error("Branch must end with a period (.): " + branch); michael@0: } michael@0: michael@0: if (!policy) { michael@0: throw new Error("Must provide policy to HealthReporter constructor."); michael@0: } michael@0: michael@0: this._log = Log.repository.getLogger("Services.HealthReport.HealthReporter"); michael@0: this._log.info("Initializing health reporter instance against " + branch); michael@0: michael@0: this._branch = branch; michael@0: this._prefs = new Preferences(branch); michael@0: michael@0: this._policy = policy; michael@0: this.sessionRecorder = sessionRecorder; michael@0: michael@0: this._dbName = this._prefs.get("dbName") || DEFAULT_DATABASE_NAME; michael@0: michael@0: this._storage = null; michael@0: this._storageInProgress = false; michael@0: this._providerManager = null; michael@0: this._providerManagerInProgress = false; michael@0: this._initializeStarted = false; michael@0: this._initialized = false; michael@0: this._initializeHadError = false; michael@0: this._initializedDeferred = Promise.defer(); michael@0: this._shutdownRequested = false; michael@0: this._shutdownInitiated = false; michael@0: this._shutdownComplete = false; michael@0: this._shutdownCompleteCallback = null; michael@0: michael@0: this._errors = []; michael@0: michael@0: this._lastDailyDate = null; michael@0: michael@0: // Yes, this will probably run concurrently with remaining constructor work. michael@0: let hasFirstRun = this._prefs.get("service.firstRun", false); michael@0: this._initHistogram = hasFirstRun ? TELEMETRY_INIT : TELEMETRY_INIT_FIRSTRUN; michael@0: this._dbOpenHistogram = hasFirstRun ? TELEMETRY_DB_OPEN : TELEMETRY_DB_OPEN_FIRSTRUN; michael@0: } michael@0: michael@0: AbstractHealthReporter.prototype = Object.freeze({ michael@0: QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]), michael@0: michael@0: /** michael@0: * Whether the service is fully initialized and running. michael@0: * michael@0: * If this is false, it is not safe to call most functions. michael@0: */ michael@0: get initialized() { michael@0: return this._initialized; michael@0: }, michael@0: michael@0: /** michael@0: * Initialize the instance. michael@0: * michael@0: * This must be called once after object construction or the instance is michael@0: * useless. michael@0: */ michael@0: init: function () { michael@0: if (this._initializeStarted) { michael@0: throw new Error("We have already started initialization."); michael@0: } michael@0: michael@0: this._initializeStarted = true; michael@0: michael@0: TelemetryStopwatch.start(this._initHistogram, this); michael@0: michael@0: this._initializeState().then(this._onStateInitialized.bind(this), michael@0: this._onInitError.bind(this)); michael@0: michael@0: return this.onInit(); michael@0: }, michael@0: michael@0: //---------------------------------------------------- michael@0: // SERVICE CONTROL FUNCTIONS michael@0: // michael@0: // You shouldn't need to call any of these externally. michael@0: //---------------------------------------------------- michael@0: michael@0: _onInitError: function (error) { michael@0: TelemetryStopwatch.cancel(this._initHistogram, this); michael@0: TelemetryStopwatch.cancel(this._dbOpenHistogram, this); michael@0: delete this._initHistogram; michael@0: delete this._dbOpenHistogram; michael@0: michael@0: this._recordError("Error during initialization", error); michael@0: this._initializeHadError = true; michael@0: this._initiateShutdown(); michael@0: this._initializedDeferred.reject(error); michael@0: michael@0: // FUTURE consider poisoning prototype's functions so calls fail with a michael@0: // useful error message. michael@0: }, michael@0: michael@0: _initializeState: function () { michael@0: return Promise.resolve(); michael@0: }, michael@0: michael@0: _onStateInitialized: function () { michael@0: return Task.spawn(function onStateInitialized () { michael@0: try { michael@0: if (!this._state._s.removedOutdatedLastpayload) { michael@0: yield this._deleteOldLastPayload(); michael@0: this._state._s.removedOutdatedLastpayload = true; michael@0: // Normally we should save this to a file but it directly conflicts with michael@0: // the "application re-upgrade" decision in HealthReporterState::init() michael@0: // which specifically does not save the state to a file. michael@0: } michael@0: } catch (ex) { michael@0: this._log.error("Error deleting last payload: " + michael@0: CommonUtils.exceptionStr(ex)); michael@0: } michael@0: // As soon as we have could storage, we need to register cleanup or michael@0: // else bad things happen on shutdown. michael@0: Services.obs.addObserver(this, "quit-application", false); michael@0: Services.obs.addObserver(this, "profile-before-change", false); michael@0: michael@0: this._storageInProgress = true; michael@0: TelemetryStopwatch.start(this._dbOpenHistogram, this); michael@0: michael@0: Metrics.Storage(this._dbName).then(this._onStorageCreated.bind(this), michael@0: this._onInitError.bind(this)); michael@0: }.bind(this)); michael@0: }, michael@0: michael@0: michael@0: /** michael@0: * Removes the outdated lastpaylaod.json and lastpayload.json.tmp files michael@0: * @see Bug #867902 michael@0: * @return a promise for when all the files have been deleted michael@0: */ michael@0: _deleteOldLastPayload: function () { michael@0: let paths = [this._state._lastPayloadPath, this._state._lastPayloadPath + ".tmp"]; michael@0: return Task.spawn(function removeAllFiles () { michael@0: for (let path of paths) { michael@0: try { michael@0: OS.File.remove(path); michael@0: } catch (ex) { michael@0: if (!ex.becauseNoSuchFile) { michael@0: this._log.error("Exception when removing outdated payload files: " + michael@0: CommonUtils.exceptionStr(ex)); michael@0: } michael@0: } michael@0: } michael@0: }.bind(this)); michael@0: }, michael@0: michael@0: // Called when storage has been opened. michael@0: _onStorageCreated: function (storage) { michael@0: TelemetryStopwatch.finish(this._dbOpenHistogram, this); michael@0: delete this._dbOpenHistogram; michael@0: this._log.info("Storage initialized."); michael@0: this._storage = storage; michael@0: this._storageInProgress = false; michael@0: michael@0: if (this._shutdownRequested) { michael@0: this._initiateShutdown(); michael@0: return; michael@0: } michael@0: michael@0: Task.spawn(this._initializeProviderManager.bind(this)) michael@0: .then(this._onProviderManagerInitialized.bind(this), michael@0: this._onInitError.bind(this)); michael@0: }, michael@0: michael@0: _initializeProviderManager: function () { michael@0: if (this._collector) { michael@0: throw new Error("Provider manager has already been initialized."); michael@0: } michael@0: michael@0: this._log.info("Initializing provider manager."); michael@0: this._providerManager = new Metrics.ProviderManager(this._storage); michael@0: this._providerManager.onProviderError = this._recordError.bind(this); michael@0: this._providerManager.onProviderInit = this._initProvider.bind(this); michael@0: this._providerManagerInProgress = true; michael@0: michael@0: let catString = this._prefs.get("service.providerCategories") || ""; michael@0: if (catString.length) { michael@0: for (let category of catString.split(",")) { michael@0: yield this._providerManager.registerProvidersFromCategoryManager(category); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: _onProviderManagerInitialized: function () { michael@0: TelemetryStopwatch.finish(this._initHistogram, this); michael@0: delete this._initHistogram; michael@0: this._log.debug("Provider manager initialized."); michael@0: this._providerManagerInProgress = false; michael@0: michael@0: if (this._shutdownRequested) { michael@0: this._initiateShutdown(); michael@0: return; michael@0: } michael@0: michael@0: this._log.info("HealthReporter started."); michael@0: this._initialized = true; michael@0: Services.obs.addObserver(this, "idle-daily", false); michael@0: michael@0: // If upload is not enabled, ensure daily collection works. If upload michael@0: // is enabled, this will be performed as part of upload. michael@0: // michael@0: // This is important because it ensures about:healthreport contains michael@0: // longitudinal data even if upload is disabled. Having about:healthreport michael@0: // provide useful info even if upload is disabled was a core launch michael@0: // requirement. michael@0: // michael@0: // We do not catch changes to the backing pref. So, if the session lasts michael@0: // many days, we may fail to collect. However, most sessions are short and michael@0: // this code will likely be refactored as part of splitting up policy to michael@0: // serve Android. So, meh. michael@0: if (!this._policy.healthReportUploadEnabled) { michael@0: this._log.info("Upload not enabled. Scheduling daily collection."); michael@0: // Since the timer manager is a singleton and there could be multiple michael@0: // HealthReporter instances, we need to encode a unique identifier in michael@0: // the timer ID. michael@0: try { michael@0: let timerName = this._branch.replace(".", "-", "g") + "lastDailyCollection"; michael@0: let tm = Cc["@mozilla.org/updates/timer-manager;1"] michael@0: .getService(Ci.nsIUpdateTimerManager); michael@0: tm.registerTimer(timerName, this.collectMeasurements.bind(this), michael@0: 24 * 60 * 60); michael@0: } catch (ex) { michael@0: this._log.error("Error registering collection timer: " + michael@0: CommonUtils.exceptionStr(ex)); michael@0: } michael@0: } michael@0: michael@0: // Clean up caches and reduce memory usage. michael@0: this._storage.compact(); michael@0: this._initializedDeferred.resolve(this); michael@0: }, michael@0: michael@0: // nsIObserver to handle shutdown. michael@0: observe: function (subject, topic, data) { michael@0: switch (topic) { michael@0: case "quit-application": michael@0: Services.obs.removeObserver(this, "quit-application"); michael@0: this._initiateShutdown(); michael@0: break; michael@0: michael@0: case "profile-before-change": michael@0: Services.obs.removeObserver(this, "profile-before-change"); michael@0: this._waitForShutdown(); michael@0: break; michael@0: michael@0: case "idle-daily": michael@0: this._performDailyMaintenance(); michael@0: break; michael@0: } michael@0: }, michael@0: michael@0: _initiateShutdown: function () { michael@0: // Ensure we only begin the main shutdown sequence once. michael@0: if (this._shutdownInitiated) { michael@0: this._log.warn("Shutdown has already been initiated. No-op."); michael@0: return; michael@0: } michael@0: michael@0: this._log.info("Request to shut down."); michael@0: michael@0: this._initialized = false; michael@0: this._shutdownRequested = true; michael@0: michael@0: if (this._initializeHadError) { michael@0: this._log.warn("Initialization had error. Shutting down immediately."); michael@0: } else { michael@0: if (this._providerManagerInProcess) { michael@0: this._log.warn("Provider manager is in progress of initializing. " + michael@0: "Waiting to finish."); michael@0: return; michael@0: } michael@0: michael@0: // If storage is in the process of initializing, we need to wait for it michael@0: // to finish before continuing. The initialization process will call us michael@0: // again once storage has initialized. michael@0: if (this._storageInProgress) { michael@0: this._log.warn("Storage is in progress of initializing. Waiting to finish."); michael@0: return; michael@0: } michael@0: } michael@0: michael@0: this._log.warn("Initiating main shutdown procedure."); michael@0: michael@0: // Everything from here must only be performed once or else race conditions michael@0: // could occur. michael@0: michael@0: TelemetryStopwatch.start(TELEMETRY_SHUTDOWN, this); michael@0: this._shutdownInitiated = true; michael@0: michael@0: // We may not have registered the observer yet. If not, this will michael@0: // throw. michael@0: try { michael@0: Services.obs.removeObserver(this, "idle-daily"); michael@0: } catch (ex) { } michael@0: michael@0: if (this._providerManager) { michael@0: let onShutdown = this._onProviderManagerShutdown.bind(this); michael@0: Task.spawn(this._shutdownProviderManager.bind(this)) michael@0: .then(onShutdown, onShutdown); michael@0: return; michael@0: } michael@0: michael@0: this._log.warn("Don't have provider manager. Proceeding to storage shutdown."); michael@0: this._shutdownStorage(); michael@0: }, michael@0: michael@0: _shutdownProviderManager: function () { michael@0: this._log.info("Shutting down provider manager."); michael@0: for (let provider of this._providerManager.providers) { michael@0: try { michael@0: yield provider.shutdown(); michael@0: } catch (ex) { michael@0: this._log.warn("Error when shutting down provider: " + michael@0: CommonUtils.exceptionStr(ex)); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: _onProviderManagerShutdown: function () { michael@0: this._log.info("Provider manager shut down."); michael@0: this._providerManager = null; michael@0: this._shutdownStorage(); michael@0: }, michael@0: michael@0: _shutdownStorage: function () { michael@0: if (!this._storage) { michael@0: this._onShutdownComplete(); michael@0: } michael@0: michael@0: this._log.info("Shutting down storage."); michael@0: let onClose = this._onStorageClose.bind(this); michael@0: this._storage.close().then(onClose, onClose); michael@0: }, michael@0: michael@0: _onStorageClose: function (error) { michael@0: this._log.info("Storage has been closed."); michael@0: michael@0: if (error) { michael@0: this._log.warn("Error when closing storage: " + michael@0: CommonUtils.exceptionStr(error)); michael@0: } michael@0: michael@0: this._storage = null; michael@0: this._onShutdownComplete(); michael@0: }, michael@0: michael@0: _onShutdownComplete: function () { michael@0: this._log.warn("Shutdown complete."); michael@0: this._shutdownComplete = true; michael@0: TelemetryStopwatch.finish(TELEMETRY_SHUTDOWN, this); michael@0: michael@0: if (this._shutdownCompleteCallback) { michael@0: this._shutdownCompleteCallback(); michael@0: } michael@0: }, michael@0: michael@0: _waitForShutdown: function () { michael@0: if (this._shutdownComplete) { michael@0: return; michael@0: } michael@0: michael@0: TelemetryStopwatch.start(TELEMETRY_SHUTDOWN_DELAY, this); michael@0: try { michael@0: this._shutdownCompleteCallback = Async.makeSpinningCallback(); michael@0: this._shutdownCompleteCallback.wait(); michael@0: this._shutdownCompleteCallback = null; michael@0: } finally { michael@0: TelemetryStopwatch.finish(TELEMETRY_SHUTDOWN_DELAY, this); michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Convenience method to shut down the instance. michael@0: * michael@0: * This should *not* be called outside of tests. michael@0: */ michael@0: _shutdown: function () { michael@0: this._initiateShutdown(); michael@0: this._waitForShutdown(); michael@0: }, michael@0: michael@0: /** michael@0: * Return a promise that is resolved once the service has been initialized. michael@0: */ michael@0: onInit: function () { michael@0: if (this._initializeHadError) { michael@0: throw new Error("Service failed to initialize."); michael@0: } michael@0: michael@0: if (this._initialized) { michael@0: return CommonUtils.laterTickResolvingPromise(this); michael@0: } michael@0: michael@0: return this._initializedDeferred.promise; michael@0: }, michael@0: michael@0: _performDailyMaintenance: function () { michael@0: this._log.info("Request to perform daily maintenance."); michael@0: michael@0: if (!this._initialized) { michael@0: return; michael@0: } michael@0: michael@0: let now = new Date(); michael@0: let cutoff = new Date(now.getTime() - MILLISECONDS_PER_DAY * (DAYS_IN_PAYLOAD - 1)); michael@0: michael@0: // The operation is enqueued and put in a transaction by the storage module. michael@0: this._storage.pruneDataBefore(cutoff); michael@0: }, michael@0: michael@0: //-------------------- michael@0: // Provider Management michael@0: //-------------------- michael@0: michael@0: /** michael@0: * Obtain a provider from its name. michael@0: * michael@0: * This will only return providers that are currently initialized. If michael@0: * a provider is lazy initialized (like pull-only providers) this michael@0: * will likely not return anything. michael@0: */ michael@0: getProvider: function (name) { michael@0: if (!this._providerManager) { michael@0: return null; michael@0: } michael@0: michael@0: return this._providerManager.getProvider(name); michael@0: }, michael@0: michael@0: _initProvider: function (provider) { michael@0: provider.healthReporter = this; michael@0: }, michael@0: michael@0: /** michael@0: * Record an exception for reporting in the payload. michael@0: * michael@0: * A side effect is the exception is logged. michael@0: * michael@0: * Note that callers need to be extra sensitive about ensuring personal michael@0: * or otherwise private details do not leak into this. All of the user data michael@0: * on the stack in FHR code should be limited to data we were collecting with michael@0: * the intent to submit. So, it is covered under the user's consent to use michael@0: * the feature. michael@0: * michael@0: * @param message michael@0: * (string) Human readable message describing error. michael@0: * @param ex michael@0: * (Error) The error that should be captured. michael@0: */ michael@0: _recordError: function (message, ex) { michael@0: let recordMessage = message; michael@0: let logMessage = message; michael@0: michael@0: if (ex) { michael@0: recordMessage += ": " + CommonUtils.exceptionStr(ex); michael@0: logMessage += ": " + CommonUtils.exceptionStr(ex); michael@0: } michael@0: michael@0: // Scrub out potentially identifying information from strings that could michael@0: // make the payload. michael@0: let appData = Services.dirsvc.get("UAppData", Ci.nsIFile); michael@0: let profile = Services.dirsvc.get("ProfD", Ci.nsIFile); michael@0: michael@0: let appDataURI = Services.io.newFileURI(appData); michael@0: let profileURI = Services.io.newFileURI(profile); michael@0: michael@0: // Order of operation is important here. We do the URI before the path version michael@0: // because the path may be a subset of the URI. We also have to check for the case michael@0: // where UAppData is underneath the profile directory (or vice-versa) so we michael@0: // don't substitute incomplete strings. michael@0: michael@0: function replace(uri, path, thing) { michael@0: // Try is because .spec can throw on invalid URI. michael@0: try { michael@0: recordMessage = recordMessage.replace(uri.spec, '<' + thing + 'URI>', 'g'); michael@0: } catch (ex) { } michael@0: michael@0: recordMessage = recordMessage.replace(path, '<' + thing + 'Path>', 'g'); michael@0: } michael@0: michael@0: if (appData.path.contains(profile.path)) { michael@0: replace(appDataURI, appData.path, 'AppData'); michael@0: replace(profileURI, profile.path, 'Profile'); michael@0: } else { michael@0: replace(profileURI, profile.path, 'Profile'); michael@0: replace(appDataURI, appData.path, 'AppData'); michael@0: } michael@0: michael@0: this._log.warn(logMessage); michael@0: this._errors.push(recordMessage); michael@0: }, michael@0: michael@0: /** michael@0: * Collect all measurements for all registered providers. michael@0: */ michael@0: collectMeasurements: function () { michael@0: if (!this._initialized) { michael@0: return Promise.reject(new Error("Not initialized.")); michael@0: } michael@0: michael@0: return Task.spawn(function doCollection() { michael@0: yield this._providerManager.ensurePullOnlyProvidersRegistered(); michael@0: michael@0: try { michael@0: TelemetryStopwatch.start(TELEMETRY_COLLECT_CONSTANT, this); michael@0: yield this._providerManager.collectConstantData(); michael@0: TelemetryStopwatch.finish(TELEMETRY_COLLECT_CONSTANT, this); michael@0: } catch (ex) { michael@0: TelemetryStopwatch.cancel(TELEMETRY_COLLECT_CONSTANT, this); michael@0: this._log.warn("Error collecting constant data: " + michael@0: CommonUtils.exceptionStr(ex)); michael@0: } michael@0: michael@0: // Daily data is collected if it hasn't yet been collected this michael@0: // application session or if it has been more than a day since the michael@0: // last collection. This means that providers could see many calls to michael@0: // collectDailyData per calendar day. However, this collection API michael@0: // makes no guarantees about limits. The alternative would involve michael@0: // recording state. The simpler implementation prevails for now. michael@0: if (!this._lastDailyDate || michael@0: Date.now() - this._lastDailyDate > MILLISECONDS_PER_DAY) { michael@0: michael@0: try { michael@0: TelemetryStopwatch.start(TELEMETRY_COLLECT_DAILY, this); michael@0: this._lastDailyDate = new Date(); michael@0: yield this._providerManager.collectDailyData(); michael@0: TelemetryStopwatch.finish(TELEMETRY_COLLECT_DAILY, this); michael@0: } catch (ex) { michael@0: TelemetryStopwatch.cancel(TELEMETRY_COLLECT_DAILY, this); michael@0: this._log.warn("Error collecting daily data from providers: " + michael@0: CommonUtils.exceptionStr(ex)); michael@0: } michael@0: } michael@0: michael@0: yield this._providerManager.ensurePullOnlyProvidersUnregistered(); michael@0: michael@0: // Flush gathered data to disk. This will incur an fsync. But, if michael@0: // there is ever a time we want to persist data to disk, it's michael@0: // after a massive collection. michael@0: try { michael@0: TelemetryStopwatch.start(TELEMETRY_COLLECT_CHECKPOINT, this); michael@0: yield this._storage.checkpoint(); michael@0: TelemetryStopwatch.finish(TELEMETRY_COLLECT_CHECKPOINT, this); michael@0: } catch (ex) { michael@0: TelemetryStopwatch.cancel(TELEMETRY_COLLECT_CHECKPOINT, this); michael@0: throw ex; michael@0: } michael@0: michael@0: throw new Task.Result(); michael@0: }.bind(this)); michael@0: }, michael@0: michael@0: /** michael@0: * Helper function to perform data collection and obtain the JSON payload. michael@0: * michael@0: * If you are looking for an up-to-date snapshot of FHR data that pulls in michael@0: * new data since the last upload, this is how you should obtain it. michael@0: * michael@0: * @param asObject michael@0: * (bool) Whether to resolve an object or JSON-encoded string of that michael@0: * object (the default). michael@0: * michael@0: * @return Promise michael@0: */ michael@0: collectAndObtainJSONPayload: function (asObject=false) { michael@0: if (!this._initialized) { michael@0: return Promise.reject(new Error("Not initialized.")); michael@0: } michael@0: michael@0: return Task.spawn(function collectAndObtain() { michael@0: yield this._storage.setAutoCheckpoint(0); michael@0: yield this._providerManager.ensurePullOnlyProvidersRegistered(); michael@0: michael@0: let payload; michael@0: let error; michael@0: michael@0: try { michael@0: yield this.collectMeasurements(); michael@0: payload = yield this.getJSONPayload(asObject); michael@0: } catch (ex) { michael@0: error = ex; michael@0: this._collectException("Error collecting and/or retrieving JSON payload", michael@0: ex); michael@0: } finally { michael@0: yield this._providerManager.ensurePullOnlyProvidersUnregistered(); michael@0: yield this._storage.setAutoCheckpoint(1); michael@0: michael@0: if (error) { michael@0: throw error; michael@0: } michael@0: } michael@0: michael@0: // We hold off throwing to ensure that behavior between finally michael@0: // and generators and throwing is sane. michael@0: throw new Task.Result(payload); michael@0: }.bind(this)); michael@0: }, michael@0: michael@0: michael@0: /** michael@0: * Obtain the JSON payload for currently-collected data. michael@0: * michael@0: * The payload only contains data that has been recorded to FHR. Some michael@0: * providers may have newer data available. If you want to ensure you michael@0: * have all available data, call `collectAndObtainJSONPayload` michael@0: * instead. michael@0: * michael@0: * @param asObject michael@0: * (bool) Whether to return an object or JSON encoding of that michael@0: * object (the default). michael@0: * michael@0: * @return Promise michael@0: */ michael@0: getJSONPayload: function (asObject=false) { michael@0: TelemetryStopwatch.start(TELEMETRY_GENERATE_PAYLOAD, this); michael@0: let deferred = Promise.defer(); michael@0: michael@0: Task.spawn(this._getJSONPayload.bind(this, this._now(), asObject)).then( michael@0: function onResult(result) { michael@0: TelemetryStopwatch.finish(TELEMETRY_GENERATE_PAYLOAD, this); michael@0: deferred.resolve(result); michael@0: }.bind(this), michael@0: function onError(error) { michael@0: TelemetryStopwatch.cancel(TELEMETRY_GENERATE_PAYLOAD, this); michael@0: deferred.reject(error); michael@0: }.bind(this) michael@0: ); michael@0: michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: _getJSONPayload: function (now, asObject=false) { michael@0: let pingDateString = this._formatDate(now); michael@0: this._log.info("Producing JSON payload for " + pingDateString); michael@0: michael@0: // May not be present if we are generating as a result of init error. michael@0: if (this._providerManager) { michael@0: yield this._providerManager.ensurePullOnlyProvidersRegistered(); michael@0: } michael@0: michael@0: let o = { michael@0: version: 2, michael@0: clientID: this._state.clientID, michael@0: clientIDVersion: this._state.clientIDVersion, michael@0: thisPingDate: pingDateString, michael@0: geckoAppInfo: this.obtainAppInfo(this._log), michael@0: data: {last: {}, days: {}}, michael@0: }; michael@0: michael@0: let outputDataDays = o.data.days; michael@0: michael@0: // Guard here in case we don't track this (e.g., on Android). michael@0: let lastPingDate = this.lastPingDate; michael@0: if (lastPingDate && lastPingDate.getTime() > 0) { michael@0: o.lastPingDate = this._formatDate(lastPingDate); michael@0: } michael@0: michael@0: // We can still generate a payload even if we're not initialized. michael@0: // This is to facilitate error upload on init failure. michael@0: if (this._initialized) { michael@0: for (let provider of this._providerManager.providers) { michael@0: let providerName = provider.name; michael@0: michael@0: let providerEntry = { michael@0: measurements: {}, michael@0: }; michael@0: michael@0: // Measurement name to recorded version. michael@0: let lastVersions = {}; michael@0: // Day string to mapping of measurement name to recorded version. michael@0: let dayVersions = {}; michael@0: michael@0: for (let [measurementKey, measurement] of provider.measurements) { michael@0: let name = providerName + "." + measurement.name; michael@0: let version = measurement.version; michael@0: michael@0: let serializer; michael@0: try { michael@0: // The measurement is responsible for returning a serializer which michael@0: // is aware of the measurement version. michael@0: serializer = measurement.serializer(measurement.SERIALIZE_JSON); michael@0: } catch (ex) { michael@0: this._recordError("Error obtaining serializer for measurement: " + michael@0: name, ex); michael@0: continue; michael@0: } michael@0: michael@0: let data; michael@0: try { michael@0: data = yield measurement.getValues(); michael@0: } catch (ex) { michael@0: this._recordError("Error obtaining data for measurement: " + name, michael@0: ex); michael@0: continue; michael@0: } michael@0: michael@0: if (data.singular.size) { michael@0: try { michael@0: let serialized = serializer.singular(data.singular); michael@0: if (serialized) { michael@0: // Only replace the existing data if there is no data or if our michael@0: // version is newer than the old one. michael@0: if (!(name in o.data.last) || version > lastVersions[name]) { michael@0: o.data.last[name] = serialized; michael@0: lastVersions[name] = version; michael@0: } michael@0: } michael@0: } catch (ex) { michael@0: this._recordError("Error serializing singular data: " + name, michael@0: ex); michael@0: continue; michael@0: } michael@0: } michael@0: michael@0: let dataDays = data.days; michael@0: for (let i = 0; i < DAYS_IN_PAYLOAD; i++) { michael@0: let date = new Date(now.getTime() - i * MILLISECONDS_PER_DAY); michael@0: if (!dataDays.hasDay(date)) { michael@0: continue; michael@0: } michael@0: let dateFormatted = this._formatDate(date); michael@0: michael@0: try { michael@0: let serialized = serializer.daily(dataDays.getDay(date)); michael@0: if (!serialized) { michael@0: continue; michael@0: } michael@0: michael@0: if (!(dateFormatted in outputDataDays)) { michael@0: outputDataDays[dateFormatted] = {}; michael@0: } michael@0: michael@0: // This needs to be separate because dayVersions is provider michael@0: // specific and gets blown away in a loop while outputDataDays michael@0: // is persistent. michael@0: if (!(dateFormatted in dayVersions)) { michael@0: dayVersions[dateFormatted] = {}; michael@0: } michael@0: michael@0: if (!(name in outputDataDays[dateFormatted]) || michael@0: version > dayVersions[dateFormatted][name]) { michael@0: outputDataDays[dateFormatted][name] = serialized; michael@0: dayVersions[dateFormatted][name] = version; michael@0: } michael@0: } catch (ex) { michael@0: this._recordError("Error populating data for day: " + name, ex); michael@0: continue; michael@0: } michael@0: } michael@0: } michael@0: } michael@0: } else { michael@0: o.notInitialized = 1; michael@0: this._log.warn("Not initialized. Sending report with only error info."); michael@0: } michael@0: michael@0: if (this._errors.length) { michael@0: o.errors = this._errors.slice(0, 20); michael@0: } michael@0: michael@0: if (this._initialized) { michael@0: this._storage.compact(); michael@0: } michael@0: michael@0: if (!asObject) { michael@0: TelemetryStopwatch.start(TELEMETRY_JSON_PAYLOAD_SERIALIZE, this); michael@0: o = JSON.stringify(o); michael@0: TelemetryStopwatch.finish(TELEMETRY_JSON_PAYLOAD_SERIALIZE, this); michael@0: } michael@0: michael@0: if (this._providerManager) { michael@0: yield this._providerManager.ensurePullOnlyProvidersUnregistered(); michael@0: } michael@0: michael@0: throw new Task.Result(o); michael@0: }, michael@0: michael@0: _now: function _now() { michael@0: return new Date(); michael@0: }, michael@0: michael@0: // These are stolen from AppInfoProvider. michael@0: appInfoVersion: 1, michael@0: appInfoFields: { michael@0: // From nsIXULAppInfo. michael@0: vendor: "vendor", michael@0: name: "name", michael@0: id: "ID", michael@0: version: "version", michael@0: appBuildID: "appBuildID", michael@0: platformVersion: "platformVersion", michael@0: platformBuildID: "platformBuildID", michael@0: michael@0: // From nsIXULRuntime. michael@0: os: "OS", michael@0: xpcomabi: "XPCOMABI", michael@0: }, michael@0: michael@0: /** michael@0: * Statically return a bundle of app info data, a subset of that produced by michael@0: * AppInfoProvider._populateConstants. This allows us to more usefully handle michael@0: * payloads that, due to error, contain no data. michael@0: * michael@0: * Returns a very sparse object if Services.appinfo is unavailable. michael@0: */ michael@0: obtainAppInfo: function () { michael@0: let out = {"_v": this.appInfoVersion}; michael@0: try { michael@0: let ai = Services.appinfo; michael@0: for (let [k, v] in Iterator(this.appInfoFields)) { michael@0: out[k] = ai[v]; michael@0: } michael@0: } catch (ex) { michael@0: this._log.warn("Could not obtain Services.appinfo: " + michael@0: CommonUtils.exceptionStr(ex)); michael@0: } michael@0: michael@0: try { michael@0: out["updateChannel"] = UpdateChannel.get(); michael@0: } catch (ex) { michael@0: this._log.warn("Could not obtain update channel: " + michael@0: CommonUtils.exceptionStr(ex)); michael@0: } michael@0: michael@0: return out; michael@0: }, michael@0: }); michael@0: michael@0: /** michael@0: * HealthReporter and its abstract superclass coordinate collection and michael@0: * submission of health report metrics. michael@0: * michael@0: * This is the main type for Firefox Health Report on desktop. It glues all the michael@0: * lower-level components (such as collection and submission) together. michael@0: * michael@0: * An instance of this type is created as an XPCOM service. See michael@0: * DataReportingService.js and michael@0: * DataReporting.manifest/HealthReportComponents.manifest. michael@0: * michael@0: * It is theoretically possible to have multiple instances of this running michael@0: * in the application. For example, this type may one day handle submission michael@0: * of telemetry data as well. However, there is some moderate coupling between michael@0: * this type and *the* Firefox Health Report (e.g., the policy). This could michael@0: * be abstracted if needed. michael@0: * michael@0: * Note that `AbstractHealthReporter` exists to allow for Firefox Health Report michael@0: * to be more easily implemented on platforms where a separate controlling michael@0: * layer is responsible for payload upload and deletion. michael@0: * michael@0: * IMPLEMENTATION NOTES michael@0: * ==================== michael@0: * michael@0: * These notes apply to the combination of `HealthReporter` and michael@0: * `AbstractHealthReporter`. michael@0: * michael@0: * Initialization and shutdown are somewhat complicated and worth explaining michael@0: * in extra detail. michael@0: * michael@0: * The complexity is driven by the requirements of SQLite connection management. michael@0: * Once you have a SQLite connection, it isn't enough to just let the michael@0: * application shut down. If there is an open connection or if there are michael@0: * outstanding SQL statements come XPCOM shutdown time, Storage will assert. michael@0: * On debug builds you will crash. On release builds you will get a shutdown michael@0: * hang. This must be avoided! michael@0: * michael@0: * During initialization, the second we create a SQLite connection (via michael@0: * Metrics.Storage) we register observers for application shutdown. The michael@0: * "quit-application" notification initiates our shutdown procedure. The michael@0: * subsequent "profile-do-change" notification ensures it has completed. michael@0: * michael@0: * The handler for "profile-do-change" may result in event loop spinning. This michael@0: * is because of race conditions between our shutdown code and application michael@0: * shutdown. michael@0: * michael@0: * All of our shutdown routines are async. There is the potential that these michael@0: * async functions will not complete before XPCOM shutdown. If they don't michael@0: * finish in time, we could get assertions in Storage. Our solution is to michael@0: * initiate storage early in the shutdown cycle ("quit-application"). michael@0: * Hopefully all the async operations have completed by the time we reach michael@0: * "profile-do-change." If so, great. If not, we spin the event loop until michael@0: * they have completed, avoiding potential race conditions. michael@0: * michael@0: * @param branch michael@0: * (string) The preferences branch to use for state storage. The value michael@0: * must end with a period (.). michael@0: * michael@0: * @param policy michael@0: * (HealthReportPolicy) Policy driving execution of HealthReporter. michael@0: */ michael@0: this.HealthReporter = function (branch, policy, sessionRecorder, stateLeaf=null) { michael@0: this._stateLeaf = stateLeaf; michael@0: this._uploadInProgress = false; michael@0: michael@0: AbstractHealthReporter.call(this, branch, policy, sessionRecorder); michael@0: michael@0: if (!this.serverURI) { michael@0: throw new Error("No server URI defined. Did you forget to define the pref?"); michael@0: } michael@0: michael@0: if (!this.serverNamespace) { michael@0: throw new Error("No server namespace defined. Did you forget a pref?"); michael@0: } michael@0: michael@0: this._state = new HealthReporterState(this); michael@0: } michael@0: michael@0: this.HealthReporter.prototype = Object.freeze({ michael@0: __proto__: AbstractHealthReporter.prototype, michael@0: michael@0: QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]), michael@0: michael@0: get lastSubmitID() { michael@0: return this._state.lastSubmitID; michael@0: }, michael@0: michael@0: /** michael@0: * When we last successfully submitted data to the server. michael@0: * michael@0: * This is sent as part of the upload. This is redundant with similar data michael@0: * in the policy because we like the modules to be loosely coupled and the michael@0: * similar data in the policy is only used for forensic purposes. michael@0: */ michael@0: get lastPingDate() { michael@0: return this._state.lastPingDate; michael@0: }, michael@0: michael@0: /** michael@0: * The base URI of the document server to which to submit data. michael@0: * michael@0: * This is typically a Bagheera server instance. It is the URI up to but not michael@0: * including the version prefix. e.g. https://data.metrics.mozilla.com/ michael@0: */ michael@0: get serverURI() { michael@0: return this._prefs.get("documentServerURI", null); michael@0: }, michael@0: michael@0: set serverURI(value) { michael@0: if (!value) { michael@0: throw new Error("serverURI must have a value."); michael@0: } michael@0: michael@0: if (typeof(value) != "string") { michael@0: throw new Error("serverURI must be a string: " + value); michael@0: } michael@0: michael@0: this._prefs.set("documentServerURI", value); michael@0: }, michael@0: michael@0: /** michael@0: * The namespace on the document server to which we will be submitting data. michael@0: */ michael@0: get serverNamespace() { michael@0: return this._prefs.get("documentServerNamespace", "metrics"); michael@0: }, michael@0: michael@0: set serverNamespace(value) { michael@0: if (!value) { michael@0: throw new Error("serverNamespace must have a value."); michael@0: } michael@0: michael@0: if (typeof(value) != "string") { michael@0: throw new Error("serverNamespace must be a string: " + value); michael@0: } michael@0: michael@0: this._prefs.set("documentServerNamespace", value); michael@0: }, michael@0: michael@0: /** michael@0: * Whether this instance will upload data to a server. michael@0: */ michael@0: get willUploadData() { michael@0: return this._policy.dataSubmissionPolicyAccepted && michael@0: this._policy.healthReportUploadEnabled; michael@0: }, michael@0: michael@0: /** michael@0: * Whether remote data is currently stored. michael@0: * michael@0: * @return bool michael@0: */ michael@0: haveRemoteData: function () { michael@0: return !!this._state.lastSubmitID; michael@0: }, michael@0: michael@0: /** michael@0: * Called to initiate a data upload. michael@0: * michael@0: * The passed argument is a `DataSubmissionRequest` from policy.jsm. michael@0: */ michael@0: requestDataUpload: function (request) { michael@0: if (!this._initialized) { michael@0: return Promise.reject(new Error("Not initialized.")); michael@0: } michael@0: michael@0: return Task.spawn(function doUpload() { michael@0: yield this._providerManager.ensurePullOnlyProvidersRegistered(); michael@0: try { michael@0: yield this.collectMeasurements(); michael@0: try { michael@0: yield this._uploadData(request); michael@0: } catch (ex) { michael@0: this._onSubmitDataRequestFailure(ex); michael@0: } michael@0: } finally { michael@0: yield this._providerManager.ensurePullOnlyProvidersUnregistered(); michael@0: } michael@0: }.bind(this)); michael@0: }, michael@0: michael@0: /** michael@0: * Request that server data be deleted. michael@0: * michael@0: * If deletion is scheduled to occur immediately, a promise will be returned michael@0: * that will be fulfilled when the deletion attempt finishes. Otherwise, michael@0: * callers should poll haveRemoteData() to determine when remote data is michael@0: * deleted. michael@0: */ michael@0: requestDeleteRemoteData: function (reason) { michael@0: if (!this.haveRemoteData()) { michael@0: return; michael@0: } michael@0: michael@0: return this._policy.deleteRemoteData(reason); michael@0: }, michael@0: michael@0: _initializeState: function() { michael@0: return this._state.init(); michael@0: }, michael@0: michael@0: /** michael@0: * Override default handler to incur an upload describing the error. michael@0: */ michael@0: _onInitError: function (error) { michael@0: // Need to capture this before we call the parent else it's always michael@0: // set. michael@0: let inShutdown = this._shutdownRequested; michael@0: michael@0: let result; michael@0: try { michael@0: result = AbstractHealthReporter.prototype._onInitError.call(this, error); michael@0: } catch (ex) { michael@0: this._log.error("Error when calling _onInitError: " + michael@0: CommonUtils.exceptionStr(ex)); michael@0: } michael@0: michael@0: // This bypasses a lot of the checks in policy, such as respect for michael@0: // backoff. We should arguably not do this. However, reporting michael@0: // startup errors is important. And, they should not occur with much michael@0: // frequency in the wild. So, it shouldn't be too big of a deal. michael@0: if (!inShutdown && michael@0: this._policy.ensureNotifyResponse(new Date()) && michael@0: this._policy.healthReportUploadEnabled) { michael@0: // We don't care about what happens to this request. It's best michael@0: // effort. michael@0: let request = { michael@0: onNoDataAvailable: function () {}, michael@0: onSubmissionSuccess: function () {}, michael@0: onSubmissionFailureSoft: function () {}, michael@0: onSubmissionFailureHard: function () {}, michael@0: onUploadInProgress: function () {}, michael@0: }; michael@0: michael@0: this._uploadData(request); michael@0: } michael@0: michael@0: return result; michael@0: }, michael@0: michael@0: _onBagheeraResult: function (request, isDelete, date, result) { michael@0: this._log.debug("Received Bagheera result."); michael@0: michael@0: return Task.spawn(function onBagheeraResult() { michael@0: let hrProvider = this.getProvider("org.mozilla.healthreport"); michael@0: michael@0: if (!result.transportSuccess) { michael@0: // The built-in provider may not be initialized if this instance failed michael@0: // to initialize fully. michael@0: if (hrProvider && !isDelete) { michael@0: hrProvider.recordEvent("uploadTransportFailure", date); michael@0: } michael@0: michael@0: request.onSubmissionFailureSoft("Network transport error."); michael@0: throw new Task.Result(false); michael@0: } michael@0: michael@0: if (!result.serverSuccess) { michael@0: if (hrProvider && !isDelete) { michael@0: hrProvider.recordEvent("uploadServerFailure", date); michael@0: } michael@0: michael@0: request.onSubmissionFailureHard("Server failure."); michael@0: throw new Task.Result(false); michael@0: } michael@0: michael@0: if (hrProvider && !isDelete) { michael@0: hrProvider.recordEvent("uploadSuccess", date); michael@0: } michael@0: michael@0: if (isDelete) { michael@0: this._log.warn("Marking delete as successful."); michael@0: yield this._state.removeRemoteIDs([result.id]); michael@0: } else { michael@0: this._log.warn("Marking upload as successful."); michael@0: yield this._state.updateLastPingAndRemoveRemoteIDs(date, result.deleteIDs); michael@0: } michael@0: michael@0: request.onSubmissionSuccess(this._now()); michael@0: michael@0: throw new Task.Result(true); michael@0: }.bind(this)); michael@0: }, michael@0: michael@0: _onSubmitDataRequestFailure: function (error) { michael@0: this._log.error("Error processing request to submit data: " + michael@0: CommonUtils.exceptionStr(error)); michael@0: }, michael@0: michael@0: _formatDate: function (date) { michael@0: // Why, oh, why doesn't JS have a strftime() equivalent? michael@0: return date.toISOString().substr(0, 10); michael@0: }, michael@0: michael@0: _uploadData: function (request) { michael@0: // Under ideal circumstances, clients should never race to this michael@0: // function. However, server logs have observed behavior where michael@0: // racing to this function could be a cause. So, this lock was michael@0: // instituted. michael@0: if (this._uploadInProgress) { michael@0: this._log.warn("Upload requested but upload already in progress."); michael@0: let provider = this.getProvider("org.mozilla.healthreport"); michael@0: let promise = provider.recordEvent("uploadAlreadyInProgress"); michael@0: request.onUploadInProgress("Upload already in progress."); michael@0: return promise; michael@0: } michael@0: michael@0: let id = CommonUtils.generateUUID(); michael@0: michael@0: this._log.info("Uploading data to server: " + this.serverURI + " " + michael@0: this.serverNamespace + ":" + id); michael@0: let client = new BagheeraClient(this.serverURI); michael@0: let now = this._now(); michael@0: michael@0: return Task.spawn(function doUpload() { michael@0: try { michael@0: // The test for upload locking monkeypatches getJSONPayload. michael@0: // If the next two lines change, be sure to verify the test is michael@0: // accurate! michael@0: this._uploadInProgress = true; michael@0: let payload = yield this.getJSONPayload(); michael@0: michael@0: let histogram = Services.telemetry.getHistogramById(TELEMETRY_PAYLOAD_SIZE_UNCOMPRESSED); michael@0: histogram.add(payload.length); michael@0: michael@0: let lastID = this.lastSubmitID; michael@0: yield this._state.addRemoteID(id); michael@0: michael@0: let hrProvider = this.getProvider("org.mozilla.healthreport"); michael@0: if (hrProvider) { michael@0: let event = lastID ? "continuationUploadAttempt" michael@0: : "firstDocumentUploadAttempt"; michael@0: hrProvider.recordEvent(event, now); michael@0: } michael@0: michael@0: TelemetryStopwatch.start(TELEMETRY_UPLOAD, this); michael@0: let result; michael@0: try { michael@0: let options = { michael@0: deleteIDs: this._state.remoteIDs.filter((x) => { return x != id; }), michael@0: telemetryCompressed: TELEMETRY_PAYLOAD_SIZE_COMPRESSED, michael@0: }; michael@0: result = yield client.uploadJSON(this.serverNamespace, id, payload, michael@0: options); michael@0: TelemetryStopwatch.finish(TELEMETRY_UPLOAD, this); michael@0: } catch (ex) { michael@0: TelemetryStopwatch.cancel(TELEMETRY_UPLOAD, this); michael@0: if (hrProvider) { michael@0: hrProvider.recordEvent("uploadClientFailure", now); michael@0: } michael@0: throw ex; michael@0: } michael@0: michael@0: yield this._onBagheeraResult(request, false, now, result); michael@0: } finally { michael@0: this._uploadInProgress = false; michael@0: } michael@0: }.bind(this)); michael@0: }, michael@0: michael@0: /** michael@0: * Request deletion of remote data. michael@0: * michael@0: * @param request michael@0: * (DataSubmissionRequest) Tracks progress of this request. michael@0: */ michael@0: deleteRemoteData: function (request) { michael@0: if (!this._state.lastSubmitID) { michael@0: this._log.info("Received request to delete remote data but no data stored."); michael@0: request.onNoDataAvailable(); michael@0: return; michael@0: } michael@0: michael@0: this._log.warn("Deleting remote data."); michael@0: let client = new BagheeraClient(this.serverURI); michael@0: michael@0: return Task.spawn(function* doDelete() { michael@0: try { michael@0: let result = yield client.deleteDocument(this.serverNamespace, michael@0: this.lastSubmitID); michael@0: yield this._onBagheeraResult(request, true, this._now(), result); michael@0: } catch (ex) { michael@0: this._log.error("Error processing request to delete data: " + michael@0: CommonUtils.exceptionStr(error)); michael@0: } finally { michael@0: // If we don't have any remote documents left, nuke the ID. michael@0: // This is done for privacy reasons. Why preserve the ID if we michael@0: // don't need to? michael@0: if (!this.haveRemoteData()) { michael@0: yield this._state.resetClientID(); michael@0: } michael@0: } michael@0: }.bind(this)); michael@0: }, michael@0: }); michael@0: