Bug 1133536 - Detect & report aborted sessions in Telemetry. r=vladan
authorAlessio Placitelli <alessio.placitelli@gmail.com>
Tue, 24 Mar 2015 14:43:20 +0100
changeset 264239 94fa5538015cd37e500eaa4fa83349fc0de3f0b2
parent 264162 58d55fdfd89ddb8ea2711e70436912920b841392
child 264240 a8ca1b6f2f841334bd73c18d46ad723f06bf115d
push id4718
push userraliiev@mozilla.com
push dateMon, 11 May 2015 18:39:53 +0000
treeherdermozilla-beta@c20c4ef55f08 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersvladan
bugs1133536
milestone39.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
Bug 1133536 - Detect & report aborted sessions in Telemetry. r=vladan
toolkit/components/telemetry/TelemetryFile.jsm
toolkit/components/telemetry/TelemetryPing.jsm
toolkit/components/telemetry/TelemetrySession.jsm
--- a/toolkit/components/telemetry/TelemetryFile.jsm
+++ b/toolkit/components/telemetry/TelemetryFile.jsm
@@ -119,16 +119,38 @@ this.TelemetryFile = {
       p.push(this.savePing(ping, false));
       return p;}, [this.savePing(sessionPing, true)]);
 
     pendingPings = [];
     return Promise.all(p);
   },
 
   /**
+   * Add a ping to the saved pings directory so that it gets along with other pings. Note
+   * that the original ping file will not be modified.
+   *
+   * @param {String} aFilePath The path to the ping file that needs to be added to the
+   *                           saved pings directory.
+   * @return {Promise} A promise resolved when the ping is saved to the pings directory.
+   */
+  addPendingPing: function(aPingPath) {
+    // Pings in the saved ping directory need to have the ping id or slug (old format) as
+    // the file name. We load the ping content, check that it is valid, and use it to save
+    // the ping file with the correct file name.
+    return loadPingFile(aPingPath).then(ping => {
+        // Append the ping to the pending list.
+        pendingPings.push(ping);
+        // Since we read a ping successfully, update the related histogram.
+        Telemetry.getHistogramById("READ_SAVED_PING_SUCCESS").add(1);
+        // Save the ping to the saved pings directory.
+        return this.savePing(ping, false);
+      });
+  },
+
+  /**
    * Remove the file for a ping
    *
    * @param {object} ping The ping.
    * @returns {promise}
    */
   cleanupPingFile: function(ping) {
     return OS.File.remove(pingFilePath(ping));
   },
@@ -272,34 +294,42 @@ function getPingDirectory() {
       yield OS.File.makeDir(directory, { unixMode: OS.Constants.S_IRWXU });
       isPingDirectoryCreated = true;
     }
 
     return directory;
   });
 }
 
+/**
+ * Loads a ping file.
+ * @param {String} aFilePath The path of the ping file.
+ * @return {Promise<Object>} A promise resolved with the ping content or rejected if the
+ *                           ping contains invalid data.
+ */
+let loadPingFile = Task.async(function* (aFilePath) {
+  let array = yield OS.File.read(aFilePath);
+  let decoder = new TextDecoder();
+  let string = decoder.decode(array);
+
+  let ping = JSON.parse(string);
+  // The ping's payload used to be stringified JSON.  Deal with that.
+  if (typeof(ping.payload) == "string") {
+    ping.payload = JSON.parse(ping.payload);
+  }
+  return ping;
+});
+
 function addToPendingPings(file) {
   function onLoad(success) {
     let success_histogram = Telemetry.getHistogramById("READ_SAVED_PING_SUCCESS");
     success_histogram.add(success);
   }
 
-  return Task.spawn(function*() {
-    try {
-      let array = yield OS.File.read(file);
-      let decoder = new TextDecoder();
-      let string = decoder.decode(array);
-
-      let ping = JSON.parse(string);
-      // The ping's payload used to be stringified JSON.  Deal with that.
-      if (typeof(ping.payload) == "string") {
-        ping.payload = JSON.parse(ping.payload);
-      }
-
+  return loadPingFile(file).then(ping => {
       pendingPings.push(ping);
       onLoad(true);
-    } catch (e) {
+    },
+    () => {
       onLoad(false);
-      yield OS.File.remove(file);
-    }
-  });
+      return OS.File.remove(file);
+    });
 }
--- a/toolkit/components/telemetry/TelemetryPing.jsm
+++ b/toolkit/components/telemetry/TelemetryPing.jsm
@@ -9,16 +9,17 @@ const Cc = Components.classes;
 const Ci = Components.interfaces;
 const Cr = Components.results;
 const Cu = Components.utils;
 
 Cu.import("resource://gre/modules/Log.jsm");
 Cu.import("resource://gre/modules/debug.js", this);
 Cu.import("resource://gre/modules/Services.jsm", this);
 Cu.import("resource://gre/modules/XPCOMUtils.jsm", this);
+Cu.import("resource://gre/modules/osfile.jsm", this);
 Cu.import("resource://gre/modules/Promise.jsm", this);
 Cu.import("resource://gre/modules/DeferredTask.jsm", this);
 Cu.import("resource://gre/modules/Preferences.jsm");
 
 const LOGGER_NAME = "Toolkit.Telemetry";
 const LOGGER_PREFIX = "TelemetryPing::";
 
 const PREF_BRANCH = "toolkit.telemetry.";
