1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/services/healthreport/healthreporter.jsm Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,1548 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this 1.6 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 + 1.8 +"use strict"; 1.9 + 1.10 +#ifndef MERGED_COMPARTMENT 1.11 + 1.12 +this.EXPORTED_SYMBOLS = ["HealthReporter"]; 1.13 + 1.14 +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; 1.15 + 1.16 +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; 1.17 + 1.18 +Cu.import("resource://gre/modules/Metrics.jsm"); 1.19 +Cu.import("resource://services-common/async.js"); 1.20 + 1.21 +Cu.import("resource://services-common/bagheeraclient.js"); 1.22 +#endif 1.23 + 1.24 +Cu.import("resource://gre/modules/Log.jsm"); 1.25 +Cu.import("resource://services-common/utils.js"); 1.26 +Cu.import("resource://gre/modules/Promise.jsm"); 1.27 +Cu.import("resource://gre/modules/osfile.jsm"); 1.28 +Cu.import("resource://gre/modules/Preferences.jsm"); 1.29 +Cu.import("resource://gre/modules/Services.jsm"); 1.30 +Cu.import("resource://gre/modules/Task.jsm"); 1.31 +Cu.import("resource://gre/modules/TelemetryStopwatch.jsm"); 1.32 +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); 1.33 + 1.34 +XPCOMUtils.defineLazyModuleGetter(this, "UpdateChannel", 1.35 + "resource://gre/modules/UpdateChannel.jsm"); 1.36 + 1.37 +// Oldest year to allow in date preferences. This module was implemented in 1.38 +// 2012 and no dates older than that should be encountered. 1.39 +const OLDEST_ALLOWED_YEAR = 2012; 1.40 + 1.41 +const DAYS_IN_PAYLOAD = 180; 1.42 + 1.43 +const DEFAULT_DATABASE_NAME = "healthreport.sqlite"; 1.44 + 1.45 +const TELEMETRY_INIT = "HEALTHREPORT_INIT_MS"; 1.46 +const TELEMETRY_INIT_FIRSTRUN = "HEALTHREPORT_INIT_FIRSTRUN_MS"; 1.47 +const TELEMETRY_DB_OPEN = "HEALTHREPORT_DB_OPEN_MS"; 1.48 +const TELEMETRY_DB_OPEN_FIRSTRUN = "HEALTHREPORT_DB_OPEN_FIRSTRUN_MS"; 1.49 +const TELEMETRY_GENERATE_PAYLOAD = "HEALTHREPORT_GENERATE_JSON_PAYLOAD_MS"; 1.50 +const TELEMETRY_JSON_PAYLOAD_SERIALIZE = "HEALTHREPORT_JSON_PAYLOAD_SERIALIZE_MS"; 1.51 +const TELEMETRY_PAYLOAD_SIZE_UNCOMPRESSED = "HEALTHREPORT_PAYLOAD_UNCOMPRESSED_BYTES"; 1.52 +const TELEMETRY_PAYLOAD_SIZE_COMPRESSED = "HEALTHREPORT_PAYLOAD_COMPRESSED_BYTES"; 1.53 +const TELEMETRY_UPLOAD = "HEALTHREPORT_UPLOAD_MS"; 1.54 +const TELEMETRY_SHUTDOWN_DELAY = "HEALTHREPORT_SHUTDOWN_DELAY_MS"; 1.55 +const TELEMETRY_COLLECT_CONSTANT = "HEALTHREPORT_COLLECT_CONSTANT_DATA_MS"; 1.56 +const TELEMETRY_COLLECT_DAILY = "HEALTHREPORT_COLLECT_DAILY_MS"; 1.57 +const TELEMETRY_SHUTDOWN = "HEALTHREPORT_SHUTDOWN_MS"; 1.58 +const TELEMETRY_COLLECT_CHECKPOINT = "HEALTHREPORT_POST_COLLECT_CHECKPOINT_MS"; 1.59 + 1.60 + 1.61 +/** 1.62 + * Helper type to assist with management of Health Reporter state. 1.63 + * 1.64 + * Instances are not meant to be created outside of a HealthReporter instance. 1.65 + * 1.66 + * There are two types of IDs associated with clients. 1.67 + * 1.68 + * Since the beginning of FHR, there has existed a per-upload ID: a UUID is 1.69 + * generated at upload time and associated with the state before upload starts. 1.70 + * That same upload includes a request to delete all other upload IDs known by 1.71 + * the client. 1.72 + * 1.73 + * Per-upload IDs had the unintended side-effect of creating "orphaned" 1.74 + * records/upload IDs on the server. So, a stable client identifer has been 1.75 + * introduced. This client identifier is generated when it's missing and sent 1.76 + * as part of every upload. 1.77 + * 1.78 + * There is a high chance we may remove upload IDs in the future. 1.79 + */ 1.80 +function HealthReporterState(reporter) { 1.81 + this._reporter = reporter; 1.82 + 1.83 + let profD = OS.Constants.Path.profileDir; 1.84 + 1.85 + if (!profD || !profD.length) { 1.86 + throw new Error("Could not obtain profile directory. OS.File not " + 1.87 + "initialized properly?"); 1.88 + } 1.89 + 1.90 + this._log = reporter._log; 1.91 + 1.92 + this._stateDir = OS.Path.join(profD, "healthreport"); 1.93 + 1.94 + // To facilitate testing. 1.95 + let leaf = reporter._stateLeaf || "state.json"; 1.96 + 1.97 + this._filename = OS.Path.join(this._stateDir, leaf); 1.98 + this._log.debug("Storing state in " + this._filename); 1.99 + this._s = null; 1.100 +} 1.101 + 1.102 +HealthReporterState.prototype = Object.freeze({ 1.103 + /** 1.104 + * Persistent string identifier associated with this client. 1.105 + */ 1.106 + get clientID() { 1.107 + return this._s.clientID; 1.108 + }, 1.109 + 1.110 + /** 1.111 + * The version associated with the client ID. 1.112 + */ 1.113 + get clientIDVersion() { 1.114 + return this._s.clientIDVersion; 1.115 + }, 1.116 + 1.117 + get lastPingDate() { 1.118 + return new Date(this._s.lastPingTime); 1.119 + }, 1.120 + 1.121 + get lastSubmitID() { 1.122 + return this._s.remoteIDs[0]; 1.123 + }, 1.124 + 1.125 + get remoteIDs() { 1.126 + return this._s.remoteIDs; 1.127 + }, 1.128 + 1.129 + get _lastPayloadPath() { 1.130 + return OS.Path.join(this._stateDir, "lastpayload.json"); 1.131 + }, 1.132 + 1.133 + init: function () { 1.134 + return Task.spawn(function init() { 1.135 + try { 1.136 + OS.File.makeDir(this._stateDir); 1.137 + } catch (ex if ex instanceof OS.FileError) { 1.138 + if (!ex.becauseExists) { 1.139 + throw ex; 1.140 + } 1.141 + } 1.142 + 1.143 + let resetObjectState = function () { 1.144 + this._s = { 1.145 + // The payload version. This is bumped whenever there is a 1.146 + // backwards-incompatible change. 1.147 + v: 1, 1.148 + // The persistent client identifier. 1.149 + clientID: CommonUtils.generateUUID(), 1.150 + // Denotes the mechanism used to generate the client identifier. 1.151 + // 1: Random UUID. 1.152 + clientIDVersion: 1, 1.153 + // Upload IDs that might be on the server. 1.154 + remoteIDs: [], 1.155 + // When we last performed an uploaded. 1.156 + lastPingTime: 0, 1.157 + // Tracks whether we removed an outdated payload. 1.158 + removedOutdatedLastpayload: false, 1.159 + }; 1.160 + }.bind(this); 1.161 + 1.162 + try { 1.163 + this._s = yield CommonUtils.readJSON(this._filename); 1.164 + } catch (ex if ex instanceof OS.File.Error) { 1.165 + if (!ex.becauseNoSuchFile) { 1.166 + throw ex; 1.167 + } 1.168 + 1.169 + this._log.warn("Saved state file does not exist."); 1.170 + resetObjectState(); 1.171 + } catch (ex) { 1.172 + this._log.error("Exception when reading state from disk: " + 1.173 + CommonUtils.exceptionStr(ex)); 1.174 + resetObjectState(); 1.175 + 1.176 + // Don't save in case it goes away on next run. 1.177 + } 1.178 + 1.179 + if (typeof(this._s) != "object") { 1.180 + this._log.warn("Read state is not an object. Resetting state."); 1.181 + resetObjectState(); 1.182 + yield this.save(); 1.183 + } 1.184 + 1.185 + if (this._s.v != 1) { 1.186 + this._log.warn("Unknown version in state file: " + this._s.v); 1.187 + resetObjectState(); 1.188 + // We explicitly don't save here in the hopes an application re-upgrade 1.189 + // comes along and fixes us. 1.190 + } 1.191 + 1.192 + let regen = false; 1.193 + if (!this._s.clientID) { 1.194 + this._log.warn("No client ID stored. Generating random ID."); 1.195 + regen = true; 1.196 + } 1.197 + 1.198 + if (typeof(this._s.clientID) != "string") { 1.199 + this._log.warn("Client ID is not a string. Regenerating."); 1.200 + regen = true; 1.201 + } 1.202 + 1.203 + if (regen) { 1.204 + this._s.clientID = CommonUtils.generateUUID(); 1.205 + this._s.clientIDVersion = 1; 1.206 + yield this.save(); 1.207 + } 1.208 + 1.209 + // Always look for preferences. This ensures that downgrades followed 1.210 + // by reupgrades don't result in excessive data loss. 1.211 + for (let promise of this._migratePrefs()) { 1.212 + yield promise; 1.213 + } 1.214 + }.bind(this)); 1.215 + }, 1.216 + 1.217 + save: function () { 1.218 + this._log.info("Writing state file: " + this._filename); 1.219 + return CommonUtils.writeJSON(this._s, this._filename); 1.220 + }, 1.221 + 1.222 + addRemoteID: function (id) { 1.223 + this._log.warn("Recording new remote ID: " + id); 1.224 + this._s.remoteIDs.push(id); 1.225 + return this.save(); 1.226 + }, 1.227 + 1.228 + removeRemoteID: function (id) { 1.229 + return this.removeRemoteIDs(id ? [id] : []); 1.230 + }, 1.231 + 1.232 + removeRemoteIDs: function (ids) { 1.233 + if (!ids || !ids.length) { 1.234 + this._log.warn("No IDs passed for removal."); 1.235 + return Promise.resolve(); 1.236 + } 1.237 + 1.238 + this._log.warn("Removing documents from remote ID list: " + ids); 1.239 + let filtered = this._s.remoteIDs.filter((x) => ids.indexOf(x) === -1); 1.240 + 1.241 + if (filtered.length == this._s.remoteIDs.length) { 1.242 + return Promise.resolve(); 1.243 + } 1.244 + 1.245 + this._s.remoteIDs = filtered; 1.246 + return this.save(); 1.247 + }, 1.248 + 1.249 + setLastPingDate: function (date) { 1.250 + this._s.lastPingTime = date.getTime(); 1.251 + 1.252 + return this.save(); 1.253 + }, 1.254 + 1.255 + updateLastPingAndRemoveRemoteID: function (date, id) { 1.256 + return this.updateLastPingAndRemoveRemoteIDs(date, id ? [id] : []); 1.257 + }, 1.258 + 1.259 + updateLastPingAndRemoveRemoteIDs: function (date, ids) { 1.260 + if (!ids) { 1.261 + return this.setLastPingDate(date); 1.262 + } 1.263 + 1.264 + this._log.info("Recording last ping time and deleted remote document."); 1.265 + this._s.lastPingTime = date.getTime(); 1.266 + return this.removeRemoteIDs(ids); 1.267 + }, 1.268 + 1.269 + /** 1.270 + * Reset the client ID to something else. 1.271 + * 1.272 + * This fails if remote IDs are stored because changing the client ID 1.273 + * while there is remote data will create orphaned records. 1.274 + */ 1.275 + resetClientID: function () { 1.276 + if (this.remoteIDs.length) { 1.277 + throw new Error("Cannot reset client ID while remote IDs are stored."); 1.278 + } 1.279 + 1.280 + this._log.warn("Resetting client ID."); 1.281 + this._s.clientID = CommonUtils.generateUUID(); 1.282 + this._s.clientIDVersion = 1; 1.283 + 1.284 + return this.save(); 1.285 + }, 1.286 + 1.287 + _migratePrefs: function () { 1.288 + let prefs = this._reporter._prefs; 1.289 + 1.290 + let lastID = prefs.get("lastSubmitID", null); 1.291 + let lastPingDate = CommonUtils.getDatePref(prefs, "lastPingTime", 1.292 + 0, this._log, OLDEST_ALLOWED_YEAR); 1.293 + 1.294 + // If we have state from prefs, migrate and save it to a file then clear 1.295 + // out old prefs. 1.296 + if (lastID || (lastPingDate && lastPingDate.getTime() > 0)) { 1.297 + this._log.warn("Migrating saved state from preferences."); 1.298 + 1.299 + if (lastID) { 1.300 + this._log.info("Migrating last saved ID: " + lastID); 1.301 + this._s.remoteIDs.push(lastID); 1.302 + } 1.303 + 1.304 + let ourLast = this.lastPingDate; 1.305 + 1.306 + if (lastPingDate && lastPingDate.getTime() > ourLast.getTime()) { 1.307 + this._log.info("Migrating last ping time: " + lastPingDate); 1.308 + this._s.lastPingTime = lastPingDate.getTime(); 1.309 + } 1.310 + 1.311 + yield this.save(); 1.312 + prefs.reset(["lastSubmitID", "lastPingTime"]); 1.313 + } else { 1.314 + this._log.warn("No prefs data found."); 1.315 + } 1.316 + }, 1.317 +}); 1.318 + 1.319 +/** 1.320 + * This is the abstract base class of `HealthReporter`. It exists so that 1.321 + * we can sanely divide work on platforms where control of Firefox Health 1.322 + * Report is outside of Gecko (e.g., Android). 1.323 + */ 1.324 +function AbstractHealthReporter(branch, policy, sessionRecorder) { 1.325 + if (!branch.endsWith(".")) { 1.326 + throw new Error("Branch must end with a period (.): " + branch); 1.327 + } 1.328 + 1.329 + if (!policy) { 1.330 + throw new Error("Must provide policy to HealthReporter constructor."); 1.331 + } 1.332 + 1.333 + this._log = Log.repository.getLogger("Services.HealthReport.HealthReporter"); 1.334 + this._log.info("Initializing health reporter instance against " + branch); 1.335 + 1.336 + this._branch = branch; 1.337 + this._prefs = new Preferences(branch); 1.338 + 1.339 + this._policy = policy; 1.340 + this.sessionRecorder = sessionRecorder; 1.341 + 1.342 + this._dbName = this._prefs.get("dbName") || DEFAULT_DATABASE_NAME; 1.343 + 1.344 + this._storage = null; 1.345 + this._storageInProgress = false; 1.346 + this._providerManager = null; 1.347 + this._providerManagerInProgress = false; 1.348 + this._initializeStarted = false; 1.349 + this._initialized = false; 1.350 + this._initializeHadError = false; 1.351 + this._initializedDeferred = Promise.defer(); 1.352 + this._shutdownRequested = false; 1.353 + this._shutdownInitiated = false; 1.354 + this._shutdownComplete = false; 1.355 + this._shutdownCompleteCallback = null; 1.356 + 1.357 + this._errors = []; 1.358 + 1.359 + this._lastDailyDate = null; 1.360 + 1.361 + // Yes, this will probably run concurrently with remaining constructor work. 1.362 + let hasFirstRun = this._prefs.get("service.firstRun", false); 1.363 + this._initHistogram = hasFirstRun ? TELEMETRY_INIT : TELEMETRY_INIT_FIRSTRUN; 1.364 + this._dbOpenHistogram = hasFirstRun ? TELEMETRY_DB_OPEN : TELEMETRY_DB_OPEN_FIRSTRUN; 1.365 +} 1.366 + 1.367 +AbstractHealthReporter.prototype = Object.freeze({ 1.368 + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]), 1.369 + 1.370 + /** 1.371 + * Whether the service is fully initialized and running. 1.372 + * 1.373 + * If this is false, it is not safe to call most functions. 1.374 + */ 1.375 + get initialized() { 1.376 + return this._initialized; 1.377 + }, 1.378 + 1.379 + /** 1.380 + * Initialize the instance. 1.381 + * 1.382 + * This must be called once after object construction or the instance is 1.383 + * useless. 1.384 + */ 1.385 + init: function () { 1.386 + if (this._initializeStarted) { 1.387 + throw new Error("We have already started initialization."); 1.388 + } 1.389 + 1.390 + this._initializeStarted = true; 1.391 + 1.392 + TelemetryStopwatch.start(this._initHistogram, this); 1.393 + 1.394 + this._initializeState().then(this._onStateInitialized.bind(this), 1.395 + this._onInitError.bind(this)); 1.396 + 1.397 + return this.onInit(); 1.398 + }, 1.399 + 1.400 + //---------------------------------------------------- 1.401 + // SERVICE CONTROL FUNCTIONS 1.402 + // 1.403 + // You shouldn't need to call any of these externally. 1.404 + //---------------------------------------------------- 1.405 + 1.406 + _onInitError: function (error) { 1.407 + TelemetryStopwatch.cancel(this._initHistogram, this); 1.408 + TelemetryStopwatch.cancel(this._dbOpenHistogram, this); 1.409 + delete this._initHistogram; 1.410 + delete this._dbOpenHistogram; 1.411 + 1.412 + this._recordError("Error during initialization", error); 1.413 + this._initializeHadError = true; 1.414 + this._initiateShutdown(); 1.415 + this._initializedDeferred.reject(error); 1.416 + 1.417 + // FUTURE consider poisoning prototype's functions so calls fail with a 1.418 + // useful error message. 1.419 + }, 1.420 + 1.421 + _initializeState: function () { 1.422 + return Promise.resolve(); 1.423 + }, 1.424 + 1.425 + _onStateInitialized: function () { 1.426 + return Task.spawn(function onStateInitialized () { 1.427 + try { 1.428 + if (!this._state._s.removedOutdatedLastpayload) { 1.429 + yield this._deleteOldLastPayload(); 1.430 + this._state._s.removedOutdatedLastpayload = true; 1.431 + // Normally we should save this to a file but it directly conflicts with 1.432 + // the "application re-upgrade" decision in HealthReporterState::init() 1.433 + // which specifically does not save the state to a file. 1.434 + } 1.435 + } catch (ex) { 1.436 + this._log.error("Error deleting last payload: " + 1.437 + CommonUtils.exceptionStr(ex)); 1.438 + } 1.439 + // As soon as we have could storage, we need to register cleanup or 1.440 + // else bad things happen on shutdown. 1.441 + Services.obs.addObserver(this, "quit-application", false); 1.442 + Services.obs.addObserver(this, "profile-before-change", false); 1.443 + 1.444 + this._storageInProgress = true; 1.445 + TelemetryStopwatch.start(this._dbOpenHistogram, this); 1.446 + 1.447 + Metrics.Storage(this._dbName).then(this._onStorageCreated.bind(this), 1.448 + this._onInitError.bind(this)); 1.449 + }.bind(this)); 1.450 + }, 1.451 + 1.452 + 1.453 + /** 1.454 + * Removes the outdated lastpaylaod.json and lastpayload.json.tmp files 1.455 + * @see Bug #867902 1.456 + * @return a promise for when all the files have been deleted 1.457 + */ 1.458 + _deleteOldLastPayload: function () { 1.459 + let paths = [this._state._lastPayloadPath, this._state._lastPayloadPath + ".tmp"]; 1.460 + return Task.spawn(function removeAllFiles () { 1.461 + for (let path of paths) { 1.462 + try { 1.463 + OS.File.remove(path); 1.464 + } catch (ex) { 1.465 + if (!ex.becauseNoSuchFile) { 1.466 + this._log.error("Exception when removing outdated payload files: " + 1.467 + CommonUtils.exceptionStr(ex)); 1.468 + } 1.469 + } 1.470 + } 1.471 + }.bind(this)); 1.472 + }, 1.473 + 1.474 + // Called when storage has been opened. 1.475 + _onStorageCreated: function (storage) { 1.476 + TelemetryStopwatch.finish(this._dbOpenHistogram, this); 1.477 + delete this._dbOpenHistogram; 1.478 + this._log.info("Storage initialized."); 1.479 + this._storage = storage; 1.480 + this._storageInProgress = false; 1.481 + 1.482 + if (this._shutdownRequested) { 1.483 + this._initiateShutdown(); 1.484 + return; 1.485 + } 1.486 + 1.487 + Task.spawn(this._initializeProviderManager.bind(this)) 1.488 + .then(this._onProviderManagerInitialized.bind(this), 1.489 + this._onInitError.bind(this)); 1.490 + }, 1.491 + 1.492 + _initializeProviderManager: function () { 1.493 + if (this._collector) { 1.494 + throw new Error("Provider manager has already been initialized."); 1.495 + } 1.496 + 1.497 + this._log.info("Initializing provider manager."); 1.498 + this._providerManager = new Metrics.ProviderManager(this._storage); 1.499 + this._providerManager.onProviderError = this._recordError.bind(this); 1.500 + this._providerManager.onProviderInit = this._initProvider.bind(this); 1.501 + this._providerManagerInProgress = true; 1.502 + 1.503 + let catString = this._prefs.get("service.providerCategories") || ""; 1.504 + if (catString.length) { 1.505 + for (let category of catString.split(",")) { 1.506 + yield this._providerManager.registerProvidersFromCategoryManager(category); 1.507 + } 1.508 + } 1.509 + }, 1.510 + 1.511 + _onProviderManagerInitialized: function () { 1.512 + TelemetryStopwatch.finish(this._initHistogram, this); 1.513 + delete this._initHistogram; 1.514 + this._log.debug("Provider manager initialized."); 1.515 + this._providerManagerInProgress = false; 1.516 + 1.517 + if (this._shutdownRequested) { 1.518 + this._initiateShutdown(); 1.519 + return; 1.520 + } 1.521 + 1.522 + this._log.info("HealthReporter started."); 1.523 + this._initialized = true; 1.524 + Services.obs.addObserver(this, "idle-daily", false); 1.525 + 1.526 + // If upload is not enabled, ensure daily collection works. If upload 1.527 + // is enabled, this will be performed as part of upload. 1.528 + // 1.529 + // This is important because it ensures about:healthreport contains 1.530 + // longitudinal data even if upload is disabled. Having about:healthreport 1.531 + // provide useful info even if upload is disabled was a core launch 1.532 + // requirement. 1.533 + // 1.534 + // We do not catch changes to the backing pref. So, if the session lasts 1.535 + // many days, we may fail to collect. However, most sessions are short and 1.536 + // this code will likely be refactored as part of splitting up policy to 1.537 + // serve Android. So, meh. 1.538 + if (!this._policy.healthReportUploadEnabled) { 1.539 + this._log.info("Upload not enabled. Scheduling daily collection."); 1.540 + // Since the timer manager is a singleton and there could be multiple 1.541 + // HealthReporter instances, we need to encode a unique identifier in 1.542 + // the timer ID. 1.543 + try { 1.544 + let timerName = this._branch.replace(".", "-", "g") + "lastDailyCollection"; 1.545 + let tm = Cc["@mozilla.org/updates/timer-manager;1"] 1.546 + .getService(Ci.nsIUpdateTimerManager); 1.547 + tm.registerTimer(timerName, this.collectMeasurements.bind(this), 1.548 + 24 * 60 * 60); 1.549 + } catch (ex) { 1.550 + this._log.error("Error registering collection timer: " + 1.551 + CommonUtils.exceptionStr(ex)); 1.552 + } 1.553 + } 1.554 + 1.555 + // Clean up caches and reduce memory usage. 1.556 + this._storage.compact(); 1.557 + this._initializedDeferred.resolve(this); 1.558 + }, 1.559 + 1.560 + // nsIObserver to handle shutdown. 1.561 + observe: function (subject, topic, data) { 1.562 + switch (topic) { 1.563 + case "quit-application": 1.564 + Services.obs.removeObserver(this, "quit-application"); 1.565 + this._initiateShutdown(); 1.566 + break; 1.567 + 1.568 + case "profile-before-change": 1.569 + Services.obs.removeObserver(this, "profile-before-change"); 1.570 + this._waitForShutdown(); 1.571 + break; 1.572 + 1.573 + case "idle-daily": 1.574 + this._performDailyMaintenance(); 1.575 + break; 1.576 + } 1.577 + }, 1.578 + 1.579 + _initiateShutdown: function () { 1.580 + // Ensure we only begin the main shutdown sequence once. 1.581 + if (this._shutdownInitiated) { 1.582 + this._log.warn("Shutdown has already been initiated. No-op."); 1.583 + return; 1.584 + } 1.585 + 1.586 + this._log.info("Request to shut down."); 1.587 + 1.588 + this._initialized = false; 1.589 + this._shutdownRequested = true; 1.590 + 1.591 + if (this._initializeHadError) { 1.592 + this._log.warn("Initialization had error. Shutting down immediately."); 1.593 + } else { 1.594 + if (this._providerManagerInProcess) { 1.595 + this._log.warn("Provider manager is in progress of initializing. " + 1.596 + "Waiting to finish."); 1.597 + return; 1.598 + } 1.599 + 1.600 + // If storage is in the process of initializing, we need to wait for it 1.601 + // to finish before continuing. The initialization process will call us 1.602 + // again once storage has initialized. 1.603 + if (this._storageInProgress) { 1.604 + this._log.warn("Storage is in progress of initializing. Waiting to finish."); 1.605 + return; 1.606 + } 1.607 + } 1.608 + 1.609 + this._log.warn("Initiating main shutdown procedure."); 1.610 + 1.611 + // Everything from here must only be performed once or else race conditions 1.612 + // could occur. 1.613 + 1.614 + TelemetryStopwatch.start(TELEMETRY_SHUTDOWN, this); 1.615 + this._shutdownInitiated = true; 1.616 + 1.617 + // We may not have registered the observer yet. If not, this will 1.618 + // throw. 1.619 + try { 1.620 + Services.obs.removeObserver(this, "idle-daily"); 1.621 + } catch (ex) { } 1.622 + 1.623 + if (this._providerManager) { 1.624 + let onShutdown = this._onProviderManagerShutdown.bind(this); 1.625 + Task.spawn(this._shutdownProviderManager.bind(this)) 1.626 + .then(onShutdown, onShutdown); 1.627 + return; 1.628 + } 1.629 + 1.630 + this._log.warn("Don't have provider manager. Proceeding to storage shutdown."); 1.631 + this._shutdownStorage(); 1.632 + }, 1.633 + 1.634 + _shutdownProviderManager: function () { 1.635 + this._log.info("Shutting down provider manager."); 1.636 + for (let provider of this._providerManager.providers) { 1.637 + try { 1.638 + yield provider.shutdown(); 1.639 + } catch (ex) { 1.640 + this._log.warn("Error when shutting down provider: " + 1.641 + CommonUtils.exceptionStr(ex)); 1.642 + } 1.643 + } 1.644 + }, 1.645 + 1.646 + _onProviderManagerShutdown: function () { 1.647 + this._log.info("Provider manager shut down."); 1.648 + this._providerManager = null; 1.649 + this._shutdownStorage(); 1.650 + }, 1.651 + 1.652 + _shutdownStorage: function () { 1.653 + if (!this._storage) { 1.654 + this._onShutdownComplete(); 1.655 + } 1.656 + 1.657 + this._log.info("Shutting down storage."); 1.658 + let onClose = this._onStorageClose.bind(this); 1.659 + this._storage.close().then(onClose, onClose); 1.660 + }, 1.661 + 1.662 + _onStorageClose: function (error) { 1.663 + this._log.info("Storage has been closed."); 1.664 + 1.665 + if (error) { 1.666 + this._log.warn("Error when closing storage: " + 1.667 + CommonUtils.exceptionStr(error)); 1.668 + } 1.669 + 1.670 + this._storage = null; 1.671 + this._onShutdownComplete(); 1.672 + }, 1.673 + 1.674 + _onShutdownComplete: function () { 1.675 + this._log.warn("Shutdown complete."); 1.676 + this._shutdownComplete = true; 1.677 + TelemetryStopwatch.finish(TELEMETRY_SHUTDOWN, this); 1.678 + 1.679 + if (this._shutdownCompleteCallback) { 1.680 + this._shutdownCompleteCallback(); 1.681 + } 1.682 + }, 1.683 + 1.684 + _waitForShutdown: function () { 1.685 + if (this._shutdownComplete) { 1.686 + return; 1.687 + } 1.688 + 1.689 + TelemetryStopwatch.start(TELEMETRY_SHUTDOWN_DELAY, this); 1.690 + try { 1.691 + this._shutdownCompleteCallback = Async.makeSpinningCallback(); 1.692 + this._shutdownCompleteCallback.wait(); 1.693 + this._shutdownCompleteCallback = null; 1.694 + } finally { 1.695 + TelemetryStopwatch.finish(TELEMETRY_SHUTDOWN_DELAY, this); 1.696 + } 1.697 + }, 1.698 + 1.699 + /** 1.700 + * Convenience method to shut down the instance. 1.701 + * 1.702 + * This should *not* be called outside of tests. 1.703 + */ 1.704 + _shutdown: function () { 1.705 + this._initiateShutdown(); 1.706 + this._waitForShutdown(); 1.707 + }, 1.708 + 1.709 + /** 1.710 + * Return a promise that is resolved once the service has been initialized. 1.711 + */ 1.712 + onInit: function () { 1.713 + if (this._initializeHadError) { 1.714 + throw new Error("Service failed to initialize."); 1.715 + } 1.716 + 1.717 + if (this._initialized) { 1.718 + return CommonUtils.laterTickResolvingPromise(this); 1.719 + } 1.720 + 1.721 + return this._initializedDeferred.promise; 1.722 + }, 1.723 + 1.724 + _performDailyMaintenance: function () { 1.725 + this._log.info("Request to perform daily maintenance."); 1.726 + 1.727 + if (!this._initialized) { 1.728 + return; 1.729 + } 1.730 + 1.731 + let now = new Date(); 1.732 + let cutoff = new Date(now.getTime() - MILLISECONDS_PER_DAY * (DAYS_IN_PAYLOAD - 1)); 1.733 + 1.734 + // The operation is enqueued and put in a transaction by the storage module. 1.735 + this._storage.pruneDataBefore(cutoff); 1.736 + }, 1.737 + 1.738 + //-------------------- 1.739 + // Provider Management 1.740 + //-------------------- 1.741 + 1.742 + /** 1.743 + * Obtain a provider from its name. 1.744 + * 1.745 + * This will only return providers that are currently initialized. If 1.746 + * a provider is lazy initialized (like pull-only providers) this 1.747 + * will likely not return anything. 1.748 + */ 1.749 + getProvider: function (name) { 1.750 + if (!this._providerManager) { 1.751 + return null; 1.752 + } 1.753 + 1.754 + return this._providerManager.getProvider(name); 1.755 + }, 1.756 + 1.757 + _initProvider: function (provider) { 1.758 + provider.healthReporter = this; 1.759 + }, 1.760 + 1.761 + /** 1.762 + * Record an exception for reporting in the payload. 1.763 + * 1.764 + * A side effect is the exception is logged. 1.765 + * 1.766 + * Note that callers need to be extra sensitive about ensuring personal 1.767 + * or otherwise private details do not leak into this. All of the user data 1.768 + * on the stack in FHR code should be limited to data we were collecting with 1.769 + * the intent to submit. So, it is covered under the user's consent to use 1.770 + * the feature. 1.771 + * 1.772 + * @param message 1.773 + * (string) Human readable message describing error. 1.774 + * @param ex 1.775 + * (Error) The error that should be captured. 1.776 + */ 1.777 + _recordError: function (message, ex) { 1.778 + let recordMessage = message; 1.779 + let logMessage = message; 1.780 + 1.781 + if (ex) { 1.782 + recordMessage += ": " + CommonUtils.exceptionStr(ex); 1.783 + logMessage += ": " + CommonUtils.exceptionStr(ex); 1.784 + } 1.785 + 1.786 + // Scrub out potentially identifying information from strings that could 1.787 + // make the payload. 1.788 + let appData = Services.dirsvc.get("UAppData", Ci.nsIFile); 1.789 + let profile = Services.dirsvc.get("ProfD", Ci.nsIFile); 1.790 + 1.791 + let appDataURI = Services.io.newFileURI(appData); 1.792 + let profileURI = Services.io.newFileURI(profile); 1.793 + 1.794 + // Order of operation is important here. We do the URI before the path version 1.795 + // because the path may be a subset of the URI. We also have to check for the case 1.796 + // where UAppData is underneath the profile directory (or vice-versa) so we 1.797 + // don't substitute incomplete strings. 1.798 + 1.799 + function replace(uri, path, thing) { 1.800 + // Try is because .spec can throw on invalid URI. 1.801 + try { 1.802 + recordMessage = recordMessage.replace(uri.spec, '<' + thing + 'URI>', 'g'); 1.803 + } catch (ex) { } 1.804 + 1.805 + recordMessage = recordMessage.replace(path, '<' + thing + 'Path>', 'g'); 1.806 + } 1.807 + 1.808 + if (appData.path.contains(profile.path)) { 1.809 + replace(appDataURI, appData.path, 'AppData'); 1.810 + replace(profileURI, profile.path, 'Profile'); 1.811 + } else { 1.812 + replace(profileURI, profile.path, 'Profile'); 1.813 + replace(appDataURI, appData.path, 'AppData'); 1.814 + } 1.815 + 1.816 + this._log.warn(logMessage); 1.817 + this._errors.push(recordMessage); 1.818 + }, 1.819 + 1.820 + /** 1.821 + * Collect all measurements for all registered providers. 1.822 + */ 1.823 + collectMeasurements: function () { 1.824 + if (!this._initialized) { 1.825 + return Promise.reject(new Error("Not initialized.")); 1.826 + } 1.827 + 1.828 + return Task.spawn(function doCollection() { 1.829 + yield this._providerManager.ensurePullOnlyProvidersRegistered(); 1.830 + 1.831 + try { 1.832 + TelemetryStopwatch.start(TELEMETRY_COLLECT_CONSTANT, this); 1.833 + yield this._providerManager.collectConstantData(); 1.834 + TelemetryStopwatch.finish(TELEMETRY_COLLECT_CONSTANT, this); 1.835 + } catch (ex) { 1.836 + TelemetryStopwatch.cancel(TELEMETRY_COLLECT_CONSTANT, this); 1.837 + this._log.warn("Error collecting constant data: " + 1.838 + CommonUtils.exceptionStr(ex)); 1.839 + } 1.840 + 1.841 + // Daily data is collected if it hasn't yet been collected this 1.842 + // application session or if it has been more than a day since the 1.843 + // last collection. This means that providers could see many calls to 1.844 + // collectDailyData per calendar day. However, this collection API 1.845 + // makes no guarantees about limits. The alternative would involve 1.846 + // recording state. The simpler implementation prevails for now. 1.847 + if (!this._lastDailyDate || 1.848 + Date.now() - this._lastDailyDate > MILLISECONDS_PER_DAY) { 1.849 + 1.850 + try { 1.851 + TelemetryStopwatch.start(TELEMETRY_COLLECT_DAILY, this); 1.852 + this._lastDailyDate = new Date(); 1.853 + yield this._providerManager.collectDailyData(); 1.854 + TelemetryStopwatch.finish(TELEMETRY_COLLECT_DAILY, this); 1.855 + } catch (ex) { 1.856 + TelemetryStopwatch.cancel(TELEMETRY_COLLECT_DAILY, this); 1.857 + this._log.warn("Error collecting daily data from providers: " + 1.858 + CommonUtils.exceptionStr(ex)); 1.859 + } 1.860 + } 1.861 + 1.862 + yield this._providerManager.ensurePullOnlyProvidersUnregistered(); 1.863 + 1.864 + // Flush gathered data to disk. This will incur an fsync. But, if 1.865 + // there is ever a time we want to persist data to disk, it's 1.866 + // after a massive collection. 1.867 + try { 1.868 + TelemetryStopwatch.start(TELEMETRY_COLLECT_CHECKPOINT, this); 1.869 + yield this._storage.checkpoint(); 1.870 + TelemetryStopwatch.finish(TELEMETRY_COLLECT_CHECKPOINT, this); 1.871 + } catch (ex) { 1.872 + TelemetryStopwatch.cancel(TELEMETRY_COLLECT_CHECKPOINT, this); 1.873 + throw ex; 1.874 + } 1.875 + 1.876 + throw new Task.Result(); 1.877 + }.bind(this)); 1.878 + }, 1.879 + 1.880 + /** 1.881 + * Helper function to perform data collection and obtain the JSON payload. 1.882 + * 1.883 + * If you are looking for an up-to-date snapshot of FHR data that pulls in 1.884 + * new data since the last upload, this is how you should obtain it. 1.885 + * 1.886 + * @param asObject 1.887 + * (bool) Whether to resolve an object or JSON-encoded string of that 1.888 + * object (the default). 1.889 + * 1.890 + * @return Promise<Object | string> 1.891 + */ 1.892 + collectAndObtainJSONPayload: function (asObject=false) { 1.893 + if (!this._initialized) { 1.894 + return Promise.reject(new Error("Not initialized.")); 1.895 + } 1.896 + 1.897 + return Task.spawn(function collectAndObtain() { 1.898 + yield this._storage.setAutoCheckpoint(0); 1.899 + yield this._providerManager.ensurePullOnlyProvidersRegistered(); 1.900 + 1.901 + let payload; 1.902 + let error; 1.903 + 1.904 + try { 1.905 + yield this.collectMeasurements(); 1.906 + payload = yield this.getJSONPayload(asObject); 1.907 + } catch (ex) { 1.908 + error = ex; 1.909 + this._collectException("Error collecting and/or retrieving JSON payload", 1.910 + ex); 1.911 + } finally { 1.912 + yield this._providerManager.ensurePullOnlyProvidersUnregistered(); 1.913 + yield this._storage.setAutoCheckpoint(1); 1.914 + 1.915 + if (error) { 1.916 + throw error; 1.917 + } 1.918 + } 1.919 + 1.920 + // We hold off throwing to ensure that behavior between finally 1.921 + // and generators and throwing is sane. 1.922 + throw new Task.Result(payload); 1.923 + }.bind(this)); 1.924 + }, 1.925 + 1.926 + 1.927 + /** 1.928 + * Obtain the JSON payload for currently-collected data. 1.929 + * 1.930 + * The payload only contains data that has been recorded to FHR. Some 1.931 + * providers may have newer data available. If you want to ensure you 1.932 + * have all available data, call `collectAndObtainJSONPayload` 1.933 + * instead. 1.934 + * 1.935 + * @param asObject 1.936 + * (bool) Whether to return an object or JSON encoding of that 1.937 + * object (the default). 1.938 + * 1.939 + * @return Promise<string|object> 1.940 + */ 1.941 + getJSONPayload: function (asObject=false) { 1.942 + TelemetryStopwatch.start(TELEMETRY_GENERATE_PAYLOAD, this); 1.943 + let deferred = Promise.defer(); 1.944 + 1.945 + Task.spawn(this._getJSONPayload.bind(this, this._now(), asObject)).then( 1.946 + function onResult(result) { 1.947 + TelemetryStopwatch.finish(TELEMETRY_GENERATE_PAYLOAD, this); 1.948 + deferred.resolve(result); 1.949 + }.bind(this), 1.950 + function onError(error) { 1.951 + TelemetryStopwatch.cancel(TELEMETRY_GENERATE_PAYLOAD, this); 1.952 + deferred.reject(error); 1.953 + }.bind(this) 1.954 + ); 1.955 + 1.956 + return deferred.promise; 1.957 + }, 1.958 + 1.959 + _getJSONPayload: function (now, asObject=false) { 1.960 + let pingDateString = this._formatDate(now); 1.961 + this._log.info("Producing JSON payload for " + pingDateString); 1.962 + 1.963 + // May not be present if we are generating as a result of init error. 1.964 + if (this._providerManager) { 1.965 + yield this._providerManager.ensurePullOnlyProvidersRegistered(); 1.966 + } 1.967 + 1.968 + let o = { 1.969 + version: 2, 1.970 + clientID: this._state.clientID, 1.971 + clientIDVersion: this._state.clientIDVersion, 1.972 + thisPingDate: pingDateString, 1.973 + geckoAppInfo: this.obtainAppInfo(this._log), 1.974 + data: {last: {}, days: {}}, 1.975 + }; 1.976 + 1.977 + let outputDataDays = o.data.days; 1.978 + 1.979 + // Guard here in case we don't track this (e.g., on Android). 1.980 + let lastPingDate = this.lastPingDate; 1.981 + if (lastPingDate && lastPingDate.getTime() > 0) { 1.982 + o.lastPingDate = this._formatDate(lastPingDate); 1.983 + } 1.984 + 1.985 + // We can still generate a payload even if we're not initialized. 1.986 + // This is to facilitate error upload on init failure. 1.987 + if (this._initialized) { 1.988 + for (let provider of this._providerManager.providers) { 1.989 + let providerName = provider.name; 1.990 + 1.991 + let providerEntry = { 1.992 + measurements: {}, 1.993 + }; 1.994 + 1.995 + // Measurement name to recorded version. 1.996 + let lastVersions = {}; 1.997 + // Day string to mapping of measurement name to recorded version. 1.998 + let dayVersions = {}; 1.999 + 1.1000 + for (let [measurementKey, measurement] of provider.measurements) { 1.1001 + let name = providerName + "." + measurement.name; 1.1002 + let version = measurement.version; 1.1003 + 1.1004 + let serializer; 1.1005 + try { 1.1006 + // The measurement is responsible for returning a serializer which 1.1007 + // is aware of the measurement version. 1.1008 + serializer = measurement.serializer(measurement.SERIALIZE_JSON); 1.1009 + } catch (ex) { 1.1010 + this._recordError("Error obtaining serializer for measurement: " + 1.1011 + name, ex); 1.1012 + continue; 1.1013 + } 1.1014 + 1.1015 + let data; 1.1016 + try { 1.1017 + data = yield measurement.getValues(); 1.1018 + } catch (ex) { 1.1019 + this._recordError("Error obtaining data for measurement: " + name, 1.1020 + ex); 1.1021 + continue; 1.1022 + } 1.1023 + 1.1024 + if (data.singular.size) { 1.1025 + try { 1.1026 + let serialized = serializer.singular(data.singular); 1.1027 + if (serialized) { 1.1028 + // Only replace the existing data if there is no data or if our 1.1029 + // version is newer than the old one. 1.1030 + if (!(name in o.data.last) || version > lastVersions[name]) { 1.1031 + o.data.last[name] = serialized; 1.1032 + lastVersions[name] = version; 1.1033 + } 1.1034 + } 1.1035 + } catch (ex) { 1.1036 + this._recordError("Error serializing singular data: " + name, 1.1037 + ex); 1.1038 + continue; 1.1039 + } 1.1040 + } 1.1041 + 1.1042 + let dataDays = data.days; 1.1043 + for (let i = 0; i < DAYS_IN_PAYLOAD; i++) { 1.1044 + let date = new Date(now.getTime() - i * MILLISECONDS_PER_DAY); 1.1045 + if (!dataDays.hasDay(date)) { 1.1046 + continue; 1.1047 + } 1.1048 + let dateFormatted = this._formatDate(date); 1.1049 + 1.1050 + try { 1.1051 + let serialized = serializer.daily(dataDays.getDay(date)); 1.1052 + if (!serialized) { 1.1053 + continue; 1.1054 + } 1.1055 + 1.1056 + if (!(dateFormatted in outputDataDays)) { 1.1057 + outputDataDays[dateFormatted] = {}; 1.1058 + } 1.1059 + 1.1060 + // This needs to be separate because dayVersions is provider 1.1061 + // specific and gets blown away in a loop while outputDataDays 1.1062 + // is persistent. 1.1063 + if (!(dateFormatted in dayVersions)) { 1.1064 + dayVersions[dateFormatted] = {}; 1.1065 + } 1.1066 + 1.1067 + if (!(name in outputDataDays[dateFormatted]) || 1.1068 + version > dayVersions[dateFormatted][name]) { 1.1069 + outputDataDays[dateFormatted][name] = serialized; 1.1070 + dayVersions[dateFormatted][name] = version; 1.1071 + } 1.1072 + } catch (ex) { 1.1073 + this._recordError("Error populating data for day: " + name, ex); 1.1074 + continue; 1.1075 + } 1.1076 + } 1.1077 + } 1.1078 + } 1.1079 + } else { 1.1080 + o.notInitialized = 1; 1.1081 + this._log.warn("Not initialized. Sending report with only error info."); 1.1082 + } 1.1083 + 1.1084 + if (this._errors.length) { 1.1085 + o.errors = this._errors.slice(0, 20); 1.1086 + } 1.1087 + 1.1088 + if (this._initialized) { 1.1089 + this._storage.compact(); 1.1090 + } 1.1091 + 1.1092 + if (!asObject) { 1.1093 + TelemetryStopwatch.start(TELEMETRY_JSON_PAYLOAD_SERIALIZE, this); 1.1094 + o = JSON.stringify(o); 1.1095 + TelemetryStopwatch.finish(TELEMETRY_JSON_PAYLOAD_SERIALIZE, this); 1.1096 + } 1.1097 + 1.1098 + if (this._providerManager) { 1.1099 + yield this._providerManager.ensurePullOnlyProvidersUnregistered(); 1.1100 + } 1.1101 + 1.1102 + throw new Task.Result(o); 1.1103 + }, 1.1104 + 1.1105 + _now: function _now() { 1.1106 + return new Date(); 1.1107 + }, 1.1108 + 1.1109 + // These are stolen from AppInfoProvider. 1.1110 + appInfoVersion: 1, 1.1111 + appInfoFields: { 1.1112 + // From nsIXULAppInfo. 1.1113 + vendor: "vendor", 1.1114 + name: "name", 1.1115 + id: "ID", 1.1116 + version: "version", 1.1117 + appBuildID: "appBuildID", 1.1118 + platformVersion: "platformVersion", 1.1119 + platformBuildID: "platformBuildID", 1.1120 + 1.1121 + // From nsIXULRuntime. 1.1122 + os: "OS", 1.1123 + xpcomabi: "XPCOMABI", 1.1124 + }, 1.1125 + 1.1126 + /** 1.1127 + * Statically return a bundle of app info data, a subset of that produced by 1.1128 + * AppInfoProvider._populateConstants. This allows us to more usefully handle 1.1129 + * payloads that, due to error, contain no data. 1.1130 + * 1.1131 + * Returns a very sparse object if Services.appinfo is unavailable. 1.1132 + */ 1.1133 + obtainAppInfo: function () { 1.1134 + let out = {"_v": this.appInfoVersion}; 1.1135 + try { 1.1136 + let ai = Services.appinfo; 1.1137 + for (let [k, v] in Iterator(this.appInfoFields)) { 1.1138 + out[k] = ai[v]; 1.1139 + } 1.1140 + } catch (ex) { 1.1141 + this._log.warn("Could not obtain Services.appinfo: " + 1.1142 + CommonUtils.exceptionStr(ex)); 1.1143 + } 1.1144 + 1.1145 + try { 1.1146 + out["updateChannel"] = UpdateChannel.get(); 1.1147 + } catch (ex) { 1.1148 + this._log.warn("Could not obtain update channel: " + 1.1149 + CommonUtils.exceptionStr(ex)); 1.1150 + } 1.1151 + 1.1152 + return out; 1.1153 + }, 1.1154 +}); 1.1155 + 1.1156 +/** 1.1157 + * HealthReporter and its abstract superclass coordinate collection and 1.1158 + * submission of health report metrics. 1.1159 + * 1.1160 + * This is the main type for Firefox Health Report on desktop. It glues all the 1.1161 + * lower-level components (such as collection and submission) together. 1.1162 + * 1.1163 + * An instance of this type is created as an XPCOM service. See 1.1164 + * DataReportingService.js and 1.1165 + * DataReporting.manifest/HealthReportComponents.manifest. 1.1166 + * 1.1167 + * It is theoretically possible to have multiple instances of this running 1.1168 + * in the application. For example, this type may one day handle submission 1.1169 + * of telemetry data as well. However, there is some moderate coupling between 1.1170 + * this type and *the* Firefox Health Report (e.g., the policy). This could 1.1171 + * be abstracted if needed. 1.1172 + * 1.1173 + * Note that `AbstractHealthReporter` exists to allow for Firefox Health Report 1.1174 + * to be more easily implemented on platforms where a separate controlling 1.1175 + * layer is responsible for payload upload and deletion. 1.1176 + * 1.1177 + * IMPLEMENTATION NOTES 1.1178 + * ==================== 1.1179 + * 1.1180 + * These notes apply to the combination of `HealthReporter` and 1.1181 + * `AbstractHealthReporter`. 1.1182 + * 1.1183 + * Initialization and shutdown are somewhat complicated and worth explaining 1.1184 + * in extra detail. 1.1185 + * 1.1186 + * The complexity is driven by the requirements of SQLite connection management. 1.1187 + * Once you have a SQLite connection, it isn't enough to just let the 1.1188 + * application shut down. If there is an open connection or if there are 1.1189 + * outstanding SQL statements come XPCOM shutdown time, Storage will assert. 1.1190 + * On debug builds you will crash. On release builds you will get a shutdown 1.1191 + * hang. This must be avoided! 1.1192 + * 1.1193 + * During initialization, the second we create a SQLite connection (via 1.1194 + * Metrics.Storage) we register observers for application shutdown. The 1.1195 + * "quit-application" notification initiates our shutdown procedure. The 1.1196 + * subsequent "profile-do-change" notification ensures it has completed. 1.1197 + * 1.1198 + * The handler for "profile-do-change" may result in event loop spinning. This 1.1199 + * is because of race conditions between our shutdown code and application 1.1200 + * shutdown. 1.1201 + * 1.1202 + * All of our shutdown routines are async. There is the potential that these 1.1203 + * async functions will not complete before XPCOM shutdown. If they don't 1.1204 + * finish in time, we could get assertions in Storage. Our solution is to 1.1205 + * initiate storage early in the shutdown cycle ("quit-application"). 1.1206 + * Hopefully all the async operations have completed by the time we reach 1.1207 + * "profile-do-change." If so, great. If not, we spin the event loop until 1.1208 + * they have completed, avoiding potential race conditions. 1.1209 + * 1.1210 + * @param branch 1.1211 + * (string) The preferences branch to use for state storage. The value 1.1212 + * must end with a period (.). 1.1213 + * 1.1214 + * @param policy 1.1215 + * (HealthReportPolicy) Policy driving execution of HealthReporter. 1.1216 + */ 1.1217 +this.HealthReporter = function (branch, policy, sessionRecorder, stateLeaf=null) { 1.1218 + this._stateLeaf = stateLeaf; 1.1219 + this._uploadInProgress = false; 1.1220 + 1.1221 + AbstractHealthReporter.call(this, branch, policy, sessionRecorder); 1.1222 + 1.1223 + if (!this.serverURI) { 1.1224 + throw new Error("No server URI defined. Did you forget to define the pref?"); 1.1225 + } 1.1226 + 1.1227 + if (!this.serverNamespace) { 1.1228 + throw new Error("No server namespace defined. Did you forget a pref?"); 1.1229 + } 1.1230 + 1.1231 + this._state = new HealthReporterState(this); 1.1232 +} 1.1233 + 1.1234 +this.HealthReporter.prototype = Object.freeze({ 1.1235 + __proto__: AbstractHealthReporter.prototype, 1.1236 + 1.1237 + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]), 1.1238 + 1.1239 + get lastSubmitID() { 1.1240 + return this._state.lastSubmitID; 1.1241 + }, 1.1242 + 1.1243 + /** 1.1244 + * When we last successfully submitted data to the server. 1.1245 + * 1.1246 + * This is sent as part of the upload. This is redundant with similar data 1.1247 + * in the policy because we like the modules to be loosely coupled and the 1.1248 + * similar data in the policy is only used for forensic purposes. 1.1249 + */ 1.1250 + get lastPingDate() { 1.1251 + return this._state.lastPingDate; 1.1252 + }, 1.1253 + 1.1254 + /** 1.1255 + * The base URI of the document server to which to submit data. 1.1256 + * 1.1257 + * This is typically a Bagheera server instance. It is the URI up to but not 1.1258 + * including the version prefix. e.g. https://data.metrics.mozilla.com/ 1.1259 + */ 1.1260 + get serverURI() { 1.1261 + return this._prefs.get("documentServerURI", null); 1.1262 + }, 1.1263 + 1.1264 + set serverURI(value) { 1.1265 + if (!value) { 1.1266 + throw new Error("serverURI must have a value."); 1.1267 + } 1.1268 + 1.1269 + if (typeof(value) != "string") { 1.1270 + throw new Error("serverURI must be a string: " + value); 1.1271 + } 1.1272 + 1.1273 + this._prefs.set("documentServerURI", value); 1.1274 + }, 1.1275 + 1.1276 + /** 1.1277 + * The namespace on the document server to which we will be submitting data. 1.1278 + */ 1.1279 + get serverNamespace() { 1.1280 + return this._prefs.get("documentServerNamespace", "metrics"); 1.1281 + }, 1.1282 + 1.1283 + set serverNamespace(value) { 1.1284 + if (!value) { 1.1285 + throw new Error("serverNamespace must have a value."); 1.1286 + } 1.1287 + 1.1288 + if (typeof(value) != "string") { 1.1289 + throw new Error("serverNamespace must be a string: " + value); 1.1290 + } 1.1291 + 1.1292 + this._prefs.set("documentServerNamespace", value); 1.1293 + }, 1.1294 + 1.1295 + /** 1.1296 + * Whether this instance will upload data to a server. 1.1297 + */ 1.1298 + get willUploadData() { 1.1299 + return this._policy.dataSubmissionPolicyAccepted && 1.1300 + this._policy.healthReportUploadEnabled; 1.1301 + }, 1.1302 + 1.1303 + /** 1.1304 + * Whether remote data is currently stored. 1.1305 + * 1.1306 + * @return bool 1.1307 + */ 1.1308 + haveRemoteData: function () { 1.1309 + return !!this._state.lastSubmitID; 1.1310 + }, 1.1311 + 1.1312 + /** 1.1313 + * Called to initiate a data upload. 1.1314 + * 1.1315 + * The passed argument is a `DataSubmissionRequest` from policy.jsm. 1.1316 + */ 1.1317 + requestDataUpload: function (request) { 1.1318 + if (!this._initialized) { 1.1319 + return Promise.reject(new Error("Not initialized.")); 1.1320 + } 1.1321 + 1.1322 + return Task.spawn(function doUpload() { 1.1323 + yield this._providerManager.ensurePullOnlyProvidersRegistered(); 1.1324 + try { 1.1325 + yield this.collectMeasurements(); 1.1326 + try { 1.1327 + yield this._uploadData(request); 1.1328 + } catch (ex) { 1.1329 + this._onSubmitDataRequestFailure(ex); 1.1330 + } 1.1331 + } finally { 1.1332 + yield this._providerManager.ensurePullOnlyProvidersUnregistered(); 1.1333 + } 1.1334 + }.bind(this)); 1.1335 + }, 1.1336 + 1.1337 + /** 1.1338 + * Request that server data be deleted. 1.1339 + * 1.1340 + * If deletion is scheduled to occur immediately, a promise will be returned 1.1341 + * that will be fulfilled when the deletion attempt finishes. Otherwise, 1.1342 + * callers should poll haveRemoteData() to determine when remote data is 1.1343 + * deleted. 1.1344 + */ 1.1345 + requestDeleteRemoteData: function (reason) { 1.1346 + if (!this.haveRemoteData()) { 1.1347 + return; 1.1348 + } 1.1349 + 1.1350 + return this._policy.deleteRemoteData(reason); 1.1351 + }, 1.1352 + 1.1353 + _initializeState: function() { 1.1354 + return this._state.init(); 1.1355 + }, 1.1356 + 1.1357 + /** 1.1358 + * Override default handler to incur an upload describing the error. 1.1359 + */ 1.1360 + _onInitError: function (error) { 1.1361 + // Need to capture this before we call the parent else it's always 1.1362 + // set. 1.1363 + let inShutdown = this._shutdownRequested; 1.1364 + 1.1365 + let result; 1.1366 + try { 1.1367 + result = AbstractHealthReporter.prototype._onInitError.call(this, error); 1.1368 + } catch (ex) { 1.1369 + this._log.error("Error when calling _onInitError: " + 1.1370 + CommonUtils.exceptionStr(ex)); 1.1371 + } 1.1372 + 1.1373 + // This bypasses a lot of the checks in policy, such as respect for 1.1374 + // backoff. We should arguably not do this. However, reporting 1.1375 + // startup errors is important. And, they should not occur with much 1.1376 + // frequency in the wild. So, it shouldn't be too big of a deal. 1.1377 + if (!inShutdown && 1.1378 + this._policy.ensureNotifyResponse(new Date()) && 1.1379 + this._policy.healthReportUploadEnabled) { 1.1380 + // We don't care about what happens to this request. It's best 1.1381 + // effort. 1.1382 + let request = { 1.1383 + onNoDataAvailable: function () {}, 1.1384 + onSubmissionSuccess: function () {}, 1.1385 + onSubmissionFailureSoft: function () {}, 1.1386 + onSubmissionFailureHard: function () {}, 1.1387 + onUploadInProgress: function () {}, 1.1388 + }; 1.1389 + 1.1390 + this._uploadData(request); 1.1391 + } 1.1392 + 1.1393 + return result; 1.1394 + }, 1.1395 + 1.1396 + _onBagheeraResult: function (request, isDelete, date, result) { 1.1397 + this._log.debug("Received Bagheera result."); 1.1398 + 1.1399 + return Task.spawn(function onBagheeraResult() { 1.1400 + let hrProvider = this.getProvider("org.mozilla.healthreport"); 1.1401 + 1.1402 + if (!result.transportSuccess) { 1.1403 + // The built-in provider may not be initialized if this instance failed 1.1404 + // to initialize fully. 1.1405 + if (hrProvider && !isDelete) { 1.1406 + hrProvider.recordEvent("uploadTransportFailure", date); 1.1407 + } 1.1408 + 1.1409 + request.onSubmissionFailureSoft("Network transport error."); 1.1410 + throw new Task.Result(false); 1.1411 + } 1.1412 + 1.1413 + if (!result.serverSuccess) { 1.1414 + if (hrProvider && !isDelete) { 1.1415 + hrProvider.recordEvent("uploadServerFailure", date); 1.1416 + } 1.1417 + 1.1418 + request.onSubmissionFailureHard("Server failure."); 1.1419 + throw new Task.Result(false); 1.1420 + } 1.1421 + 1.1422 + if (hrProvider && !isDelete) { 1.1423 + hrProvider.recordEvent("uploadSuccess", date); 1.1424 + } 1.1425 + 1.1426 + if (isDelete) { 1.1427 + this._log.warn("Marking delete as successful."); 1.1428 + yield this._state.removeRemoteIDs([result.id]); 1.1429 + } else { 1.1430 + this._log.warn("Marking upload as successful."); 1.1431 + yield this._state.updateLastPingAndRemoveRemoteIDs(date, result.deleteIDs); 1.1432 + } 1.1433 + 1.1434 + request.onSubmissionSuccess(this._now()); 1.1435 + 1.1436 + throw new Task.Result(true); 1.1437 + }.bind(this)); 1.1438 + }, 1.1439 + 1.1440 + _onSubmitDataRequestFailure: function (error) { 1.1441 + this._log.error("Error processing request to submit data: " + 1.1442 + CommonUtils.exceptionStr(error)); 1.1443 + }, 1.1444 + 1.1445 + _formatDate: function (date) { 1.1446 + // Why, oh, why doesn't JS have a strftime() equivalent? 1.1447 + return date.toISOString().substr(0, 10); 1.1448 + }, 1.1449 + 1.1450 + _uploadData: function (request) { 1.1451 + // Under ideal circumstances, clients should never race to this 1.1452 + // function. However, server logs have observed behavior where 1.1453 + // racing to this function could be a cause. So, this lock was 1.1454 + // instituted. 1.1455 + if (this._uploadInProgress) { 1.1456 + this._log.warn("Upload requested but upload already in progress."); 1.1457 + let provider = this.getProvider("org.mozilla.healthreport"); 1.1458 + let promise = provider.recordEvent("uploadAlreadyInProgress"); 1.1459 + request.onUploadInProgress("Upload already in progress."); 1.1460 + return promise; 1.1461 + } 1.1462 + 1.1463 + let id = CommonUtils.generateUUID(); 1.1464 + 1.1465 + this._log.info("Uploading data to server: " + this.serverURI + " " + 1.1466 + this.serverNamespace + ":" + id); 1.1467 + let client = new BagheeraClient(this.serverURI); 1.1468 + let now = this._now(); 1.1469 + 1.1470 + return Task.spawn(function doUpload() { 1.1471 + try { 1.1472 + // The test for upload locking monkeypatches getJSONPayload. 1.1473 + // If the next two lines change, be sure to verify the test is 1.1474 + // accurate! 1.1475 + this._uploadInProgress = true; 1.1476 + let payload = yield this.getJSONPayload(); 1.1477 + 1.1478 + let histogram = Services.telemetry.getHistogramById(TELEMETRY_PAYLOAD_SIZE_UNCOMPRESSED); 1.1479 + histogram.add(payload.length); 1.1480 + 1.1481 + let lastID = this.lastSubmitID; 1.1482 + yield this._state.addRemoteID(id); 1.1483 + 1.1484 + let hrProvider = this.getProvider("org.mozilla.healthreport"); 1.1485 + if (hrProvider) { 1.1486 + let event = lastID ? "continuationUploadAttempt" 1.1487 + : "firstDocumentUploadAttempt"; 1.1488 + hrProvider.recordEvent(event, now); 1.1489 + } 1.1490 + 1.1491 + TelemetryStopwatch.start(TELEMETRY_UPLOAD, this); 1.1492 + let result; 1.1493 + try { 1.1494 + let options = { 1.1495 + deleteIDs: this._state.remoteIDs.filter((x) => { return x != id; }), 1.1496 + telemetryCompressed: TELEMETRY_PAYLOAD_SIZE_COMPRESSED, 1.1497 + }; 1.1498 + result = yield client.uploadJSON(this.serverNamespace, id, payload, 1.1499 + options); 1.1500 + TelemetryStopwatch.finish(TELEMETRY_UPLOAD, this); 1.1501 + } catch (ex) { 1.1502 + TelemetryStopwatch.cancel(TELEMETRY_UPLOAD, this); 1.1503 + if (hrProvider) { 1.1504 + hrProvider.recordEvent("uploadClientFailure", now); 1.1505 + } 1.1506 + throw ex; 1.1507 + } 1.1508 + 1.1509 + yield this._onBagheeraResult(request, false, now, result); 1.1510 + } finally { 1.1511 + this._uploadInProgress = false; 1.1512 + } 1.1513 + }.bind(this)); 1.1514 + }, 1.1515 + 1.1516 + /** 1.1517 + * Request deletion of remote data. 1.1518 + * 1.1519 + * @param request 1.1520 + * (DataSubmissionRequest) Tracks progress of this request. 1.1521 + */ 1.1522 + deleteRemoteData: function (request) { 1.1523 + if (!this._state.lastSubmitID) { 1.1524 + this._log.info("Received request to delete remote data but no data stored."); 1.1525 + request.onNoDataAvailable(); 1.1526 + return; 1.1527 + } 1.1528 + 1.1529 + this._log.warn("Deleting remote data."); 1.1530 + let client = new BagheeraClient(this.serverURI); 1.1531 + 1.1532 + return Task.spawn(function* doDelete() { 1.1533 + try { 1.1534 + let result = yield client.deleteDocument(this.serverNamespace, 1.1535 + this.lastSubmitID); 1.1536 + yield this._onBagheeraResult(request, true, this._now(), result); 1.1537 + } catch (ex) { 1.1538 + this._log.error("Error processing request to delete data: " + 1.1539 + CommonUtils.exceptionStr(error)); 1.1540 + } finally { 1.1541 + // If we don't have any remote documents left, nuke the ID. 1.1542 + // This is done for privacy reasons. Why preserve the ID if we 1.1543 + // don't need to? 1.1544 + if (!this.haveRemoteData()) { 1.1545 + yield this._state.resetClientID(); 1.1546 + } 1.1547 + } 1.1548 + }.bind(this)); 1.1549 + }, 1.1550 +}); 1.1551 +