Merge m-c to s-c.
authorRichard Newman <rnewman@mozilla.com>
Wed, 05 Jun 2013 11:52:39 -0700
changeset 134133 1658dc513572b96829432b8d8debc3dc3c2379e3
parent 134078 ddb7b23166ef3ddafad31b326fd97bd8a2a9def4 (current diff)
parent 134132 168173d49c8768a3272b32aa75353badf25a25fc (diff)
child 134138 89a5a4c14185ebbd8d79bb72d732ba722f228c9d
push id29067
push userryanvm@gmail.com
push dateWed, 05 Jun 2013 20:37:20 +0000
treeherdermozilla-inbound@72fbfb2f8e51 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
milestone24.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 s-c.
mobile/android/base/GeckoApp.java
--- a/mobile/android/base/GeckoApp.java
+++ b/mobile/android/base/GeckoApp.java
@@ -11,16 +11,17 @@ 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.PluginLayer;
 import org.mozilla.gecko.menu.GeckoMenu;
 import org.mozilla.gecko.menu.GeckoMenuInflater;
 import org.mozilla.gecko.menu.MenuPanel;
 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;
@@ -243,16 +244,20 @@ abstract public class GeckoApp
         }
         return this;
     }
 
     public SensorEventListener getSensorEventListener() {
         return this;
     }
 
+    public static SharedPreferences getAppSharedPreferences() {
+        return GeckoApp.sAppContext.getSharedPreferences(PREFS_NAME, 0);
+    }
+
     public SurfaceView getCameraView() {
         return mCameraView;
     }
 
     public void addAppStateListener(GeckoAppShell.AppStateListener listener) {
         mAppStateListeners.add(listener);
     }
 
@@ -539,16 +544,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();
                 geckoConnected();
+
+                // 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
@@ -1235,39 +1245,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.sAppContext.getSharedPreferences(PREFS_NAME, 0);
+                final SharedPreferences prefs = GeckoApp.getAppSharedPreferences();
 
-                boolean wasOOM = prefs.getBoolean(PREFS_OOM_EXCEPTION, false);
-                boolean wasStopped = prefs.getBoolean(PREFS_WAS_STOPPED, true);
-                if (wasOOM || !wasStopped) {
+                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(sAppContext, profilePath, dispatcher);
+                mHealthRecorder = new BrowserHealthRecorder(sAppContext, profilePath, dispatcher,
+                                                            previousSession);
             }
         });
 
         GeckoAppShell.setNotificationClient(makeNotificationClient());
     }
 
     protected void initializeChrome() {
         mDoorHangerPopup = new DoorHangerPopup(this, null);
@@ -1468,22 +1479,26 @@ abstract public class GeckoApp
             @Override public void prefValue(String pref, int value) {
                 if (value == 1)
                     mShouldReportGeoData = true;
                 else
                     mShouldReportGeoData = false;
             }
         });
 
-        // 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.sAppContext;
                 AnnouncementsBroadcastService.recordLastLaunch(context);
