Bug 868445 - Part 2: sessions provider for FHR on Android. r=nalexander, a=lsblakk
authorRichard Newman <rnewman@mozilla.com>
Tue, 04 Jun 2013 17:16:57 -0700
changeset 142931 a04d6f9d11318629d4dcf3e51b3eaabe644a7145
parent 142930 01d641ffb1fe7486f1fd33ae1afe4df72069e100
child 142932 659227d5485cf09586446aa16f134f26f5f9b825
push id2579
push userakeybl@mozilla.com
push dateMon, 24 Jun 2013 18:52:47 +0000
treeherdermozilla-beta@b69b7de8a05a [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnalexander, lsblakk
bugs868445
milestone23.0a2
Bug 868445 - Part 2: sessions provider for FHR on Android. r=nalexander, a=lsblakk * * * Bug 868445 - Follow-up: correct missing import. r=trivial
mobile/android/base/GeckoApp.java
mobile/android/base/Telemetry.java
mobile/android/base/health/BrowserHealthRecorder.java
--- a/mobile/android/base/GeckoApp.java
+++ b/mobile/android/base/GeckoApp.java
@@ -10,16 +10,17 @@ import org.mozilla.gecko.background.anno
 import org.mozilla.gecko.db.BrowserDB;
 import org.mozilla.gecko.gfx.BitmapUtils;
 import org.mozilla.gecko.gfx.Layer;
 import org.mozilla.gecko.gfx.LayerView;
 import org.mozilla.gecko.gfx.PanZoomController;
 import org.mozilla.gecko.gfx.PluginLayer;
 import org.mozilla.gecko.gfx.PointUtils;
 import org.mozilla.gecko.health.BrowserHealthRecorder;
+import org.mozilla.gecko.health.BrowserHealthRecorder.SessionInformation;
 import org.mozilla.gecko.updater.UpdateService;
 import org.mozilla.gecko.updater.UpdateServiceHelper;
 import org.mozilla.gecko.util.EventDispatcher;
 import org.mozilla.gecko.util.GeckoEventListener;
 import org.mozilla.gecko.util.GeckoEventResponder;
 import org.mozilla.gecko.util.HardwareUtils;
 import org.mozilla.gecko.util.ThreadUtils;
 import org.mozilla.gecko.util.UiAsyncTask;
@@ -204,16 +205,20 @@ abstract public class GeckoApp
             super(message);
         }
     }
 
     void toggleChrome(final boolean aShow) { }
 
     void focusChrome() { }
 
