Bumping gaia.json for 10 gaia revision(s) a=gaia-bump
[gecko.git] / services / datareporting / sessions.jsm
blobd741c8e66aec5c4609ee77b2635d513256aa5191
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 #ifndef MERGED_COMPARTMENT
7 "use strict";
9 this.EXPORTED_SYMBOLS = [
10   "SessionRecorder",
13 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
15 #endif
17 Cu.import("resource://gre/modules/Preferences.jsm");
18 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
19 Cu.import("resource://gre/modules/Log.jsm");
20 Cu.import("resource://services-common/utils.js");
23 // We automatically prune sessions older than this.
24 const MAX_SESSION_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days.
25 const STARTUP_RETRY_INTERVAL_MS = 5000;
27 // Wait up to 5 minutes for startup measurements before giving up.
28 const MAX_STARTUP_TRIES = 300000 / STARTUP_RETRY_INTERVAL_MS;
30 /**
31  * Records information about browser sessions.
32  *
33  * This serves as an interface to both current session information as
34  * well as a history of previous sessions.
35  *
36  * Typically only one instance of this will be installed in an
37  * application. It is typically managed by an XPCOM service. The
38  * instance is instantiated at application start; onStartup is called
39  * once the profile is installed; onShutdown is called during shutdown.
40  *
41  * We currently record state in preferences. However, this should be
42  * invisible to external consumers. We could easily swap in a different
43  * storage mechanism if desired.
44  *
45  * Please note the different semantics for storing times and dates in
46  * preferences. Full dates (notably the session start time) are stored
47  * as strings because preferences have a 32-bit limit on integer values
48  * and milliseconds since UNIX epoch would overflow. Many times are
49  * stored as integer offsets from the session start time because they
50  * should not overflow 32 bits.
51  *
52  * Since this records history of all sessions, there is a possibility
53  * for unbounded data aggregation. This is curtailed through:
54  *
55  *   1) An "idle-daily" observer which delete sessions older than
56  *      MAX_SESSION_AGE_MS.
57  *   2) The creator of this instance explicitly calling
58  *      `pruneOldSessions`.
59  *
60  * @param branch
61  *        (string) Preferences branch on which to record state.
62  */
63 this.SessionRecorder = function (branch) {
64   if (!branch) {
65     throw new Error("branch argument must be defined.");
66   }
68   if (!branch.endsWith(".")) {
69     throw new Error("branch argument must end with '.': " + branch);
70   }
72   this._log = Log.repository.getLogger("Services.DataReporting.SessionRecorder");
74   this._prefs = new Preferences(branch);
75   this._lastActivityWasInactive = false;
76   this._activeTicks = 0;
77   this.fineTotalTime = 0;
78   this._started = false;
79   this._timer = null;
80   this._startupFieldTries = 0;
82   this._os = Cc["@mozilla.org/observer-service;1"]
83                .getService(Ci.nsIObserverService);
87 SessionRecorder.prototype = Object.freeze({
88   QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]),
90   STARTUP_RETRY_INTERVAL_MS: STARTUP_RETRY_INTERVAL_MS,
92   get _currentIndex() {
93     return this._prefs.get("currentIndex", 0);
94   },
96   set _currentIndex(value) {
97     this._prefs.set("currentIndex", value);
98   },
100   get _prunedIndex() {
101     return this._prefs.get("prunedIndex", 0);
102   },
104   set _prunedIndex(value) {
105     this._prefs.set("prunedIndex", value);
106   },
108   get startDate() {
109     return CommonUtils.getDatePref(this._prefs, "current.startTime");
110   },
112   set _startDate(value) {
113     CommonUtils.setDatePref(this._prefs, "current.startTime", value);
114   },
116   get activeTicks() {
117     return this._prefs.get("current.activeTicks", 0);
118   },
120   incrementActiveTicks: function () {
121     this._prefs.set("current.activeTicks", ++this._activeTicks);
122   },
124   /**
125    * Total time of this session in integer seconds.
126    *
127    * See also fineTotalTime for the time in milliseconds.
128    */
129   get totalTime() {
130     return this._prefs.get("current.totalTime", 0);
131   },
133   updateTotalTime: function () {
134     // We store millisecond precision internally to prevent drift from
135     // repeated rounding.
136     this.fineTotalTime = Date.now() - this.startDate;
137     this._prefs.set("current.totalTime", Math.floor(this.fineTotalTime / 1000));
138   },
140   get main() {
141     return this._prefs.get("current.main", -1);
142   },
144   set _main(value) {
145     if (!Number.isInteger(value)) {
146       throw new Error("main time must be an integer.");
147     }
149     this._prefs.set("current.main", value);
150   },
152   get firstPaint() {
153     return this._prefs.get("current.firstPaint", -1);
154   },
156   set _firstPaint(value) {
157     if (!Number.isInteger(value)) {
158       throw new Error("firstPaint must be an integer.");
159     }
161     this._prefs.set("current.firstPaint", value);
162   },
164   get sessionRestored() {
165     return this._prefs.get("current.sessionRestored", -1);
166   },
168   set _sessionRestored(value) {
169     if (!Number.isInteger(value)) {
170       throw new Error("sessionRestored must be an integer.");
171     }
173     this._prefs.set("current.sessionRestored", value);
174   },
176   getPreviousSessions: function () {
177     let result = {};
179     for (let i = this._prunedIndex; i < this._currentIndex; i++) {
180       let s = this.getPreviousSession(i);
181       if (!s) {
182         continue;
183       }
185       result[i] = s;
186     }
188     return result;
189   },
191   getPreviousSession: function (index) {
192     return this._deserialize(this._prefs.get("previous." + index));
193   },
195   /**
196    * Prunes old, completed sessions that started earlier than the
197    * specified date.
198    */
199   pruneOldSessions: function (date) {
200     for (let i = this._prunedIndex; i < this._currentIndex; i++) {
201       let s = this.getPreviousSession(i);
202       if (!s) {
203         continue;
204       }
206       if (s.startDate >= date) {
207         continue;
208       }
210       this._log.debug("Pruning session #" + i + ".");
211       this._prefs.reset("previous." + i);
212       this._prunedIndex = i;
213     }
214   },
216   recordStartupFields: function () {
217     let si = this._getStartupInfo();
219     if (!si.process) {
220       throw new Error("Startup info not available.");
221     }
223     let missing = false;
225     for (let field of ["main", "firstPaint", "sessionRestored"]) {
226       if (!(field in si)) {
227         this._log.debug("Missing startup field: " + field);
228         missing = true;
229         continue;
230       }
232       this["_" + field] = si[field].getTime() - si.process.getTime();
233     }
235     if (!missing || this._startupFieldTries > MAX_STARTUP_TRIES) {
236       this._clearStartupTimer();
237       return;
238     }
240     // If we have missing fields, install a timer and keep waiting for
241     // data.
242     this._startupFieldTries++;
244     if (!this._timer) {
245       this._timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
246       this._timer.initWithCallback({
247         notify: this.recordStartupFields.bind(this),
248       }, this.STARTUP_RETRY_INTERVAL_MS, this._timer.TYPE_REPEATING_SLACK);
249     }
250   },
252   _clearStartupTimer: function () {
253     if (this._timer) {
254       this._timer.cancel();
255       delete this._timer;
256     }
257   },
259   /**
260    * Perform functionality on application startup.
261    *
262    * This is typically called in a "profile-do-change" handler.
263    */
264   onStartup: function () {
265     if (this._started) {
266       throw new Error("onStartup has already been called.");
267     }
269     let si = this._getStartupInfo();
270     if (!si.process) {
271       throw new Error("Process information not available. Misconfigured app?");
272     }
274     this._started = true;
276     this._os.addObserver(this, "profile-before-change", false);
277     this._os.addObserver(this, "user-interaction-active", false);
278     this._os.addObserver(this, "user-interaction-inactive", false);
279     this._os.addObserver(this, "idle-daily", false);
281     // This has the side-effect of clearing current session state.
282     this._moveCurrentToPrevious();
284     this._startDate = si.process;
285     this._prefs.set("current.activeTicks", 0);
286     this.updateTotalTime();
288     this.recordStartupFields();
289   },
291   /**
292    * Record application activity.
293    */
294   onActivity: function (active) {
295     let updateActive = active && !this._lastActivityWasInactive;
296     this._lastActivityWasInactive = !active;
298     this.updateTotalTime();
300     if (updateActive) {
301       this.incrementActiveTicks();
302     }
303   },
305   onShutdown: function () {
306     this._log.info("Recording clean session shutdown.");
307     this._prefs.set("current.clean", true);
308     this.updateTotalTime();
309     this._clearStartupTimer();
311     this._os.removeObserver(this, "profile-before-change");
312     this._os.removeObserver(this, "user-interaction-active");
313     this._os.removeObserver(this, "user-interaction-inactive");
314     this._os.removeObserver(this, "idle-daily");
315   },
317   _CURRENT_PREFS: [
318     "current.startTime",
319     "current.activeTicks",
320     "current.totalTime",
321     "current.main",
322     "current.firstPaint",
323     "current.sessionRestored",
324     "current.clean",
325   ],
327   // This is meant to be called only during onStartup().
328   _moveCurrentToPrevious: function () {
329     try {
330       if (!this.startDate.getTime()) {
331         this._log.info("No previous session. Is this first app run?");
332         return;
333       }
335       let clean = this._prefs.get("current.clean", false);
337       let count = this._currentIndex++;
338       let obj = {
339         s: this.startDate.getTime(),
340         a: this.activeTicks,
341         t: this.totalTime,
342         c: clean,
343         m: this.main,
344         fp: this.firstPaint,
345         sr: this.sessionRestored,
346       };
348       this._log.debug("Recording last sessions as #" + count + ".");
349       this._prefs.set("previous." + count, JSON.stringify(obj));
350     } catch (ex) {
351       this._log.warn("Exception when migrating last session: " +
352                      CommonUtils.exceptionStr(ex));
353     } finally {
354       this._log.debug("Resetting prefs from last session.");
355       for (let pref of this._CURRENT_PREFS) {
356         this._prefs.reset(pref);
357       }
358     }
359   },
361   _deserialize: function (s) {
362     let o;
363     try {
364       o = JSON.parse(s);
365     } catch (ex) {
366       return null;
367     }
369     return {
370       startDate: new Date(o.s),
371       activeTicks: o.a,
372       totalTime: o.t,
373       clean: !!o.c,
374       main: o.m,
375       firstPaint: o.fp,
376       sessionRestored: o.sr,
377     };
378   },
380   // Implemented as a function to allow for monkeypatching in tests.
381   _getStartupInfo: function () {
382     return Cc["@mozilla.org/toolkit/app-startup;1"]
383              .getService(Ci.nsIAppStartup)
384              .getStartupInfo();
385   },
387   observe: function (subject, topic, data) {
388     switch (topic) {
389       case "profile-before-change":
390         this.onShutdown();
391         break;
393       case "user-interaction-active":
394         this.onActivity(true);
395         break;
397       case "user-interaction-inactive":
398         this.onActivity(false);
399         break;
401       case "idle-daily":
402         this.pruneOldSessions(new Date(Date.now() - MAX_SESSION_AGE_MS));
403         break;
404     }
405   },