@@ -1588,17 +1603,17 @@ abstract public class GeckoApp
         return mProfile;
     }
 
     protected int getSessionRestoreState(Bundle savedInstanceState) {
         if (savedInstanceState != null) {
             return RESTORE_OOM;
         }
 
-        final SharedPreferences prefs = GeckoApp.sAppContext.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
@@ -1787,29 +1802,44 @@ abstract public class GeckoApp
         }
 
         GeckoScreenOrientationListener.getInstance().start();
 
         // User may have enabled/disabled accessibility.
         GeckoAccessibility.updateAccessibilitySettings(this);
 
         if (mAppStateListeners != null) {
-            for(GeckoAppShell.AppStateListener listener: mAppStateListeners) {
+            for (GeckoAppShell.AppStateListener listener: mAppStateListeners) {
                 listener.onResume();
             }
         }
 
+        // 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.sAppContext.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);
 
@@ -1817,26 +1847,30 @@ 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.sAppContext.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();
 
         if (mAppStateListeners != null) {
             for(GeckoAppShell.AppStateListener listener: mAppStateListeners) {
@@ -1848,18 +1882,17 @@ abstract public class GeckoApp
     }
 
     @Override
     public void onRestart()
     {
         ThreadUtils.postToBackgroundThread(new Runnable() {
             @Override
             public void run() {
-                SharedPreferences prefs =
-                    GeckoApp.sAppContext.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();
     }
@@ -1922,19 +1955,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/background/healthreport/Environment.java
+++ b/mobile/android/base/background/healthreport/Environment.java
@@ -236,16 +236,20 @@ public abstract class Environment {
     }
   }
 
   public void setJSONForAddons(byte[] json) throws Exception {
     setJSONForAddons(new String(json, "UTF-8"));
   }
 
   public void setJSONForAddons(String json) throws Exception {
+    if (json == null || "null".equals(json)) {
+      addons = null;
+      return;
+    }
     addons = new JSONObject(json);
   }
 
   public void setJSONForAddons(JSONObject json) {
     addons = json;
   }
 
   /**
--- a/mobile/android/base/background/healthreport/HealthReportDatabaseStorage.java
+++ b/mobile/android/base/background/healthreport/HealthReportDatabaseStorage.java
@@ -6,16 +6,17 @@ package org.mozilla.gecko.background.hea
 
 import java.io.File;
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Executor;
 import java.util.concurrent.Executors;
 
+import org.json.JSONObject;
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.background.healthreport.HealthReportStorage.MeasurementFields.FieldSpec;
 
 import android.content.ContentValues;
 import android.content.Context;
 import android.content.ContextWrapper;
 import android.database.Cursor;
 import android.database.SQLException;
@@ -210,17 +211,17 @@ public class HealthReportDatabaseStorage
       // *is* called and still support v8.
       // Instead we check the version code in the HealthReportSQLiteOpenHelper
       // constructor, and only use this workaround if we need to.
       @Override
       public SQLiteDatabase openOrCreateDatabase(String name,
                                                  int mode,
                                                  SQLiteDatabase.CursorFactory factory) {
         final File path = getDatabasePath(name);
-        Logger.info(LOG_TAG, "Opening database through absolute path " + path.getAbsolutePath());
+        Logger.pii(LOG_TAG, "Opening database through absolute path " + path.getAbsolutePath());
         return SQLiteDatabase.openOrCreateDatabase(path, null);
       }
     }
 
     public static String getAbsolutePath(File parent, String name) {
       return parent.getAbsolutePath() + File.separator + name;
     }
 
@@ -228,17 +229,17 @@ public class HealthReportDatabaseStorage
     public HealthReportSQLiteOpenHelper(Context context, File profileDirectory, String name) {
       super(
           (CAN_USE_ABSOLUTE_DB_PATH ? context : new AbsolutePathContext(context, profileDirectory)),
           (CAN_USE_ABSOLUTE_DB_PATH ? getAbsolutePath(profileDirectory, name) : name),
           null,
           CURRENT_VERSION);
 
       if (CAN_USE_ABSOLUTE_DB_PATH) {
-        Logger.info(LOG_TAG, "Opening: " + getAbsolutePath(profileDirectory, name));
+        Logger.pii(LOG_TAG, "Opening: " + getAbsolutePath(profileDirectory, name));
       }
     }
 
     @Override
     public void onCreate(SQLiteDatabase db) {
       db.beginTransaction();
       try {
         db.execSQL("CREATE TABLE addons (id INTEGER PRIMARY KEY AUTOINCREMENT, " +
@@ -1030,16 +1031,21 @@ public class HealthReportDatabaseStorage
       v.put("env", env);
       v.put("field", field);
       v.put("date", day);
       db.insert(table, null, v);
     }
   }
 
   @Override
+  public void recordDailyLast(int env, int day, int field, JSONObject value) {
+    this.recordDailyLast(env, day, field, value == null ? "null" : value.toString(), EVENTS_TEXTUAL);
+  }
+
+  @Override
   public void recordDailyLast(int env, int day, int field, String value) {
     this.recordDailyLast(env, day, field, value, EVENTS_TEXTUAL);
   }
 
   @Override
   public void recordDailyLast(int env, int day, int field, int value) {
     this.recordDailyLast(env, day, field, Integer.valueOf(value), EVENTS_INTEGER);
   }
@@ -1056,16 +1062,21 @@ public class HealthReportDatabaseStorage
     v.put("date", day);
 
     final SQLiteDatabase db = this.helper.getWritableDatabase();
     putValue(v, value);
     db.insert(table, null, v);
   }
 
   @Override
+  public void recordDailyDiscrete(int env, int day, int field, JSONObject value) {
+    this.recordDailyDiscrete(env, day, field, value == null ? "null" : value.toString(), EVENTS_TEXTUAL);
+  }
+
+  @Override
   public void recordDailyDiscrete(int env, int day, int field, String value) {
     this.recordDailyDiscrete(env, day, field, value, EVENTS_TEXTUAL);
   }
 
   @Override
   public void recordDailyDiscrete(int env, int day, int field, int value) {
     this.recordDailyDiscrete(env, day, field, value, EVENTS_INTEGER);
   }
--- a/mobile/android/base/background/healthreport/HealthReportGenerator.java
+++ b/mobile/android/base/background/healthreport/HealthReportGenerator.java
@@ -107,17 +107,17 @@ public class HealthReportGenerator {
       for (int i = 0; i < envs.size(); ++i) {
         Logger.trace(LOG_TAG, "Days environment " + envs.keyAt(i) + ": " + envs.get(envs.keyAt(i)).getHash());
       }
     }
 
     JSONObject days = new JSONObject();
     Cursor cursor = storage.getRawEventsSince(since);
     try {
-      if (!cursor.moveToNext()) {
+      if (!cursor.moveToFirst()) {
         return days;
       }
 
       // A classic walking partition.
       // Columns are "date", "env", "field", "value".
       // Note that we care about the type (integer, string) and kind
       // (last/counter, discrete) of each field.
       // Each field will be accessed once for each date/env pair, so
@@ -181,16 +181,32 @@ public class HealthReportGenerator {
       }
       days.put(HealthReportUtils.getDateStringForDay(lastDate), dateObject);
     } finally {
       cursor.close();
     }
     return days;
   }
 
+  /**
+   * Return the {@link JSONObject} parsed from the provided index of the given
+   * cursor, or {@link JSONObject#NULL} if either SQL <code>NULL</code> or
+   * string <code>"null"</code> is present at that index.
+   */
+  private static Object getJSONAtIndex(Cursor cursor, int index) throws JSONException {
+    if (cursor.isNull(index)) {
+      return JSONObject.NULL;
+    }
+    final String value = cursor.getString(index);
+    if ("null".equals(value)) {
+      return JSONObject.NULL;
+    }
+    return new JSONObject(value);
+  }
+
   protected static void recordMeasurementFromCursor(final Field field,
                                              JSONObject measurement,
                                              Cursor cursor)
                                                            throws JSONException {
     if (field.isDiscreteField()) {
       // Discrete counted. Increment the named counter.
       if (field.isCountedField()) {
         if (!field.isStringField()) {
@@ -200,28 +216,36 @@ public class HealthReportGenerator {
         return;
       }
 
       // Discrete string or integer. Append it.
       if (field.isStringField()) {
         HealthReportUtils.append(measurement, field.fieldName, cursor.getString(3));
         return;
       }
+      if (field.isJSONField()) {
+        HealthReportUtils.append(measurement, field.fieldName, getJSONAtIndex(cursor, 3));
+        return;
+      }
       if (field.isIntegerField()) {
         HealthReportUtils.append(measurement, field.fieldName, cursor.getLong(3));
         return;
       }
       throw new IllegalStateException("Unknown field type: " + field.flags);
     }
 
     // Non-discrete -- must be LAST or COUNTER, so just accumulate the value.
     if (field.isStringField()) {
       measurement.put(field.fieldName, cursor.getString(3));
       return;
     }
+    if (field.isJSONField()) {
+      measurement.put(field.fieldName, getJSONAtIndex(cursor, 3));
+      return;
+    }
     measurement.put(field.fieldName, cursor.getLong(3));
   }
 
   public static JSONObject getEnvironmentsJSON(Environment currentEnvironment,
                                                SparseArray<Environment> envs) throws JSONException {
     JSONObject environments = new JSONObject();
 
     // Always do this, even if it hasn't recorded anything in the DB.
--- a/mobile/android/base/background/healthreport/HealthReportStorage.java
+++ b/mobile/android/base/background/healthreport/HealthReportStorage.java
@@ -1,14 +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/. */
 
 package org.mozilla.gecko.background.healthreport;
 
+import org.json.JSONObject;
+
 import android.database.Cursor;
 import android.util.SparseArray;
 
 /**
  * Abstraction over storage for Firefox Health Report on Android.
  */
 public interface HealthReportStorage {
   // Right now we only care about the name of the field.
@@ -24,30 +26,34 @@ public interface HealthReportStorage {
     Iterable<FieldSpec> getFields();
   }
 
   public abstract class Field {
     protected static final int UNKNOWN_TYPE_OR_FIELD_ID = -1;
 
     protected static final int FLAG_INTEGER  = 1 << 0;
     protected static final int FLAG_STRING   = 1 << 1;
+    protected static final int FLAG_JSON     = 1 << 2;
 
     protected static final int FLAG_DISCRETE = 1 << 8;
     protected static final int FLAG_LAST     = 1 << 9;
     protected static final int FLAG_COUNTER  = 1 << 10;
 
     protected static final int FLAG_COUNTED  = 1 << 14;
 
     public static final int TYPE_INTEGER_DISCRETE = FLAG_INTEGER | FLAG_DISCRETE;
     public static final int TYPE_INTEGER_LAST     = FLAG_INTEGER | FLAG_LAST;
     public static final int TYPE_INTEGER_COUNTER  = FLAG_INTEGER | FLAG_COUNTER;
 
     public static final int TYPE_STRING_DISCRETE  = FLAG_STRING | FLAG_DISCRETE;
     public static final int TYPE_STRING_LAST      = FLAG_STRING | FLAG_LAST;
 
+    public static final int TYPE_JSON_DISCRETE    = FLAG_JSON | FLAG_DISCRETE;
+    public static final int TYPE_JSON_LAST        = FLAG_JSON | FLAG_LAST;
+
     public static final int TYPE_COUNTED_STRING_DISCRETE = FLAG_COUNTED | TYPE_STRING_DISCRETE;
 
     protected int fieldID = UNKNOWN_TYPE_OR_FIELD_ID;
     protected int flags;
 
     protected final String measurementName;
     protected final String measurementVersion;
     protected final String fieldName;
@@ -68,16 +74,24 @@ public interface HealthReportStorage {
     public boolean isIntegerField() {
       return (this.flags & FLAG_INTEGER) > 0;
     }
 
     public boolean isStringField() {
       return (this.flags & FLAG_STRING) > 0;
     }
 
+    public boolean isJSONField() {
+      return (this.flags & FLAG_JSON) > 0;
+    }
+
+    public boolean isStoredAsString() {
+      return (this.flags & (FLAG_JSON | FLAG_STRING)) > 0;
+    }
+
     public boolean isDiscreteField() {
       return (this.flags & FLAG_DISCRETE) > 0;
     }
 
     /**
      * True if the accrued values are intended to be bucket-counted. For strings,
      * each discrete value will name a bucket, with the number of instances per
      * day being the value in the bucket.
@@ -149,18 +163,20 @@ public interface HealthReportStorage {
                         String fieldName);
 
   /**
    * @return a mapping from field IDs to {@link Field} instances, suitable for
    *         use in payload generation.
    */
   public SparseArray<Field> getFieldsByID();
 
+  public void recordDailyLast(int env, int day, int field, JSONObject value);
   public void recordDailyLast(int env, int day, int field, String value);
   public void recordDailyLast(int env, int day, int field, int value);
+  public void recordDailyDiscrete(int env, int day, int field, JSONObject value);
   public void recordDailyDiscrete(int env, int day, int field, String value);
   public void recordDailyDiscrete(int env, int day, int field, int value);
   public void incrementDailyCount(int env, int day, int field, int by);
   public void incrementDailyCount(int env, int day, int field);
 
   /**
    * Obtain a cursor over events that were recorded since <code>time</code>.
    * This cursor exposes 'raw' events, with integer identifiers for values.
--- 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);
+    }
 }