+    public static SharedPreferences getAppSharedPreferences() {
+        return GeckoApp.mAppContext.getSharedPreferences(PREFS_NAME, 0);
+    }
+
     @Override
     public void onTabChanged(Tab tab, Tabs.TabEvents msg, Object data) {
         // When a tab is closed, it is always unselected first.
         // When a tab is unselected, another tab is always selected first.
         switch(msg) {
             case UNSELECTED:
                 hidePlugins(tab);
                 break;
@@ -750,16 +755,21 @@ abstract public class GeckoApp
             } else if (event.equals("Reader:FaviconRequest")) {
                 final String url = message.getString("url");
                 handleFaviconRequest(url);
             } else if (event.equals("Reader:GoToReadingList")) {
                 showReadingList();
             } else if (event.equals("Gecko:Ready")) {
                 mGeckoReadyStartupTimer.stop();
                 connectGeckoLayerClient();
+
+                // This method is already running on the background thread, so we
+                // know that mHealthRecorder will exist. This method is cheap, so
+                // don't spawn a new runnable.
+                mHealthRecorder.recordGeckoStartupTime(mGeckoReadyStartupTimer.getElapsed());
             } else if (event.equals("ToggleChrome:Hide")) {
                 toggleChrome(false);
             } else if (event.equals("ToggleChrome:Show")) {
                 toggleChrome(true);
             } else if (event.equals("ToggleChrome:Focus")) {
                 focusChrome();
             } else if (event.equals("DOMFullScreen:Start")) {
                 // Local ref to layerView for thread safety
@@ -1444,39 +1454,40 @@ abstract public class GeckoApp
 
             mPrivateBrowsingSession = savedInstanceState.getString(SAVED_STATE_PRIVATE_SESSION);
         }
 
         // Perform background initialization.
         ThreadUtils.postToBackgroundThread(new Runnable() {
             @Override
             public void run() {
-                SharedPreferences prefs =
-                    GeckoApp.mAppContext.getSharedPreferences(PREFS_NAME, 0);
-
-                boolean wasOOM = prefs.getBoolean(PREFS_OOM_EXCEPTION, false);
-                boolean wasStopped = prefs.getBoolean(PREFS_WAS_STOPPED, true);
-                if (wasOOM || !wasStopped) {
+                final SharedPreferences prefs = GeckoApp.getAppSharedPreferences();
+
+                SessionInformation previousSession = SessionInformation.fromSharedPrefs(prefs);
+                if (previousSession.wasKilled()) {
                     Telemetry.HistogramAdd("FENNEC_WAS_KILLED", 1);
                 }
+
                 SharedPreferences.Editor editor = prefs.edit();
                 editor.putBoolean(GeckoApp.PREFS_OOM_EXCEPTION, false);
 
-                // Put a flag to check if we got a normal onSaveInstanceState
-                // on exit, or if we were suddenly killed (crash or native OOM)
+                // Put a flag to check if we got a normal `onSaveInstanceState`
+                // on exit, or if we were suddenly killed (crash or native OOM).
                 editor.putBoolean(GeckoApp.PREFS_WAS_STOPPED, false);
+
                 editor.commit();
 
                 // The lifecycle of mHealthRecorder is "shortly after onCreate"
                 // through "onDestroy" -- essentially the same as the lifecycle
                 // of the activity itself.
                 final String profilePath = getProfile().getDir().getAbsolutePath();
                 final EventDispatcher dispatcher = GeckoAppShell.getEventDispatcher();
                 Log.i(LOGTAG, "Creating BrowserHealthRecorder.");
-                mHealthRecorder = new BrowserHealthRecorder(GeckoApp.mAppContext, profilePath, dispatcher);
+                mHealthRecorder = new BrowserHealthRecorder(GeckoApp.mAppContext, profilePath, dispatcher,
+                                                            previousSession);
             }
         });
 
         GeckoAppShell.setNotificationClient(makeNotificationClient());
     }
 
     protected void initializeChrome() {
         mDoorHangerPopup = new DoorHangerPopup(this, null);
@@ -1675,22 +1686,26 @@ abstract public class GeckoApp
                                            this);
 
         PrefsHelper.getPref("app.update.autodownload", new PrefsHelper.PrefHandlerBase() {
             @Override public void prefValue(String pref, String value) {
                 UpdateServiceHelper.registerForUpdates(GeckoApp.this, value);
             }
         });
 
-        // End of the startup of our Java App
+        // Trigger the completion of the telemetry timer that wraps activity startup,
+        // then grab the duration to give to FHR.
         mJavaUiStartupTimer.stop();
+        final long javaDuration = mJavaUiStartupTimer.getElapsed();
 
         ThreadUtils.getBackgroundHandler().postDelayed(new Runnable() {
             @Override
             public void run() {
+                mHealthRecorder.recordJavaStartupTime(javaDuration);
+
                 // Sync settings need Gecko to be loaded, so
                 // no hurry in starting this.
                 checkMigrateSync();
 
                 // Record our launch time for the announcements service
                 // to use in assessing inactivity.
                 final Context context = GeckoApp.mAppContext;
                 AnnouncementsBroadcastService.recordLastLaunch(context);
@@ -1795,17 +1810,17 @@ abstract public class GeckoApp
         return mProfile;
     }
 
     protected int getSessionRestoreState(Bundle savedInstanceState) {
         if (savedInstanceState != null) {
             return RESTORE_OOM;
         }
 
-        final SharedPreferences prefs = GeckoApp.mAppContext.getSharedPreferences(PREFS_NAME, 0);
+        final SharedPreferences prefs = GeckoApp.getAppSharedPreferences();
 
         // We record crashes in the crash reporter. If sessionstore.js
         // exists, but we didn't flag a crash in the crash reporter, we
         // were probably just force killed by the user, so we shouldn't do
         // a restore.
         if (prefs.getBoolean(PREFS_CRASHED, false)) {
             ThreadUtils.postToBackgroundThread(new Runnable() {
                 @Override
@@ -1999,24 +2014,39 @@ abstract public class GeckoApp
             refreshChrome();
         }
 
         GeckoScreenOrientationListener.getInstance().start();
 
         // User may have enabled/disabled accessibility.
         GeckoAccessibility.updateAccessibilitySettings(this);
 
+        // We use two times: a pseudo-unique wall-clock time to identify the
+        // current session across power cycles, and the elapsed realtime to
+        // track the duration of the session.
+        final long now = System.currentTimeMillis();
+        final long realTime = android.os.SystemClock.elapsedRealtime();
+        final BrowserHealthRecorder rec = mHealthRecorder;
+
         ThreadUtils.postToBackgroundThread(new Runnable() {
             @Override
             public void run() {
-                SharedPreferences prefs =
-                    GeckoApp.mAppContext.getSharedPreferences(GeckoApp.PREFS_NAME, 0);
+                // Now construct the new session on BrowserHealthRecorder's behalf. We do this here
+                // so it can benefit from a single near-startup prefs commit.
+                SessionInformation currentSession = new SessionInformation(now, realTime);
+
+                SharedPreferences prefs = GeckoApp.getAppSharedPreferences();
                 SharedPreferences.Editor editor = prefs.edit();
                 editor.putBoolean(GeckoApp.PREFS_WAS_STOPPED, false);
+                currentSession.recordBegin(editor);
                 editor.commit();
+
+                if (rec != null) {
+                    rec.setCurrentSession(currentSession);
+                }
             }
          });
     }
 
     @Override
     public void onWindowFocusChanged(boolean hasFocus) {
         super.onWindowFocusChanged(hasFocus);
 
@@ -2024,43 +2054,46 @@ abstract public class GeckoApp
             initialize();
             getWindow().setBackgroundDrawable(null);
         }
     }
 
     @Override
     public void onPause()
     {
+        final BrowserHealthRecorder rec = mHealthRecorder;
+
         // In some way it's sad that Android will trigger StrictMode warnings
         // here as the whole point is to save to disk while the activity is not
         // interacting with the user.
         ThreadUtils.postToBackgroundThread(new Runnable() {
             @Override
             public void run() {
-                SharedPreferences prefs =
-                    GeckoApp.mAppContext.getSharedPreferences(GeckoApp.PREFS_NAME, 0);
+                SharedPreferences prefs = GeckoApp.getAppSharedPreferences();
                 SharedPreferences.Editor editor = prefs.edit();
                 editor.putBoolean(GeckoApp.PREFS_WAS_STOPPED, true);
+                if (rec != null) {
+                    rec.recordSessionEnd("P", editor);
+                }
                 editor.commit();
             }
         });
 
         GeckoScreenOrientationListener.getInstance().stop();
 
         super.onPause();
     }
 
     @Override
     public void onRestart()
     {
         ThreadUtils.postToBackgroundThread(new Runnable() {
             @Override
             public void run() {
-                SharedPreferences prefs =
-                    GeckoApp.mAppContext.getSharedPreferences(GeckoApp.PREFS_NAME, 0);
+                SharedPreferences prefs = GeckoApp.getAppSharedPreferences();
                 SharedPreferences.Editor editor = prefs.edit();
                 editor.putBoolean(GeckoApp.PREFS_WAS_STOPPED, false);
                 editor.commit();
             }
         });
 
         super.onRestart();
     }
@@ -2123,19 +2156,26 @@ abstract public class GeckoApp
         Tabs.getInstance().detachFromActivity(this);
 
         if (SmsManager.getInstance() != null) {
             SmsManager.getInstance().stop();
             if (isFinishing())
                 SmsManager.getInstance().shutdown();
         }
 
-        if (mHealthRecorder != null) {
-            mHealthRecorder.close();
-            mHealthRecorder = null;
+        final BrowserHealthRecorder rec = mHealthRecorder;
+        mHealthRecorder = null;
+        if (rec != null) {
+            // Closing a BrowserHealthRecorder could incur a write.
+            ThreadUtils.postToBackgroundThread(new Runnable() {
+                @Override
+                public void run() {
+                    rec.close();
+                }
+            });
         }
 
         super.onDestroy();
 
         Tabs.unregisterOnTabsChangedListener(this);
     }
 
     protected void registerEventListener(String event) {
--- a/mobile/android/base/Telemetry.java
+++ b/mobile/android/base/Telemetry.java
@@ -31,36 +31,42 @@ public class Telemetry {
             Log.e(LOGTAG, "JSON exception: ", e);
         }
     }
 
     public static class Timer {
         private long mStartTime;
         private String mName;
         private boolean mHasFinished;
+        private volatile long mElapsed = -1;
 
         public Timer(String name) {
             mName = name;
             mStartTime = SystemClock.uptimeMillis();
             mHasFinished = false;
         }
 
         public void cancel() {
             mHasFinished = true;
         }
 
+        public long getElapsed() {
+          return mElapsed;
+        }
+
         public void stop() {
             // Only the first stop counts.
             if (mHasFinished) {
                 return;
             } else {
                 mHasFinished = true;
             }
 
-            long elapsed = SystemClock.uptimeMillis() - mStartTime;
+            final long elapsed = SystemClock.uptimeMillis() - mStartTime;
+            mElapsed = elapsed;
             if (elapsed < Integer.MAX_VALUE) {
                 HistogramAdd(mName, (int)(elapsed));
             } else {
-                Log.e(LOGTAG, "Duration of " + elapsed + " ms is too long.");
+                Log.e(LOGTAG, "Duration of " + elapsed + " ms is too long to add to histogram.");
             }
         }
     }
 }
--- a/mobile/android/base/health/BrowserHealthRecorder.java
+++ b/mobile/android/base/health/BrowserHealthRecorder.java
@@ -4,60 +4,64 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.health;
 
 import java.util.ArrayList;
 
 import android.content.Context;
 import android.content.ContentProviderClient;
+import android.content.SharedPreferences;
 import android.util.Log;
 
 import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.GeckoApp;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoEvent;
 import org.mozilla.gecko.PrefsHelper;
 import org.mozilla.gecko.PrefsHelper.PrefHandler;
 
 import org.mozilla.gecko.background.healthreport.EnvironmentBuilder;
 import org.mozilla.gecko.background.healthreport.HealthReportDatabaseStorage;
 import org.mozilla.gecko.background.healthreport.HealthReportStorage.Field;
 import org.mozilla.gecko.background.healthreport.HealthReportStorage.MeasurementFields;
 import org.mozilla.gecko.background.healthreport.HealthReportStorage.MeasurementFields.FieldSpec;
 import org.mozilla.gecko.background.healthreport.ProfileInformationCache;
 
 import org.mozilla.gecko.util.EventDispatcher;
 import org.mozilla.gecko.util.GeckoEventListener;
 import org.mozilla.gecko.util.ThreadUtils;
 
+import org.json.JSONException;
 import org.json.JSONObject;
 
 import java.io.File;
 import java.io.FileOutputStream;
 import java.io.OutputStreamWriter;
 import java.nio.charset.Charset;
 import java.util.ArrayList;
 import java.util.Scanner;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
  * BrowserHealthRecorder is the browser's interface to the Firefox Health
  * Report storage system. It manages environments (a collection of attributes
  * that are tracked longitudinally) on the browser's behalf, exposing a simpler
  * interface for recording changes.
  *
  * Keep an instance of this class around.
  *
  * Tell it when an environment attribute has changed: call {@link
  * #onBlocklistPrefChanged(boolean)} or {@link
  * #onTelemetryPrefChanged(boolean)}, followed by {@link
  * #onEnvironmentChanged()}.
  *
  * Use it to record events: {@link #recordSearch(String, String)}.
  *
- * Shut it down when you're done being a browser: {@link #close(EventDispatcher)}.
+ * Shut it down when you're done being a browser: {@link #close()}.
  */
 public class BrowserHealthRecorder implements GeckoEventListener {
     private static final String LOG_TAG = "GeckoHealthRec";
     private static final String PREF_BLOCKLIST_ENABLED = "extensions.blocklist.enabled";
     private static final String EVENT_ADDONS_ALL = "Addons:All";
     private static final String EVENT_ADDONS_CHANGE = "Addons:Change";
     private static final String EVENT_PREF_CHANGE = "Pref:Change";
  
@@ -73,54 +77,181 @@ public class BrowserHealthRecorder imple
         INITIALIZING,
         INITIALIZED,
         INITIALIZATION_FAILED,
         CLOSED
     }
 
     protected volatile State state = State.NOT_INITIALIZED;
 
+    private final AtomicBoolean orphanChecked = new AtomicBoolean(false);
     private volatile int env = -1;
     private volatile HealthReportDatabaseStorage storage;
     private final ProfileInformationCache profileCache;
     private ContentProviderClient client;
     private final EventDispatcher dispatcher;
 
+    public static class SessionInformation {
+        private static final String LOG_TAG = "GeckoSessInfo";
+
+        public static final String PREFS_SESSION_START = "sessionStart";
+
+        public final long wallStartTime;    // System wall clock.
+        public final long realStartTime;    // Realtime clock.
+
+        private final boolean wasOOM;
+        private final boolean wasStopped;
+
+        private volatile long timedGeckoStartup = -1;
+        private volatile long timedJavaStartup = -1;
+
+        // Current sessions don't (right now) care about wasOOM/wasStopped.
+        // Eventually we might want to lift that logic out of GeckoApp.
+        public SessionInformation(long wallTime, long realTime) {
+            this(wallTime, realTime, false, false);
+        }
+
+        // Previous sessions do...
+        public SessionInformation(long wallTime, long realTime, boolean wasOOM, boolean wasStopped) {
+            this.wallStartTime = wallTime;
+            this.realStartTime = realTime;
+            this.wasOOM = wasOOM;
+            this.wasStopped = wasStopped;
+        }
+
+        /**
+         * Initialize a new SessionInformation instance from the supplied prefs object.
+         *
+         * This includes retrieving OOM/crash data, as well as timings.
+         *
+         * If no wallStartTime was found, that implies that the previous
+         * session was correctly recorded, and an object with a zero
+         * wallStartTime is returned.
+         */
+        public static SessionInformation fromSharedPrefs(SharedPreferences prefs) {
+            boolean wasOOM = prefs.getBoolean(GeckoApp.PREFS_OOM_EXCEPTION, false);
+            boolean wasStopped = prefs.getBoolean(GeckoApp.PREFS_WAS_STOPPED, true);
+            long wallStartTime = prefs.getLong(PREFS_SESSION_START, 0L);
+            long realStartTime = 0L;
+            Log.d(LOG_TAG, "Building SessionInformation from prefs: " +
+                           wallStartTime + ", " + realStartTime + ", " +
+                           wasStopped + ", " + wasOOM);
+            return new SessionInformation(wallStartTime, realStartTime, wasOOM, wasStopped);
+        }
+
+        public boolean wasKilled() {
+            return wasOOM || !wasStopped;
+        }
+
+        /**
+         * Record the beginning of this session to SharedPreferences by
+         * recording our start time. If a session was already recorded, it is
+         * overwritten (there can only be one running session at a time). Does
+         * not commit the editor.
+         */
+        public void recordBegin(SharedPreferences.Editor editor) {
+            Log.d(LOG_TAG, "Recording start of session: " + this.wallStartTime);
+            editor.putLong(PREFS_SESSION_START, this.wallStartTime);
+        }
+
+        /**
+         * Record the completion of this session to SharedPreferences by
+         * deleting our start time. Does not commit the editor.
+         */
+        public void recordCompletion(SharedPreferences.Editor editor) {
+            Log.d(LOG_TAG, "Recording session done: " + this.wallStartTime);
+            editor.remove(PREFS_SESSION_START);
+        }
+
+        /**
+         * Return the JSON that we'll put in the DB for this session.
+         */
+        public JSONObject getCompletionJSON(String reason, long realEndTime) throws JSONException {
+            long durationSecs = (realEndTime - this.realStartTime) / 1000;
+            JSONObject out = new JSONObject();
+            out.put("r", reason);
+            out.put("d", durationSecs);
+            if (this.timedGeckoStartup > 0) {
+                out.put("sg", this.timedGeckoStartup);
+            }
+            if (this.timedJavaStartup > 0) {
+                out.put("sj", this.timedJavaStartup);
+            }
+            return out;
+        }
+
+        public JSONObject getCrashedJSON() throws JSONException {
+            JSONObject out = new JSONObject();
+            // We use ints here instead of booleans, because we're packing
+            // stuff into JSON, and saving bytes in the DB is a worthwhile
+            // goal.
+            out.put("oom", this.wasOOM ? 1 : 0);
+            out.put("stopped", this.wasStopped ? 1 : 0);
+            out.put("r", "A");
+            return out;
+        }
+    }
+
+    // We track previousSession to avoid order-of-initialization confusion. We
+    // accept it in the constructor, and process it after init.
+    private final SessionInformation previousSession;
+    private volatile SessionInformation session = null;
+    public SessionInformation getCurrentSession() {
+        return this.session;
+    }
+
+    public void setCurrentSession(SessionInformation session) {
+        this.session = session;
+    }
+
+    public void recordGeckoStartupTime(long duration) {
+        if (this.session == null) {
+            return;
+        }
+        this.session.timedGeckoStartup = duration;
+    }
+    public void recordJavaStartupTime(long duration) {
+        if (this.session == null) {
+            return;
+        }
+        this.session.timedJavaStartup = duration;
+    }
+
     /**
      * Persist the opaque identifier for the current Firefox Health Report environment.
      * This changes in certain circumstances; be sure to use the current value when recording data.
      */
     private void setHealthEnvironment(final int env) {
         this.env = env;
     }
 
     /**
      * This constructor does IO. Run it on a background thread.
      */
-    public BrowserHealthRecorder(final Context context, final String profilePath, final EventDispatcher dispatcher) {
+    public BrowserHealthRecorder(final Context context, final String profilePath, final EventDispatcher dispatcher, SessionInformation previousSession) {
         Log.d(LOG_TAG, "Initializing. Dispatcher is " + dispatcher);
         this.dispatcher = dispatcher;
+        this.previousSession = previousSession;
+
         this.client = EnvironmentBuilder.getContentProviderClient(context);
         if (this.client == null) {
             throw new IllegalStateException("Could not fetch Health Report content provider.");
         }
 
         this.storage = EnvironmentBuilder.getStorage(this.client, profilePath);
         if (this.storage == null) {
             throw new IllegalStateException("No storage in health recorder!");
         }
 
         this.profileCache = new ProfileInformationCache(profilePath);
         try {
             this.initialize(context, profilePath);
         } catch (Exception e) {
             Log.e(LOG_TAG, "Exception initializing.", e);
         }
-
-        // TODO: record session start and end?
     }
 
     /**
      * Shut down database connections, unregister event listeners, and perform
      * provider-specific uninitialization.
      */
     public synchronized void close() {
         switch (this.state) {
@@ -348,29 +479,34 @@ public class BrowserHealthRecorder imple
                     }
 
                     try {
                         // Listen for add-ons and prefs changes.
                         dispatcher.registerEventListener(EVENT_ADDONS_CHANGE, self);
                         dispatcher.registerEventListener(EVENT_PREF_CHANGE, self);
 
                         // Initialize each provider here.
+                        initializeSessionsProvider();
                         initializeSearchProvider();
 
                         Log.d(LOG_TAG, "Ensuring environment.");
                         ensureEnvironment();
 
                         Log.d(LOG_TAG, "Finishing init.");
                         storage.finishInitialization();
                         state = State.INITIALIZED;
                     } catch (Exception e) {
                         state = State.INITIALIZATION_FAILED;
                         storage.abortInitialization();
                         Log.e(LOG_TAG, "Initialization failed.", e);
+                        return;
                     }
+
+                    // Now do whatever we do after we start up.
+                    checkForOrphanSessions();
                 }
             }
         });
     }
 
     /**
      * Add provider-specific initialization in this method.
      */
@@ -378,17 +514,17 @@ public class BrowserHealthRecorder imple
                                          final String profilePath)
         throws java.io.IOException {
 
         Log.d(LOG_TAG, "Initializing profile cache.");
         this.state = State.INITIALIZING;
 
         // If we can restore state from last time, great.
         if (this.profileCache.restoreUnlessInitialized()) {
-            Log.i(LOG_TAG, "Successfully restored state. Initializing storage.");
+            Log.d(LOG_TAG, "Successfully restored state. Initializing storage.");
             initializeStorage();
             return;
         }
 
         // Otherwise, let's initialize it from scratch.
         this.profileCache.beginInitialization();
         this.profileCache.setProfileCreationTime(getAndPersistProfileInitTime(context, profilePath));
 
@@ -636,10 +772,160 @@ public class BrowserHealthRecorder imple
                 final int searchField = storage.getField(MEASUREMENT_NAME_SEARCH_COUNTS,
                                                          MEASUREMENT_VERSION_SEARCH_COUNTS,
                                                          location)
                                                .getID();
                 storage.recordDailyDiscrete(env, day, searchField, key);
             }
         });
     }
