1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/services/datareporting/sessions.jsm Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,406 @@ 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 = [ 1.13 + "SessionRecorder", 1.14 +]; 1.15 + 1.16 +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; 1.17 + 1.18 +#endif 1.19 + 1.20 +Cu.import("resource://gre/modules/Preferences.jsm"); 1.21 +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); 1.22 +Cu.import("resource://gre/modules/Log.jsm"); 1.23 +Cu.import("resource://services-common/utils.js"); 1.24 + 1.25 + 1.26 +// We automatically prune sessions older than this. 1.27 +const MAX_SESSION_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days. 1.28 +const STARTUP_RETRY_INTERVAL_MS = 5000; 1.29 + 1.30 +// Wait up to 5 minutes for startup measurements before giving up. 1.31 +const MAX_STARTUP_TRIES = 300000 / STARTUP_RETRY_INTERVAL_MS; 1.32 + 1.33 +/** 1.34 + * Records information about browser sessions. 1.35 + * 1.36 + * This serves as an interface to both current session information as 1.37 + * well as a history of previous sessions. 1.38 + * 1.39 + * Typically only one instance of this will be installed in an 1.40 + * application. It is typically managed by an XPCOM service. The 1.41 + * instance is instantiated at application start; onStartup is called 1.42 + * once the profile is installed; onShutdown is called during shutdown. 1.43 + * 1.44 + * We currently record state in preferences. However, this should be 1.45 + * invisible to external consumers. We could easily swap in a different 1.46 + * storage mechanism if desired. 1.47 + * 1.48 + * Please note the different semantics for storing times and dates in 1.49 + * preferences. Full dates (notably the session start time) are stored 1.50 + * as strings because preferences have a 32-bit limit on integer values 1.51 + * and milliseconds since UNIX epoch would overflow. Many times are 1.52 + * stored as integer offsets from the session start time because they 1.53 + * should not overflow 32 bits. 1.54 + * 1.55 + * Since this records history of all sessions, there is a possibility 1.56 + * for unbounded data aggregation. This is curtailed through: 1.57 + * 1.58 + * 1) An "idle-daily" observer which delete sessions older than 1.59 + * MAX_SESSION_AGE_MS. 1.60 + * 2) The creator of this instance explicitly calling 1.61 + * `pruneOldSessions`. 1.62 + * 1.63 + * @param branch 1.64 + * (string) Preferences branch on which to record state. 1.65 + */ 1.66 +this.SessionRecorder = function (branch) { 1.67 + if (!branch) { 1.68 + throw new Error("branch argument must be defined."); 1.69 + } 1.70 + 1.71 + if (!branch.endsWith(".")) { 1.72 + throw new Error("branch argument must end with '.': " + branch); 1.73 + } 1.74 + 1.75 + this._log = Log.repository.getLogger("Services.DataReporting.SessionRecorder"); 1.76 + 1.77 + this._prefs = new Preferences(branch); 1.78 + this._lastActivityWasInactive = false; 1.79 + this._activeTicks = 0; 1.80 + this.fineTotalTime = 0; 1.81 + this._started = false; 1.82 + this._timer = null; 1.83 + this._startupFieldTries = 0; 1.84 + 1.85 + this._os = Cc["@mozilla.org/observer-service;1"] 1.86 + .getService(Ci.nsIObserverService); 1.87 + 1.88 +}; 1.89 + 1.90 +SessionRecorder.prototype = Object.freeze({ 1.91 + QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]), 1.92 + 1.93 + STARTUP_RETRY_INTERVAL_MS: STARTUP_RETRY_INTERVAL_MS, 1.94 + 1.95 + get _currentIndex() { 1.96 + return this._prefs.get("currentIndex", 0); 1.97 + }, 1.98 + 1.99 + set _currentIndex(value) { 1.100 + this._prefs.set("currentIndex", value); 1.101 + }, 1.102 + 1.103 + get _prunedIndex() { 1.104 + return this._prefs.get("prunedIndex", 0); 1.105 + }, 1.106 + 1.107 + set _prunedIndex(value) { 1.108 + this._prefs.set("prunedIndex", value); 1.109 + }, 1.110 + 1.111 + get startDate() { 1.112 + return CommonUtils.getDatePref(this._prefs, "current.startTime"); 1.113 + }, 1.114 + 1.115 + set _startDate(value) { 1.116 + CommonUtils.setDatePref(this._prefs, "current.startTime", value); 1.117 + }, 1.118 + 1.119 + get activeTicks() { 1.120 + return this._prefs.get("current.activeTicks", 0); 1.121 + }, 1.122 + 1.123 + incrementActiveTicks: function () { 1.124 + this._prefs.set("current.activeTicks", ++this._activeTicks); 1.125 + }, 1.126 + 1.127 + /** 1.128 + * Total time of this session in integer seconds. 1.129 + * 1.130 + * See also fineTotalTime for the time in milliseconds. 1.131 + */ 1.132 + get totalTime() { 1.133 + return this._prefs.get("current.totalTime", 0); 1.134 + }, 1.135 + 1.136 + updateTotalTime: function () { 1.137 + // We store millisecond precision internally to prevent drift from 1.138 + // repeated rounding. 1.139 + this.fineTotalTime = Date.now() - this.startDate; 1.140 + this._prefs.set("current.totalTime", Math.floor(this.fineTotalTime / 1000)); 1.141 + }, 1.142 + 1.143 + get main() { 1.144 + return this._prefs.get("current.main", -1); 1.145 + }, 1.146 + 1.147 + set _main(value) { 1.148 + if (!Number.isInteger(value)) { 1.149 + throw new Error("main time must be an integer."); 1.150 + } 1.151 + 1.152 + this._prefs.set("current.main", value); 1.153 + }, 1.154 + 1.155 + get firstPaint() { 1.156 + return this._prefs.get("current.firstPaint", -1); 1.157 + }, 1.158 + 1.159 + set _firstPaint(value) { 1.160 + if (!Number.isInteger(value)) { 1.161 + throw new Error("firstPaint must be an integer."); 1.162 + } 1.163 + 1.164 + this._prefs.set("current.firstPaint", value); 1.165 + }, 1.166 + 1.167 + get sessionRestored() { 1.168 + return this._prefs.get("current.sessionRestored", -1); 1.169 + }, 1.170 + 1.171 + set _sessionRestored(value) { 1.172 + if (!Number.isInteger(value)) { 1.173 + throw new Error("sessionRestored must be an integer."); 1.174 + } 1.175 + 1.176 + this._prefs.set("current.sessionRestored", value); 1.177 + }, 1.178 + 1.179 + getPreviousSessions: function () { 1.180 + let result = {}; 1.181 + 1.182 + for (let i = this._prunedIndex; i < this._currentIndex; i++) { 1.183 + let s = this.getPreviousSession(i); 1.184 + if (!s) { 1.185 + continue; 1.186 + } 1.187 + 1.188 + result[i] = s; 1.189 + } 1.190 + 1.191 + return result; 1.192 + }, 1.193 + 1.194 + getPreviousSession: function (index) { 1.195 + return this._deserialize(this._prefs.get("previous." + index)); 1.196 + }, 1.197 + 1.198 + /** 1.199 + * Prunes old, completed sessions that started earlier than the 1.200 + * specified date. 1.201 + */ 1.202 + pruneOldSessions: function (date) { 1.203 + for (let i = this._prunedIndex; i < this._currentIndex; i++) { 1.204 + let s = this.getPreviousSession(i); 1.205 + if (!s) { 1.206 + continue; 1.207 + } 1.208 + 1.209 + if (s.startDate >= date) { 1.210 + continue; 1.211 + } 1.212 + 1.213 + this._log.debug("Pruning session #" + i + "."); 1.214 + this._prefs.reset("previous." + i); 1.215 + this._prunedIndex = i; 1.216 + } 1.217 + }, 1.218 + 1.219 + recordStartupFields: function () { 1.220 + let si = this._getStartupInfo(); 1.221 + 1.222 + if (!si.process) { 1.223 + throw new Error("Startup info not available."); 1.224 + } 1.225 + 1.226 + let missing = false; 1.227 + 1.228 + for (let field of ["main", "firstPaint", "sessionRestored"]) { 1.229 + if (!(field in si)) { 1.230 + this._log.debug("Missing startup field: " + field); 1.231 + missing = true; 1.232 + continue; 1.233 + } 1.234 + 1.235 + this["_" + field] = si[field].getTime() - si.process.getTime(); 1.236 + } 1.237 + 1.238 + if (!missing || this._startupFieldTries > MAX_STARTUP_TRIES) { 1.239 + this._clearStartupTimer(); 1.240 + return; 1.241 + } 1.242 + 1.243 + // If we have missing fields, install a timer and keep waiting for 1.244 + // data. 1.245 + this._startupFieldTries++; 1.246 + 1.247 + if (!this._timer) { 1.248 + this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); 1.249 + this._timer.initWithCallback({ 1.250 + notify: this.recordStartupFields.bind(this), 1.251 + }, this.STARTUP_RETRY_INTERVAL_MS, this._timer.TYPE_REPEATING_SLACK); 1.252 + } 1.253 + }, 1.254 + 1.255 + _clearStartupTimer: function () { 1.256 + if (this._timer) { 1.257 + this._timer.cancel(); 1.258 + delete this._timer; 1.259 + } 1.260 + }, 1.261 + 1.262 + /** 1.263 + * Perform functionality on application startup. 1.264 + * 1.265 + * This is typically called in a "profile-do-change" handler. 1.266 + */ 1.267 + onStartup: function () { 1.268 + if (this._started) { 1.269 + throw new Error("onStartup has already been called."); 1.270 + } 1.271 + 1.272 + let si = this._getStartupInfo(); 1.273 + if (!si.process) { 1.274 + throw new Error("Process information not available. Misconfigured app?"); 1.275 + } 1.276 + 1.277 + this._started = true; 1.278 + 1.279 + this._os.addObserver(this, "profile-before-change", false); 1.280 + this._os.addObserver(this, "user-interaction-active", false); 1.281 + this._os.addObserver(this, "user-interaction-inactive", false); 1.282 + this._os.addObserver(this, "idle-daily", false); 1.283 + 1.284 + // This has the side-effect of clearing current session state. 1.285 + this._moveCurrentToPrevious(); 1.286 + 1.287 + this._startDate = si.process; 1.288 + this._prefs.set("current.activeTicks", 0); 1.289 + this.updateTotalTime(); 1.290 + 1.291 + this.recordStartupFields(); 1.292 + }, 1.293 + 1.294 + /** 1.295 + * Record application activity. 1.296 + */ 1.297 + onActivity: function (active) { 1.298 + let updateActive = active && !this._lastActivityWasInactive; 1.299 + this._lastActivityWasInactive = !active; 1.300 + 1.301 + this.updateTotalTime(); 1.302 + 1.303 + if (updateActive) { 1.304 + this.incrementActiveTicks(); 1.305 + } 1.306 + }, 1.307 + 1.308 + onShutdown: function () { 1.309 + this._log.info("Recording clean session shutdown."); 1.310 + this._prefs.set("current.clean", true); 1.311 + this.updateTotalTime(); 1.312 + this._clearStartupTimer(); 1.313 + 1.314 + this._os.removeObserver(this, "profile-before-change"); 1.315 + this._os.removeObserver(this, "user-interaction-active"); 1.316 + this._os.removeObserver(this, "user-interaction-inactive"); 1.317 + this._os.removeObserver(this, "idle-daily"); 1.318 + }, 1.319 + 1.320 + _CURRENT_PREFS: [ 1.321 + "current.startTime", 1.322 + "current.activeTicks", 1.323 + "current.totalTime", 1.324 + "current.main", 1.325 + "current.firstPaint", 1.326 + "current.sessionRestored", 1.327 + "current.clean", 1.328 + ], 1.329 + 1.330 + // This is meant to be called only during onStartup(). 1.331 + _moveCurrentToPrevious: function () { 1.332 + try { 1.333 + if (!this.startDate.getTime()) { 1.334 + this._log.info("No previous session. Is this first app run?"); 1.335 + return; 1.336 + } 1.337 + 1.338 + let clean = this._prefs.get("current.clean", false); 1.339 + 1.340 + let count = this._currentIndex++; 1.341 + let obj = { 1.342 + s: this.startDate.getTime(), 1.343 + a: this.activeTicks, 1.344 + t: this.totalTime, 1.345 + c: clean, 1.346 + m: this.main, 1.347 + fp: this.firstPaint, 1.348 + sr: this.sessionRestored, 1.349 + }; 1.350 + 1.351 + this._log.debug("Recording last sessions as #" + count + "."); 1.352 + this._prefs.set("previous." + count, JSON.stringify(obj)); 1.353 + } catch (ex) { 1.354 + this._log.warn("Exception when migrating last session: " + 1.355 + CommonUtils.exceptionStr(ex)); 1.356 + } finally { 1.357 + this._log.debug("Resetting prefs from last session."); 1.358 + for (let pref of this._CURRENT_PREFS) { 1.359 + this._prefs.reset(pref); 1.360 + } 1.361 + } 1.362 + }, 1.363 + 1.364 + _deserialize: function (s) { 1.365 + let o; 1.366 + try { 1.367 + o = JSON.parse(s); 1.368 + } catch (ex) { 1.369 + return null; 1.370 + } 1.371 + 1.372 + return { 1.373 + startDate: new Date(o.s), 1.374 + activeTicks: o.a, 1.375 + totalTime: o.t, 1.376 + clean: !!o.c, 1.377 + main: o.m, 1.378 + firstPaint: o.fp, 1.379 + sessionRestored: o.sr, 1.380 + }; 1.381 + }, 1.382 + 1.383 + // Implemented as a function to allow for monkeypatching in tests. 1.384 + _getStartupInfo: function () { 1.385 + return Cc["@mozilla.org/toolkit/app-startup;1"] 1.386 + .getService(Ci.nsIAppStartup) 1.387 + .getStartupInfo(); 1.388 + }, 1.389 + 1.390 + observe: function (subject, topic, data) { 1.391 + switch (topic) { 1.392 + case "profile-before-change": 1.393 + this.onShutdown(); 1.394 + break; 1.395 + 1.396 + case "user-interaction-active": 1.397 + this.onActivity(true); 1.398 + break; 1.399 + 1.400 + case "user-interaction-inactive": 1.401 + this.onActivity(false); 1.402 + break; 1.403 + 1.404 + case "idle-daily": 1.405 + this.pruneOldSessions(new Date(Date.now() - MAX_SESSION_AGE_MS)); 1.406 + break; 1.407 + } 1.408 + }, 1.409 +});