@@ -141,16 +142,29 @@ this.TelemetryPing = Object.freeze({
   /**
    * Sets a server to send pings to.
    */
   setServer: function(aServer) {
     return Impl.setServer(aServer);
   },
 
   /**
+   * Adds a ping to the pending ping list by moving it to the saved pings directory
+   * and adding it to the pending ping list.
+   *
+   * @param {String} aPingPath The path of the ping to add to the pending ping list.
+   * @param {Boolean} [aRemoveOriginal] If true, deletes the ping at aPingPath after adding
+   *                  it to the saved pings directory.
+   * @return {Promise} Resolved when the ping is correctly moved to the saved pings directory.
+   */
+  addPendingPing: function(aPingPath, aRemoveOriginal) {
+    return Impl.addPendingPing(aPingPath, aRemoveOriginal);
+  },
+
+  /**
    * Send payloads to the server.
    *
    * @param {String} aType The type of the ping.
    * @param {Object} aPayload The actual data payload for the ping.
    * @param {Object} [aOptions] Options object.
    * @param {Number} [aOptions.retentionDays=14] The number of days to keep the ping on disk
    *                 if sending fails.
    * @param {Boolean} [aOptions.addClientId=false] true if the ping should contain the client
@@ -200,60 +214,33 @@ this.TelemetryPing = Object.freeze({
    * @param {Number} [aOptions.retentionDays=14] The number of days to keep the ping on disk
    *                 if sending fails.
    * @param {Boolean} [aOptions.addClientId=false] true if the ping should contain the client
    *                  id, false otherwise.
    * @param {Boolean} [aOptions.addEnvironment=false] true if the ping should contain the
    *                  environment data.
    * @param {Boolean} [aOptions.overwrite=false] true overwrites a ping with the same name,
    *                  if found.
+   * @param {String} [aOptions.filePath] The path to save the ping to. Will save to default
+   *                 ping location if not provided.
    *
-   * @returns {Promise} A promise that resolves when the ping is saved to disk.
+   * @returns {Promise<Integer>} A promise that resolves with the ping id when the ping is
+   *                             saved to disk.
    */
   savePing: function(aType, aPayload, aOptions = {}) {
     let options = aOptions;
     options.retentionDays = aOptions.retentionDays || DEFAULT_RETENTION_DAYS;
     options.addClientId = aOptions.addClientId || false;
     options.addEnvironment = aOptions.addEnvironment || false;
     options.overwrite = aOptions.overwrite || false;
 
     return Impl.savePing(aType, aPayload, options);
   },
 
   /**
-   * Only used for testing. Saves a ping to disk and return the ping id once done.
-   *
-   * @param {String} aType The type of the ping.
-   * @param {Object} aPayload The actual data payload for the ping.
-   * @param {Object} [aOptions] Options object.
-   * @param {Number} [aOptions.retentionDays=14] The number of days to keep the ping on disk
-   *                 if sending fails.
-   * @param {Boolean} [aOptions.addClientId=false] true if the ping should contain the client
-   *                  id, false otherwise.
-   * @param {Boolean} [aOptions.addEnvironment=false] true if the ping should contain the
-   *                  environment data.
-   * @param {Boolean} [aOptions.overwrite=false] true overwrites a ping with the same name,
-   *                  if found.
-   * @param {String} [aOptions.filePath] The path to save the ping to. Will save to default
-   *                 ping location if not provided.
-   *
-   * @returns {Promise<Integer>} A promise that resolves with the ping id when the ping is
-   *                             saved to disk.
-   */
-  testSavePingToFile: function(aType, aPayload, aOptions = {}) {
-    let options = aOptions;
-    options.retentionDays = aOptions.retentionDays || DEFAULT_RETENTION_DAYS;
-    options.addClientId = aOptions.addClientId || false;
-    options.addEnvironment = aOptions.addEnvironment || false;
-    options.overwrite = aOptions.overwrite || false;
-
-    return Impl.testSavePingToFile(aType, aPayload, options);
-  },
-
-  /**
    * The client id send with the telemetry ping.
    *
    * @return The client id as string, or null.
    */
    get clientID() {
     return Impl.clientID;
    },
 
@@ -373,16 +360,33 @@ let Impl = {
   /**
    * Only used in tests.
    */
   setServer: function (aServer) {
     this._server = aServer;
   },
 
   /**
+   * Adds a ping to the pending ping list by moving it to the saved pings directory
+   * and adding it to the pending ping list.
+   *
+   * @param {String} aPingPath The path of the ping to add to the pending ping list.
+   * @param {Boolean} [aRemoveOriginal] If true, deletes the ping at aPingPath after adding
+   *                  it to the saved pings directory.
+   * @return {Promise} Resolved when the ping is correctly moved to the saved pings directory.
+   */
+  addPendingPing: function(aPingPath, aRemoveOriginal) {
+    return TelemetryFile.addPendingPing(aPingPath).then(() => {
+        if (aRemoveOriginal) {
+          return OS.File.remove(aPingPath);
+        }
+      }, error => this._log.error("addPendingPing - Unable to add the pending ping", error));
+  },
+
+  /**
    * Build a complete ping and send data to the server. Record success/send-time in
    * histograms.
    *
    * @param {String} aType The type of the ping.
    * @param {Object} aPayload The actual data payload for the ping.
    * @param {Object} aOptions Options object.
    * @param {Number} aOptions.retentionDays The number of days to keep the ping on disk
    *                 if sending fails.
@@ -453,60 +457,36 @@ let Impl = {
    * @param {Object} aOptions Options object.
    * @param {Number} aOptions.retentionDays The number of days to keep the ping on disk
    *                 if sending fails.
    * @param {Boolean} aOptions.addClientId true if the ping should contain the client id,
    *                  false otherwise.
    * @param {Boolean} aOptions.addEnvironment true if the ping should contain the
    *                  environment data.
    * @param {Boolean} aOptions.overwrite true overwrites a ping with the same name, if found.
+   * @param {String} [aOptions.filePath] The path to save the ping to. Will save to default
+   *                 ping location if not provided.
    *
-   * @returns {Promise} A promise that resolves when the ping is saved to disk.
+   * @returns {Promise} A promise that resolves with the ping id when the ping is saved to
+   *                    disk.
    */
   savePing: function savePing(aType, aPayload, aOptions) {
     this._log.trace("savePing - Type " + aType + ", Server " + this._server +
                     ", aOptions " + JSON.stringify(aOptions));
 
     return this.assemblePing(aType, aPayload, aOptions)
-        .then(pingData => TelemetryFile.savePing(pingData, aOptions.overwrite),
-              error => this._log.error("savePing - Rejection", error));
-  },
-
-  /**
-   * Save a ping to disk and return the ping id when done.
-   *
-   * @param {String} aType The type of the ping.
-   * @param {Object} aPayload The actual data payload for the ping.
-   * @param {Object} aOptions Options object.
-   * @param {Number} aOptions.retentionDays The number of days to keep the ping on disk
-   *                 if sending fails.
-   * @param {Boolean} aOptions.addClientId true if the ping should contain the client id,
-   *                  false otherwise.
-   * @param {Boolean} aOptions.addEnvironment true if the ping should contain the
-   *                  environment data.
-   * @param {Boolean} aOptions.overwrite true overwrites a ping with the same name, if found.
-   * @param {String} [aOptions.filePath] The path to save the ping to. Will save to default
-   *                 ping location if not provided.
-   *
-   * @returns {Promise} A promise that resolves with the ping id when the ping is saved to
-   *                    disk.
-   */
-  testSavePingToFile: function testSavePingToFile(aType, aPayload, aOptions) {
-    this._log.trace("testSavePingToFile - Type " + aType + ", Server " + this._server +
-                    ", aOptions " + JSON.stringify(aOptions));
-    return this.assemblePing(aType, aPayload, aOptions)
-        .then(pingData => {
-            if (aOptions.filePath) {
-              return TelemetryFile.savePingToFile(pingData, aOptions.filePath, aOptions.overwrite)
-                                  .then(() => { return pingData.id; });
-            } else {
-              return TelemetryFile.savePing(pingData, aOptions.overwrite)
-                                  .then(() => { return pingData.id; });
-            }
-        }, error => this._log.error("testSavePing - Rejection", error));
+      .then(pingData => {
+        if ("filePath" in aOptions) {
+          return TelemetryFile.savePingToFile(pingData, aOptions.filePath, aOptions.overwrite)
+                              .then(() => { return pingData.id; });
+        } else {
+          return TelemetryFile.savePing(pingData, aOptions.overwrite)
+                              .then(() => { return pingData.id; });
+        }
+      }, error => this._log.error("savePing - Rejection", error));
   },
 
   finishPingRequest: function finishPingRequest(success, startTime, ping, isPersisted) {
     this._log.trace("finishPingRequest - Success " + success + ", Persisted " + isPersisted);
 
     let hping = Telemetry.getHistogramById("TELEMETRY_PING");
     let hsuccess = Telemetry.getHistogramById("TELEMETRY_SUCCESS");
 
--- a/toolkit/components/telemetry/TelemetrySession.jsm
+++ b/toolkit/components/telemetry/TelemetrySession.jsm
@@ -16,29 +16,32 @@ Cu.import("resource://gre/modules/osfile
 Cu.import("resource://gre/modules/Services.jsm", this);
 Cu.import("resource://gre/modules/XPCOMUtils.jsm", this);
 Cu.import("resource://gre/modules/Promise.jsm", this);
 Cu.import("resource://gre/modules/DeferredTask.jsm", this);
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource://gre/modules/Task.jsm");
 Cu.import("resource://gre/modules/Timer.jsm");
 
+const myScope = this;
+
 const IS_CONTENT_PROCESS = (function() {
   // We cannot use Services.appinfo here because in telemetry xpcshell tests,
   // appinfo is initially unavailable, and becomes available only later on.
   let runtime = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime);
   return runtime.processType == Ci.nsIXULRuntime.PROCESS_TYPE_CONTENT;
 })();
 
 // When modifying the payload in incompatible ways, please bump this version number
 const PAYLOAD_VERSION = 4;
 const PING_TYPE_MAIN = "main";
 const PING_TYPE_SAVED_SESSION = "saved-session";
 const RETENTION_DAYS = 14;
 
+const REASON_ABORTED_SESSION = "aborted-session";
 const REASON_DAILY = "daily";
 const REASON_SAVED_SESSION = "saved-session";
 const REASON_IDLE_DAILY = "idle-daily";
 const REASON_GATHER_PAYLOAD = "gather-payload";
 const REASON_TEST_PING = "test-ping";
 const REASON_ENVIRONMENT_CHANGE = "environment-change";
 const REASON_SHUTDOWN = "shutdown";
 
@@ -62,33 +65,51 @@ const PREF_ENABLED = PREF_BRANCH + "enab
 const PREF_PREVIOUS_BUILDID = PREF_BRANCH + "previousBuildID";
 const PREF_CACHED_CLIENTID = PREF_BRANCH + "cachedClientID"
 const PREF_FHR_UPLOAD_ENABLED = "datareporting.healthreport.uploadEnabled";
 const PREF_ASYNC_PLUGIN_INIT = "dom.ipc.plugins.asyncInit";
 
 const MESSAGE_TELEMETRY_PAYLOAD = "Telemetry:Payload";
 const MESSAGE_TELEMETRY_GET_CHILD_PAYLOAD = "Telemetry:GetChildPayload";
 
+const DATAREPORTING_DIRECTORY = "datareporting";
+const ABORTED_SESSION_FILE_NAME = "aborted-session-ping";
+
 const SESSION_STATE_FILE_NAME = "session-state.json";
 
 // Maximum number of content payloads that we are willing to store.
 const MAX_NUM_CONTENT_PAYLOADS = 10;
 
 // Do not gather data more than once a minute
 const TELEMETRY_INTERVAL = 60000;
 // Delay before intializing telemetry (ms)
 const TELEMETRY_DELAY = 60000;
 // Delay before initializing telemetry if we're testing (ms)
 const TELEMETRY_TEST_DELAY = 100;
+// Execute a scheduler tick every 5 minutes.
+const SCHEDULER_TICK_INTERVAL_MS = 5 * 60 * 1000;
+// The maximum number of times a scheduled operation can fail.
+const SCHEDULER_RETRY_ATTEMPTS = 3;
+
+// The tolerance we have when checking if it's midnight (15 minutes).
+const SCHEDULER_MIDNIGHT_TOLERANCE_MS = 15 * 60 * 1000;
+
+// Coalesce the daily and aborted-session pings if they are both due within
+// two minutes from each other.
+const SCHEDULER_COALESCE_THRESHOLD_MS = 2 * 60 * 1000;
 
 // Seconds of idle time before pinging.
 // On idle-daily a gather-telemetry notification is fired, during it probes can
 // start asynchronous tasks to gather data.  On the next idle the data is sent.
 const IDLE_TIMEOUT_SECONDS = 5 * 60;
 
+// The frequency at which we persist session data to the disk to prevent data loss
+// in case of aborted sessions (currently 5 minutes).
+const ABORTED_SESSION_UPDATE_INTERVAL_MS = 5 * 60 * 1000;
+
 var gLastMemoryPoll = null;
 
 let gWasDebuggerAttached = false;
 
 function getLocale() {
   return Cc["@mozilla.org/chrome/chrome-registry;1"].
          getService(Ci.nsIXULChromeRegistry).
          getSelectedLocale('global');
@@ -142,31 +163,63 @@ function generateUUID() {
 
 /**
  * This is a policy object used to override behavior for testing.
  */
 let Policy = {
   now: () => new Date(),
   generateSessionUUID: () => generateUUID(),
   generateSubsessionUUID: () => generateUUID(),
-  setDailyTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
-  clearDailyTimeout: (id) => clearTimeout(id),
+  setSchedulerTickTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
+  clearSchedulerTickTimeout: id => clearTimeout(id),
 };
 
 /**
  * Takes a date and returns it trunctated to a date with daily precision.
  */
 function truncateToDays(date) {
   return new Date(date.getFullYear(),
                   date.getMonth(),
                   date.getDate(),
                   0, 0, 0, 0);
 }
 
 /**
+ * Check if the difference between the times is within the provided tolerance.
+ * @param {Number} t1 A time in milliseconds.
+ * @param {Number} t2 A time in milliseconds.
+ * @param {Number} tolerance The tolerance, in milliseconds.
+ * @return {Boolean} True if the absolute time difference is within the tolerance, false
+ *                   otherwise.
+ */
+function areTimesClose(t1, t2, tolerance) {
+  return Math.abs(t1 - t2) <= tolerance;
+}
+
+/**
+ * Get the midnight which is closer to the provided date.
+ * @param {Object} date The date object to check.
+ * @return {Object} The Date object representing the closes midnight, or null if midnight
+ *                  is not within the midnight tolerance.
+ */
+function getNearestMidnight(date) {
+  let lastMidnight = truncateToDays(date);
+  if (areTimesClose(date.getTime(), lastMidnight.getTime(), SCHEDULER_MIDNIGHT_TOLERANCE_MS)) {
+    return lastMidnight;
+  }
+
+  let nextMidnightDate = new Date(lastMidnight);
+  nextMidnightDate.setDate(nextMidnightDate.getDate() + 1);
+  if (areTimesClose(date.getTime(), nextMidnightDate.getTime(), SCHEDULER_MIDNIGHT_TOLERANCE_MS)) {
+    return nextMidnightDate;
+  }
+  return null;
+}
+
+/**
  * Get the ping type based on the payload.
  * @param {Object} aPayload The ping payload.
  * @return {String} A string representing the ping type.
  */
 function getPingType(aPayload) {
   // To remain consistent with server-side ping handling, set "saved-session" as the ping
   // type for "saved-session" payload reasons.
   if (aPayload.info.reason == REASON_SAVED_SESSION) {
@@ -253,21 +306,23 @@ let processInfo = {
 
 /**
  * This object allows the serialisation of asynchronous tasks. This is particularly
  * useful to serialise write access to the disk in order to prevent race conditions
  * to corrupt the data being written.
  * We are using this to synchronize saving to the file that TelemetrySession persists
  * its state in.
  */
-let gStateSaveSerializer = {
-  _queuedOperations: [],
-  _queuedInProgress: false,
-  _log: Log.repository.getLoggerWithMessagePrefix(LOGGER_NAME, LOGGER_PREFIX),
+function SaveSerializer() {
+  this._queuedOperations = [];
+  this._queuedInProgress = false;
+  this._log = Log.repository.getLoggerWithMessagePrefix(LOGGER_NAME, LOGGER_PREFIX);
+}
 
+SaveSerializer.prototype = {
   /**
    * Enqueues an operation to a list to serialise their execution in order to prevent race
    * conditions. Useful to serialise access to disk.
    *
    * @param {Function} aFunction The task function to enqueue. It must return a promise.
    * @return {Promise} A promise resolved when the enqueued task completes.
    */
   enqueueTask: function (aFunction) {
@@ -335,16 +390,275 @@ let gStateSaveSerializer = {
                        error);
         this._queuedInProgress = false;
         reject(error);
         this._popAndPerformQueuedOperation();
       });
   },
 };
 
+/**
+ * TelemetryScheduler contains a single timer driving all regularly-scheduled
+ * Telemetry related jobs. Having a single place with this logic simplifies
+ * reasoning about scheduling actions in a single place, making it easier to
+ * coordinate jobs and coalesce them.
+ */
+let TelemetryScheduler = {
+  _lastDailyPingTime: 0,
+  _lastSessionCheckpointTime: 0,
+
+  // For sanity checking.
+  _lastAdhocPingTime: 0,
+  _lastTickTime: 0,
+
+  _log: null,
+
+  // The number of times a daily ping fails.
+  _dailyPingRetryAttempts: 0,
+
+  // The timer which drives the scheduler.
+  _schedulerTimer: null,
+  _shuttingDown: true,
+
+  /**
+   * Initialises the scheduler and schedules the first daily/aborted session pings.
+   */
+  init: function() {
+    this._log = Log.repository.getLoggerWithMessagePrefix(LOGGER_NAME, "TelemetryScheduler::");
+    this._log.trace("init");
+    this._shuttingDown = false;
+    // Initialize the last daily ping and aborted session last due times to the current time.
+    // Otherwise, we might end up sending daily pings even if the subsession is not long enough.
+    let now = Policy.now();
+    this._lastDailyPingTime = now.getTime();
+    this._lastSessionCheckpointTime = now.getTime();
+    this._rescheduleTimeout();
+  },
+
+  /**
+   * Reschedules the tick timer.
+   */
+  _rescheduleTimeout: function() {
+    this._log.trace("_rescheduleTimeout");
+    if (this._shuttingDown) {
+      this._log.warn("_rescheduleTimeout - already shutdown");
+      return;
+    }
+
+    if (this._schedulerTimer) {
+      Policy.clearSchedulerTickTimeout(this._schedulerTimer);
+    }
+
+    this._schedulerTimer =
+      Policy.setSchedulerTickTimeout(() => this._onSchedulerTick(), SCHEDULER_TICK_INTERVAL_MS);
+  },
+
+  /**
+   * Checks if we can send a daily ping or not.
+   * @param {Object} nowDate A date object.
+   * @return {Boolean} True if we can send the daily ping, false otherwise.
+   */
+  _isDailyPingDue: function(nowDate) {
+    let nearestMidnight = getNearestMidnight(nowDate);
+    if (nearestMidnight) {
+      let subsessionLength = Math.abs(nowDate.getTime() - this._lastDailyPingTime);
+      if (subsessionLength < MIN_SUBSESSION_LENGTH_MS) {
+        // Generating a daily ping now would create a very short subsession.
+        return false;
+      } else if (areTimesClose(this._lastDailyPingTime, nearestMidnight.getTime(),
+                               SCHEDULER_MIDNIGHT_TOLERANCE_MS)) {
+        // We've already sent a ping for this midnight.
+        return false;
+      }
+      return true;
+    }
+
+    let lastDailyPingDate = truncateToDays(new Date(this._lastDailyPingTime));
+    // This is today's date and also the previous midnight (0:00).
+    let todayDate = truncateToDays(nowDate);
+    // Check that _lastDailyPingTime isn't today nor within SCHEDULER_MIDNIGHT_TOLERANCE_MS of the
+    // *previous* midnight.
+    if ((lastDailyPingDate.getTime() != todayDate.getTime()) &&
+        !areTimesClose(this._lastDailyPingTime, todayDate.getTime(), SCHEDULER_MIDNIGHT_TOLERANCE_MS)) {
+      // Computer must have gone to sleep, the daily ping is overdue.
+      return true;
+    }
+    return false;
+  },
+
+  /**
+   * An helper function to save an aborted-session ping.
+   * @param {Number} now The current time, in milliseconds.
+   * @param {Object} [competingPayload=null] If we are coalescing the daily and the
+   *                 aborted-session pings, this is the payload for the former. Note
+   *                 that the reason field of this payload will be changed.
+   * @return {Promise} A promise resolved when the ping is saved.
+   */
+  _saveAbortedPing: function(now, competingPayload=null) {
+    this._lastSessionCheckpointTime = now;
+    return Impl._saveAbortedSessionPing(competingPayload)
+                .catch(e => this._log.error("_saveAbortedPing - Failed", e));
+  },
+
+  /**
+   * Performs a scheduler tick. This function manages Telemetry recurring operations.
+   * @return {Promise} A promise, only used when testing, resolved when the scheduled
+   *                   operation completes.
+   */
+  _onSchedulerTick: function() {
+    if (this._shuttingDown) {
+      this._log.warn("_onSchedulerTick - already shutdown.");
+      return;
+    }
+
+    let promise = Promise.resolve();
+    try {
+      promise = this._schedulerTickLogic();
+    } catch (e) {
+      this._log.error("_onSchedulerTick - There was an exception", e);
+    } finally {
+      this._rescheduleTimeout();
+    }
+
+    // This promise is returned to make testing easier.
+    return promise;
+  },
+
+  /**
+   * Implements the scheduler logic.
+   * @return {Promise} Resolved when the scheduled task completes. Only used in tests.
+   */
+  _schedulerTickLogic: function() {
+    this._log.trace("_schedulerTickLogic");
+
+    let nowDate = Policy.now();
+    let now = nowDate.getTime();
+
+    if (now - this._lastTickTime > 1.1 * SCHEDULER_TICK_INTERVAL_MS) {
+      this._log.trace("_schedulerTickLogic - First scheduler tick after sleep or startup.");
+    }
+    this._lastTickTime = now;
+
+    // Check if aborted-session ping is due.
+    let isAbortedPingDue =
+      (now - this._lastSessionCheckpointTime) >= ABORTED_SESSION_UPDATE_INTERVAL_MS;
+    // Check if daily ping is due.
+    let shouldSendDaily = this._isDailyPingDue(nowDate);
+    // We can combine the daily-ping and the aborted-session ping in the following cases:
+    // - If both the daily and the aborted session pings are due (a laptop that wakes
+    //   up after a few hours).
+    // - If either the daily ping is due and the other one would follow up shortly
+    //   (whithin the coalescence threshold).
+    let nextSessionCheckpoint =
+      this._lastSessionCheckpointTime + ABORTED_SESSION_UPDATE_INTERVAL_MS;
+    let combineActions = (shouldSendDaily && isAbortedPingDue) || (shouldSendDaily &&
+                          areTimesClose(now, nextSessionCheckpoint, SCHEDULER_COALESCE_THRESHOLD_MS));
+
+    if (combineActions) {
+      this._log.trace("_schedulerTickLogic - Combining pings.");
+      // Send the daily ping and also save its payload as an aborted-session ping.
+      return Impl._sendDailyPing(true).then(() => this._dailyPingSucceeded(now),
+                                            () => this._dailyPingFailed(now));
+    } else if (shouldSendDaily) {
+      this._log.trace("_schedulerTickLogic - Daily ping due.");
+      return Impl._sendDailyPing().then(() => this._dailyPingSucceeded(now),
+                                        () => this._dailyPingFailed(now));
+    } else if (isAbortedPingDue) {
+      this._log.trace("_schedulerTickLogic - Aborted session ping due.");
+      return this._saveAbortedPing(now);
+    }
+
+    // No ping is due.
+    this._log.trace("_schedulerTickLogic - No ping due.");
+    // It's possible, because of sleeps, that we're no longer within midnight tolerance for
+    // daily pings. Because of that, daily retry attempts would not be 0 on the next midnight.
+    // Reset that count on do-nothing ticks.
+    this._dailyPingRetryAttempts = 0;
+    return Promise.resolve();
+  },
+
+  /**
+   * Update the scheduled pings if some other ping was sent.
+   * @param {String} reason The reason of the ping that was sent.
+   * @param {Object} [competingPayload=null] The payload of the ping that was sent. The
+   *                 reason of this payload will be changed.
+   */
+  reschedulePings: function(reason, competingPayload = null) {
+    if (this._shuttingDown) {
+      this._log.error("reschedulePings - already shutdown");
+      return;
+    }
+
+    this._log.trace("reschedulePings - reason: " + reason);
+    let now = Policy.now();
+    this._lastAdhocPingTime = now.getTime();
+    if (reason == REASON_ENVIRONMENT_CHANGE) {
+      // We just generated an environment-changed ping, save it as an aborted session and
+      // update the schedules.
+      this._saveAbortedPing(now.getTime(), competingPayload);
+      // If we're close to midnight, skip today's daily ping and reschedule it for tomorrow.
+      let nearestMidnight = getNearestMidnight(now);
+      if (nearestMidnight) {
+        this._lastDailyPingTime = now.getTime();
+      }
+    }
+
+    this._rescheduleTimeout();
+  },
+
+  /**
+   * Called when a scheduled operation successfully completes (ping sent or saved).
+   * @param {Number} now The current time, in milliseconds.
+   */
+  _dailyPingSucceeded: function(now) {
+    this._log.trace("_dailyPingSucceeded");
+    this._lastDailyPingTime = now;
+    this._dailyPingRetryAttempts = 0;
+  },
+
+  /**
+   * Called when a scheduled operation fails (ping sent or saved).
+   * @param {Number} now The current time, in milliseconds.
+   */
+  _dailyPingFailed: function(now) {
+    this._log.error("_dailyPingFailed");
+    this._dailyPingRetryAttempts++;
+
+    // If we reach the maximum number of retry attempts for a daily ping, log the error
+    // and skip this daily ping.
+    if (this._dailyPingRetryAttempts >= SCHEDULER_RETRY_ATTEMPTS) {
+      this._log.error("_pingFailed - The daily ping failed too many times. Skipping it.");
+      this._dailyPingRetryAttempts = 0;
+      this._lastDailyPingTime = now;
+    }
+  },
+
+  /**
+   * Stops the scheduler.
+   */
+  shutdown: function() {
+    if (this._shuttingDown) {
+      if (this._log) {
+        this._log.error("shutdown - Already shut down");
+      } else {
+        Cu.reportError("TelemetryScheduler.shutdown - Already shut down");
+      }
+      return;
+    }
+
+    this._log.trace("shutdown");
+    if (this._schedulerTimer) {
+      Policy.clearSchedulerTickTimeout(this._schedulerTimer);
+      this._schedulerTimer = null;
+    }
+
+    this._shuttingDown = true;
+  }
+};
+
 this.EXPORTED_SYMBOLS = ["TelemetrySession"];
 
 this.TelemetrySession = Object.freeze({
   Constants: Object.freeze({
     PREF_ENABLED: PREF_ENABLED,
     PREF_SERVER: PREF_SERVER,
     PREF_PREVIOUS_BUILDID: PREF_PREVIOUS_BUILDID,
   }),
@@ -485,22 +799,24 @@ let Impl = {
   // null on first run.
   _previousSubsessionId: null,
   // The running no. of subsessions since the start of the browser session
   _subsessionCounter: 0,
   // The running no. of all subsessions for the whole profile life time
   _profileSubsessionCounter: 0,
   // Date of the last session split
   _subsessionStartDate: null,
-  // The timer used for daily collections.
-  _dailyTimerId: null,
   // A task performing delayed initialization of the chrome process
   _delayedInitTask: null,
   // The deferred promise resolved when the initialization task completes.
   _delayedInitTaskDeferred: null,
+  // Used to serialize session state writes to disk.
+  _stateSaveSerializer: new SaveSerializer(),
+  // Used to serialize aborted session ping writes to disk.
+  _abortedSessionSerializer: new SaveSerializer(),
 
   /**
    * Gets a series of simple measurements (counters). At the moment, this
    * only returns startup data from nsIAppStartup.getStartupInfo().
    *
    * @return simple measurements as a dictionary.
    */
   getSimpleMeasurements: function getSimpleMeasurements(forSavedSession) {
@@ -1009,18 +1325,17 @@ let Impl = {
     let measurements = this.getSimpleMeasurements(reason == REASON_SAVED_SESSION);
     let info = !IS_CONTENT_PROCESS ? this.getMetadata(reason) : null;
     let payload = this.assemblePayloadWithMeasurements(measurements, info, reason, clearSubsession);
 
     if (!IS_CONTENT_PROCESS && clearSubsession) {
       this.startNewSubsession();
       // Persist session data to disk (don't wait until it completes).
       let sessionData = this._getSessionDataObject();
-      gStateSaveSerializer.enqueueTask(() => this._saveSessionData(sessionData));
-      this._rescheduleDailyTimer();
+      this._stateSaveSerializer.enqueueTask(() => this._saveSessionData(sessionData));
     }
 
     return payload;
   },
 
   /**
    * Send data to the server. Record success/send-time in histograms
    */
@@ -1163,19 +1478,30 @@ let Impl = {
             this._log.error("setupChromeProcess - Could not write session data to disk."));
         }
         this.attachObservers();
         this.gatherMemory();
 
         Telemetry.asyncFetchTelemetryData(function () {});
 
 #if !defined(MOZ_WIDGET_GONK) && !defined(MOZ_WIDGET_ANDROID)
-        this._rescheduleDailyTimer();
+        // Check for a previously written aborted session ping.
+        yield this._checkAbortedSessionPing();
+
         TelemetryEnvironment.registerChangeListener(ENVIRONMENT_CHANGE_LISTENER,
                                                     () => this._onEnvironmentChange());
+        // Write the first aborted-session ping as early as possible. Just do that
+        // if we are not testing, since calling Telemetry.reset() will make a previous
+        // aborted ping a pending ping.
+        if (!testing) {
+          yield this._saveAbortedSessionPing();
+        }
+
+        // Start the scheduler.
+        TelemetryScheduler.init();
 #endif
 
         this._delayedInitTaskDeferred.resolve();
       } catch (e) {
         this._delayedInitTaskDeferred.reject();
       } finally {
         this._delayedInitTask = null;
         this._delayedInitTaskDeferred = null;
@@ -1316,17 +1642,17 @@ let Impl = {
     let payload = this.getSessionPayload(REASON_SAVED_SESSION, false);
     let options = {
       retentionDays: RETENTION_DAYS,
       addClientId: true,
       addEnvironment: true,
       overwrite: true,
       filePath: file.path,
     };
-    return TelemetryPing.testSavePingToFile(getPingType(payload), payload, options);
+    return TelemetryPing.savePing(getPingType(payload), payload, options);
   },
 
   /**
    * Remove observers to avoid leaks
    */
   uninstall: function uninstall() {
     this.detachObservers();
     if (this._hasWindowRestoredObserver) {
@@ -1496,31 +1822,32 @@ let Impl = {
    *                can send pings or not, which is used for testing.
    */
   shutdownChromeProcess: function(testing = false) {
     this._log.trace("shutdownChromeProcess - testing: " + testing);
 
     let cleanup = () => {
 #if !defined(MOZ_WIDGET_GONK) && !defined(MOZ_WIDGET_ANDROID)
       TelemetryEnvironment.unregisterChangeListener(ENVIRONMENT_CHANGE_LISTENER);
+      TelemetryScheduler.shutdown();
 #endif
-      if (this._dailyTimerId) {
-        Policy.clearDailyTimeout(this._dailyTimerId);
-        this._dailyTimerId = null;
-      }
       this.uninstall();
 
       let reset = () => {
         this._initStarted = false;
         this._initialized = false;
       };
 
       if (Telemetry.canSend || testing) {
         return this.savePendingPings()
-                .then(() => gStateSaveSerializer.flushTasks())
+                .then(() => this._stateSaveSerializer.flushTasks())
+#if !defined(MOZ_WIDGET_GONK) && !defined(MOZ_WIDGET_ANDROID)
+                .then(() => this._abortedSessionSerializer
+                                .enqueueTask(() => this._removeAbortedSessionPing()))
+#endif
                 .then(reset);
       }
 
       reset();
       return Promise.resolve();
     };
 
     // We can be in one the following states here:
@@ -1540,66 +1867,49 @@ let Impl = {
       // We already ran the delayed initialization.
       return cleanup();
      }
 
     // This handles 2) and 3).
     return this._delayedInitTask.finalize().then(cleanup);
    },
 
-  _rescheduleDailyTimer: function() {
-    if (this._dailyTimerId) {
-      this._log.trace("_rescheduleDailyTimer - clearing existing timeout");
-      Policy.clearDailyTimeout(this._dailyTimerId);
-    }
-
-    let now = Policy.now();
-    let midnight = truncateToDays(now).getTime() + MS_IN_ONE_DAY;
-    let msUntilCollection = midnight - now.getTime();
-    if (msUntilCollection < MIN_SUBSESSION_LENGTH_MS) {
-      msUntilCollection += MS_IN_ONE_DAY;
-    }
-
-    this._log.trace("_rescheduleDailyTimer - now: " + now
-                    + ", scheduled: " + new Date(now.getTime() + msUntilCollection));
-    this._dailyTimerId = Policy.setDailyTimeout(() => this._onDailyTimer(), msUntilCollection);
-  },
-
-  _onDailyTimer: function() {
-    if (!this._initStarted) {
-      if (this._log) {
-        this._log.warn("_onDailyTimer - not initialized");
-      } else {
-        Cu.reportError("TelemetrySession._onDailyTimer - not initialized");
-      }
-      return;
-    }
-
-    this._log.trace("_onDailyTimer");
+  /**
+   * Gather and send a daily ping.
+   * @param {Boolean} [saveAsAborted=false] Also saves the payload as an aborted-session
+   *                  ping.
+   * @return {Promise} Resolved when the ping is sent.
+   */
+  _sendDailyPing: function(saveAsAborted = false) {
+    this._log.trace("_sendDailyPing");
     let payload = this.getSessionPayload(REASON_DAILY, true);
 
     let options = {
       retentionDays: RETENTION_DAYS,
       addClientId: true,
       addEnvironment: true,
     };
+
     let promise = TelemetryPing.send(getPingType(payload), payload, options);
-
-    this._rescheduleDailyTimer();
-    // Return the promise so tests can wait on the ping submission.
+#if !defined(MOZ_WIDGET_GONK) && !defined(MOZ_WIDGET_ANDROID)
+    // If required, also save the payload as an aborted session.
+    if (saveAsAborted) {
+      return promise.then(() => this._saveAbortedSessionPing(payload));
+    }
+#endif
     return promise;
   },
 
   /**
    * Loads session data from the session data file.
    * @return {Promise<boolean>} A promise which is resolved with a true argument when
    *                            loading has completed, with false otherwise.
    */
   _loadSessionData: Task.async(function* () {
-    let dataFile = OS.Path.join(OS.Constants.Path.profileDir, "datareporting",
+    let dataFile = OS.Path.join(OS.Constants.Path.profileDir, DATAREPORTING_DIRECTORY,
                                 SESSION_STATE_FILE_NAME);
 
     // Try to load the "profileSubsessionCounter" from the state file.
     try {
       let data = yield CommonUtils.readJSON(dataFile);
       if (data &&
           "profileSubsessionCounter" in data &&
           typeof(data.profileSubsessionCounter) == "number" &&
@@ -1627,37 +1937,40 @@ let Impl = {
       profileSubsessionCounter: this._profileSubsessionCounter,
     };
   },
 
   /**
    * Saves session data to disk.
    */
   _saveSessionData: Task.async(function* (sessionData) {
-    let dataDir = OS.Path.join(OS.Constants.Path.profileDir, "datareporting");
+    let dataDir = OS.Path.join(OS.Constants.Path.profileDir, DATAREPORTING_DIRECTORY);
     yield OS.File.makeDir(dataDir);
 
     let filePath = OS.Path.join(dataDir, SESSION_STATE_FILE_NAME);
     try {
       yield CommonUtils.writeJSON(sessionData, filePath);
     } catch(e) {
       this._log.error("_saveSessionData - Failed to write session data to " + filePath, e);
     }
   }),
 
   _onEnvironmentChange: function() {
     this._log.trace("_onEnvironmentChange");
     let payload = this.getSessionPayload(REASON_ENVIRONMENT_CHANGE, true);
 
+    let clonedPayload = Cu.cloneInto(payload, myScope);
+    TelemetryScheduler.reschedulePings(REASON_ENVIRONMENT_CHANGE, clonedPayload);
+
     let options = {
       retentionDays: RETENTION_DAYS,
       addClientId: true,
       addEnvironment: true,
     };
-    let promise = TelemetryPing.send(getPingType(payload), payload, options);
+    TelemetryPing.send(getPingType(payload), payload, options);
   },
 
   _isClassicReason: function(reason) {
     const classicReasons = [
       REASON_SAVED_SESSION,
       REASON_IDLE_DAILY,
       REASON_GATHER_PAYLOAD,
       REASON_TEST_PING,
@@ -1668,12 +1981,78 @@ let Impl = {
   /**
    * Get an object describing the current state of this module for AsyncShutdown diagnostics.
    */
   _getState: function() {
     return {
       initialized: this._initialized,
       initStarted: this._initStarted,
       haveDelayedInitTask: !!this._delayedInitTask,
-      dailyTimerScheduled: !!this._dailyTimerId,
     };
   },
+
+  /**
+   * Deletes the aborted session ping. This is called during shutdown.
+   * @return {Promise} Resolved when the aborted session ping is removed or if it doesn't
+   *                   exist.
+   */
+  _removeAbortedSessionPing: function() {
+    const FILE_PATH = OS.Path.join(OS.Constants.Path.profileDir, DATAREPORTING_DIRECTORY,
+                                   ABORTED_SESSION_FILE_NAME);
+    try {
+      return OS.File.remove(FILE_PATH);
+    } catch (ex if ex.becauseNoSuchFile) { }
+    return Promise.resolve();
+  },
+
+  /**
+   * Check if there's any aborted session ping available. If so, tell TelemetryPing about
+   * it.
+   */
+  _checkAbortedSessionPing: Task.async(function* () {
+    // Create the subdirectory that will contain te aborted session ping. We put it in a
+    // subdirectory so that it doesn't get picked up as a pending ping. Please note that
+    // this does nothing if the directory does not already exist.
+    const ABORTED_SESSIONS_DIR =
+      OS.Path.join(OS.Constants.Path.profileDir, DATAREPORTING_DIRECTORY);
+    yield OS.File.makeDir(ABORTED_SESSIONS_DIR, { ignoreExisting: true });
+
+    const FILE_PATH = OS.Path.join(OS.Constants.Path.profileDir, DATAREPORTING_DIRECTORY,
+                                   ABORTED_SESSION_FILE_NAME);
+    let abortedExists = yield OS.File.exists(FILE_PATH);
+    if (abortedExists) {
+      this._log.trace("_checkAbortedSessionPing - aborted session found: " + FILE_PATH);
+      yield this._abortedSessionSerializer.enqueueTask(
+        () => TelemetryPing.addPendingPing(FILE_PATH, true));
+    }
+  }),
+
+  /**
+   * Saves the aborted session ping to disk.
+   * @param {Object} [aProvidedPayload=null] A payload object to be used as an aborted
+   *                 session ping. The reason of this payload is changed to aborted-session.
+   *                 If not provided, a new payload is gathered.
+   */
+  _saveAbortedSessionPing: function(aProvidedPayload = null) {
+    const FILE_PATH = OS.Path.join(OS.Constants.Path.profileDir, DATAREPORTING_DIRECTORY,
+                                   ABORTED_SESSION_FILE_NAME);
+    this._log.trace("_saveAbortedSessionPing - ping path: " + FILE_PATH);
+
+    let payload = null;
+    if (aProvidedPayload) {
+      payload = aProvidedPayload;
+      // Overwrite the original reason.
+      payload.info.reason = REASON_ABORTED_SESSION;
+    } else {
+      payload = this.getSessionPayload(REASON_ABORTED_SESSION, false);
+    }
+
+    let options = {
+      retentionDays: RETENTION_DAYS,
+      addClientId: true,
+      addEnvironment: true,
+      overwrite: true,
+      filePath: FILE_PATH,
+    };
+    return this._abortedSessionSerializer.enqueueTask(() =>
+      TelemetryPing.savePing(getPingType(payload), payload, options));
+  },
 };