+
+    /*
+     * Sessions.
+     *
+     * We record session beginnings in SharedPreferences, because it's cheaper
+     * to do that than to either write to then update the DB (which requires
+     * keeping a row identifier to update, as well as two writes) or to record
+     * two events (which doubles storage space and requires rollup logic).
+     *
+     * The pattern is:
+     *
+     * 1. On startup, determine whether an orphan session exists by looking for
+     *    a saved timestamp in prefs. If it does, then record the orphan in FHR
+     *    storage.
+     * 2. Record in prefs that a new session has begun. Track the timestamp (so
+     *    we know to which day the session belongs).
+     * 3. As startup timings become available, accumulate them in memory.
+     * 4. On clean shutdown, read the values from here, write them to the DB, and
+     *    delete the sentinel time from SharedPreferences.
+     * 5. On a dirty shutdown, the in-memory session will not be written to the
+     *    DB, and the current session will be orphaned.
+     *
+     * Sessions are begun in onResume (and thus implicitly onStart) and ended
+     * in onPause.
+     *
+     * Session objects are stored as discrete JSON.
+     *
+     *   "org.mozilla.appSessions": {
+     *     _v: 4,
+     *     "normal": [
+     *       {"r":"P", "d": 123},
+     *     ],
+     *     "abnormal": [
+     *       {"r":"A", "oom": true, "stopped": false}
+     *     ]
+     *   }
+     *
+     * "r": reason. Values are "P" (activity paused), "A" (abnormal termination)
+     * "d": duration. Value in seconds.
+     * "sg": Gecko startup time. Present if this is a clean launch.
+     * "sj": Java startup time. Present if this is a clean launch.
+     *
+     * Abnormal terminations will be missing a duration and will feature these keys:
+     *
+     * "oom": was the session killed by an OOM exception?
+     * "stopped": was the session stopped gently?
+     */
+
+    public static final String MEASUREMENT_NAME_SESSIONS = "org.mozilla.appSessions";
+    public static final int MEASUREMENT_VERSION_SESSIONS = 4;
+
+    private void initializeSessionsProvider() {
+        this.storage.ensureMeasurementInitialized(
+            MEASUREMENT_NAME_SESSIONS,
+            MEASUREMENT_VERSION_SESSIONS,
+            new MeasurementFields() {
+                @Override
+                public Iterable<FieldSpec> getFields() {
+                    ArrayList<FieldSpec> out = new ArrayList<FieldSpec>(2);
+                    out.add(new FieldSpec("normal", Field.TYPE_JSON_DISCRETE));
+                    out.add(new FieldSpec("abnormal", Field.TYPE_JSON_DISCRETE));
+                    return out;
+                }
+        });
+    }
+
+    /**
+     * Logic shared between crashed and normal sessions.
+     */
+    private void recordSessionEntry(String field, SessionInformation session, JSONObject value) {
+        try {
+            final int sessionField = storage.getField(MEASUREMENT_NAME_SESSIONS,
+                                                      MEASUREMENT_VERSION_SESSIONS,
+                                                      field)
+                                            .getID();
+            final int day = storage.getDay(session.wallStartTime);
+            storage.recordDailyDiscrete(env, day, sessionField, value);
+        } catch (Exception e) {
+            Log.w(LOG_TAG, "Unable to record session completion.", e);
+        }
+    }
+
+    public void checkForOrphanSessions() {
+        if (!this.orphanChecked.compareAndSet(false, true)) {
+            Log.w(LOG_TAG, "Attempting to check for orphan sessions more than once.");
+            return;
+        }
+
+        Log.d(LOG_TAG, "Checking for orphan session.");
+        if (this.previousSession == null) {
+            return;
+        }
+        if (this.previousSession.wallStartTime == 0) {
+            return;
+        }
+
+        if (state != State.INITIALIZED) {
+            // Something has gone awry.
+            Log.e(LOG_TAG, "Attempted to record bad session end without initialized recorder.");
+            return;
+        }
+
+        try {
+            recordSessionEntry("abnormal", this.previousSession, this.previousSession.getCrashedJSON());
+        } catch (Exception e) {
+            Log.w(LOG_TAG, "Unable to generate session JSON.", e);
+
+            // Future: record this exception in FHR's own error submitter.
+        }
+    }
+
+    /**
+     * Record that the current session ended. Does not commit the provided editor.
+     */
+    public void recordSessionEnd(String reason, SharedPreferences.Editor editor) {
+        Log.d(LOG_TAG, "Recording session end: " + reason);
+        if (state != State.INITIALIZED) {
+            // Something has gone awry.
+            Log.e(LOG_TAG, "Attempted to record session end without initialized recorder.");
+            return;
+        }
+
+        final SessionInformation session = this.session;
+        this.session = null;        // So it can't be double-recorded.
+
+        if (session == null) {
+            Log.w(LOG_TAG, "Unable to record session end: no session. Already ended?");
+            return;
+        }
+
+        if (session.wallStartTime <= 0) {
+            Log.e(LOG_TAG, "Session start " + session.wallStartTime + " isn't valid! Can't record end.");
+            return;
+        }
+
+        long realEndTime = android.os.SystemClock.elapsedRealtime();
+        try {
+            JSONObject json = session.getCompletionJSON(reason, realEndTime);
+            recordSessionEntry("normal", session, json);
+        } catch (JSONException e) {
+            Log.w(LOG_TAG, "Unable to generate session JSON.", e);
+
+            // Continue so we don't hit it next time.
+            // Future: record this exception in FHR's own error submitter.
+        }
+
+        // Track the end of this session in shared prefs, so it doesn't get
+        // double-counted on next run.
+        session.recordCompletion(editor);
+    }
 }