Bug 827157 - Part 1: Robust and performance sensitive recording of sessions; r=rnewman, a=bajaj
authorGregory Szorc <gps@mozilla.com>
Fri, 18 Jan 2013 22:35:07 -0800
changeset 127294 e82bed1e09070dffd3be0203642c7c03d0aa06e4
parent 127293 fdcde29caa787e553065b7c62f1f49dd7a64d3d8
child 127295 ae750f9ad2d6a67bb895a916d5c0553b6ffb2205
push id2151
push userlsblakk@mozilla.com
push dateTue, 19 Feb 2013 18:06:57 +0000
treeherdermozilla-beta@4952e88741ec [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersrnewman, bajaj
bugs827157
milestone20.0a2
Bug 827157 - Part 1: Robust and performance sensitive recording of sessions; r=rnewman, a=bajaj We have introduced a new background service that captures session state in preferences. Firefox Health Report now moves entries from preferences to its database at payload generation time. We've also introduced a few random APIs, such as enqueueTransaction() and the ability for providers to have access to their own pref branch.
services/datareporting/DataReportingService.js
services/datareporting/Makefile.in
services/datareporting/sessions.jsm
services/datareporting/tests/xpcshell/head.js
services/datareporting/tests/xpcshell/test_session_recorder.js
services/datareporting/tests/xpcshell/xpcshell.ini
services/healthreport/healthreporter.jsm
services/healthreport/providers.jsm
services/healthreport/tests/xpcshell/test_provider_sessions.js
services/metrics/Metrics.jsm
services/metrics/dataprovider.jsm
services/metrics/storage.jsm
services/metrics/tests/xpcshell/test_metrics_provider.js
services/metrics/tests/xpcshell/test_metrics_storage.js
--- a/services/datareporting/DataReportingService.js
+++ b/services/datareporting/DataReportingService.js
@@ -2,23 +2,26 @@
  * 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";
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/services/datareporting/policy.jsm");
+Cu.import("resource://gre/modules/services/datareporting/sessions.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://services-common/observers.js");
 Cu.import("resource://services-common/preferences.js");
+Cu.import("resource://services-common/utils.js");
 
 
 const ROOT_BRANCH = "datareporting.";
 const POLICY_BRANCH = ROOT_BRANCH + "policy.";
+const SESSIONS_BRANCH = ROOT_BRANCH + "sessions.";
 const HEALTHREPORT_BRANCH = ROOT_BRANCH + "healthreport.";
 const HEALTHREPORT_LOGGING_BRANCH = HEALTHREPORT_BRANCH + "logging.";
 const DEFAULT_LOAD_DELAY_MSEC = 10 * 1000;
 
 /**
  * The Firefox Health Report XPCOM service.
  *
  * External consumers will be interested in the "reporter" property of this
@@ -101,26 +104,48 @@ DataReportingService.prototype = Object.
       case "app-startup":
         this._os.addObserver(this, "profile-after-change", true);
         break;
 
       case "profile-after-change":
         this._os.removeObserver(this, "profile-after-change");
         this._os.addObserver(this, "sessionstore-windows-restored", true);
 
+        this._prefs = new Preferences(HEALTHREPORT_BRANCH);
+
+        // We don't initialize the sessions recorder unless Health Report is
+        // around to provide pruning of data.
+        //
+        // FUTURE consider having the SessionsRecorder always enabled and/or
+        // living in its own XPCOM service.
+        if (this._prefs.get("service.enabled", true)) {
+          this.sessionRecorder = new SessionRecorder(SESSIONS_BRANCH);
+          this.sessionRecorder.onStartup();
+        }
+
         // We can't interact with prefs until after the profile is present.
         let policyPrefs = new Preferences(POLICY_BRANCH);
-        this._prefs = new Preferences(HEALTHREPORT_BRANCH);
         this.policy = new DataReportingPolicy(policyPrefs, this._prefs, this);
+
         break;
 
       case "sessionstore-windows-restored":
         this._os.removeObserver(this, "sessionstore-windows-restored");
         this._os.addObserver(this, "quit-application", false);
 
+        // When the session recorder starts up above, first paint and session
+        // restore times likely aren't available. So, we wait until they are (here)
+        // and record them. In the case of session restore time, that appears
+        // to be set by an observer of this notification. So, we delay
+        // recording until the next tick of the event loop.
+        if (this.sessionRecorder) {
+          CommonUtils.nextTick(this.sessionRecorder.recordStartupFields,
+                               this.sessionRecorder);
+        }
+
         this.policy.startPolling();
 
         // Don't initialize Firefox Health Reporter collection and submission
         // service unless it is enabled.
         if (!this._prefs.get("service.enabled", true)) {
           return;
         }
 
@@ -220,14 +245,15 @@ DataReportingService.prototype = Object.
       for (let name of LOGGERS) {
         let logger = ns.Log4Moz.repository.getLogger(name);
         logger.addAppender(appender);
       }
     }
 
     // The reporter initializes in the background.
     this._healthReporter = new ns.HealthReporter(HEALTHREPORT_BRANCH,
-                                                 this.policy);
+                                                 this.policy,
+                                                 this.sessionRecorder);
   },
 });
 
 this.NSGetFactory = XPCOMUtils.generateNSGetFactory([DataReportingService]);
 
--- a/services/datareporting/Makefile.in
+++ b/services/datareporting/Makefile.in
@@ -6,17 +6,17 @@ DEPTH     = @DEPTH@
 topsrcdir = @top_srcdir@
 srcdir    = @srcdir@
 VPATH     = @srcdir@
 
 include $(DEPTH)/config/autoconf.mk
 
 TEST_DIRS += tests
 
-MODULES_FILES := policy.jsm
+MODULES_FILES := policy.jsm sessions.jsm
 MODULES_DEST = $(FINAL_TARGET)/modules/services/datareporting
 INSTALL_TARGETS += MODULES
 
 TESTING_JS_MODULES := $(addprefix modules-testing/,mocks.jsm)
 TESTING_JS_MODULE_DIR := services/datareporting
 
 EXTRA_COMPONENTS := \
   DataReporting.manifest \
new file mode 100644
--- /dev/null
+++ b/services/datareporting/sessions.jsm
@@ -0,0 +1,358 @@
+/* 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/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.
+
+
+/**
+ * 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._started = false;
+
+  this._os = Cc["@mozilla.org/observer-service;1"]
+               .getService(Ci.nsIObserverService);
+
+};
+
+SessionRecorder.prototype = Object.freeze({
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]),
+
+  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);
+  },
+
+  get totalTime() {
+    return this._prefs.get("current.totalTime", 0);
+  },
+
+  updateTotalTime: function () {
+    this._prefs.set("current.totalTime", Date.now() - this.startDate);
+  },
+
+  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.");
+    }
+
+    for (let field of ["main", "firstPaint", "sessionRestored"]) {
+      if (!(field in si)) {
+        continue;
+      }
+
+      this["_" + field] = si[field].getTime() - si.process.getTime();
+    }
+  },
+
+  /**
+   * 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._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;
+    }
+  },
+});
new file mode 100644
--- /dev/null
+++ b/services/datareporting/tests/xpcshell/head.js
@@ -0,0 +1,16 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// We need to initialize the profile or OS.File may not work. See bug 810543.
+do_get_profile();
+
+(function initTestingInfrastructure() {
+  let ns = {};
+  Components.utils.import("resource://testing-common/services-common/logging.js",
+                          ns);
+
+  ns.initTestLogging();
+}).call(this);
+
new file mode 100644
--- /dev/null
+++ b/services/datareporting/tests/xpcshell/test_session_recorder.js
@@ -0,0 +1,222 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/commonjs/promise/core.js");
+Cu.import("resource://gre/modules/services/datareporting/sessions.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://services-common/utils.js");
+
+
+function run_test() {
+  run_next_test();
+}
+
+function monkeypatchStartupInfo(recorder, start=new Date(), offset=500) {
+  Object.defineProperty(recorder, "_getStartupInfo", {
+    value: function _getStartupInfo() {
+      return {
+        process: start,
+        main: new Date(start.getTime() + offset),
+        firstPaint: new Date(start.getTime() + 2 * offset),
+        sessionRestored: new Date(start.getTime() + 3 * offset),
+      };
+    }
+  });
+}
+
+function sleep(wait) {
+  let deferred = Promise.defer();
+
+  let timer = CommonUtils.namedTimer(function onTimer() {
+    deferred.resolve();
+  }, wait, deferred.promise, "_sleepTimer");
+
+  return deferred.promise;
+}
+
+function getRecorder(name, start, offset) {
+  let recorder = new SessionRecorder("testing." + name + ".");
+  monkeypatchStartupInfo(recorder, start, offset);
+
+  return recorder;
+}
+
+add_test(function test_basic() {
+  let recorder = getRecorder("basic");
+  recorder.onStartup();
+  recorder.onShutdown();
+
+  run_next_test();
+});
+
+add_task(function test_current_properties() {
+  let now = new Date();
+  let recorder = getRecorder("current_properties", now);
+  yield sleep(25);
+  recorder.onStartup();
+
+  do_check_eq(recorder.startDate.getTime(), now.getTime());
+  do_check_eq(recorder.activeTicks, 0);
+  do_check_true(recorder.totalTime > 0);
+  do_check_eq(recorder.main, 500);
+  do_check_eq(recorder.firstPaint, 1000);
+  do_check_eq(recorder.sessionRestored, 1500);
+
+  recorder.incrementActiveTicks();
+  do_check_eq(recorder.activeTicks, 1);
+
+  recorder.onShutdown();
+});
+
+add_task(function test_previous_clean() {
+  let now = new Date();
+  let recorder = getRecorder("previous_clean", now);
+  yield sleep(25);
+  recorder.onStartup();
+
+  recorder.incrementActiveTicks();
+  recorder.incrementActiveTicks();
+
+  yield sleep(25);
+  recorder.onShutdown();
+
+  let total = recorder.totalTime;
+
+  yield sleep(25);
+  let now2 = new Date();
+  let recorder2 = getRecorder("previous_clean", now2, 100);
+  yield sleep(25);
+  recorder2.onStartup();
+
+  do_check_eq(recorder2.startDate.getTime(), now2.getTime());
+  do_check_eq(recorder2.main, 100);
+  do_check_eq(recorder2.firstPaint, 200);
+  do_check_eq(recorder2.sessionRestored, 300);
+
+  let sessions = recorder2.getPreviousSessions();
+  do_check_eq(Object.keys(sessions).length, 1);
+  do_check_true(0 in sessions);
+  let session = sessions[0];
+  do_check_true(session.clean);
+  do_check_eq(session.startDate.getTime(), now.getTime());
+  do_check_eq(session.main, 500);
+  do_check_eq(session.firstPaint, 1000);
+  do_check_eq(session.sessionRestored, 1500);
+  do_check_eq(session.totalTime, total);
+  do_check_eq(session.activeTicks, 2);
+
+  recorder2.onShutdown();
+});
+
+add_task(function test_previous_abort() {
+  let now = new Date();
+  let recorder = getRecorder("previous_abort", now);
+  yield sleep(25);
+  recorder.onStartup();
+  recorder.incrementActiveTicks();
+  yield sleep(25);
+  let total = recorder.totalTime;
+  yield sleep(25);
+
+  let now2 = new Date();
+  let recorder2 = getRecorder("previous_abort", now2);
+  yield sleep(25);
+  recorder2.onStartup();
+
+  let sessions = recorder2.getPreviousSessions();
+  do_check_eq(Object.keys(sessions).length, 1);
+  do_check_true(0 in sessions);
+  let session = sessions[0];
+  do_check_false(session.clean);
+  do_check_eq(session.totalTime, total);
+
+  recorder.onShutdown();
+  recorder2.onShutdown();
+});
+
+add_task(function test_multiple_sessions() {
+  for (let i = 0; i < 10; i++) {
+    let recorder = getRecorder("multiple_sessions");
+    yield sleep(25);
+    recorder.onStartup();
+    for (let j = 0; j < i; j++) {
+      recorder.incrementActiveTicks();
+    }
+    yield sleep(25);
+    recorder.onShutdown();
+    yield sleep(25);
+  }
+
+  let recorder = getRecorder("multiple_sessions");
+  recorder.onStartup();
+
+  let sessions = recorder.getPreviousSessions();
+  do_check_eq(Object.keys(sessions).length, 10);
+
+  for (let [i, session] in Iterator(sessions)) {
+    do_check_eq(session.activeTicks, i);
+
+    if (i > 0) {
+      do_check_true(session.startDate.getTime() > sessions[i-1].startDate.getTime());
+    }
+  }
+
+  // #6 is preserved since >=.
+  let threshold = sessions[6].startDate;
+  recorder.pruneOldSessions(threshold);
+
+  sessions = recorder.getPreviousSessions();
+  do_check_eq(Object.keys(sessions).length, 4);
+
+  recorder.pruneOldSessions(threshold);
+  sessions = recorder.getPreviousSessions();
+  do_check_eq(Object.keys(sessions).length, 4);
+  do_check_eq(recorder._prunedIndex, 5);
+
+  recorder.onShutdown();
+});
+
+add_task(function test_record_activity() {
+  let recorder = getRecorder("record_activity");
+  yield sleep(25);
+  recorder.onStartup();
+  let total = recorder.totalTime;
+  yield sleep(25);
+
+  for (let i = 0; i < 3; i++) {
+    Services.obs.notifyObservers(null, "user-interaction-active", null);
+    yield sleep(25);
+    do_check_true(recorder.totalTime > total);
+    total = recorder.totalTime;
+  }
+
+  do_check_eq(recorder.activeTicks, 3);
+
+  // Now send inactive. We should increment total time but not active.
+  Services.obs.notifyObservers(null, "user-interaction-inactive", null);
+  do_check_eq(recorder.activeTicks, 3);
+  do_check_true(recorder.totalTime > total);
+  total = recorder.totalTime;
+  yield sleep(25);
+
+  // If we send active again, this should be counted as inactive.
+  Services.obs.notifyObservers(null, "user-interaction-active", null);
+  do_check_eq(recorder.activeTicks, 3);
+  do_check_true(recorder.totalTime > total);
+  total = recorder.totalTime;
+  yield sleep(25);
+
+  // If we send active again, this should be counted as active.
+  Services.obs.notifyObservers(null, "user-interaction-active", null);
+  do_check_eq(recorder.activeTicks, 4);
+
+  Services.obs.notifyObservers(null, "user-interaction-active", null);
+  do_check_eq(recorder.activeTicks, 5);
+
+  recorder.onShutdown();
+});
+
--- a/services/datareporting/tests/xpcshell/xpcshell.ini
+++ b/services/datareporting/tests/xpcshell/xpcshell.ini
@@ -1,5 +1,6 @@
 [DEFAULT]
-head =
+head = head.js
 tail =
 
 [test_policy.js]
+[test_session_recorder.js]
--- a/services/healthreport/healthreporter.jsm
+++ b/services/healthreport/healthreporter.jsm
@@ -78,39 +78,41 @@ const DEFAULT_DATABASE_NAME = "healthrep
  *
  * @param branch
  *        (string) The preferences branch to use for state storage. The value
  *        must end with a period (.).
  *
  * @param policy
  *        (HealthReportPolicy) Policy driving execution of HealthReporter.
  */
-function HealthReporter(branch, policy) {
+function HealthReporter(branch, policy, sessionRecorder) {
   if (!branch.endsWith(".")) {
     throw new Error("Branch must end with a period (.): " + branch);
   }
 
   if (!policy) {
     throw new Error("Must provide policy to HealthReporter constructor.");
   }
 
   this._log = Log4Moz.repository.getLogger("Services.HealthReport.HealthReporter");
   this._log.info("Initializing health reporter instance against " + branch);
 
+  this._branch = branch;
   this._prefs = new Preferences(branch);
 
   if (!this.serverURI) {
     throw new Error("No server URI defined. Did you forget to define the pref?");
   }
 
   if (!this.serverNamespace) {
     throw new Error("No server namespace defined. Did you forget a pref?");
   }
 
   this._policy = policy;
+  this.sessionRecorder = sessionRecorder;
 
   this._dbName = this._prefs.get("dbName") || DEFAULT_DATABASE_NAME;
 
   this._storage = null;
   this._storageInProgress = false;
   this._collector = null;
   this._collectorInProgress = false;
   this._initialized = false;
@@ -534,16 +536,18 @@ HealthReporter.prototype = Object.freeze
       this._log.info("Attempting to load provider from category manager: " +
                      entry + " from " + uri);
 
       try {
         let ns = {};
         Cu.import(uri, ns);
 
         let provider = new ns[entry]();
+        provider.initPreferences(this._branch + "provider.");
+        provider.healthReporter = this;
         promises.push(this.registerProvider(provider));
       } catch (ex) {
         this._log.warn("Error registering provider from category manager: " +
                        entry + "; " + CommonUtils.exceptionStr(ex));
         continue;
       }
     }
 
--- a/services/healthreport/providers.jsm
+++ b/services/healthreport/providers.jsm
@@ -350,75 +350,75 @@ SysInfoProvider.prototype = Object.freez
 });
 
 
 /**
  * Holds information about the current/active session.
  *
  * The fields within the current session are moved to daily session fields when
  * the application is shut down.
+ *
+ * This measurement is backed by the SessionRecorder, not the database.
  */
 function CurrentSessionMeasurement() {
   Metrics.Measurement.call(this);
 }
 
 CurrentSessionMeasurement.prototype = Object.freeze({
   __proto__: Metrics.Measurement.prototype,
 
   name: "current",
-  version: 1,
-
-  LAST_NUMERIC_FIELDS: [
-    // Day on which the session was started.
-    // This is used to determine which day the record will be moved under when
-    // moved to daily sessions.
-    "startDay",
-
-    // Time in milliseconds the session was active for.
-    "activeTime",
-
-    // Total time in milliseconds of the session.
-    "totalTime",
-
-    // Startup times, in milliseconds.
-    "main",
-    "firstPaint",
-    "sessionRestored",
-  ],
+  version: 2,
 
   configureStorage: function () {
-    return Task.spawn(function configureStorage() {
-      for (let field of this.LAST_NUMERIC_FIELDS) {
-        yield this.registerStorageField(field, this.storage.FIELD_LAST_NUMERIC);
-      }
-    }.bind(this));
+    return Promise.resolve();
+  },
+
+  /**
+   * All data is stored in prefs, so we have a custom implementation.
+   */
+  getValues: function () {
+    let sessions = this.provider.healthReporter.sessionRecorder;
+
+    let fields = new Map();
+    let now = new Date();
+    fields.set("startDay", [now, Metrics.dateToDays(sessions.startDate)]);
+    fields.set("activeTicks", [now, sessions.activeTicks]);
+    fields.set("totalTime", [now, sessions.totalTime]);
+    fields.set("main", [now, sessions.main]);
+    fields.set("firstPaint", [now, sessions.firstPaint]);
+    fields.set("sessionRestored", [now, sessions.sessionRestored]);
+
+    return Promise.resolve({
+      days: new Metrics.DailyValues(),
+      singular: fields,
+    });
   },
 });
 
