summaryrefslogtreecommitdiffstats
path: root/toolkit/modules/SessionRecorder.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/modules/SessionRecorder.jsm')
-rw-r--r--toolkit/modules/SessionRecorder.jsm403
1 files changed, 403 insertions, 0 deletions
diff --git a/toolkit/modules/SessionRecorder.jsm b/toolkit/modules/SessionRecorder.jsm
new file mode 100644
index 000000000..174be08e3
--- /dev/null
+++ b/toolkit/modules/SessionRecorder.jsm
@@ -0,0 +1,403 @@
+/* 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";
+
+this.EXPORTED_SYMBOLS = [
+ "SessionRecorder",
+];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+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;
+
+const LOGGER_NAME = "Toolkit.Telemetry";
+const LOGGER_PREFIX = "SessionRecorder::";
+
+/**
+ * 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 = Log.repository.getLoggerWithMessagePrefix(LOGGER_NAME, LOGGER_PREFIX);
+
+ 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", 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;
+ }
+ },
+});