services/healthreport/healthreporter.jsm

Tue, 06 Jan 2015 21:39:09 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Tue, 06 Jan 2015 21:39:09 +0100
branch
TOR_BUG_9701
changeset 8
97036ab72558
permissions
-rw-r--r--

Conditionally force memory storage according to privacy.thirdparty.isolate;
This solves Tor bug #9701, complying with disk avoidance documented in
https://www.torproject.org/projects/torbrowser/design/#disk-avoidance.

michael@0 1 /* This Source Code Form is subject to the terms of the Mozilla Public
michael@0 2 * License, v. 2.0. If a copy of the MPL was not distributed with this
michael@0 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
michael@0 4
michael@0 5 "use strict";
michael@0 6
michael@0 7 #ifndef MERGED_COMPARTMENT
michael@0 8
michael@0 9 this.EXPORTED_SYMBOLS = ["HealthReporter"];
michael@0 10
michael@0 11 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
michael@0 12
michael@0 13 const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
michael@0 14
michael@0 15 Cu.import("resource://gre/modules/Metrics.jsm");
michael@0 16 Cu.import("resource://services-common/async.js");
michael@0 17
michael@0 18 Cu.import("resource://services-common/bagheeraclient.js");
michael@0 19 #endif
michael@0 20
michael@0 21 Cu.import("resource://gre/modules/Log.jsm");
michael@0 22 Cu.import("resource://services-common/utils.js");
michael@0 23 Cu.import("resource://gre/modules/Promise.jsm");
michael@0 24 Cu.import("resource://gre/modules/osfile.jsm");
michael@0 25 Cu.import("resource://gre/modules/Preferences.jsm");
michael@0 26 Cu.import("resource://gre/modules/Services.jsm");
michael@0 27 Cu.import("resource://gre/modules/Task.jsm");
michael@0 28 Cu.import("resource://gre/modules/TelemetryStopwatch.jsm");
michael@0 29 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
michael@0 30
michael@0 31 XPCOMUtils.defineLazyModuleGetter(this, "UpdateChannel",
michael@0 32 "resource://gre/modules/UpdateChannel.jsm");
michael@0 33
michael@0 34 // Oldest year to allow in date preferences. This module was implemented in
michael@0 35 // 2012 and no dates older than that should be encountered.
michael@0 36 const OLDEST_ALLOWED_YEAR = 2012;
michael@0 37
michael@0 38 const DAYS_IN_PAYLOAD = 180;
michael@0 39
michael@0 40 const DEFAULT_DATABASE_NAME = "healthreport.sqlite";
michael@0 41
michael@0 42 const TELEMETRY_INIT = "HEALTHREPORT_INIT_MS";
michael@0 43 const TELEMETRY_INIT_FIRSTRUN = "HEALTHREPORT_INIT_FIRSTRUN_MS";
michael@0 44 const TELEMETRY_DB_OPEN = "HEALTHREPORT_DB_OPEN_MS";
michael@0 45 const TELEMETRY_DB_OPEN_FIRSTRUN = "HEALTHREPORT_DB_OPEN_FIRSTRUN_MS";
michael@0 46 const TELEMETRY_GENERATE_PAYLOAD = "HEALTHREPORT_GENERATE_JSON_PAYLOAD_MS";
michael@0 47 const TELEMETRY_JSON_PAYLOAD_SERIALIZE = "HEALTHREPORT_JSON_PAYLOAD_SERIALIZE_MS";
michael@0 48 const TELEMETRY_PAYLOAD_SIZE_UNCOMPRESSED = "HEALTHREPORT_PAYLOAD_UNCOMPRESSED_BYTES";
michael@0 49 const TELEMETRY_PAYLOAD_SIZE_COMPRESSED = "HEALTHREPORT_PAYLOAD_COMPRESSED_BYTES";
michael@0 50 const TELEMETRY_UPLOAD = "HEALTHREPORT_UPLOAD_MS";
michael@0 51 const TELEMETRY_SHUTDOWN_DELAY = "HEALTHREPORT_SHUTDOWN_DELAY_MS";
michael@0 52 const TELEMETRY_COLLECT_CONSTANT = "HEALTHREPORT_COLLECT_CONSTANT_DATA_MS";
michael@0 53 const TELEMETRY_COLLECT_DAILY = "HEALTHREPORT_COLLECT_DAILY_MS";
michael@0 54 const TELEMETRY_SHUTDOWN = "HEALTHREPORT_SHUTDOWN_MS";
michael@0 55 const TELEMETRY_COLLECT_CHECKPOINT = "HEALTHREPORT_POST_COLLECT_CHECKPOINT_MS";
michael@0 56
michael@0 57
michael@0 58 /**
michael@0 59 * Helper type to assist with management of Health Reporter state.
michael@0 60 *
michael@0 61 * Instances are not meant to be created outside of a HealthReporter instance.
michael@0 62 *
michael@0 63 * There are two types of IDs associated with clients.
michael@0 64 *
michael@0 65 * Since the beginning of FHR, there has existed a per-upload ID: a UUID is
michael@0 66 * generated at upload time and associated with the state before upload starts.
michael@0 67 * That same upload includes a request to delete all other upload IDs known by
michael@0 68 * the client.
michael@0 69 *
michael@0 70 * Per-upload IDs had the unintended side-effect of creating "orphaned"
michael@0 71 * records/upload IDs on the server. So, a stable client identifer has been
michael@0 72 * introduced. This client identifier is generated when it's missing and sent
michael@0 73 * as part of every upload.
michael@0 74 *
michael@0 75 * There is a high chance we may remove upload IDs in the future.
michael@0 76 */
michael@0 77 function HealthReporterState(reporter) {
michael@0 78 this._reporter = reporter;
michael@0 79
michael@0 80 let profD = OS.Constants.Path.profileDir;
michael@0 81
michael@0 82 if (!profD || !profD.length) {
michael@0 83 throw new Error("Could not obtain profile directory. OS.File not " +
michael@0 84 "initialized properly?");
michael@0 85 }
michael@0 86
michael@0 87 this._log = reporter._log;
michael@0 88
michael@0 89 this._stateDir = OS.Path.join(profD, "healthreport");
michael@0 90
michael@0 91 // To facilitate testing.
michael@0 92 let leaf = reporter._stateLeaf || "state.json";
michael@0 93
michael@0 94 this._filename = OS.Path.join(this._stateDir, leaf);
michael@0 95 this._log.debug("Storing state in " + this._filename);
michael@0 96 this._s = null;
michael@0 97 }
michael@0 98
michael@0 99 HealthReporterState.prototype = Object.freeze({
michael@0 100 /**
michael@0 101 * Persistent string identifier associated with this client.
michael@0 102 */
michael@0 103 get clientID() {
michael@0 104 return this._s.clientID;
michael@0 105 },
michael@0 106
michael@0 107 /**
michael@0 108 * The version associated with the client ID.
michael@0 109 */
michael@0 110 get clientIDVersion() {
michael@0 111 return this._s.clientIDVersion;
michael@0 112 },
michael@0 113
michael@0 114 get lastPingDate() {
michael@0 115 return new Date(this._s.lastPingTime);
michael@0 116 },
michael@0 117
michael@0 118 get lastSubmitID() {
michael@0 119 return this._s.remoteIDs[0];
michael@0 120 },
michael@0 121
michael@0 122 get remoteIDs() {
michael@0 123 return this._s.remoteIDs;
michael@0 124 },
michael@0 125
michael@0 126 get _lastPayloadPath() {
michael@0 127 return OS.Path.join(this._stateDir, "lastpayload.json");
michael@0 128 },
michael@0 129
michael@0 130 init: function () {
michael@0 131 return Task.spawn(function init() {
michael@0 132 try {
michael@0 133 OS.File.makeDir(this._stateDir);
michael@0 134 } catch (ex if ex instanceof OS.FileError) {
michael@0 135 if (!ex.becauseExists) {
michael@0 136 throw ex;
michael@0 137 }
michael@0 138 }
michael@0 139
michael@0 140 let resetObjectState = function () {
michael@0 141 this._s = {
michael@0 142 // The payload version. This is bumped whenever there is a
michael@0 143 // backwards-incompatible change.
michael@0 144 v: 1,
michael@0 145 // The persistent client identifier.
michael@0 146 clientID: CommonUtils.generateUUID(),
michael@0 147 // Denotes the mechanism used to generate the client identifier.
michael@0 148 // 1: Random UUID.
michael@0 149 clientIDVersion: 1,
michael@0 150 // Upload IDs that might be on the server.
michael@0 151 remoteIDs: [],
michael@0 152 // When we last performed an uploaded.
michael@0 153 lastPingTime: 0,
michael@0 154 // Tracks whether we removed an outdated payload.
michael@0 155 removedOutdatedLastpayload: false,
michael@0 156 };
michael@0 157 }.bind(this);
michael@0 158
michael@0 159 try {
michael@0 160 this._s = yield CommonUtils.readJSON(this._filename);
michael@0 161 } catch (ex if ex instanceof OS.File.Error) {
michael@0 162 if (!ex.becauseNoSuchFile) {
michael@0 163 throw ex;
michael@0 164 }
michael@0 165
michael@0 166 this._log.warn("Saved state file does not exist.");
michael@0 167 resetObjectState();
michael@0 168 } catch (ex) {
michael@0 169 this._log.error("Exception when reading state from disk: " +
michael@0 170 CommonUtils.exceptionStr(ex));
michael@0 171 resetObjectState();
michael@0 172
michael@0 173 // Don't save in case it goes away on next run.
michael@0 174 }
michael@0 175
michael@0 176 if (typeof(this._s) != "object") {
michael@0 177 this._log.warn("Read state is not an object. Resetting state.");
michael@0 178 resetObjectState();
michael@0 179 yield this.save();
michael@0 180 }
michael@0 181
michael@0 182 if (this._s.v != 1) {
michael@0 183 this._log.warn("Unknown version in state file: " + this._s.v);
michael@0 184 resetObjectState();
michael@0 185 // We explicitly don't save here in the hopes an application re-upgrade
michael@0 186 // comes along and fixes us.
michael@0 187 }
michael@0 188
michael@0 189 let regen = false;
michael@0 190 if (!this._s.clientID) {
michael@0 191 this._log.warn("No client ID stored. Generating random ID.");
michael@0 192 regen = true;
michael@0 193 }
michael@0 194
michael@0 195 if (typeof(this._s.clientID) != "string") {
michael@0 196 this._log.warn("Client ID is not a string. Regenerating.");
michael@0 197 regen = true;
michael@0 198 }
michael@0 199
michael@0 200 if (regen) {
michael@0 201 this._s.clientID = CommonUtils.generateUUID();
michael@0 202 this._s.clientIDVersion = 1;
michael@0 203 yield this.save();
michael@0 204 }
michael@0 205
michael@0 206 // Always look for preferences. This ensures that downgrades followed
michael@0 207 // by reupgrades don't result in excessive data loss.
michael@0 208 for (let promise of this._migratePrefs()) {
michael@0 209 yield promise;
michael@0 210 }
michael@0 211 }.bind(this));
michael@0 212 },
michael@0 213
michael@0 214 save: function () {
michael@0 215 this._log.info("Writing state file: " + this._filename);
michael@0 216 return CommonUtils.writeJSON(this._s, this._filename);
michael@0 217 },
michael@0 218
michael@0 219 addRemoteID: function (id) {
michael@0 220 this._log.warn("Recording new remote ID: " + id);
michael@0 221 this._s.remoteIDs.push(id);
michael@0 222 return this.save();
michael@0 223 },
michael@0 224
michael@0 225 removeRemoteID: function (id) {
michael@0 226 return this.removeRemoteIDs(id ? [id] : []);
michael@0 227 },
michael@0 228
michael@0 229 removeRemoteIDs: function (ids) {
michael@0 230 if (!ids || !ids.length) {
michael@0 231 this._log.warn("No IDs passed for removal.");
michael@0 232 return Promise.resolve();
michael@0 233 }
michael@0 234
michael@0 235 this._log.warn("Removing documents from remote ID list: " + ids);
michael@0 236 let filtered = this._s.remoteIDs.filter((x) => ids.indexOf(x) === -1);
michael@0 237
michael@0 238 if (filtered.length == this._s.remoteIDs.length) {
michael@0 239 return Promise.resolve();
michael@0 240 }
michael@0 241
michael@0 242 this._s.remoteIDs = filtered;
michael@0 243 return this.save();
michael@0 244 },
michael@0 245
michael@0 246 setLastPingDate: function (date) {
michael@0 247 this._s.lastPingTime = date.getTime();
michael@0 248
michael@0 249 return this.save();
michael@0 250 },
michael@0 251
michael@0 252 updateLastPingAndRemoveRemoteID: function (date, id) {
michael@0 253 return this.updateLastPingAndRemoveRemoteIDs(date, id ? [id] : []);
michael@0 254 },
michael@0 255
michael@0 256 updateLastPingAndRemoveRemoteIDs: function (date, ids) {
michael@0 257 if (!ids) {
michael@0 258 return this.setLastPingDate(date);
michael@0 259 }
michael@0 260
michael@0 261 this._log.info("Recording last ping time and deleted remote document.");
michael@0 262 this._s.lastPingTime = date.getTime();
michael@0 263 return this.removeRemoteIDs(ids);
michael@0 264 },
michael@0 265
michael@0 266 /**
michael@0 267 * Reset the client ID to something else.
michael@0 268 *
michael@0 269 * This fails if remote IDs are stored because changing the client ID
michael@0 270 * while there is remote data will create orphaned records.
michael@0 271 */
michael@0 272 resetClientID: function () {
michael@0 273 if (this.remoteIDs.length) {
michael@0 274 throw new Error("Cannot reset client ID while remote IDs are stored.");
michael@0 275 }
michael@0 276
michael@0 277 this._log.warn("Resetting client ID.");
michael@0 278 this._s.clientID = CommonUtils.generateUUID();
michael@0 279 this._s.clientIDVersion = 1;
michael@0 280
michael@0 281 return this.save();
michael@0 282 },
michael@0 283
michael@0 284 _migratePrefs: function () {
michael@0 285 let prefs = this._reporter._prefs;
michael@0 286
michael@0 287 let lastID = prefs.get("lastSubmitID", null);
michael@0 288 let lastPingDate = CommonUtils.getDatePref(prefs, "lastPingTime",
michael@0 289 0, this._log, OLDEST_ALLOWED_YEAR);
michael@0 290
michael@0 291 // If we have state from prefs, migrate and save it to a file then clear
michael@0 292 // out old prefs.
michael@0 293 if (lastID || (lastPingDate && lastPingDate.getTime() > 0)) {
michael@0 294 this._log.warn("Migrating saved state from preferences.");
michael@0 295
michael@0 296 if (lastID) {
michael@0 297 this._log.info("Migrating last saved ID: " + lastID);
michael@0 298 this._s.remoteIDs.push(lastID);
michael@0 299 }
michael@0 300
michael@0 301 let ourLast = this.lastPingDate;
michael@0 302
michael@0 303 if (lastPingDate && lastPingDate.getTime() > ourLast.getTime()) {
michael@0 304 this._log.info("Migrating last ping time: " + lastPingDate);
michael@0 305 this._s.lastPingTime = lastPingDate.getTime();
michael@0 306 }
michael@0 307
michael@0 308 yield this.save();
michael@0 309 prefs.reset(["lastSubmitID", "lastPingTime"]);
michael@0 310 } else {
michael@0 311 this._log.warn("No prefs data found.");
michael@0 312 }
michael@0 313 },
michael@0 314 });
michael@0 315
michael@0 316 /**
michael@0 317 * This is the abstract base class of `HealthReporter`. It exists so that
michael@0 318 * we can sanely divide work on platforms where control of Firefox Health
michael@0 319 * Report is outside of Gecko (e.g., Android).
michael@0 320 */
michael@0 321 function AbstractHealthReporter(branch, policy, sessionRecorder) {
michael@0 322 if (!branch.endsWith(".")) {
michael@0 323 throw new Error("Branch must end with a period (.): " + branch);
michael@0 324 }
michael@0 325
michael@0 326 if (!policy) {
michael@0 327 throw new Error("Must provide policy to HealthReporter constructor.");
michael@0 328 }
michael@0 329
michael@0 330 this._log = Log.repository.getLogger("Services.HealthReport.HealthReporter");
michael@0 331 this._log.info("Initializing health reporter instance against " + branch);
michael@0 332
michael@0 333 this._branch = branch;
michael@0 334 this._prefs = new Preferences(branch);
michael@0 335
michael@0 336 this._policy = policy;
michael@0 337 this.sessionRecorder = sessionRecorder;
michael@0 338
michael@0 339 this._dbName = this._prefs.get("dbName") || DEFAULT_DATABASE_NAME;
michael@0 340
michael@0 341 this._storage = null;
michael@0 342 this._storageInProgress = false;
michael@0 343 this._providerManager = null;
michael@0 344 this._providerManagerInProgress = false;
michael@0 345 this._initializeStarted = false;
michael@0 346 this._initialized = false;
michael@0 347 this._initializeHadError = false;
michael@0 348 this._initializedDeferred = Promise.defer();
michael@0 349 this._shutdownRequested = false;
michael@0 350 this._shutdownInitiated = false;
michael@0 351 this._shutdownComplete = false;
michael@0 352 this._shutdownCompleteCallback = null;
michael@0 353
michael@0 354 this._errors = [];
michael@0 355
michael@0 356 this._lastDailyDate = null;
michael@0 357
michael@0 358 // Yes, this will probably run concurrently with remaining constructor work.
michael@0 359 let hasFirstRun = this._prefs.get("service.firstRun", false);
michael@0 360 this._initHistogram = hasFirstRun ? TELEMETRY_INIT : TELEMETRY_INIT_FIRSTRUN;
michael@0 361 this._dbOpenHistogram = hasFirstRun ? TELEMETRY_DB_OPEN : TELEMETRY_DB_OPEN_FIRSTRUN;
michael@0 362 }
michael@0 363
michael@0 364 AbstractHealthReporter.prototype = Object.freeze({
michael@0 365 QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]),
michael@0 366
michael@0 367 /**
michael@0 368 * Whether the service is fully initialized and running.
michael@0 369 *
michael@0 370 * If this is false, it is not safe to call most functions.
michael@0 371 */
michael@0 372 get initialized() {
michael@0 373 return this._initialized;
michael@0 374 },
michael@0 375
michael@0 376 /**
michael@0 377 * Initialize the instance.
michael@0 378 *
michael@0 379 * This must be called once after object construction or the instance is
michael@0 380 * useless.
michael@0 381 */
michael@0 382 init: function () {
michael@0 383 if (this._initializeStarted) {
michael@0 384 throw new Error("We have already started initialization.");
michael@0 385 }
michael@0 386
michael@0 387 this._initializeStarted = true;
michael@0 388
michael@0 389 TelemetryStopwatch.start(this._initHistogram, this);
michael@0 390
michael@0 391 this._initializeState().then(this._onStateInitialized.bind(this),
michael@0 392 this._onInitError.bind(this));
michael@0 393
michael@0 394 return this.onInit();
michael@0 395 },
michael@0 396
michael@0 397 //----------------------------------------------------
michael@0 398 // SERVICE CONTROL FUNCTIONS
michael@0 399 //
michael@0 400 // You shouldn't need to call any of these externally.
michael@0 401 //----------------------------------------------------
michael@0 402
michael@0 403 _onInitError: function (error) {
michael@0 404 TelemetryStopwatch.cancel(this._initHistogram, this);
michael@0 405 TelemetryStopwatch.cancel(this._dbOpenHistogram, this);
michael@0 406 delete this._initHistogram;
michael@0 407 delete this._dbOpenHistogram;
michael@0 408
michael@0 409 this._recordError("Error during initialization", error);
michael@0 410 this._initializeHadError = true;
michael@0 411 this._initiateShutdown();
michael@0 412 this._initializedDeferred.reject(error);
michael@0 413
michael@0 414 // FUTURE consider poisoning prototype's functions so calls fail with a
michael@0 415 // useful error message.
michael@0 416 },
michael@0 417
michael@0 418 _initializeState: function () {
michael@0 419 return Promise.resolve();
michael@0 420 },
michael@0 421
michael@0 422 _onStateInitialized: function () {
michael@0 423 return Task.spawn(function onStateInitialized () {
michael@0 424 try {
michael@0 425 if (!this._state._s.removedOutdatedLastpayload) {
michael@0 426 yield this._deleteOldLastPayload();
michael@0 427 this._state._s.removedOutdatedLastpayload = true;
michael@0 428 // Normally we should save this to a file but it directly conflicts with
michael@0 429 // the "application re-upgrade" decision in HealthReporterState::init()
michael@0 430 // which specifically does not save the state to a file.
michael@0 431 }
michael@0 432 } catch (ex) {
michael@0 433 this._log.error("Error deleting last payload: " +
michael@0 434 CommonUtils.exceptionStr(ex));
michael@0 435 }
michael@0 436 // As soon as we have could storage, we need to register cleanup or
michael@0 437 // else bad things happen on shutdown.
michael@0 438 Services.obs.addObserver(this, "quit-application", false);
michael@0 439 Services.obs.addObserver(this, "profile-before-change", false);
michael@0 440
michael@0 441 this._storageInProgress = true;
michael@0 442 TelemetryStopwatch.start(this._dbOpenHistogram, this);
michael@0 443
michael@0 444 Metrics.Storage(this._dbName).then(this._onStorageCreated.bind(this),
michael@0 445 this._onInitError.bind(this));
michael@0 446 }.bind(this));
michael@0 447 },
michael@0 448
michael@0 449
michael@0 450 /**
michael@0 451 * Removes the outdated lastpaylaod.json and lastpayload.json.tmp files
michael@0 452 * @see Bug #867902
michael@0 453 * @return a promise for when all the files have been deleted
michael@0 454 */
michael@0 455 _deleteOldLastPayload: function () {
michael@0 456 let paths = [this._state._lastPayloadPath, this._state._lastPayloadPath + ".tmp"];
michael@0 457 return Task.spawn(function removeAllFiles () {
michael@0 458 for (let path of paths) {
michael@0 459 try {
michael@0 460 OS.File.remove(path);
michael@0 461 } catch (ex) {
michael@0 462 if (!ex.becauseNoSuchFile) {
michael@0 463 this._log.error("Exception when removing outdated payload files: " +
michael@0 464 CommonUtils.exceptionStr(ex));
michael@0 465 }
michael@0 466 }
michael@0 467 }
michael@0 468 }.bind(this));
michael@0 469 },
michael@0 470
michael@0 471 // Called when storage has been opened.
michael@0 472 _onStorageCreated: function (storage) {
michael@0 473 TelemetryStopwatch.finish(this._dbOpenHistogram, this);
michael@0 474 delete this._dbOpenHistogram;
michael@0 475 this._log.info("Storage initialized.");
michael@0 476 this._storage = storage;
michael@0 477 this._storageInProgress = false;
michael@0 478
michael@0 479 if (this._shutdownRequested) {
michael@0 480 this._initiateShutdown();
michael@0 481 return;
michael@0 482 }
michael@0 483
michael@0 484 Task.spawn(this._initializeProviderManager.bind(this))
michael@0 485 .then(this._onProviderManagerInitialized.bind(this),
michael@0 486 this._onInitError.bind(this));
michael@0 487 },
michael@0 488
michael@0 489 _initializeProviderManager: function () {
michael@0 490 if (this._collector) {
michael@0 491 throw new Error("Provider manager has already been initialized.");
michael@0 492 }
michael@0 493
michael@0 494 this._log.info("Initializing provider manager.");
michael@0 495 this._providerManager = new Metrics.ProviderManager(this._storage);
michael@0 496 this._providerManager.onProviderError = this._recordError.bind(this);
michael@0 497 this._providerManager.onProviderInit = this._initProvider.bind(this);
michael@0 498 this._providerManagerInProgress = true;
michael@0 499
michael@0 500 let catString = this._prefs.get("service.providerCategories") || "";
michael@0 501 if (catString.length) {
michael@0 502 for (let category of catString.split(",")) {
michael@0 503 yield this._providerManager.registerProvidersFromCategoryManager(category);
michael@0 504 }
michael@0 505 }
michael@0 506 },
michael@0 507
michael@0 508 _onProviderManagerInitialized: function () {
michael@0 509 TelemetryStopwatch.finish(this._initHistogram, this);
michael@0 510 delete this._initHistogram;
michael@0 511 this._log.debug("Provider manager initialized.");
michael@0 512 this._providerManagerInProgress = false;
michael@0 513
michael@0 514 if (this._shutdownRequested) {
michael@0 515 this._initiateShutdown();
michael@0 516 return;
michael@0 517 }
michael@0 518
michael@0 519 this._log.info("HealthReporter started.");
michael@0 520 this._initialized = true;
michael@0 521 Services.obs.addObserver(this, "idle-daily", false);
michael@0 522
michael@0 523 // If upload is not enabled, ensure daily collection works. If upload
michael@0 524 // is enabled, this will be performed as part of upload.
michael@0 525 //
michael@0 526 // This is important because it ensures about:healthreport contains
michael@0 527 // longitudinal data even if upload is disabled. Having about:healthreport
michael@0 528 // provide useful info even if upload is disabled was a core launch
michael@0 529 // requirement.
michael@0 530 //
michael@0 531 // We do not catch changes to the backing pref. So, if the session lasts
michael@0 532 // many days, we may fail to collect. However, most sessions are short and
michael@0 533 // this code will likely be refactored as part of splitting up policy to
michael@0 534 // serve Android. So, meh.
michael@0 535 if (!this._policy.healthReportUploadEnabled) {
michael@0 536 this._log.info("Upload not enabled. Scheduling daily collection.");
michael@0 537 // Since the timer manager is a singleton and there could be multiple
michael@0 538 // HealthReporter instances, we need to encode a unique identifier in
michael@0 539 // the timer ID.
michael@0 540 try {
michael@0 541 let timerName = this._branch.replace(".", "-", "g") + "lastDailyCollection";
michael@0 542 let tm = Cc["@mozilla.org/updates/timer-manager;1"]
michael@0 543 .getService(Ci.nsIUpdateTimerManager);
michael@0 544 tm.registerTimer(timerName, this.collectMeasurements.bind(this),
michael@0 545 24 * 60 * 60);
michael@0 546 } catch (ex) {
michael@0 547 this._log.error("Error registering collection timer: " +
michael@0 548 CommonUtils.exceptionStr(ex));
michael@0 549 }
michael@0 550 }
michael@0 551
michael@0 552 // Clean up caches and reduce memory usage.
michael@0 553 this._storage.compact();
michael@0 554 this._initializedDeferred.resolve(this);
michael@0 555 },
michael@0 556
michael@0 557 // nsIObserver to handle shutdown.
michael@0 558 observe: function (subject, topic, data) {
michael@0 559 switch (topic) {
michael@0 560 case "quit-application":
michael@0 561 Services.obs.removeObserver(this, "quit-application");
michael@0 562 this._initiateShutdown();
michael@0 563 break;
michael@0 564
michael@0 565 case "profile-before-change":
michael@0 566 Services.obs.removeObserver(this, "profile-before-change");
michael@0 567 this._waitForShutdown();
michael@0 568 break;
michael@0 569
michael@0 570 case "idle-daily":
michael@0 571 this._performDailyMaintenance();
michael@0 572 break;
michael@0 573 }
michael@0 574 },
michael@0 575
michael@0 576 _initiateShutdown: function () {
michael@0 577 // Ensure we only begin the main shutdown sequence once.
michael@0 578 if (this._shutdownInitiated) {
michael@0 579 this._log.warn("Shutdown has already been initiated. No-op.");
michael@0 580 return;
michael@0 581 }
michael@0 582
michael@0 583 this._log.info("Request to shut down.");
michael@0 584
michael@0 585 this._initialized = false;
michael@0 586 this._shutdownRequested = true;
michael@0 587
michael@0 588 if (this._initializeHadError) {
michael@0 589 this._log.warn("Initialization had error. Shutting down immediately.");
michael@0 590 } else {
michael@0 591 if (this._providerManagerInProcess) {
michael@0 592 this._log.warn("Provider manager is in progress of initializing. " +
michael@0 593 "Waiting to finish.");
michael@0 594 return;
michael@0 595 }
michael@0 596
michael@0 597 // If storage is in the process of initializing, we need to wait for it
michael@0 598 // to finish before continuing. The initialization process will call us
michael@0 599 // again once storage has initialized.
michael@0 600 if (this._storageInProgress) {
michael@0 601 this._log.warn("Storage is in progress of initializing. Waiting to finish.");
michael@0 602 return;
michael@0 603 }
michael@0 604 }
michael@0 605
michael@0 606 this._log.warn("Initiating main shutdown procedure.");
michael@0 607
michael@0 608 // Everything from here must only be performed once or else race conditions
michael@0 609 // could occur.
michael@0 610
michael@0 611 TelemetryStopwatch.start(TELEMETRY_SHUTDOWN, this);
michael@0 612 this._shutdownInitiated = true;
michael@0 613
michael@0 614 // We may not have registered the observer yet. If not, this will
michael@0 615 // throw.
michael@0 616 try {
michael@0 617 Services.obs.removeObserver(this, "idle-daily");
michael@0 618 } catch (ex) { }
michael@0 619
michael@0 620 if (this._providerManager) {
michael@0 621 let onShutdown = this._onProviderManagerShutdown.bind(this);
michael@0 622 Task.spawn(this._shutdownProviderManager.bind(this))
michael@0 623 .then(onShutdown, onShutdown);
michael@0 624 return;
michael@0 625 }
michael@0 626
michael@0 627 this._log.warn("Don't have provider manager. Proceeding to storage shutdown.");
michael@0 628 this._shutdownStorage();
michael@0 629 },
michael@0 630
michael@0 631 _shutdownProviderManager: function () {
michael@0 632 this._log.info("Shutting down provider manager.");
michael@0 633 for (let provider of this._providerManager.providers) {
michael@0 634 try {
michael@0 635 yield provider.shutdown();
michael@0 636 } catch (ex) {
michael@0 637 this._log.warn("Error when shutting down provider: " +
michael@0 638 CommonUtils.exceptionStr(ex));
michael@0 639 }
michael@0 640 }
michael@0 641 },
michael@0 642
michael@0 643 _onProviderManagerShutdown: function () {
michael@0 644 this._log.info("Provider manager shut down.");
michael@0 645 this._providerManager = null;
michael@0 646 this._shutdownStorage();
michael@0 647 },
michael@0 648
michael@0 649 _shutdownStorage: function () {
michael@0 650 if (!this._storage) {
michael@0 651 this._onShutdownComplete();
michael@0 652 }
michael@0 653
michael@0 654 this._log.info("Shutting down storage.");
michael@0 655 let onClose = this._onStorageClose.bind(this);
michael@0 656 this._storage.close().then(onClose, onClose);
michael@0 657 },
michael@0 658
michael@0 659 _onStorageClose: function (error) {
michael@0 660 this._log.info("Storage has been closed.");
michael@0 661
michael@0 662 if (error) {
michael@0 663 this._log.warn("Error when closing storage: " +
michael@0 664 CommonUtils.exceptionStr(error));
michael@0 665 }
michael@0 666
michael@0 667 this._storage = null;
michael@0 668 this._onShutdownComplete();
michael@0 669 },
michael@0 670
michael@0 671 _onShutdownComplete: function () {
michael@0 672 this._log.warn("Shutdown complete.");
michael@0 673 this._shutdownComplete = true;
michael@0 674 TelemetryStopwatch.finish(TELEMETRY_SHUTDOWN, this);
michael@0 675
michael@0 676 if (this._shutdownCompleteCallback) {
michael@0 677 this._shutdownCompleteCallback();
michael@0 678 }
michael@0 679 },
michael@0 680
michael@0 681 _waitForShutdown: function () {
michael@0 682 if (this._shutdownComplete) {
michael@0 683 return;
michael@0 684 }
michael@0 685
michael@0 686 TelemetryStopwatch.start(TELEMETRY_SHUTDOWN_DELAY, this);
michael@0 687 try {
michael@0 688 this._shutdownCompleteCallback = Async.makeSpinningCallback();
michael@0 689 this._shutdownCompleteCallback.wait();
michael@0 690 this._shutdownCompleteCallback = null;
michael@0 691 } finally {
michael@0 692 TelemetryStopwatch.finish(TELEMETRY_SHUTDOWN_DELAY, this);
michael@0 693 }
michael@0 694 },
michael@0 695
michael@0 696 /**
michael@0 697 * Convenience method to shut down the instance.
michael@0 698 *
michael@0 699 * This should *not* be called outside of tests.
michael@0 700 */
michael@0 701 _shutdown: function () {
michael@0 702 this._initiateShutdown();
michael@0 703 this._waitForShutdown();
michael@0 704 },
michael@0 705
michael@0 706 /**
michael@0 707 * Return a promise that is resolved once the service has been initialized.
michael@0 708 */
michael@0 709 onInit: function () {
michael@0 710 if (this._initializeHadError) {
michael@0 711 throw new Error("Service failed to initialize.");
michael@0 712 }
michael@0 713
michael@0 714 if (this._initialized) {
michael@0 715 return CommonUtils.laterTickResolvingPromise(this);
michael@0 716 }
michael@0 717
michael@0 718 return this._initializedDeferred.promise;
michael@0 719 },
michael@0 720
michael@0 721 _performDailyMaintenance: function () {
michael@0 722 this._log.info("Request to perform daily maintenance.");
michael@0 723
michael@0 724 if (!this._initialized) {
michael@0 725 return;
michael@0 726 }
michael@0 727
michael@0 728 let now = new Date();
michael@0 729 let cutoff = new Date(now.getTime() - MILLISECONDS_PER_DAY * (DAYS_IN_PAYLOAD - 1));
michael@0 730
michael@0 731 // The operation is enqueued and put in a transaction by the storage module.
michael@0 732 this._storage.pruneDataBefore(cutoff);
michael@0 733 },
michael@0 734
michael@0 735 //--------------------
michael@0 736 // Provider Management
michael@0 737 //--------------------
michael@0 738
michael@0 739 /**
michael@0 740 * Obtain a provider from its name.
michael@0 741 *
michael@0 742 * This will only return providers that are currently initialized. If
michael@0 743 * a provider is lazy initialized (like pull-only providers) this
michael@0 744 * will likely not return anything.
michael@0 745 */
michael@0 746 getProvider: function (name) {
michael@0 747 if (!this._providerManager) {
michael@0 748 return null;
michael@0 749 }
michael@0 750
michael@0 751 return this._providerManager.getProvider(name);
michael@0 752 },
michael@0 753
michael@0 754 _initProvider: function (provider) {
michael@0 755 provider.healthReporter = this;
michael@0 756 },
michael@0 757
michael@0 758 /**
michael@0 759 * Record an exception for reporting in the payload.
michael@0 760 *
michael@0 761 * A side effect is the exception is logged.
michael@0 762 *
michael@0 763 * Note that callers need to be extra sensitive about ensuring personal
michael@0 764 * or otherwise private details do not leak into this. All of the user data
michael@0 765 * on the stack in FHR code should be limited to data we were collecting with
michael@0 766 * the intent to submit. So, it is covered under the user's consent to use
michael@0 767 * the feature.
michael@0 768 *
michael@0 769 * @param message
michael@0 770 * (string) Human readable message describing error.
michael@0 771 * @param ex
michael@0 772 * (Error) The error that should be captured.
michael@0 773 */
michael@0 774 _recordError: function (message, ex) {
michael@0 775 let recordMessage = message;
michael@0 776 let logMessage = message;
michael@0 777
michael@0 778 if (ex) {
michael@0 779 recordMessage += ": " + CommonUtils.exceptionStr(ex);
michael@0 780 logMessage += ": " + CommonUtils.exceptionStr(ex);
michael@0 781 }
michael@0 782
michael@0 783 // Scrub out potentially identifying information from strings that could
michael@0 784 // make the payload.
michael@0 785 let appData = Services.dirsvc.get("UAppData", Ci.nsIFile);
michael@0 786 let profile = Services.dirsvc.get("ProfD", Ci.nsIFile);
michael@0 787
michael@0 788 let appDataURI = Services.io.newFileURI(appData);
michael@0 789 let profileURI = Services.io.newFileURI(profile);
michael@0 790
michael@0 791 // Order of operation is important here. We do the URI before the path version
michael@0 792 // because the path may be a subset of the URI. We also have to check for the case
michael@0 793 // where UAppData is underneath the profile directory (or vice-versa) so we
michael@0 794 // don't substitute incomplete strings.
michael@0 795
michael@0 796 function replace(uri, path, thing) {
michael@0 797 // Try is because .spec can throw on invalid URI.
michael@0 798 try {
michael@0 799 recordMessage = recordMessage.replace(uri.spec, '<' + thing + 'URI>', 'g');
michael@0 800 } catch (ex) { }
michael@0 801
michael@0 802 recordMessage = recordMessage.replace(path, '<' + thing + 'Path>', 'g');
michael@0 803 }
michael@0 804
michael@0 805 if (appData.path.contains(profile.path)) {
michael@0 806 replace(appDataURI, appData.path, 'AppData');
michael@0 807 replace(profileURI, profile.path, 'Profile');
michael@0 808 } else {
michael@0 809 replace(profileURI, profile.path, 'Profile');
michael@0 810 replace(appDataURI, appData.path, 'AppData');
michael@0 811 }
michael@0 812
michael@0 813 this._log.warn(logMessage);
michael@0 814 this._errors.push(recordMessage);
michael@0 815 },
michael@0 816
michael@0 817 /**
michael@0 818 * Collect all measurements for all registered providers.
michael@0 819 */
michael@0 820 collectMeasurements: function () {
michael@0 821 if (!this._initialized) {
michael@0 822 return Promise.reject(new Error("Not initialized."));
michael@0 823 }
michael@0 824
michael@0 825 return Task.spawn(function doCollection() {
michael@0 826 yield this._providerManager.ensurePullOnlyProvidersRegistered();
michael@0 827
michael@0 828 try {
michael@0 829 TelemetryStopwatch.start(TELEMETRY_COLLECT_CONSTANT, this);
michael@0 830 yield this._providerManager.collectConstantData();
michael@0 831 TelemetryStopwatch.finish(TELEMETRY_COLLECT_CONSTANT, this);
michael@0 832 } catch (ex) {
michael@0 833 TelemetryStopwatch.cancel(TELEMETRY_COLLECT_CONSTANT, this);
michael@0 834 this._log.warn("Error collecting constant data: " +
michael@0 835 CommonUtils.exceptionStr(ex));
michael@0 836 }
michael@0 837
michael@0 838 // Daily data is collected if it hasn't yet been collected this
michael@0 839 // application session or if it has been more than a day since the
michael@0 840 // last collection. This means that providers could see many calls to
michael@0 841 // collectDailyData per calendar day. However, this collection API
michael@0 842 // makes no guarantees about limits. The alternative would involve
michael@0 843 // recording state. The simpler implementation prevails for now.
michael@0 844 if (!this._lastDailyDate ||
michael@0 845 Date.now() - this._lastDailyDate > MILLISECONDS_PER_DAY) {
michael@0 846
michael@0 847 try {
michael@0 848 TelemetryStopwatch.start(TELEMETRY_COLLECT_DAILY, this);
michael@0 849 this._lastDailyDate = new Date();
michael@0 850 yield this._providerManager.collectDailyData();
michael@0 851 TelemetryStopwatch.finish(TELEMETRY_COLLECT_DAILY, this);
michael@0 852 } catch (ex) {
michael@0 853 TelemetryStopwatch.cancel(TELEMETRY_COLLECT_DAILY, this);
michael@0 854 this._log.warn("Error collecting daily data from providers: " +
michael@0 855 CommonUtils.exceptionStr(ex));
michael@0 856 }
michael@0 857 }
michael@0 858
michael@0 859 yield this._providerManager.ensurePullOnlyProvidersUnregistered();
michael@0 860
michael@0 861 // Flush gathered data to disk. This will incur an fsync. But, if
michael@0 862 // there is ever a time we want to persist data to disk, it's
michael@0 863 // after a massive collection.
michael@0 864 try {
michael@0 865 TelemetryStopwatch.start(TELEMETRY_COLLECT_CHECKPOINT, this);
michael@0 866 yield this._storage.checkpoint();
michael@0 867 TelemetryStopwatch.finish(TELEMETRY_COLLECT_CHECKPOINT, this);
michael@0 868 } catch (ex) {
michael@0 869 TelemetryStopwatch.cancel(TELEMETRY_COLLECT_CHECKPOINT, this);
michael@0 870 throw ex;
michael@0 871 }
michael@0 872
michael@0 873 throw new Task.Result();
michael@0 874 }.bind(this));
michael@0 875 },
michael@0 876
michael@0 877 /**
michael@0 878 * Helper function to perform data collection and obtain the JSON payload.
michael@0 879 *
michael@0 880 * If you are looking for an up-to-date snapshot of FHR data that pulls in
michael@0 881 * new data since the last upload, this is how you should obtain it.
michael@0 882 *
michael@0 883 * @param asObject
michael@0 884 * (bool) Whether to resolve an object or JSON-encoded string of that
michael@0 885 * object (the default).
michael@0 886 *
michael@0 887 * @return Promise<Object | string>
michael@0 888 */
michael@0 889 collectAndObtainJSONPayload: function (asObject=false) {
michael@0 890 if (!this._initialized) {
michael@0 891 return Promise.reject(new Error("Not initialized."));
michael@0 892 }
michael@0 893
michael@0 894 return Task.spawn(function collectAndObtain() {
michael@0 895 yield this._storage.setAutoCheckpoint(0);
michael@0 896 yield this._providerManager.ensurePullOnlyProvidersRegistered();
michael@0 897
michael@0 898 let payload;
michael@0 899 let error;
michael@0 900
michael@0 901 try {
michael@0 902 yield this.collectMeasurements();
michael@0 903 payload = yield this.getJSONPayload(asObject);
michael@0 904 } catch (ex) {
michael@0 905 error = ex;
michael@0 906 this._collectException("Error collecting and/or retrieving JSON payload",
michael@0 907 ex);
michael@0 908 } finally {
michael@0 909 yield this._providerManager.ensurePullOnlyProvidersUnregistered();
michael@0 910 yield this._storage.setAutoCheckpoint(1);
michael@0 911
michael@0 912 if (error) {
michael@0 913 throw error;
michael@0 914 }
michael@0 915 }
michael@0 916
michael@0 917 // We hold off throwing to ensure that behavior between finally
michael@0 918 // and generators and throwing is sane.
michael@0 919 throw new Task.Result(payload);
michael@0 920 }.bind(this));
michael@0 921 },
michael@0 922
michael@0 923
michael@0 924 /**
michael@0 925 * Obtain the JSON payload for currently-collected data.
michael@0 926 *
michael@0 927 * The payload only contains data that has been recorded to FHR. Some
michael@0 928 * providers may have newer data available. If you want to ensure you
michael@0 929 * have all available data, call `collectAndObtainJSONPayload`
michael@0 930 * instead.
michael@0 931 *
michael@0 932 * @param asObject
michael@0 933 * (bool) Whether to return an object or JSON encoding of that
michael@0 934 * object (the default).
michael@0 935 *
michael@0 936 * @return Promise<string|object>
michael@0 937 */
michael@0 938 getJSONPayload: function (asObject=false) {
michael@0 939 TelemetryStopwatch.start(TELEMETRY_GENERATE_PAYLOAD, this);
michael@0 940 let deferred = Promise.defer();
michael@0 941
michael@0 942 Task.spawn(this._getJSONPayload.bind(this, this._now(), asObject)).then(
michael@0 943 function onResult(result) {
michael@0 944 TelemetryStopwatch.finish(TELEMETRY_GENERATE_PAYLOAD, this);
michael@0 945 deferred.resolve(result);
michael@0 946 }.bind(this),
michael@0 947 function onError(error) {
michael@0 948 TelemetryStopwatch.cancel(TELEMETRY_GENERATE_PAYLOAD, this);
michael@0 949 deferred.reject(error);
michael@0 950 }.bind(this)
michael@0 951 );
michael@0 952
michael@0 953 return deferred.promise;
michael@0 954 },
michael@0 955
michael@0 956 _getJSONPayload: function (now, asObject=false) {
michael@0 957 let pingDateString = this._formatDate(now);
michael@0 958 this._log.info("Producing JSON payload for " + pingDateString);
michael@0 959
michael@0 960 // May not be present if we are generating as a result of init error.
michael@0 961 if (this._providerManager) {
michael@0 962 yield this._providerManager.ensurePullOnlyProvidersRegistered();
michael@0 963 }
michael@0 964
michael@0 965 let o = {
michael@0 966 version: 2,
michael@0 967 clientID: this._state.clientID,
michael@0 968 clientIDVersion: this._state.clientIDVersion,
michael@0 969 thisPingDate: pingDateString,
michael@0 970 geckoAppInfo: this.obtainAppInfo(this._log),
michael@0 971 data: {last: {}, days: {}},
michael@0 972 };
michael@0 973
michael@0 974 let outputDataDays = o.data.days;
michael@0 975
michael@0 976 // Guard here in case we don't track this (e.g., on Android).
michael@0 977 let lastPingDate = this.lastPingDate;
michael@0 978 if (lastPingDate && lastPingDate.getTime() > 0) {
michael@0 979 o.lastPingDate = this._formatDate(lastPingDate);
michael@0 980 }
michael@0 981
michael@0 982 // We can still generate a payload even if we're not initialized.
michael@0 983 // This is to facilitate error upload on init failure.
michael@0 984 if (this._initialized) {
michael@0 985 for (let provider of this._providerManager.providers) {
michael@0 986 let providerName = provider.name;
michael@0 987
michael@0 988 let providerEntry = {
michael@0 989 measurements: {},
michael@0 990 };
michael@0 991
michael@0 992 // Measurement name to recorded version.
michael@0 993 let lastVersions = {};
michael@0 994 // Day string to mapping of measurement name to recorded version.
michael@0 995 let dayVersions = {};
michael@0 996
michael@0 997 for (let [measurementKey, measurement] of provider.measurements) {
michael@0 998 let name = providerName + "." + measurement.name;
michael@0 999 let version = measurement.version;
michael@0 1000
michael@0 1001 let serializer;
michael@0 1002 try {
michael@0 1003 // The measurement is responsible for returning a serializer which
michael@0 1004 // is aware of the measurement version.
michael@0 1005 serializer = measurement.serializer(measurement.SERIALIZE_JSON);
michael@0 1006 } catch (ex) {
michael@0 1007 this._recordError("Error obtaining serializer for measurement: " +
michael@0 1008 name, ex);
michael@0 1009 continue;
michael@0 1010 }
michael@0 1011
michael@0 1012 let data;
michael@0 1013 try {
michael@0 1014 data = yield measurement.getValues();
michael@0 1015 } catch (ex) {
michael@0 1016 this._recordError("Error obtaining data for measurement: " + name,
michael@0 1017 ex);
michael@0 1018 continue;
michael@0 1019 }
michael@0 1020
michael@0 1021 if (data.singular.size) {
michael@0 1022 try {
michael@0 1023 let serialized = serializer.singular(data.singular);
michael@0 1024 if (serialized) {
michael@0 1025 // Only replace the existing data if there is no data or if our
michael@0 1026 // version is newer than the old one.
michael@0 1027 if (!(name in o.data.last) || version > lastVersions[name]) {
michael@0 1028 o.data.last[name] = serialized;
michael@0 1029 lastVersions[name] = version;
michael@0 1030 }
michael@0 1031 }
michael@0 1032 } catch (ex) {
michael@0 1033 this._recordError("Error serializing singular data: " + name,
michael@0 1034 ex);
michael@0 1035 continue;
michael@0 1036 }
michael@0 1037 }
michael@0 1038
michael@0 1039 let dataDays = data.days;
michael@0 1040 for (let i = 0; i < DAYS_IN_PAYLOAD; i++) {
michael@0 1041 let date = new Date(now.getTime() - i * MILLISECONDS_PER_DAY);
michael@0 1042 if (!dataDays.hasDay(date)) {
michael@0 1043 continue;
michael@0 1044 }
michael@0 1045 let dateFormatted = this._formatDate(date);
michael@0 1046
michael@0 1047 try {
michael@0 1048 let serialized = serializer.daily(dataDays.getDay(date));
michael@0 1049 if (!serialized) {
michael@0 1050 continue;
michael@0 1051 }
michael@0 1052
michael@0 1053 if (!(dateFormatted in outputDataDays)) {
michael@0 1054 outputDataDays[dateFormatted] = {};
michael@0 1055 }
michael@0 1056
michael@0 1057 // This needs to be separate because dayVersions is provider
michael@0 1058 // specific and gets blown away in a loop while outputDataDays
michael@0 1059 // is persistent.
michael@0 1060 if (!(dateFormatted in dayVersions)) {
michael@0 1061 dayVersions[dateFormatted] = {};
michael@0 1062 }
michael@0 1063
michael@0 1064 if (!(name in outputDataDays[dateFormatted]) ||
michael@0 1065 version > dayVersions[dateFormatted][name]) {
michael@0 1066 outputDataDays[dateFormatted][name] = serialized;
michael@0 1067 dayVersions[dateFormatted][name] = version;
michael@0 1068 }
michael@0 1069 } catch (ex) {
michael@0 1070 this._recordError("Error populating data for day: " + name, ex);
michael@0 1071 continue;
michael@0 1072 }
michael@0 1073 }
michael@0 1074 }
michael@0 1075 }
michael@0 1076 } else {
michael@0 1077 o.notInitialized = 1;
michael@0 1078 this._log.warn("Not initialized. Sending report with only error info.");
michael@0 1079 }
michael@0 1080
michael@0 1081 if (this._errors.length) {
michael@0 1082 o.errors = this._errors.slice(0, 20);
michael@0 1083 }
michael@0 1084
michael@0 1085 if (this._initialized) {
michael@0 1086 this._storage.compact();
michael@0 1087 }
michael@0 1088
michael@0 1089 if (!asObject) {
michael@0 1090 TelemetryStopwatch.start(TELEMETRY_JSON_PAYLOAD_SERIALIZE, this);
michael@0 1091 o = JSON.stringify(o);
michael@0 1092 TelemetryStopwatch.finish(TELEMETRY_JSON_PAYLOAD_SERIALIZE, this);
michael@0 1093 }
michael@0 1094
michael@0 1095 if (this._providerManager) {
michael@0 1096 yield this._providerManager.ensurePullOnlyProvidersUnregistered();
michael@0 1097 }
michael@0 1098
michael@0 1099 throw new Task.Result(o);
michael@0 1100 },
michael@0 1101
michael@0 1102 _now: function _now() {
michael@0 1103 return new Date();
michael@0 1104 },
michael@0 1105
michael@0 1106 // These are stolen from AppInfoProvider.
michael@0 1107 appInfoVersion: 1,
michael@0 1108 appInfoFields: {
michael@0 1109 // From nsIXULAppInfo.
michael@0 1110 vendor: "vendor",
michael@0 1111 name: "name",
michael@0 1112 id: "ID",
michael@0 1113 version: "version",
michael@0 1114 appBuildID: "appBuildID",
michael@0 1115 platformVersion: "platformVersion",
michael@0 1116 platformBuildID: "platformBuildID",
michael@0 1117
michael@0 1118 // From nsIXULRuntime.
michael@0 1119 os: "OS",
michael@0 1120 xpcomabi: "XPCOMABI",
michael@0 1121 },
michael@0 1122
michael@0 1123 /**
michael@0 1124 * Statically return a bundle of app info data, a subset of that produced by
michael@0 1125 * AppInfoProvider._populateConstants. This allows us to more usefully handle
michael@0 1126 * payloads that, due to error, contain no data.
michael@0 1127 *
michael@0 1128 * Returns a very sparse object if Services.appinfo is unavailable.
michael@0 1129 */
michael@0 1130 obtainAppInfo: function () {
michael@0 1131 let out = {"_v": this.appInfoVersion};
michael@0 1132 try {
michael@0 1133 let ai = Services.appinfo;
michael@0 1134 for (let [k, v] in Iterator(this.appInfoFields)) {
michael@0 1135 out[k] = ai[v];
michael@0 1136 }
michael@0 1137 } catch (ex) {
michael@0 1138 this._log.warn("Could not obtain Services.appinfo: " +
michael@0 1139 CommonUtils.exceptionStr(ex));
michael@0 1140 }
michael@0 1141
michael@0 1142 try {
michael@0 1143 out["updateChannel"] = UpdateChannel.get();
michael@0 1144 } catch (ex) {
michael@0 1145 this._log.warn("Could not obtain update channel: " +
michael@0 1146 CommonUtils.exceptionStr(ex));
michael@0 1147 }
michael@0 1148
michael@0 1149 return out;
michael@0 1150 },
michael@0 1151 });
michael@0 1152
michael@0 1153 /**
michael@0 1154 * HealthReporter and its abstract superclass coordinate collection and
michael@0 1155 * submission of health report metrics.
michael@0 1156 *
michael@0 1157 * This is the main type for Firefox Health Report on desktop. It glues all the
michael@0 1158 * lower-level components (such as collection and submission) together.
michael@0 1159 *
michael@0 1160 * An instance of this type is created as an XPCOM service. See
michael@0 1161 * DataReportingService.js and
michael@0 1162 * DataReporting.manifest/HealthReportComponents.manifest.
michael@0 1163 *
michael@0 1164 * It is theoretically possible to have multiple instances of this running
michael@0 1165 * in the application. For example, this type may one day handle submission
michael@0 1166 * of telemetry data as well. However, there is some moderate coupling between
michael@0 1167 * this type and *the* Firefox Health Report (e.g., the policy). This could
michael@0 1168 * be abstracted if needed.
michael@0 1169 *
michael@0 1170 * Note that `AbstractHealthReporter` exists to allow for Firefox Health Report
michael@0 1171 * to be more easily implemented on platforms where a separate controlling
michael@0 1172 * layer is responsible for payload upload and deletion.
michael@0 1173 *
michael@0 1174 * IMPLEMENTATION NOTES
michael@0 1175 * ====================
michael@0 1176 *
michael@0 1177 * These notes apply to the combination of `HealthReporter` and
michael@0 1178 * `AbstractHealthReporter`.
michael@0 1179 *
michael@0 1180 * Initialization and shutdown are somewhat complicated and worth explaining
michael@0 1181 * in extra detail.
michael@0 1182 *
michael@0 1183 * The complexity is driven by the requirements of SQLite connection management.
michael@0 1184 * Once you have a SQLite connection, it isn't enough to just let the
michael@0 1185 * application shut down. If there is an open connection or if there are
michael@0 1186 * outstanding SQL statements come XPCOM shutdown time, Storage will assert.
michael@0 1187 * On debug builds you will crash. On release builds you will get a shutdown
michael@0 1188 * hang. This must be avoided!
michael@0 1189 *
michael@0 1190 * During initialization, the second we create a SQLite connection (via
michael@0 1191 * Metrics.Storage) we register observers for application shutdown. The
michael@0 1192 * "quit-application" notification initiates our shutdown procedure. The
michael@0 1193 * subsequent "profile-do-change" notification ensures it has completed.
michael@0 1194 *
michael@0 1195 * The handler for "profile-do-change" may result in event loop spinning. This
michael@0 1196 * is because of race conditions between our shutdown code and application
michael@0 1197 * shutdown.
michael@0 1198 *
michael@0 1199 * All of our shutdown routines are async. There is the potential that these
michael@0 1200 * async functions will not complete before XPCOM shutdown. If they don't
michael@0 1201 * finish in time, we could get assertions in Storage. Our solution is to
michael@0 1202 * initiate storage early in the shutdown cycle ("quit-application").
michael@0 1203 * Hopefully all the async operations have completed by the time we reach
michael@0 1204 * "profile-do-change." If so, great. If not, we spin the event loop until
michael@0 1205 * they have completed, avoiding potential race conditions.
michael@0 1206 *
michael@0 1207 * @param branch
michael@0 1208 * (string) The preferences branch to use for state storage. The value
michael@0 1209 * must end with a period (.).
michael@0 1210 *
michael@0 1211 * @param policy
michael@0 1212 * (HealthReportPolicy) Policy driving execution of HealthReporter.
michael@0 1213 */
michael@0 1214 this.HealthReporter = function (branch, policy, sessionRecorder, stateLeaf=null) {
michael@0 1215 this._stateLeaf = stateLeaf;
michael@0 1216 this._uploadInProgress = false;
michael@0 1217
michael@0 1218 AbstractHealthReporter.call(this, branch, policy, sessionRecorder);
michael@0 1219
michael@0 1220 if (!this.serverURI) {
michael@0 1221 throw new Error("No server URI defined. Did you forget to define the pref?");
michael@0 1222 }
michael@0 1223
michael@0 1224 if (!this.serverNamespace) {
michael@0 1225 throw new Error("No server namespace defined. Did you forget a pref?");
michael@0 1226 }
michael@0 1227
michael@0 1228 this._state = new HealthReporterState(this);
michael@0 1229 }
michael@0 1230
michael@0 1231 this.HealthReporter.prototype = Object.freeze({
michael@0 1232 __proto__: AbstractHealthReporter.prototype,
michael@0 1233
michael@0 1234 QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]),
michael@0 1235
michael@0 1236 get lastSubmitID() {
michael@0 1237 return this._state.lastSubmitID;
michael@0 1238 },
michael@0 1239
michael@0 1240 /**
michael@0 1241 * When we last successfully submitted data to the server.
michael@0 1242 *
michael@0 1243 * This is sent as part of the upload. This is redundant with similar data
michael@0 1244 * in the policy because we like the modules to be loosely coupled and the
michael@0 1245 * similar data in the policy is only used for forensic purposes.
michael@0 1246 */
michael@0 1247 get lastPingDate() {
michael@0 1248 return this._state.lastPingDate;
michael@0 1249 },
michael@0 1250
michael@0 1251 /**
michael@0 1252 * The base URI of the document server to which to submit data.
michael@0 1253 *
michael@0 1254 * This is typically a Bagheera server instance. It is the URI up to but not
michael@0 1255 * including the version prefix. e.g. https://data.metrics.mozilla.com/
michael@0 1256 */
michael@0 1257 get serverURI() {
michael@0 1258 return this._prefs.get("documentServerURI", null);
michael@0 1259 },
michael@0 1260
michael@0 1261 set serverURI(value) {
michael@0 1262 if (!value) {
michael@0 1263 throw new Error("serverURI must have a value.");
michael@0 1264 }
michael@0 1265
michael@0 1266 if (typeof(value) != "string") {
michael@0 1267 throw new Error("serverURI must be a string: " + value);
michael@0 1268 }
michael@0 1269
michael@0 1270 this._prefs.set("documentServerURI", value);
michael@0 1271 },
michael@0 1272
michael@0 1273 /**
michael@0 1274 * The namespace on the document server to which we will be submitting data.
michael@0 1275 */
michael@0 1276 get serverNamespace() {
michael@0 1277 return this._prefs.get("documentServerNamespace", "metrics");
michael@0 1278 },
michael@0 1279
michael@0 1280 set serverNamespace(value) {
michael@0 1281 if (!value) {
michael@0 1282 throw new Error("serverNamespace must have a value.");
michael@0 1283 }
michael@0 1284
michael@0 1285 if (typeof(value) != "string") {
michael@0 1286 throw new Error("serverNamespace must be a string: " + value);
michael@0 1287 }
michael@0 1288
michael@0 1289 this._prefs.set("documentServerNamespace", value);
michael@0 1290 },
michael@0 1291
michael@0 1292 /**
michael@0 1293 * Whether this instance will upload data to a server.
michael@0 1294 */
michael@0 1295 get willUploadData() {
michael@0 1296 return this._policy.dataSubmissionPolicyAccepted &&
michael@0 1297 this._policy.healthReportUploadEnabled;
michael@0 1298 },
michael@0 1299
michael@0 1300 /**
michael@0 1301 * Whether remote data is currently stored.
michael@0 1302 *
michael@0 1303 * @return bool
michael@0 1304 */
michael@0 1305 haveRemoteData: function () {
michael@0 1306 return !!this._state.lastSubmitID;
michael@0 1307 },
michael@0 1308
michael@0 1309 /**
michael@0 1310 * Called to initiate a data upload.
michael@0 1311 *
michael@0 1312 * The passed argument is a `DataSubmissionRequest` from policy.jsm.
michael@0 1313 */
michael@0 1314 requestDataUpload: function (request) {
michael@0 1315 if (!this._initialized) {
michael@0 1316 return Promise.reject(new Error("Not initialized."));
michael@0 1317 }
michael@0 1318
michael@0 1319 return Task.spawn(function doUpload() {
michael@0 1320 yield this._providerManager.ensurePullOnlyProvidersRegistered();
michael@0 1321 try {
michael@0 1322 yield this.collectMeasurements();
michael@0 1323 try {
michael@0 1324 yield this._uploadData(request);
michael@0 1325 } catch (ex) {
michael@0 1326 this._onSubmitDataRequestFailure(ex);
michael@0 1327 }
michael@0 1328 } finally {
michael@0 1329 yield this._providerManager.ensurePullOnlyProvidersUnregistered();
michael@0 1330 }
michael@0 1331 }.bind(this));
michael@0 1332 },
michael@0 1333
michael@0 1334 /**
michael@0 1335 * Request that server data be deleted.
michael@0 1336 *
michael@0 1337 * If deletion is scheduled to occur immediately, a promise will be returned
michael@0 1338 * that will be fulfilled when the deletion attempt finishes. Otherwise,
michael@0 1339 * callers should poll haveRemoteData() to determine when remote data is
michael@0 1340 * deleted.
michael@0 1341 */
michael@0 1342 requestDeleteRemoteData: function (reason) {
michael@0 1343 if (!this.haveRemoteData()) {
michael@0 1344 return;
michael@0 1345 }
michael@0 1346
michael@0 1347 return this._policy.deleteRemoteData(reason);
michael@0 1348 },
michael@0 1349
michael@0 1350 _initializeState: function() {
michael@0 1351 return this._state.init();
michael@0 1352 },
michael@0 1353
michael@0 1354 /**
michael@0 1355 * Override default handler to incur an upload describing the error.
michael@0 1356 */
michael@0 1357 _onInitError: function (error) {
michael@0 1358 // Need to capture this before we call the parent else it's always
michael@0 1359 // set.
michael@0 1360 let inShutdown = this._shutdownRequested;
michael@0 1361
michael@0 1362 let result;
michael@0 1363 try {
michael@0 1364 result = AbstractHealthReporter.prototype._onInitError.call(this, error);
michael@0 1365 } catch (ex) {
michael@0 1366 this._log.error("Error when calling _onInitError: " +
michael@0 1367 CommonUtils.exceptionStr(ex));
michael@0 1368 }
michael@0 1369
michael@0 1370 // This bypasses a lot of the checks in policy, such as respect for
michael@0 1371 // backoff. We should arguably not do this. However, reporting
michael@0 1372 // startup errors is important. And, they should not occur with much
michael@0 1373 // frequency in the wild. So, it shouldn't be too big of a deal.
michael@0 1374 if (!inShutdown &&
michael@0 1375 this._policy.ensureNotifyResponse(new Date()) &&
michael@0 1376 this._policy.healthReportUploadEnabled) {
michael@0 1377 // We don't care about what happens to this request. It's best
michael@0 1378 // effort.
michael@0 1379 let request = {
michael@0 1380 onNoDataAvailable: function () {},
michael@0 1381 onSubmissionSuccess: function () {},
michael@0 1382 onSubmissionFailureSoft: function () {},
michael@0 1383 onSubmissionFailureHard: function () {},
michael@0 1384 onUploadInProgress: function () {},
michael@0 1385 };
michael@0 1386
michael@0 1387 this._uploadData(request);
michael@0 1388 }
michael@0 1389
michael@0 1390 return result;
michael@0 1391 },
michael@0 1392
michael@0 1393 _onBagheeraResult: function (request, isDelete, date, result) {
michael@0 1394 this._log.debug("Received Bagheera result.");
michael@0 1395
michael@0 1396 return Task.spawn(function onBagheeraResult() {
michael@0 1397 let hrProvider = this.getProvider("org.mozilla.healthreport");
michael@0 1398
michael@0 1399 if (!result.transportSuccess) {
michael@0 1400 // The built-in provider may not be initialized if this instance failed
michael@0 1401 // to initialize fully.
michael@0 1402 if (hrProvider && !isDelete) {
michael@0 1403 hrProvider.recordEvent("uploadTransportFailure", date);
michael@0 1404 }
michael@0 1405
michael@0 1406 request.onSubmissionFailureSoft("Network transport error.");
michael@0 1407 throw new Task.Result(false);
michael@0 1408 }
michael@0 1409
michael@0 1410 if (!result.serverSuccess) {
michael@0 1411 if (hrProvider && !isDelete) {
michael@0 1412 hrProvider.recordEvent("uploadServerFailure", date);
michael@0 1413 }
michael@0 1414
michael@0 1415 request.onSubmissionFailureHard("Server failure.");
michael@0 1416 throw new Task.Result(false);
michael@0 1417 }
michael@0 1418
michael@0 1419 if (hrProvider && !isDelete) {
michael@0 1420 hrProvider.recordEvent("uploadSuccess", date);
michael@0 1421 }
michael@0 1422
michael@0 1423 if (isDelete) {
michael@0 1424 this._log.warn("Marking delete as successful.");
michael@0 1425 yield this._state.removeRemoteIDs([result.id]);
michael@0 1426 } else {
michael@0 1427 this._log.warn("Marking upload as successful.");
michael@0 1428 yield this._state.updateLastPingAndRemoveRemoteIDs(date, result.deleteIDs);
michael@0 1429 }
michael@0 1430
michael@0 1431 request.onSubmissionSuccess(this._now());
michael@0 1432
michael@0 1433 throw new Task.Result(true);
michael@0 1434 }.bind(this));
michael@0 1435 },
michael@0 1436
michael@0 1437 _onSubmitDataRequestFailure: function (error) {
michael@0 1438 this._log.error("Error processing request to submit data: " +
michael@0 1439 CommonUtils.exceptionStr(error));
michael@0 1440 },
michael@0 1441
michael@0 1442 _formatDate: function (date) {
michael@0 1443 // Why, oh, why doesn't JS have a strftime() equivalent?
michael@0 1444 return date.toISOString().substr(0, 10);
michael@0 1445 },
michael@0 1446
michael@0 1447 _uploadData: function (request) {
michael@0 1448 // Under ideal circumstances, clients should never race to this
michael@0 1449 // function. However, server logs have observed behavior where
michael@0 1450 // racing to this function could be a cause. So, this lock was
michael@0 1451 // instituted.
michael@0 1452 if (this._uploadInProgress) {
michael@0 1453 this._log.warn("Upload requested but upload already in progress.");
michael@0 1454 let provider = this.getProvider("org.mozilla.healthreport");
michael@0 1455 let promise = provider.recordEvent("uploadAlreadyInProgress");
michael@0 1456 request.onUploadInProgress("Upload already in progress.");
michael@0 1457 return promise;
michael@0 1458 }
michael@0 1459
michael@0 1460 let id = CommonUtils.generateUUID();
michael@0 1461
michael@0 1462 this._log.info("Uploading data to server: " + this.serverURI + " " +
michael@0 1463 this.serverNamespace + ":" + id);
michael@0 1464 let client = new BagheeraClient(this.serverURI);
michael@0 1465 let now = this._now();
michael@0 1466
michael@0 1467 return Task.spawn(function doUpload() {
michael@0 1468 try {
michael@0 1469 // The test for upload locking monkeypatches getJSONPayload.
michael@0 1470 // If the next two lines change, be sure to verify the test is
michael@0 1471 // accurate!
michael@0 1472 this._uploadInProgress = true;
michael@0 1473 let payload = yield this.getJSONPayload();
michael@0 1474
michael@0 1475 let histogram = Services.telemetry.getHistogramById(TELEMETRY_PAYLOAD_SIZE_UNCOMPRESSED);
michael@0 1476 histogram.add(payload.length);
michael@0 1477
michael@0 1478 let lastID = this.lastSubmitID;
michael@0 1479 yield this._state.addRemoteID(id);
michael@0 1480
michael@0 1481 let hrProvider = this.getProvider("org.mozilla.healthreport");
michael@0 1482 if (hrProvider) {
michael@0 1483 let event = lastID ? "continuationUploadAttempt"
michael@0 1484 : "firstDocumentUploadAttempt";
michael@0 1485 hrProvider.recordEvent(event, now);
michael@0 1486 }
michael@0 1487
michael@0 1488 TelemetryStopwatch.start(TELEMETRY_UPLOAD, this);
michael@0 1489 let result;
michael@0 1490 try {
michael@0 1491 let options = {
michael@0 1492 deleteIDs: this._state.remoteIDs.filter((x) => { return x != id; }),
michael@0 1493 telemetryCompressed: TELEMETRY_PAYLOAD_SIZE_COMPRESSED,
michael@0 1494 };
michael@0 1495 result = yield client.uploadJSON(this.serverNamespace, id, payload,
michael@0 1496 options);
michael@0 1497 TelemetryStopwatch.finish(TELEMETRY_UPLOAD, this);
michael@0 1498 } catch (ex) {
michael@0 1499 TelemetryStopwatch.cancel(TELEMETRY_UPLOAD, this);
michael@0 1500 if (hrProvider) {
michael@0 1501 hrProvider.recordEvent("uploadClientFailure", now);
michael@0 1502 }
michael@0 1503 throw ex;
michael@0 1504 }
michael@0 1505
michael@0 1506 yield this._onBagheeraResult(request, false, now, result);
michael@0 1507 } finally {
michael@0 1508 this._uploadInProgress = false;
michael@0 1509 }
michael@0 1510 }.bind(this));
michael@0 1511 },
michael@0 1512
michael@0 1513 /**
michael@0 1514 * Request deletion of remote data.
michael@0 1515 *
michael@0 1516 * @param request
michael@0 1517 * (DataSubmissionRequest) Tracks progress of this request.
michael@0 1518 */
michael@0 1519 deleteRemoteData: function (request) {
michael@0 1520 if (!this._state.lastSubmitID) {
michael@0 1521 this._log.info("Received request to delete remote data but no data stored.");
michael@0 1522 request.onNoDataAvailable();
michael@0 1523 return;
michael@0 1524 }
michael@0 1525
michael@0 1526 this._log.warn("Deleting remote data.");
michael@0 1527 let client = new BagheeraClient(this.serverURI);
michael@0 1528
michael@0 1529 return Task.spawn(function* doDelete() {
michael@0 1530 try {
michael@0 1531 let result = yield client.deleteDocument(this.serverNamespace,
michael@0 1532 this.lastSubmitID);
michael@0 1533 yield this._onBagheeraResult(request, true, this._now(), result);
michael@0 1534 } catch (ex) {
michael@0 1535 this._log.error("Error processing request to delete data: " +
michael@0 1536 CommonUtils.exceptionStr(error));
michael@0 1537 } finally {
michael@0 1538 // If we don't have any remote documents left, nuke the ID.
michael@0 1539 // This is done for privacy reasons. Why preserve the ID if we
michael@0 1540 // don't need to?
michael@0 1541 if (!this.haveRemoteData()) {
michael@0 1542 yield this._state.resetClientID();
michael@0 1543 }
michael@0 1544 }
michael@0 1545 }.bind(this));
michael@0 1546 },
michael@0 1547 });
michael@0 1548

mercurial