Bug 1121013 part C - report a new crash ping type on main-process crashes, and record submission rate/failure information in telemetry. Plugin/content crashes are already recorded via SUBPROCESS_ABNORMAL_ABORT and SUBPROCESS_CRASHES_WITH_DUMP and this patch leaves that unchanged. r?gfritzsche draft
authorBenjamin Smedberg <benjamin@smedbergs.us>
Mon, 30 Mar 2015 17:48:11 -0400
changeset 269889 8956e08a75b9713422c07fc1f821e1c888350107
parent 269387 cd49e72bd97bf8ac840eedc025224556d1c7b5f6
child 506679 c816231642b67140b5f4747e903cd2f4b554688a
push id2580
push userbsmedberg@mozilla.com
push dateThu, 04 Jun 2015 18:48:29 +0000
reviewersgfritzsche
bugs1121013
milestone41.0a1
Bug 1121013 part C - report a new crash ping type on main-process crashes, and record submission rate/failure information in telemetry. Plugin/content crashes are already recorded via SUBPROCESS_ABNORMAL_ABORT and SUBPROCESS_CRASHES_WITH_DUMP and this patch leaves that unchanged. r?gfritzsche
toolkit/components/crashes/CrashManager.jsm
toolkit/components/crashes/tests/xpcshell/test_crash_manager.js
toolkit/components/crashes/tests/xpcshell/test_crash_store.js
toolkit/components/telemetry/Histograms.json
toolkit/components/telemetry/TelemetryEnvironment.jsm
toolkit/components/telemetry/docs/crash-ping.rst
toolkit/components/telemetry/docs/pings.rst
toolkit/components/telemetry/moz.build
toolkit/components/telemetry/tests/unit/TelemetryArchiveTesting.jsm
--- a/toolkit/components/crashes/CrashManager.jsm
+++ b/toolkit/components/crashes/CrashManager.jsm
@@ -1,24 +1,26 @@
 /* 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";
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+const myScope = this;
 
 Cu.import("resource://gre/modules/Log.jsm", this);
-Cu.import("resource://gre/modules/osfile.jsm", this)
+Cu.import("resource://gre/modules/osfile.jsm", this);
 Cu.import("resource://gre/modules/Promise.jsm", this);
 Cu.import("resource://gre/modules/Services.jsm", this);
 Cu.import("resource://gre/modules/Task.jsm", this);
 Cu.import("resource://gre/modules/Timer.jsm", this);
 Cu.import("resource://gre/modules/XPCOMUtils.jsm", this);
 Cu.import("resource://services-common/utils.js", this);
+Cu.import("resource://gre/modules/TelemetryController.jsm");
 
 this.EXPORTED_SYMBOLS = [
   "CrashManager",
 ];
 
 /**
  * How long to wait after application startup before crash event files are
  * automatically aggregated.
@@ -524,16 +526,42 @@ this.CrashManager.prototype = Object.fre
           let crashID = lines[0];
           let metadata = {};
           for (let i = 1; i < lines.length; i++) {
             let [key, val] = lines[i].split("=");
             metadata[key] = val;
           }
           store.addCrash(this.PROCESS_TYPE_MAIN, this.CRASH_TYPE_CRASH,
                          crashID, date, metadata);
+
+          // If we have a saved environment, use it. Otherwise report
+          // the current environment.
+          let crashEnvironment = null;
+          let reportMeta = Cu.cloneInto(metadata, myScope);
+          if ('TelemetryEnvironment' in reportMeta) {
+            try {
+              crashEnvironment = JSON.parse(reportMeta.TelemetryEnvironment);
+            } catch(e) {
+              Cu.reportError(e);
+            }
+            delete reportMeta.TelemetryEnvironment;
+          }
+          TelemetryController.submitExternalPing("crash",
+            {
+              version: 1,
+              crashDate: date.toISOString().slice(0, 10), // YYYY-MM-DD
+              metadata: reportMeta,
+              hasCrashEnvironment: (crashEnvironment !== null),
+            },
+            {
+              retentionDays: 180,
+              addClientId: true,
+              addEnvironment: true,
+              overrideEnvironment: crashEnvironment,
+            });
           break;
 
         case "crash.submission.1":
           if (lines.length == 3) {
             let [crashID, result, remoteID] = lines;
             store.addCrash(this.PROCESS_TYPE_MAIN, this.CRASH_TYPE_CRASH,
                            crashID, date);
 
@@ -768,24 +796,20 @@ CrashStore.prototype = Object.freeze({
         // days stored in the payload matches up to actual data.
         let actualCounts = new Map();
 
         // In the past, submissions were stored as separate crash records
         // with an id of e.g. "someID-submission". If we find IDs ending
         // with "-submission", we will need to convert the data to be stored
         // as actual submissions.
         //
-        // TODO: The old way of storing submissions was used from FF33 - FF34.
-        // We should drop the conversion code after a few releases. See bug
-        // 1056157.
-        let hasSubmissionsStoredAsCrashes = false;
-
+        // The old way of storing submissions was used from FF33 - FF34. We
+        // drop this old data on the floor.
         for (let id in data.crashes) {
           if (id.endsWith("-submission")) {
-            hasSubmissionsStoredAsCrashes = true;
             continue;
           }
 
           let crash = data.crashes[id];
           let denormalized = this._denormalize(crash);
 
           denormalized.submissions = new Map();
           if (crash.submissions) {
@@ -806,42 +830,16 @@ CrashStore.prototype = Object.freeze({
           if (denormalized.metadata && 
               denormalized.metadata.OOMAllocationSize) {
             let oomKey = key + "-oom";
             actualCounts.set(oomKey, (actualCounts.get(oomKey) || 0) + 1);
           }
 
         }
 
-        if (hasSubmissionsStoredAsCrashes) {
-          for (let id in data.crashes) {
-            if (!id.endsWith("-submission")) {
-              continue;
-            }
-
-            // This type of record will contain e.g.:
-            // {
-            //   "id": "crash1-submission",
-            //   "type": "main-crash-submission-succeeded",
-            //   "crashDate": "...",
-            // }
-            let submissionData = this._denormalize(data.crashes[id]);
-
-            let crashID = id.replace(/-submission$/, "");
-            let result = submissionData.type.endsWith("-succeeded") ?
-              CrashManager.prototype.SUBMISSION_RESULT_OK :
-              CrashManager.prototype.SUBMISSION_RESULT_FAILED;
-
-            this.addSubmissionAttempt(crashID, "converted",
-                                      submissionData.crashDate);
-            this.addSubmissionResult(crashID, "converted",
-                                     submissionData.crashDate, result);
-          }
-        }
-
         // The validation in this loop is arguably not necessary. We perform
         // it as a defense against unknown bugs.
         for (let dayKey in data.countsByDay) {
           let day = parseInt(dayKey, 10);
           for (let type in data.countsByDay[day]) {
             this._ensureCountsForDay(day);
 
             let count = data.countsByDay[day][type];
@@ -1155,73 +1153,69 @@ CrashStore.prototype = Object.freeze({
         crashes.push(crash);
       }
     }
 
     return crashes;
   },
 
   /**
-   * Obtain a particular crash submission from its ID.
-   *
-   * @return undefined | submission object
-   */
-  getSubmission: function (crashID, submissionID) {
-    let crash = this._data.crashes.get(crashID);
-    if (!crash || !submissionID) {
-      return undefined;
-    }
-
-    return crash.submissions.get(submissionID);
-  },
-
-  /**
    * Ensure the submission record is present in storage.
+   * @returns [submission, crash]
    */
   _ensureSubmissionRecord: function (crashID, submissionID) {
     let crash = this._data.crashes.get(crashID);
     if (!crash || !submissionID) {
       return null;
     }
 
     if (!crash.submissions.has(submissionID)) {
       crash.submissions.set(submissionID, {
         requestDate: null,
         responseDate: null,
         result: null,
       });
     }
 
-    return crash.submissions.get(submissionID);
+    return [crash.submissions.get(submissionID), crash];
   },
 
   /**
    * @return boolean True if the attempt was recorded.
    */
   addSubmissionAttempt: function (crashID, submissionID, date) {
-    let submission = this._ensureSubmissionRecord(crashID, submissionID);
+    let [submission, crash] =
+      this._ensureSubmissionRecord(crashID, submissionID);
     if (!submission) {
       return false;
     }
 
     submission.requestDate = date;
+    Services.telemetry.getKeyedHistogramById("PROCESS_CRASH_SUBMIT_ATTEMPT")
+      .add(crash.type, 1);
     return true;
   },
 
   /**
    * @return boolean True if the response was recorded.
    */
   addSubmissionResult: function (crashID, submissionID, date, result) {
-    let submission = this.getSubmission(crashID, submissionID);
+    let crash = this._data.crashes.get(crashID);
+    if (!crash || !submissionID) {
+      return false;
+    }
+    let submission = crash.submissions.get(submissionID);
     if (!submission) {
       return false;
     }
 
     submission.responseDate = date;
     submission.result = result;
+    Services.telemetry.getKeyedHistogramById("PROCESS_CRASH_SUBMIT_SUCCESS")
+      .add(crash.type, result == "ok");
     return true;
   },
 
   /**
    * @return boolean True if the classifications were set.
    */
   setCrashClassifications: function (crashID, classifications) {
     let crash = this._data.crashes.get(crashID);
--- a/toolkit/components/crashes/tests/xpcshell/test_crash_manager.js
+++ b/toolkit/components/crashes/tests/xpcshell/test_crash_manager.js
@@ -4,28 +4,31 @@
 "use strict";
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 let bsp = Cu.import("resource://gre/modules/CrashManager.jsm", this);
 Cu.import("resource://gre/modules/Promise.jsm", this);
 Cu.import("resource://gre/modules/Task.jsm", this);
 Cu.import("resource://gre/modules/osfile.jsm", this);
+Cu.import("resource://gre/modules/TelemetryEnvironment.jsm", this);
 
 Cu.import("resource://testing-common/CrashManagerTest.jsm", this);
+Cu.import("resource://testing-common/TelemetryArchiveTesting.jsm", this);
 
 const DUMMY_DATE = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000);
 DUMMY_DATE.setMilliseconds(0);
 
 const DUMMY_DATE_2 = new Date(Date.now() - 20 * 24 * 60 * 60 * 1000);
 DUMMY_DATE_2.setMilliseconds(0);
 
 function run_test() {
   do_get_profile();
   configureLogging();
+  TelemetryArchiveTesting.setup();
   run_next_test();
 }
 
 add_task(function* test_constructor_ok() {
   let m = new CrashManager({
     pendingDumpsDir: "/foo",
     submittedDumpsDir: "/bar",
     eventsDirs: [],
@@ -202,28 +205,69 @@ add_task(function* test_schedule_mainten
 
   yield m.scheduleMaintenance(25);
   let crashes = yield m.getCrashes();
   Assert.equal(crashes.length, 1);
   Assert.equal(crashes[0].id, "id1");
 });
 
 add_task(function* test_main_crash_event_file() {
+  let ac = new TelemetryArchiveTesting.Checker();
+  yield ac.promiseInit();
+  let theEnvironment = TelemetryEnvironment.currentEnvironment;
+
   let m = yield getManager();
-  yield m.createEventsFile("1", "crash.main.2", DUMMY_DATE, "id1\nk1=v1\nk2=v2");
+  yield m.createEventsFile("1", "crash.main.2", DUMMY_DATE, "id1\nk1=v1\nk2=v2\nTelemetryEnvironment=" + JSON.stringify(theEnvironment));
   let count = yield m.aggregateEventsFiles();
   Assert.equal(count, 1);
 
   let crashes = yield m.getCrashes();
   Assert.equal(crashes.length, 1);
   Assert.equal(crashes[0].id, "id1");
   Assert.equal(crashes[0].type, "main-crash");
-  Assert.deepEqual(crashes[0].metadata, { k1: "v1", k2: "v2"});
+  Assert.equal(crashes[0].metadata.k1, "v1");
+  Assert.equal(crashes[0].metadata.k2, "v2");
+  Assert.ok(crashes[0].metadata.TelemetryEnvironment);
+  Assert.equal(Object.getOwnPropertyNames(crashes[0].metadata).length, 3);
   Assert.deepEqual(crashes[0].crashDate, DUMMY_DATE);
 
+  let found = yield ac.promiseFindPing("crash", [
+    [["payload", "hasCrashEnvironment"], true],
+    [["payload", "metadata", "k1"], "v1"],
+  ]);
+  Assert.ok(found, "Telemetry ping submitted for found crash");
+  Assert.deepEqual(found.environment, theEnvironment, "The saved environment should be present");
+
+  count = yield m.aggregateEventsFiles();
+  Assert.equal(count, 0);
+});
+
+add_task(function* test_main_crash_event_file_noenv() {
+  let ac = new TelemetryArchiveTesting.Checker();
+  yield ac.promiseInit();
+
+  let m = yield getManager();
+  yield m.createEventsFile("1", "crash.main.2", DUMMY_DATE, "id1\nk1=v3\nk2=v2");
+  let count = yield m.aggregateEventsFiles();
+  Assert.equal(count, 1);
+
+  let crashes = yield m.getCrashes();
+  Assert.equal(crashes.length, 1);
+  Assert.equal(crashes[0].id, "id1");
+  Assert.equal(crashes[0].type, "main-crash");
+  Assert.deepEqual(crashes[0].metadata, { k1: "v3", k2: "v2"});
+  Assert.deepEqual(crashes[0].crashDate, DUMMY_DATE);
+
+  let found = yield ac.promiseFindPing("crash", [
+    [["payload", "hasCrashEnvironment"], false],
+    [["payload", "metadata", "k1"], "v3"],
+  ]);
+  Assert.ok(found, "Telemetry ping submitted for found crash");
+  Assert.ok(found.environment, "There is an environment");
+
   count = yield m.aggregateEventsFiles();
   Assert.equal(count, 0);
 });
 
 add_task(function* test_crash_submission_event_file() {
   let m = yield getManager();
   yield m.createEventsFile("1", "crash.main.2", DUMMY_DATE, "crash1");
   yield m.createEventsFile("1-submission", "crash.submission.1", DUMMY_DATE_2,
--- a/toolkit/components/crashes/tests/xpcshell/test_crash_store.js
+++ b/toolkit/components/crashes/tests/xpcshell/test_crash_store.js
@@ -487,97 +487,52 @@ add_task(function* test_high_water() {
   Assert.equal(s._countsByDay.get(day1).
                  get(PROCESS_TYPE_PLUGIN + "-" + CRASH_TYPE_CRASH),
                s.HIGH_WATER_DAILY_THRESHOLD + 1);
   Assert.equal(s._countsByDay.get(day1).
                  get(PROCESS_TYPE_PLUGIN + "-" + CRASH_TYPE_HANG),
                s.HIGH_WATER_DAILY_THRESHOLD + 1);
 });
 
-add_task(function* test_getSubmission() {
-  let s = yield getStore();
-
-  Assert.equal(s.getSubmission("crash1", "sub1"), undefined);
-  Assert.ok(s.addCrash(PROCESS_TYPE_MAIN, CRASH_TYPE_CRASH, "crash1",
-                       DUMMY_DATE));
-  Assert.equal(s.getSubmission("crash1", "sub1"), undefined);
-  Assert.ok(s.addSubmissionAttempt("crash1", "sub1", DUMMY_DATE));
-  Assert.ok(s.getSubmission("crash1", "sub1"));
-});
-
 add_task(function* test_addSubmission() {
   let s = yield getStore();
 
   Assert.ok(s.addCrash(PROCESS_TYPE_MAIN, CRASH_TYPE_CRASH, "crash1",
                        DUMMY_DATE));
 
   Assert.ok(s.addSubmissionAttempt("crash1", "sub1", DUMMY_DATE));
 
-  let submission = s.getSubmission("crash1", "sub1");
+  let crash = s.getCrash("crash1");
+  let submission = crash.submissions.get("sub1");
   Assert.ok(!!submission);
   Assert.equal(submission.requestDate.getTime(), DUMMY_DATE.getTime());
   Assert.equal(submission.responseDate, null);
   Assert.equal(submission.result, null);
 
   Assert.ok(s.addSubmissionResult("crash1", "sub1", DUMMY_DATE_2,
                                   SUBMISSION_RESULT_FAILED));
 
-  submission = s.getSubmission("crash1", "sub1");
+  crash = s.getCrash("crash1");
+  Assert.equal(crash.submissions.size, 1);
+  submission = crash.submissions.get("sub1");
   Assert.ok(!!submission);
   Assert.equal(submission.requestDate.getTime(), DUMMY_DATE.getTime());
   Assert.equal(submission.responseDate.getTime(), DUMMY_DATE_2.getTime());
   Assert.equal(submission.result, SUBMISSION_RESULT_FAILED);
 
   Assert.ok(s.addSubmissionAttempt("crash1", "sub2", DUMMY_DATE));
   Assert.ok(s.addSubmissionResult("crash1", "sub2", DUMMY_DATE_2,
                                   SUBMISSION_RESULT_OK));
 
-  submission = s.getSubmission("crash1", "sub2");
+  Assert.equal(crash.submissions.size, 2);
+  submission = crash.submissions.get("sub2");
   Assert.ok(!!submission);
   Assert.equal(submission.result, SUBMISSION_RESULT_OK);
 });
 
-add_task(function* test_convertSubmissionsStoredAsCrashes() {
-  let s = yield getStore();
-
-  let addSubmissionAsCrash = (processType, crashType, succeeded, id, date) => {
-    id = id + "-submission";
-    let process = processType + "-" + crashType + "-submission";
-    let submissionType = succeeded ? "succeeded" : "failed";
-    return s.addCrash(process, submissionType, id, date);
-  };
-
-  Assert.ok(s.addCrash(PROCESS_TYPE_MAIN, CRASH_TYPE_CRASH, "crash1",
-                       new Date()));
-  Assert.ok(addSubmissionAsCrash(PROCESS_TYPE_MAIN, CRASH_TYPE_CRASH, true,
-                                 "crash1", DUMMY_DATE));
-
-  Assert.ok(s.addCrash(PROCESS_TYPE_PLUGIN, CRASH_TYPE_HANG, "hang1",
-                       new Date()));
-  Assert.ok(addSubmissionAsCrash(PROCESS_TYPE_PLUGIN, CRASH_TYPE_HANG, false,
-                                 "hang1", DUMMY_DATE_2));
-
-  Assert.equal(s.crashes.length, 4);
-  yield s.save();
-  yield s.load();
-  Assert.equal(s.crashes.length, 2);
-
-  let submission = s.getSubmission("crash1", "converted");
-  Assert.ok(!!submission);
-  Assert.equal(submission.result, SUBMISSION_RESULT_OK);
-  Assert.equal(submission.requestDate.getTime(), DUMMY_DATE.getTime());
-  Assert.equal(submission.responseDate.getTime(), DUMMY_DATE.getTime());
-
-  submission = s.getSubmission("hang1", "converted");
-  Assert.ok(!!submission);
-  Assert.equal(submission.result, SUBMISSION_RESULT_FAILED);
-  Assert.equal(submission.requestDate.getTime(), DUMMY_DATE_2.getTime());
-  Assert.equal(submission.responseDate.getTime(), DUMMY_DATE_2.getTime());
-});
-
 add_task(function* test_setCrashClassification() {
   let s = yield getStore();
 
   Assert.ok(s.addCrash(PROCESS_TYPE_MAIN, CRASH_TYPE_CRASH, "crash1",
                        new Date()));
   let classifications = s.crashes[0].classifications;
   Assert.ok(!!classifications);
   Assert.equal(classifications.length, 0);
--- a/toolkit/components/telemetry/Histograms.json
+++ b/toolkit/components/telemetry/Histograms.json
@@ -7620,16 +7620,30 @@
     "description": "Counts of plugin/content process abnormal shutdown, whether or not a crash report was available."
   },
   "SUBPROCESS_CRASHES_WITH_DUMP": {
     "expires_in_version": "never",
     "kind": "count",
     "keyed": true,
     "description": "Counts of plugin and content process crashes which are reported with a crash dump."
   },
+  "PROCESS_CRASH_SUBMIT_ATTEMPT": {
+    "expires_in_version": "never",
+    "kind": "count",
+    "keyed": true,
+    "releaseChannelCollection": "opt-out",
+    "description": "An attempt to submit a crash. Keyed on the CrashManager Crash.type."
+  },
+  "PROCESS_CRASH_SUBMIT_SUCCESS": {
+    "expires_in_version": "never",
+    "kind": "boolean",
+    "keyed": true,
+    "releaseChannelCollection": "opt-out",
+    "description": "The submission status when main/plugin/content crashes are submitted. 1 is success, 0 is failure. Keyed on the CrashManager Crash.type."
+  },
   "STUMBLER_TIME_BETWEEN_UPLOADS_SEC": {
     "expires_in_version": "45",
     "kind": "exponential",
     "n_buckets": 50,
     "high": 259200,
     "description": "Stumbler: The time in seconds between uploads."
   },
   "STUMBLER_VOLUME_BYTES_UPLOADED_PER_SEC": {
--- a/toolkit/components/telemetry/TelemetryEnvironment.jsm
+++ b/toolkit/components/telemetry/TelemetryEnvironment.jsm
@@ -891,23 +891,23 @@ EnvironmentCache.prototype = {
   },
 
   /**
    * Get the build data in object form.
    * @return Object containing the build data.
    */
   _getBuild: function () {
     let buildData = {
-      applicationId: Services.appinfo.ID,
-      applicationName: Services.appinfo.name,
+      applicationId: Services.appinfo.ID || null,
+      applicationName: Services.appinfo.name || null,
       architecture: Services.sysinfo.get("arch"),
-      buildId: Services.appinfo.appBuildID,
-      version: Services.appinfo.version,
-      vendor: Services.appinfo.vendor,
-      platformVersion: Services.appinfo.platformVersion,
+      buildId: Services.appinfo.appBuildID || null,
+      version: Services.appinfo.version || null,
+      vendor: Services.appinfo.vendor || null,
+      platformVersion: Services.appinfo.platformVersion || null,
       xpcomAbi: Services.appinfo.XPCOMABI,
       hotfixVersion: Preferences.get(PREF_HOTFIX_LASTVERSION, null),
     };
 
     // Add |architecturesInBinary| only for Mac Universal builds.
     if ("@mozilla.org/xpcom/mac-utils;1" in Cc) {
       let macUtils = Cc["@mozilla.org/xpcom/mac-utils;1"].getService(Ci.nsIMacUtils);
       if (macUtils && macUtils.isUniversalBinary) {
new file mode 100644
--- /dev/null
+++ b/toolkit/components/telemetry/docs/crash-ping.rst
@@ -0,0 +1,24 @@
+
+"crash" ping
+============
+
+This ping is captured after the main Firefox process crashes, whether or not the crash report is submitted to crash-stats.mozilla.org. It includes non-identifying metadata about the crash.
+
+The environment block that is sent with this ping varies: if Firefox was running long enough to record the environment block before the crash, then the environment at the time of the crash will be recorded and ``hasCrashEnvironment`` will be true. If Firefox crashed before the environment was recorded, ``hasCrashEnvironment`` will be false and the recorded environment will be the environment at time of submission.
+
+The client ID is submitted with this ping.
+
+Structure::
+
+    {
+      version: 1,
+      type: "crash",
+      ... common ping data
+      clientId: <UUID>,
+      environment: { ... },
+      payload: {
+        crashDate: "YYYY-MM-DD",
+        metadata: {...}, // Annotations saved while Firefox was running. See nsExceptionHandler.cpp for more information
+        hasCrashEnvironment: bool
+      }
+    }
--- a/toolkit/components/telemetry/docs/pings.rst
+++ b/toolkit/components/telemetry/docs/pings.rst
@@ -24,17 +24,18 @@ The telemetry server team is working tow
 * `2XX` - success, don't resubmit
 * `4XX` - there was some problem with the request - the client should not try to resubmit as it would just receive the same response
 * `5XX` - there was a server-side error, the client should try to resubmit later
 
 Ping types
 ==========
 
 * :doc:`main <main-ping>` - contains the information collected by Telemetry (Histograms, hang stacks, ...)
-* :doc:`saved-session <main-ping>` - has the same format as a main ping, but it contains the *"classic"* Telemetry payload with measurements covering the whole browser session. This is only a separate type to make storage of saved-session easier server-side.
+* :doc:`saved-session <main-ping>` - has the same format as a main ping, but it contains the *"classic"* Telemetry payload with measurements covering the whole browser session. This is only a separate type to make storage of saved-session easier server-side. This is temporary and will be removed soon.
+* :doc:`crash <crash-ping>` - a ping that is captured and sent after Firefox crashes.
 * ``activation`` - *planned* - sent right after installation or profile creation
 * ``upgrade`` - *planned* - sent right after an upgrade
 * ``deletion`` - *planned* - on opt-out we may have to tell the server to delete user data
 
 Archiving
 =========
 
 When archiving is enabled through the relative preference, pings submitted to ``TelemetryController`` are also stored locally in the user profile directory, in `<profile-dir>/datareporting/archived`.
--- a/toolkit/components/telemetry/moz.build
+++ b/toolkit/components/telemetry/moz.build
@@ -40,16 +40,20 @@ EXTRA_JS_MODULES += [
 ]
 
 EXTRA_PP_JS_MODULES += [
     'TelemetryController.jsm',
     'TelemetryEnvironment.jsm',
     'TelemetrySession.jsm',
 ]
 
+TESTING_JS_MODULES += [
+  'tests/unit/TelemetryArchiveTesting.jsm',
+]
+
 FAIL_ON_WARNINGS = True
 
 include('/ipc/chromium/chromium-config.mozbuild')
 
 FINAL_LIBRARY = 'xul'
 
 GENERATED_FILES = [
     'TelemetryHistogramData.inc',
new file mode 100644
--- /dev/null
+++ b/toolkit/components/telemetry/tests/unit/TelemetryArchiveTesting.jsm
@@ -0,0 +1,89 @@
+const {utils: Cu} = Components;
+Cu.import("resource://gre/modules/TelemetryArchive.jsm");
+Cu.import("resource://testing-common/Assert.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/TelemetryController.jsm");
+
+this.EXPORTED_SYMBOLS = [
+  "TelemetryArchiveTesting",
+];
+
+function checkForProperties(ping, expected) {
+  for (let [props, val] of expected) {
+    let test = ping;
+    for (let prop of props) {
+      test = test[prop];
+      if (test === undefined) {
+        return false;
+      }
+    }
+    if (test !== val) {
+      return false;
+    }
+  }
+  return true;
+}
+
+/**
+ * A helper object that allows test code to check whether a telemetry ping
+ * was properly saved. To use, first initialize to collect the starting pings
+ * and then check for new ping data.
+ */
+function Checker() {
+}
+Checker.prototype = {
+  promiseInit: function() {
+    this._pingMap = new Map();
+    return TelemetryArchive.promiseArchivedPingList().then((plist) => {
+      for (let ping of plist) {
+        this._pingMap.set(ping.id, ping);
+      }
+    });
+  },
+
+  /**
+   * Find and return a new ping with certain properties.
+   *
+   * @param expected: an array of [['prop'...], 'value'] to check
+   * For example:
+   * [
+   *   [['environment', 'build', 'applicationId'], '20150101010101'],
+   *   [['version'], 1],
+   *   [['metadata', 'OOMAllocationSize'], 123456789],
+   * ]
+   * @returns a matching ping if found, or null
+   */
+  promiseFindPing: Task.async(function*(type, expected) {
+    let candidates = [];
+    let plist = yield TelemetryArchive.promiseArchivedPingList();
+    for (let ping of plist) {
+      if (this._pingMap.has(ping.id)) {
+        continue;
+      }
+      if (ping.type == type) {
+        candidates.push(ping);
+      }
+    }
+
+    for (let candidate of candidates) {
+      let ping = yield TelemetryArchive.promiseArchivedPingById(candidate.id);
+      if (checkForProperties(ping, expected)) {
+        return ping;
+      }
+    }
+    return null;
+  }),
+};
+
+const TelemetryArchiveTesting = {
+  setup: function() {
+    Services.prefs.setBoolPref("toolkit.telemetry.enabled", true);
+    Services.prefs.setBoolPref("datareporting.healthreport.service.enabled", true);
+    Services.prefs.setCharPref("toolkit.telemetry.log.level", "Trace");
+    Services.prefs.setBoolPref("toolkit.telemetry.archive.enabled", true);
+    TelemetryController.initLogging();
+  },
+
+  Checker: Checker,
+};