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