Merge m-c to inbound.
authorRyan VanderMeulen <ryanvm@gmail.com>
Sat, 19 Jan 2013 18:49:38 -0500
changeset 119375 587ac3f2bd30880393aa13546ae8d6c59ff8200f
parent 119374 ce70deca6e8e637e4f28f87fa5b7a92951e20a9d (current diff)
parent 119358 a97eb4f09112957e54797b332d852195524799f7 (diff)
child 119376 68f8f4b74f63d074b042af1ff4052e4fb1513dda
push id24197
push userryanvm@gmail.com
push dateSun, 20 Jan 2013 05:25:28 +0000
treeherdermozilla-central@1d122eaa9070 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
milestone21.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Merge m-c to inbound.
--- 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;
       }
     }
 
@@ -631,17 +635,17 @@ HealthReporter.prototype = Object.freeze
           this._log.warn("Error obtaining serializer for measurement: " + name +
                          ": " + CommonUtils.exceptionStr(ex));
           errors.push("Could not obtain serializer: " + name);
           continue;
         }
 
         let data;
         try {
-          data = yield this._storage.getMeasurementValues(measurement.id);
+          data = yield measurement.getValues();
         } catch (ex) {
           this._log.warn("Error obtaining data for measurement: " +
                          name + ": " + CommonUtils.exceptionStr(ex));
           errors.push("Could not obtain data: " + name);
           continue;
         }
 
         if (data.singular.size) {
--- a/services/healthreport/providers.jsm
+++ b/services/healthreport/providers.jsm
@@ -350,75 +350,85 @@ 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,
+    });
+  },
+
+  _serializeJSONSingular: function (data) {
+    let result = {};
+
+    for (let [field, value] of data) {
+      result[field] = value[1];
+    }
+
+    return result;
   },
 });
 
-
 /**
  * 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 +458,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,180 @@
 
 "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();
 });
 
+add_task(function test_serialization() {
+  let [provider, storage, recorder] = yield getProvider("serialization");
+
+  let current = provider.getMeasurement("current", 2);
+  let data = yield current.getValues();
+  do_check_true("singular" in data);
+
+  let serializer = current.serializer(current.SERIALIZE_JSON);
+  let fields = serializer.singular(data.singular);
+
+  do_check_eq(fields.activeTicks, 0);
+  do_check_eq(fields.startDay, Metrics.dateToDays(recorder.startDate));
+  do_check_eq(fields.main, 500);
+  do_check_eq(fields.firstPaint, 1000);
+  do_check_eq(fields.sessionRestored, 1500);
+  do_check_true(fields.totalTime > 0);
+
+  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);