-
 /**
  * Records a history of all application sessions.
  */
 function PreviousSessionsMeasurement() {
   Metrics.Measurement.call(this);
 }
 
 PreviousSessionsMeasurement.prototype = Object.freeze({
   __proto__: Metrics.Measurement.prototype,
 
   name: "previous",
-  version: 1,
+  version: 2,
 
   DAILY_DISCRETE_NUMERIC_FIELDS: [
     // Milliseconds of sessions that were properly shut down.
-    "cleanActiveTime",
+    "cleanActiveTicks",
     "cleanTotalTime",
 
     // Milliseconds of sessions that were not properly shut down.
-    "abortedActiveTime",
+    "abortedActiveTicks",
     "abortedTotalTime",
 
     // Startup times in milliseconds.
     "main",
     "firstPaint",
     "sessionRestored",
   ],
 
@@ -448,207 +448,51 @@ PreviousSessionsMeasurement.prototype = 
  * result of a crash. We detect aborted sessions by storing the current
  * session separate from completed sessions. We normally move the
  * current session to completed sessions on application shutdown. If a
  * current session is present on application startup, that means that
  * the previous session was aborted.
  */
 this.SessionsProvider = function () {
   Metrics.Provider.call(this);
-
-  this._startDate = null;
-  this._currentActiveTime = null;
-  this._lastActivityDate = null;
-  this._lastActivityWasInactive = false;
 };
 
 SessionsProvider.prototype = Object.freeze({
   __proto__: Metrics.Provider.prototype,
 
   name: "org.mozilla.appSessions",
 
   measurementTypes: [CurrentSessionMeasurement, PreviousSessionsMeasurement],
 
-  _OBSERVERS: ["user-interaction-active", "user-interaction-inactive"],
-
-  onInit: function () {
-    return Task.spawn(this._onInit.bind(this));
-  },
-
-  _onInit: function () {
-    // We could cross day boundary between the application started and when
-    // this code is called. Meh.
-    let now = new Date();
-    this._startDate = now;
-    let current = this.getMeasurement("current", 1);
-
-    // Initialization occurs serially so we don't need to enqueue
-    // storage operations.
-    let currentData = yield this.storage.getMeasurementLastValuesFromMeasurementID(current.id);
-
-    // Normal shutdown should purge all data for this measurement. If
-    // there is data here, the session was aborted.
-    if (currentData.size) {
-      this._log.info("Data left over from old session. Counting as aborted.");
-      yield Task.spawn(this._moveCurrentToDaily.bind(this, currentData, true));
-    }
-
-    this._currentActiveTime = 0;
-    this._lastActivityDate = now;
-
-    this._log.debug("Registering new/current session.");
-    yield current.setLastNumeric("activeTime", 0, now);
-    yield current.setLastNumeric("totalTime", 0, now);
-    yield current.setLastNumeric("startDay", this._dateToDays(now), now);
-
-    let si = this._getStartupInfo();
+  collectConstantData: function () {
+    let previous = this.getMeasurement("previous", 2);
 
-    for (let field of ["main", "firstPaint", "sessionRestored"]) {
-      if (!(field in si)) {
-        continue;
-      }
-
-      // si.process is the Date when the process actually started.
-      let value = si[field] - si.process;
-      yield current.setLastNumeric(field, value, now);
-    }
-
-    for (let channel of this._OBSERVERS) {
-      Services.obs.addObserver(this, channel, false);
-    }
-  },
-
-  onShutdown: function () {
-    for (let channel of this._OBSERVERS) {
-      Services.obs.removeObserver(this, channel);
-    }
-
-    return Task.spawn(this._onShutdown.bind(this));
-  },
-
-  _onShutdown: function () {
-    this._log.debug("Recording clean shutdown.");
-    yield this.recordBrowserActivity(true);
-    let current = this.getMeasurement("current", 1);
-
-    let self = this;
-    yield this.enqueueStorageOperation(function doShutdown() {
-      return Task.spawn(function shutdownTask() {
-        let data = yield self.storage.getMeasurementLastValuesFromMeasurementID(current.id);
-        yield self._moveCurrentToDaily(data, false);
-      });
-    });
+    return this.storage.enqueueTransaction(this._recordAndPruneSessions.bind(this));
   },
 
-  /**
-   * Record browser activity.
-   *
-   * This should be called periodically to update the stored times of how often
-   * the user was active with the browser.
-   *
-   * The underlying browser activity observer fires every 5 seconds if there
-   * is activity. If there is inactivity, it fires after 5 seconds of inactivity
-   * and doesn't fire again until there is activity.
-   *
-   * @param active
-   *        (bool) Whether the browser was active or inactive.
-   */
-  recordBrowserActivity: function (active) {
-    // There is potential for clock skew to result in incorrect measurements
-    // here. We should count ticks instead of calculating intervals.
-    // FUTURE count ticks not intervals.
-    let now = new Date();
-    this._log.trace("Recording browser activity. Active? " + !!active);
+  _recordAndPruneSessions: function () {
+    this._log.info("Moving previous sessions from session recorder to storage.");
+    let recorder = this.healthReporter.sessionRecorder;
+    let sessions = recorder.getPreviousSessions();
+    this._log.debug("Found " + Object.keys(sessions).length + " previous sessions.");
+
+    let daily = this.getMeasurement("previous", 2);
 
-    let m = this.getMeasurement("current", 1);
+    for each (let session in sessions) {
+      let type = session.clean ? "clean" : "aborted";
+      let date = session.startDate;
+      yield daily.addDailyDiscreteNumeric(type + "ActiveTicks", session.activeTicks, date);
+      yield daily.addDailyDiscreteNumeric(type + "TotalTime", session.totalTime, date);
 
-    let updateActive = active && !this._lastActivityWasInactive;
-    this._lastActivityWasInactive = !active;
-
-    if (updateActive) {
-      this._currentActiveTime += now - this._lastActivityDate;
+      for (let field of ["main", "firstPaint", "sessionRestored"]) {
+        yield daily.addDailyDiscreteNumeric(field, session[field], date);
+      }
     }
 
-    this._lastActivityDate = now;
-
-    let totalTime = now - this._startDate;
-    let activeTime = this._currentActiveTime;
-
-    return this.enqueueStorageOperation(function op() {
-      let promise = m.setLastNumeric("totalTime", totalTime, now);
-
-      if (!updateActive) {
-        return promise;
-      }
-
-      return m.setLastNumeric("activeTime", activeTime, now);
-    });
-  },
-
-  _moveCurrentToDaily: function (fields, aborted) {
-    this._log.debug("Moving current session to past. Aborted? " + aborted);
-    let current = this.getMeasurement("current", 1);
-
-    function clearCurrent() {
-      current.deleteLastNumeric("startDay");
-      current.deleteLastNumeric("activeTime");
-      current.deleteLastNumeric("totalTime");
-      current.deleteLastNumeric("main");
-      current.deleteLastNumeric("firstPaint");
-      return current.deleteLastNumeric("sessionRestored");
-    }
-
-    // We should never have incomplete values. But if we do, handle it
-    // gracefully.
-    if (!fields.has("startDay") || !fields.has("activeTime") || !fields.has("totalTime")) {
-      yield clearCurrent();
-      return;
-    }
-
-    let daily = this.getMeasurement("previous", 1);
-
-    let startDays = fields.get("startDay")[1];
-    let activeTime = fields.get("activeTime")[1];
-    let totalTime = fields.get("totalTime")[1];
-
-    let date = this._daysToDate(startDays);
-    let type = aborted ? "aborted" : "clean";
-
-    yield daily.addDailyDiscreteNumeric(type + "ActiveTime", activeTime, date);
-    yield daily.addDailyDiscreteNumeric(type + "TotalTime", totalTime, date);
-
-    for (let field of ["main", "firstPaint", "sessionRestored"]) {
-      if (!fields.has(field)) {
-        this._log.info(field + " field not recorded for current session.");
-        continue;
-      }
-
-      yield daily.addDailyDiscreteNumeric(field, fields.get(field)[1], date);
-    }
-
-    yield clearCurrent();
-  },
-
-  observe: function (subject, topic, data) {
-    switch (topic) {
-      case "user-interaction-active":
-        this.recordBrowserActivity(true);
-        break;
-
-      case "user-interaction-inactive":
-        this.recordBrowserActivity(false);
-        break;
-    }
-  },
-
-  // Implemented as a function to allow for monkeypatching in tests.
-  _getStartupInfo: function () {
-    return Cc["@mozilla.org/toolkit/app-startup;1"]
-             .getService(Ci.nsIAppStartup)
-             .getStartupInfo();
+    recorder.pruneOldSessions(new Date());
   },
 });
 
 /**
  * Stores the set of active addons in storage.
  *
  * We do things a little differently than most other measurements. Because
  * addons are difficult to shoehorn into distinct fields, we simply store a
--- a/services/healthreport/tests/xpcshell/test_provider_sessions.js
+++ b/services/healthreport/tests/xpcshell/test_provider_sessions.js
@@ -3,206 +3,160 @@
 
 "use strict";
 
 const {utils: Cu} = Components;
 
 
 Cu.import("resource://gre/modules/commonjs/promise/core.js");
 Cu.import("resource://gre/modules/Metrics.jsm");
-Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/services-common/utils.js");
+Cu.import("resource://gre/modules/services/datareporting/sessions.jsm");
 Cu.import("resource://gre/modules/services/healthreport/providers.jsm");
 
 
 function run_test() {
   run_next_test();
 }
 
 add_test(function test_constructor() {
   let provider = new SessionsProvider();
 
   run_next_test();
 });
 
 add_task(function test_init() {
   let storage = yield Metrics.Storage("init");
   let provider = new SessionsProvider();
+  provider.initPreferences("testing.init.");
   yield provider.init(storage);
   yield provider.shutdown();
 
   yield storage.close();
 });
 
-function getProvider(name, now=new Date()) {
-  return Task.spawn(function () {
-    let storage = yield Metrics.Storage(name);
-    let provider = new SessionsProvider();
-    monkeypatchStartupInfo(provider, now);
-    yield provider.init(storage);
-
-    throw new Task.Result([provider, storage]);
-  });
-}
-
-function monkeypatchStartupInfo(provider, start=new Date(), offset=500) {
-  Object.defineProperty(provider, "_getStartupInfo", {
+function monkeypatchStartupInfo(recorder, start=new Date(), offset=500) {
+  Object.defineProperty(recorder, "_getStartupInfo", {
     value: function _getStartupInfo() {
       return {
         process: start,
         main: new Date(start.getTime() + offset),
         firstPaint: new Date(start.getTime() + 2 * offset),
         sessionRestored: new Date(start.getTime() + 3 * offset),
       };
     }
   });
 }
 
-add_task(function test_record_current_on_init() {
-  let [provider, storage] = yield getProvider("record_current_on_init");
+function sleep(wait) {
+  let deferred = Promise.defer();
+
+  let timer = CommonUtils.namedTimer(function onTimer() {
+    deferred.resolve();
+  }, wait, deferred.promise, "_sleepTimer");
+
+  return deferred.promise;
+}
+
+function getProvider(name, now=new Date(), init=true) {
+  return Task.spawn(function () {
+    let storage = yield Metrics.Storage(name);
+    let provider = new SessionsProvider();
+    provider.initPreferences("testing." + name + ".healthreport.provider.");
 
-  let now = new Date();
+    let recorder = new SessionRecorder("testing." + name + ".sessions.");
+    monkeypatchStartupInfo(recorder, now);
+    provider.healthReporter = {sessionRecorder: recorder};
+    recorder.onStartup();
+
+    if (init) {
+      yield provider.init(storage);
+    }
 
-  let current = provider.getMeasurement("current", 1);
+    throw new Task.Result([provider, storage, recorder]);
+  });
+}
+
+add_task(function test_current_session() {
+  let now = new Date();
+  let [provider, storage, recorder] = yield getProvider("current_session", now);
+
+  yield sleep(25);
+  recorder.onActivity(true);
+
+  let current = provider.getMeasurement("current", 2);
   let values = yield current.getValues();
   let fields = values.singular;
-  do_check_eq(fields.size, 6);
+
+  for (let field of ["startDay", "activeTicks", "totalTime", "main", "firstPaint", "sessionRestored"]) {
+    do_check_true(fields.has(field));
+  }
+
+  do_check_eq(fields.get("startDay")[1], Metrics.dateToDays(now));
+  do_check_eq(fields.get("totalTime")[1], recorder.totalTime);
+  do_check_eq(fields.get("activeTicks")[1], 1);
   do_check_eq(fields.get("main")[1], 500);
   do_check_eq(fields.get("firstPaint")[1], 1000);
   do_check_eq(fields.get("sessionRestored")[1], 1500);
-  do_check_eq(fields.get("startDay")[1], provider._dateToDays(now));
-  do_check_eq(fields.get("activeTime")[1], 0);
-  do_check_eq(fields.get("totalTime")[1], 0);
 
   yield provider.shutdown();
   yield storage.close();
 });
 
-add_task(function test_current_moved_on_shutdown() {
-  let [provider, storage] = yield getProvider("current_moved_on_shutdown");
+add_task(function test_collect() {
   let now = new Date();
+  let [provider, storage, recorder] = yield getProvider("collect");
 
-  let previous = provider.getMeasurement("previous", 1);
-
-  yield provider.shutdown();
+  recorder.onShutdown();
+  yield sleep(25);
 
-  // This relies on undocumented behavior of the underlying measurement not
-  // being invalidated on provider shutdown. If this breaks, we should rewrite
-  // the test and not hold up implementation changes.
-  let values = yield previous.getValues();
-  do_check_eq(values.days.size, 1);
-  do_check_true(values.days.hasDay(now));
-  let fields = values.days.getDay(now);
-
-  // 3 startup + 2 clean.
-  do_check_eq(fields.size, 5);
-  for (let field of ["cleanActiveTime", "cleanTotalTime", "main", "firstPaint", "sessionRestored"]) {
-    do_check_true(fields.has(field));
-    do_check_true(Array.isArray(fields.get(field)));
-    do_check_eq(fields.get(field).length, 1);
+  for (let i = 0; i < 5; i++) {
+    let recorder2 = new SessionRecorder("testing.collect.sessions.");
+    recorder2.onStartup();
+    yield sleep(25);
+    recorder2.onShutdown();
+    yield sleep(25);
   }
 
-  do_check_eq(fields.get("main")[0], 500);
-  do_check_eq(fields.get("firstPaint")[0], 1000);
-  do_check_eq(fields.get("sessionRestored")[0], 1500);
-  do_check_true(fields.get("cleanActiveTime")[0] > 0);
-  do_check_true(fields.get("cleanTotalTime")[0] > 0);
-
-  yield storage.close();
-});
-
-add_task(function test_detect_abort() {
-  let [provider, storage] = yield getProvider("detect_abort");
+  recorder = new SessionRecorder("testing.collect.sessions.");
+  recorder.onStartup();
 
-  let now = new Date();
-
-  let m = provider.getMeasurement("current", 1);
-  let original = yield m.getValues().singular;
+  yield provider.collectConstantData();
+  let sessions = recorder.getPreviousSessions();
+  do_check_eq(Object.keys(sessions).length, 0);
 
-  let provider2 = new SessionsProvider();
-  monkeypatchStartupInfo(provider2);
-  yield provider2.init(storage);
+  let daily = provider.getMeasurement("previous", 2);
+  let values = yield daily.getValues();
+  do_check_true(values.days.hasDay(now));
+  do_check_eq(values.days.size, 1);
 
-  let previous = provider.getMeasurement("previous", 1);
-  let values = yield previous.getValues();
-  do_check_true(values.days.hasDay(now));
   let day = values.days.getDay(now);
   do_check_eq(day.size, 5);
-  do_check_true(day.has("abortedActiveTime"));
-  do_check_true(day.has("abortedTotalTime"));
-  do_check_eq(day.get("abortedActiveTime")[0], 0);
-  do_check_eq(day.get("abortedTotalTime")[0], 0);
 
-  yield provider.shutdown();
-  yield provider2.shutdown();
-  yield storage.close();
-});
-
-// This isn't a perfect test because we only simulate the observer
-// notifications. We should probably supplement this with a mochitest.
-add_task(function test_record_browser_activity() {
-  let [provider, storage] = yield getProvider("record_browser_activity");
-
-  function waitOnDB () {
-    return provider.enqueueStorageOperation(function () {
-      return storage._connection.execute("SELECT 1");
-    });
+  for (let field of ["cleanActiveTicks", "cleanTotalTime", "main", "firstPaint", "sessionRestored"]) {
+    do_check_true(day.has(field));
+    do_check_true(Array.isArray(day.get(field)));
+    do_check_eq(day.get(field).length, 6);
   }
 
-  let current = provider.getMeasurement("current", 1);
-
-  Services.obs.notifyObservers(null, "user-interaction-active", null);
-  yield waitOnDB();
-
-  let values = yield current.getValues();
-  let fields = values.singular;
-  let activeTime = fields.get("activeTime")[1];
-  let totalTime = fields.get("totalTime")[1];
-
-  do_check_eq(activeTime, totalTime);
-  do_check_true(activeTime > 0);
-
-  // Another active should have similar effects.
-  Services.obs.notifyObservers(null, "user-interaction-active", null);
-  yield waitOnDB();
-
-  values = yield current.getValues();
-  fields = values.singular;
-
-  do_check_true(fields.get("activeTime")[1] > activeTime);
-  activeTime = fields.get("activeTime")[1];
-  totalTime = fields.get("totalTime")[1];
-  do_check_eq(activeTime, totalTime);
+  // Fake an aborted sessions.
+  let recorder2 = new SessionRecorder("testing.collect.sessions.");
+  recorder2.onStartup();
+  do_check_eq(Object.keys(recorder.getPreviousSessions()).length, 1);
+  yield provider.collectConstantData();
+  do_check_eq(Object.keys(recorder.getPreviousSessions()).length, 0);
 
-  // Now send inactive. We should increment total time but not active.
-  Services.obs.notifyObservers(null, "user-interaction-inactive", null);
-  yield waitOnDB();
-  values = yield current.getValues();
-  fields = values.singular;
-  do_check_eq(fields.get("activeTime")[1], activeTime);
-  totalTime = fields.get("totalTime")[1];
-  do_check_true(totalTime > activeTime);
+  values = yield daily.getValues();
+  day = values.days.getDay(now);
+  for (let field of ["abortedActiveTicks", "abortedTotalTime"]) {
+    do_check_true(day.has(field));
+    do_check_true(Array.isArray(day.get(field)));
+    do_check_eq(day.get(field).length, 1);
+  }
 
-  // If we send an active again, this should be counted as inactive.
-  Services.obs.notifyObservers(null, "user-interaction-active", null);
-  yield waitOnDB();
-  values = yield current.getValues();
-  fields = values.singular;
-
-  do_check_eq(fields.get("activeTime")[1], activeTime);
-  do_check_true(fields.get("totalTime")[1] > totalTime);
-  do_check_neq(fields.get("activeTime")[1], fields.get("totalTime")[1]);
-  activeTime = fields.get("activeTime")[1];
-  totalTime = fields.get("totalTime")[1];
-
-  // Another active should increment active this time.
-  Services.obs.notifyObservers(null, "user-interaction-active", null);
-  yield waitOnDB();
-  values = yield current.getValues();
-  fields = values.singular;
-  do_check_true(fields.get("activeTime")[1] > activeTime);
-  do_check_true(fields.get("totalTime")[1] > totalTime);
+  recorder.onShutdown();
+  recorder2.onShutdown();
 
   yield provider.shutdown();
   yield storage.close();
 });
 
--- a/services/metrics/Metrics.jsm
+++ b/services/metrics/Metrics.jsm
@@ -10,15 +10,16 @@ const {utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/services/metrics/collector.jsm");
 Cu.import("resource://gre/modules/services/metrics/dataprovider.jsm");
 Cu.import("resource://gre/modules/services/metrics/storage.jsm");
 
 
 this.Metrics = {
   Collector: Collector,
+  DailyValues: DailyValues,
   Measurement: Measurement,
   Provider: Provider,
   Storage: MetricsStorageBackend,
   dateToDays: dateToDays,
   daysToDate: daysToDate,
 };
 
--- a/services/metrics/dataprovider.jsm
+++ b/services/metrics/dataprovider.jsm
@@ -9,16 +9,17 @@ this.EXPORTED_SYMBOLS = [
   "Provider",
 ];
 
 const {utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/commonjs/promise/core.js");
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://services-common/log4moz.js");
+Cu.import("resource://services-common/preferences.js");
 Cu.import("resource://services-common/utils.js");
 
 
 const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
 
 
 /**
  * Represents a collection of related pieces/fields of data.
@@ -400,16 +401,33 @@ Provider.prototype = Object.freeze({
 
     if (!m) {
       throw new Error("Unknown measurement: " + name + " v" + version);
     }
 
     return m;
   },
 
+  /**
+   * Initializes preferences storage for this provider.
+   *
+   * Providers are allocated preferences storage under a pref branch named
+   * after the provider.
+   *
+   * This function is typically only called by the entity that constructs the
+   * Provider instance.
+   */
+  initPreferences: function (branchParent) {
+    if (!branchParent.endsWith(".")) {
+      throw new Error("branchParent must end with '.': " + branchParent);
+    }
+
+    this._prefs = new Preferences(branchParent + this.name + ".");
+  },
+
   init: function (storage) {
     if (this.storage !== null) {
       throw new Error("Provider() not called. Did the sub-type forget to call it?");
     }
 
     if (this.storage) {
       throw new Error("Provider has already been initialized.");
     }
--- a/services/metrics/storage.jsm
+++ b/services/metrics/storage.jsm
@@ -1,15 +1,16 @@
 /* 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 = [
+  "DailyValues",
   "MetricsStorageBackend",
   "dateToDays",
   "daysToDate",
 ];
 
 const {utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/commonjs/promise/core.js");
@@ -38,19 +39,19 @@ function daysToDate(days) {
  *
  * This emulates Map by providing .size and iterator support. Note that keys
  * from the iterator are Date instances corresponding to midnight of the start
  * of the day. get(), has(), and set() are modeled as getDay(), hasDay(), and
  * setDay(), respectively.
  *
  * All days are defined in terms of UTC (as opposed to local time).
  */
-function DailyValues() {
+this.DailyValues = function () {
   this._days = new Map();
-}
+};
 
 DailyValues.prototype = Object.freeze({
   __iterator__: function () {
     for (let [k, v] of this._days) {
       yield [daysToDate(k), v];
     }
   },
 
@@ -1258,16 +1259,28 @@ MetricsStorageSqliteBackend.prototype = 
 
     if (this._queuedOperations.length == 1) {
       this._popAndPerformQueuedOperation();
     }
 
     return deferred.promise;
   },
 
+  /**
+   * Enqueue a function to be performed as a transaction.
+   *
+   * The passed function should be a generator suitable for calling with
+   * `executeTransaction` from the SQLite connection.
+   */
+  enqueueTransaction: function (func, type) {
+    return this.enqueueOperation(
+      this._connection.executeTransaction.bind(this._connection, func, type)
+    );
+  },
+
   _popAndPerformQueuedOperation: function () {
     if (!this._queuedOperations.length || this._queuedInProgress) {
       return;
     }
 
     this._log.trace("Performing queued operation.");
     let [func, deferred] = this._queuedOperations.shift();
     let promise;
--- a/services/metrics/tests/xpcshell/test_metrics_provider.js
+++ b/services/metrics/tests/xpcshell/test_metrics_provider.js
@@ -2,16 +2,17 @@
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
 "use strict";
 
 const {utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/Metrics.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://services-common/preferences.js");
 Cu.import("resource://testing-common/services/metrics/mocks.jsm");
 
 
 const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
 
 
 function getProvider(storageName) {
   return Task.spawn(function () {
@@ -55,16 +56,28 @@ add_task(function test_init() {
   do_check_eq(m.id, 1);
   do_check_eq(m._fieldsByName.size, 7);
   do_check_true(m.hasField("daily-counter"));
   do_check_false(m.hasField("does-not-exist"));
 
   yield storage.close();
 });
 
+add_test(function test_prefs_integration() {
+  let branch = "testing.prefs_integration.";
+  let provider = new DummyProvider();
+  provider.initPreferences(branch);
+  let prefs = new Preferences(branch);
+
+  prefs.set("DummyProvider.foo", "bar");
+  do_check_eq(provider._prefs.get("foo"), "bar");
+
+  run_next_test();
+});
+
 add_task(function test_default_collectors() {
   let provider = new DummyProvider();
   let storage = yield Metrics.Storage("default_collectors");
   yield provider.init(storage);
 
   for (let property in Metrics.Provider.prototype) {
     if (!property.startsWith("collect")) {
       continue;
--- a/services/metrics/tests/xpcshell/test_metrics_storage.js
+++ b/services/metrics/tests/xpcshell/test_metrics_storage.js
@@ -352,16 +352,51 @@ add_task(function test_enqueue_operation
   yield deferred.promise;
   yield promise;
 
   let count = yield backend.getDailyCounterCountFromFieldID(fID, now);
   do_check_eq(count, 1);
   yield backend.close();
 });
 
+add_task(function test_enqueue_transaction() {
+  let backend = yield Metrics.Storage("enqueue_transaction");
+
+  let mID = yield backend.registerMeasurement("foo", "bar", 1);
+  let fID = yield backend.registerField(mID, "baz", backend.FIELD_DAILY_COUNTER);
+  let now = new Date();
+
+  yield backend.incrementDailyCounterFromFieldID(fID, now);
+
+  yield backend.enqueueTransaction(function transaction() {
+    yield backend.incrementDailyCounterFromFieldID(fID, now);
+  });
+
+  let count = yield backend.getDailyCounterCountFromFieldID(fID, now);
+  do_check_eq(count, 2);
+
+  let errored = false;
+  try {
+    yield backend.enqueueTransaction(function aborted() {
+      yield backend.incrementDailyCounterFromFieldID(fID, now);
+
+      throw new Error("Some error.");
+    });
+  } catch (ex) {
+    errored = true;
+  } finally {
+    do_check_true(errored);
+  }
+
+  count = yield backend.getDailyCounterCountFromFieldID(fID, now);
+  do_check_eq(count, 2);
+
+  yield backend.close();
+});
+
 add_task(function test_increment_daily_counter_basic() {
   let backend = yield Metrics.Storage("increment_daily_counter_basic");
 
   let mID = yield backend.registerMeasurement("foo", "bar", 1);
 
   let fieldID = yield backend.registerField(mID, "baz",
                                             backend.FIELD_DAILY_COUNTER);