michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: "use strict"; michael@0: michael@0: #ifndef MERGED_COMPARTMENT michael@0: michael@0: this.EXPORTED_SYMBOLS = [ michael@0: "SessionRecorder", michael@0: ]; michael@0: michael@0: const {classes: Cc, interfaces: Ci, utils: Cu} = Components; michael@0: michael@0: #endif michael@0: michael@0: Cu.import("resource://gre/modules/Preferences.jsm"); michael@0: Cu.import("resource://gre/modules/XPCOMUtils.jsm"); michael@0: Cu.import("resource://gre/modules/Log.jsm"); michael@0: Cu.import("resource://services-common/utils.js"); michael@0: michael@0: michael@0: // We automatically prune sessions older than this. michael@0: const MAX_SESSION_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days. michael@0: const STARTUP_RETRY_INTERVAL_MS = 5000; michael@0: michael@0: // Wait up to 5 minutes for startup measurements before giving up. michael@0: const MAX_STARTUP_TRIES = 300000 / STARTUP_RETRY_INTERVAL_MS; michael@0: michael@0: /** michael@0: * Records information about browser sessions. michael@0: * michael@0: * This serves as an interface to both current session information as michael@0: * well as a history of previous sessions. michael@0: * michael@0: * Typically only one instance of this will be installed in an michael@0: * application. It is typically managed by an XPCOM service. The michael@0: * instance is instantiated at application start; onStartup is called michael@0: * once the profile is installed; onShutdown is called during shutdown. michael@0: * michael@0: * We currently record state in preferences. However, this should be michael@0: * invisible to external consumers. We could easily swap in a different michael@0: * storage mechanism if desired. michael@0: * michael@0: * Please note the different semantics for storing times and dates in michael@0: * preferences. Full dates (notably the session start time) are stored michael@0: * as strings because preferences have a 32-bit limit on integer values michael@0: * and milliseconds since UNIX epoch would overflow. Many times are michael@0: * stored as integer offsets from the session start time because they michael@0: * should not overflow 32 bits. michael@0: * michael@0: * Since this records history of all sessions, there is a possibility michael@0: * for unbounded data aggregation. This is curtailed through: michael@0: * michael@0: * 1) An "idle-daily" observer which delete sessions older than michael@0: * MAX_SESSION_AGE_MS. michael@0: * 2) The creator of this instance explicitly calling michael@0: * `pruneOldSessions`. michael@0: * michael@0: * @param branch michael@0: * (string) Preferences branch on which to record state. michael@0: */ michael@0: this.SessionRecorder = function (branch) { michael@0: if (!branch) { michael@0: throw new Error("branch argument must be defined."); michael@0: } michael@0: michael@0: if (!branch.endsWith(".")) { michael@0: throw new Error("branch argument must end with '.': " + branch); michael@0: } michael@0: michael@0: this._log = Log.repository.getLogger("Services.DataReporting.SessionRecorder"); michael@0: michael@0: this._prefs = new Preferences(branch); michael@0: this._lastActivityWasInactive = false; michael@0: this._activeTicks = 0; michael@0: this.fineTotalTime = 0; michael@0: this._started = false; michael@0: this._timer = null; michael@0: this._startupFieldTries = 0; michael@0: michael@0: this._os = Cc["@mozilla.org/observer-service;1"] michael@0: .getService(Ci.nsIObserverService); michael@0: michael@0: }; michael@0: michael@0: SessionRecorder.prototype = Object.freeze({ michael@0: QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]), michael@0: michael@0: STARTUP_RETRY_INTERVAL_MS: STARTUP_RETRY_INTERVAL_MS, michael@0: michael@0: get _currentIndex() { michael@0: return this._prefs.get("currentIndex", 0); michael@0: }, michael@0: michael@0: set _currentIndex(value) { michael@0: this._prefs.set("currentIndex", value); michael@0: }, michael@0: michael@0: get _prunedIndex() { michael@0: return this._prefs.get("prunedIndex", 0); michael@0: }, michael@0: michael@0: set _prunedIndex(value) { michael@0: this._prefs.set("prunedIndex", value); michael@0: }, michael@0: michael@0: get startDate() { michael@0: return CommonUtils.getDatePref(this._prefs, "current.startTime"); michael@0: }, michael@0: michael@0: set _startDate(value) { michael@0: CommonUtils.setDatePref(this._prefs, "current.startTime", value); michael@0: }, michael@0: michael@0: get activeTicks() { michael@0: return this._prefs.get("current.activeTicks", 0); michael@0: }, michael@0: michael@0: incrementActiveTicks: function () { michael@0: this._prefs.set("current.activeTicks", ++this._activeTicks); michael@0: }, michael@0: michael@0: /** michael@0: * Total time of this session in integer seconds. michael@0: * michael@0: * See also fineTotalTime for the time in milliseconds. michael@0: */ michael@0: get totalTime() { michael@0: return this._prefs.get("current.totalTime", 0); michael@0: }, michael@0: michael@0: updateTotalTime: function () { michael@0: // We store millisecond precision internally to prevent drift from michael@0: // repeated rounding. michael@0: this.fineTotalTime = Date.now() - this.startDate; michael@0: this._prefs.set("current.totalTime", Math.floor(this.fineTotalTime / 1000)); michael@0: }, michael@0: michael@0: get main() { michael@0: return this._prefs.get("current.main", -1); michael@0: }, michael@0: michael@0: set _main(value) { michael@0: if (!Number.isInteger(value)) { michael@0: throw new Error("main time must be an integer."); michael@0: } michael@0: michael@0: this._prefs.set("current.main", value); michael@0: }, michael@0: michael@0: get firstPaint() { michael@0: return this._prefs.get("current.firstPaint", -1); michael@0: }, michael@0: michael@0: set _firstPaint(value) { michael@0: if (!Number.isInteger(value)) { michael@0: throw new Error("firstPaint must be an integer."); michael@0: } michael@0: michael@0: this._prefs.set("current.firstPaint", value); michael@0: }, michael@0: michael@0: get sessionRestored() { michael@0: return this._prefs.get("current.sessionRestored", -1); michael@0: }, michael@0: michael@0: set _sessionRestored(value) { michael@0: if (!Number.isInteger(value)) { michael@0: throw new Error("sessionRestored must be an integer."); michael@0: } michael@0: michael@0: this._prefs.set("current.sessionRestored", value); michael@0: }, michael@0: michael@0: getPreviousSessions: function () { michael@0: let result = {}; michael@0: michael@0: for (let i = this._prunedIndex; i < this._currentIndex; i++) { michael@0: let s = this.getPreviousSession(i); michael@0: if (!s) { michael@0: continue; michael@0: } michael@0: michael@0: result[i] = s; michael@0: } michael@0: michael@0: return result; michael@0: }, michael@0: michael@0: getPreviousSession: function (index) { michael@0: return this._deserialize(this._prefs.get("previous." + index)); michael@0: }, michael@0: michael@0: /** michael@0: * Prunes old, completed sessions that started earlier than the michael@0: * specified date. michael@0: */ michael@0: pruneOldSessions: function (date) { michael@0: for (let i = this._prunedIndex; i < this._currentIndex; i++) { michael@0: let s = this.getPreviousSession(i); michael@0: if (!s) { michael@0: continue; michael@0: } michael@0: michael@0: if (s.startDate >= date) { michael@0: continue; michael@0: } michael@0: michael@0: this._log.debug("Pruning session #" + i + "."); michael@0: this._prefs.reset("previous." + i); michael@0: this._prunedIndex = i; michael@0: } michael@0: }, michael@0: michael@0: recordStartupFields: function () { michael@0: let si = this._getStartupInfo(); michael@0: michael@0: if (!si.process) { michael@0: throw new Error("Startup info not available."); michael@0: } michael@0: michael@0: let missing = false; michael@0: michael@0: for (let field of ["main", "firstPaint", "sessionRestored"]) { michael@0: if (!(field in si)) { michael@0: this._log.debug("Missing startup field: " + field); michael@0: missing = true; michael@0: continue; michael@0: } michael@0: michael@0: this["_" + field] = si[field].getTime() - si.process.getTime(); michael@0: } michael@0: michael@0: if (!missing || this._startupFieldTries > MAX_STARTUP_TRIES) { michael@0: this._clearStartupTimer(); michael@0: return; michael@0: } michael@0: michael@0: // If we have missing fields, install a timer and keep waiting for michael@0: // data. michael@0: this._startupFieldTries++; michael@0: michael@0: if (!this._timer) { michael@0: this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); michael@0: this._timer.initWithCallback({ michael@0: notify: this.recordStartupFields.bind(this), michael@0: }, this.STARTUP_RETRY_INTERVAL_MS, this._timer.TYPE_REPEATING_SLACK); michael@0: } michael@0: }, michael@0: michael@0: _clearStartupTimer: function () { michael@0: if (this._timer) { michael@0: this._timer.cancel(); michael@0: delete this._timer; michael@0: } michael@0: }, michael@0: michael@0: /** michael@0: * Perform functionality on application startup. michael@0: * michael@0: * This is typically called in a "profile-do-change" handler. michael@0: */ michael@0: onStartup: function () { michael@0: if (this._started) { michael@0: throw new Error("onStartup has already been called."); michael@0: } michael@0: michael@0: let si = this._getStartupInfo(); michael@0: if (!si.process) { michael@0: throw new Error("Process information not available. Misconfigured app?"); michael@0: } michael@0: michael@0: this._started = true; michael@0: michael@0: this._os.addObserver(this, "profile-before-change", false); michael@0: this._os.addObserver(this, "user-interaction-active", false); michael@0: this._os.addObserver(this, "user-interaction-inactive", false); michael@0: this._os.addObserver(this, "idle-daily", false); michael@0: michael@0: // This has the side-effect of clearing current session state. michael@0: this._moveCurrentToPrevious(); michael@0: michael@0: this._startDate = si.process; michael@0: this._prefs.set("current.activeTicks", 0); michael@0: this.updateTotalTime(); michael@0: michael@0: this.recordStartupFields(); michael@0: }, michael@0: michael@0: /** michael@0: * Record application activity. michael@0: */ michael@0: onActivity: function (active) { michael@0: let updateActive = active && !this._lastActivityWasInactive; michael@0: this._lastActivityWasInactive = !active; michael@0: michael@0: this.updateTotalTime(); michael@0: michael@0: if (updateActive) { michael@0: this.incrementActiveTicks(); michael@0: } michael@0: }, michael@0: michael@0: onShutdown: function () { michael@0: this._log.info("Recording clean session shutdown."); michael@0: this._prefs.set("current.clean", true); michael@0: this.updateTotalTime(); michael@0: this._clearStartupTimer(); michael@0: michael@0: this._os.removeObserver(this, "profile-before-change"); michael@0: this._os.removeObserver(this, "user-interaction-active"); michael@0: this._os.removeObserver(this, "user-interaction-inactive"); michael@0: this._os.removeObserver(this, "idle-daily"); michael@0: }, michael@0: michael@0: _CURRENT_PREFS: [ michael@0: "current.startTime", michael@0: "current.activeTicks", michael@0: "current.totalTime", michael@0: "current.main", michael@0: "current.firstPaint", michael@0: "current.sessionRestored", michael@0: "current.clean", michael@0: ], michael@0: michael@0: // This is meant to be called only during onStartup(). michael@0: _moveCurrentToPrevious: function () { michael@0: try { michael@0: if (!this.startDate.getTime()) { michael@0: this._log.info("No previous session. Is this first app run?"); michael@0: return; michael@0: } michael@0: michael@0: let clean = this._prefs.get("current.clean", false); michael@0: michael@0: let count = this._currentIndex++; michael@0: let obj = { michael@0: s: this.startDate.getTime(), michael@0: a: this.activeTicks, michael@0: t: this.totalTime, michael@0: c: clean, michael@0: m: this.main, michael@0: fp: this.firstPaint, michael@0: sr: this.sessionRestored, michael@0: }; michael@0: michael@0: this._log.debug("Recording last sessions as #" + count + "."); michael@0: this._prefs.set("previous." + count, JSON.stringify(obj)); michael@0: } catch (ex) { michael@0: this._log.warn("Exception when migrating last session: " + michael@0: CommonUtils.exceptionStr(ex)); michael@0: } finally { michael@0: this._log.debug("Resetting prefs from last session."); michael@0: for (let pref of this._CURRENT_PREFS) { michael@0: this._prefs.reset(pref); michael@0: } michael@0: } michael@0: }, michael@0: michael@0: _deserialize: function (s) { michael@0: let o; michael@0: try { michael@0: o = JSON.parse(s); michael@0: } catch (ex) { michael@0: return null; michael@0: } michael@0: michael@0: return { michael@0: startDate: new Date(o.s), michael@0: activeTicks: o.a, michael@0: totalTime: o.t, michael@0: clean: !!o.c, michael@0: main: o.m, michael@0: firstPaint: o.fp, michael@0: sessionRestored: o.sr, michael@0: }; michael@0: }, michael@0: michael@0: // Implemented as a function to allow for monkeypatching in tests. michael@0: _getStartupInfo: function () { michael@0: return Cc["@mozilla.org/toolkit/app-startup;1"] michael@0: .getService(Ci.nsIAppStartup) michael@0: .getStartupInfo(); michael@0: }, michael@0: michael@0: observe: function (subject, topic, data) { michael@0: switch (topic) { michael@0: case "profile-before-change": michael@0: this.onShutdown(); michael@0: break; michael@0: michael@0: case "user-interaction-active": michael@0: this.onActivity(true); michael@0: break; michael@0: michael@0: case "user-interaction-inactive": michael@0: this.onActivity(false); michael@0: break; michael@0: michael@0: case "idle-daily": michael@0: this.pruneOldSessions(new Date(Date.now() - MAX_SESSION_AGE_MS)); michael@0: break; michael@0: } michael@0: }, michael@0: });