Bug 1308337 - Part 1: Instrument named sync stages and broadcast collected telemetry r=nalexander draft
authorGrigory Kruglov <gkruglov@mozilla.com>
Wed, 31 May 2017 17:38:14 -0400
changeset 588424 4c7a7e1fde2e32d401eb28c70b9f04fdbd148ffd
parent 588423 d1790feae1c0f46dc5f420aeed347da12a6ac85c
child 588425 59c83e44235101f76b42f0eced867ce7b9d5a464
push id62031
push userbmo:gkruglov@mozilla.com
push dateFri, 02 Jun 2017 19:52:26 +0000
reviewersnalexander
bugs1308337
milestone55.0a1
Bug 1308337 - Part 1: Instrument named sync stages and broadcast collected telemetry r=nalexander SyncAdapter owns a TelemetryCollector, which is passed into GlobalSession to be "filled up" with telemtry data. GlobalSession obtains instances of TelemetryStageCollector from the TelemetryCollector, and passes them into individual stages. They are filled up with telemetry as stages are executed. Stage errors are recorded in TelemetryStageCollector. Various global errors are recorded in TelemetryCollector itself. On completion (success, failure, abort), telemetry is "built" and broadcasted via LocalBroadcastManager. TelemetryContract is used to establish a key convention between the "broadcaster" and whoever is on the receiving end of this telemetry. This patch instruments stages which follow the Repository<->Repository flow semantics. Other stages, such as the clients stage, meta/globa, info/* and crypto/keys are instrumented separately in follow-up patches. MozReview-Commit-ID: 5VLRc96GLdV
mobile/android/base/android-services.mozbuild
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountNotificationManager.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncAdapter.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/GlobalSession.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/GlobalSessionCallback.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AbstractSessionManagingSyncStage.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/GlobalSyncStage.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/ServerSyncStage.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SyncClientsEngineStage.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerSession.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/telemetry/TelemetryCollector.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/telemetry/TelemetryContract.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/telemetry/TelemetryStageCollector.java
mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/sync/TestClientsStage.java
mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/sync/TestResetting.java
mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/testhelpers/DefaultGlobalSessionCallback.java
mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/testhelpers/MockPrefsGlobalSession.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestClientsEngineStage.java
mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockGlobalSessionCallback.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/DefaultGlobalSessionCallback.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockPrefsGlobalSession.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/telemetry/TelemetryCollectorTest.java
--- a/mobile/android/base/android-services.mozbuild
+++ b/mobile/android/base/android-services.mozbuild
@@ -1078,17 +1078,19 @@ sync_java_files = [TOPSRCDIR + '/mobile/
     'sync/synchronizer/SessionNotBegunException.java',
     'sync/synchronizer/Synchronizer.java',
     'sync/synchronizer/SynchronizerDelegate.java',
     'sync/synchronizer/SynchronizerSession.java',
     'sync/synchronizer/SynchronizerSessionDelegate.java',
     'sync/synchronizer/UnbundleError.java',
     'sync/synchronizer/UnexpectedSessionException.java',
     'sync/SynchronizerConfiguration.java',
+    'sync/telemetry/TelemetryCollector.java',
     'sync/telemetry/TelemetryContract.java',
+    'sync/telemetry/TelemetryStageCollector.java',
     'sync/ThreadPool.java',
     'sync/UnexpectedJSONException.java',
     'sync/UnknownSynchronizerConfigurationVersionException.java',
     'sync/Utils.java',
     'tokenserver/TokenServerClient.java',
     'tokenserver/TokenServerClientDelegate.java',
     'tokenserver/TokenServerException.java',
     'tokenserver/TokenServerToken.java',
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountNotificationManager.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountNotificationManager.java
@@ -8,24 +8,22 @@ import android.app.NotificationManager;
 import android.app.PendingIntent;
 import android.content.Context;
 import android.content.Intent;
 import android.support.v4.app.NotificationCompat;
 import android.support.v4.app.NotificationCompat.Builder;
 import org.mozilla.gecko.Locales;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.background.common.log.Logger;
-import org.mozilla.gecko.background.common.telemetry.TelemetryWrapper;
 import org.mozilla.gecko.background.fxa.FxAccountUtils;
 import org.mozilla.gecko.fxa.FxAccountConstants;
 import org.mozilla.gecko.fxa.activities.FxAccountWebFlowActivity;
 import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
 import org.mozilla.gecko.fxa.login.State;
 import org.mozilla.gecko.fxa.login.State.Action;
-import org.mozilla.gecko.sync.telemetry.TelemetryContract;
 
 /**
  * Abstraction that manages notifications shown or hidden for a Firefox Account.
  * <p>
  * In future, we anticipate this tracking things like:
  * <ul>
  * <li>new engines to offer to Sync;</li>
  * <li>service interruption updates;</li>
@@ -79,17 +77,17 @@ public class FxAccountNotificationManage
       localeUpdated = true;
       Locales.getLocaleManager().getAndApplyPersistedLocale(context);
     }
 
     final String title;
     final String text;
     final Intent notificationIntent;
     if (action == Action.NeedsFinishMigrating) {
-      TelemetryWrapper.addToHistogram(TelemetryContract.SYNC11_MIGRATION_NOTIFICATIONS_OFFERED, 1);
+      //TelemetryWrapper.addToHistogram(TelemetryContract.SYNC11_MIGRATION_NOTIFICATIONS_OFFERED, 1);
 
       title = context.getResources().getString(R.string.fxaccount_sync_finish_migrating_notification_title);
       text = context.getResources().getString(R.string.fxaccount_sync_finish_migrating_notification_text, state.email);
       notificationIntent = new Intent(FxAccountConstants.ACTION_FXA_FINISH_MIGRATING);
     } else {
       title = context.getResources().getString(R.string.fxaccount_sync_sign_in_error_notification_title);
       text = context.getResources().getString(R.string.fxaccount_sync_sign_in_error_notification_text, state.email);
       notificationIntent = new Intent(FxAccountConstants.ACTION_FXA_STATUS);
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncAdapter.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncAdapter.java
@@ -1,28 +1,27 @@
 /* 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.fxa.sync;
 
 import android.accounts.Account;
 import android.content.AbstractThreadedSyncAdapter;
-import android.content.ContentProvider;
 import android.content.ContentProviderClient;
 import android.content.ContentResolver;
 import android.content.Context;
+import android.content.Intent;
 import android.content.SharedPreferences;
 import android.content.SyncResult;
 import android.os.Bundle;
 import android.os.SystemClock;
-import android.text.TextUtils;
+import android.support.v4.content.LocalBroadcastManager;
 
 import org.mozilla.gecko.background.common.log.Logger;
-import org.mozilla.gecko.background.common.telemetry.TelemetryWrapper;
 import org.mozilla.gecko.background.fxa.FxAccountUtils;
 import org.mozilla.gecko.background.fxa.SkewHandler;
 import org.mozilla.gecko.browserid.JSONWebTokenUtils;
 import org.mozilla.gecko.fxa.FirefoxAccounts;
 import org.mozilla.gecko.fxa.FxAccountConstants;
 import org.mozilla.gecko.fxa.devices.FxAccountDeviceListUpdater;
 import org.mozilla.gecko.fxa.devices.FxAccountDeviceRegistrator;
 import org.mozilla.gecko.fxa.authenticator.AccountPickler;
@@ -42,16 +41,17 @@ import org.mozilla.gecko.sync.SyncConfig
 import org.mozilla.gecko.sync.ThreadPool;
 import org.mozilla.gecko.sync.Utils;
 import org.mozilla.gecko.sync.crypto.KeyBundle;
 import org.mozilla.gecko.sync.delegates.GlobalSessionCallback;
 import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
 import org.mozilla.gecko.sync.net.AuthHeaderProvider;
 import org.mozilla.gecko.sync.net.HawkAuthHeaderProvider;
 import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
+import org.mozilla.gecko.sync.telemetry.TelemetryCollector;
 import org.mozilla.gecko.sync.telemetry.TelemetryContract;
 import org.mozilla.gecko.tokenserver.TokenServerClient;
 import org.mozilla.gecko.tokenserver.TokenServerClientDelegate;
 import org.mozilla.gecko.tokenserver.TokenServerException;
 import org.mozilla.gecko.tokenserver.TokenServerToken;
 
 import java.net.URI;
 import java.net.URISyntaxException;
@@ -81,35 +81,35 @@ public class FxAccountSyncAdapter extend
   // Non-user initiated sync can't take longer than 30 minutes.
   // To ensure we're not churning through device's battery/resources, we limit sync to 10 minutes,
   // and request a re-sync if we hit that deadline.
   private static final long SYNC_DEADLINE_DELTA_MILLIS = TimeUnit.MINUTES.toMillis(10);
 
   protected final ExecutorService executor;
   protected final FxAccountNotificationManager notificationManager;
 
+  private final TelemetryCollector telemetryCollector = new TelemetryCollector();
+
   public FxAccountSyncAdapter(Context context, boolean autoInitialize) {
     super(context, autoInitialize);
     this.executor = Executors.newSingleThreadExecutor();
     this.notificationManager = new FxAccountNotificationManager(NOTIFICATION_ID);
   }
 
   protected static class SyncDelegate extends FxAccountSyncDelegate {
     @Override
     public void handleSuccess() {
       Logger.info(LOG_TAG, "Sync succeeded.");
       super.handleSuccess();
-      TelemetryWrapper.addToHistogram(TelemetryContract.SYNC_COMPLETED, 1);
     }
 
     @Override
     public void handleError(Exception e) {
       Logger.error(LOG_TAG, "Got exception syncing.", e);
       super.handleError(e);
-      TelemetryWrapper.addToHistogram(TelemetryContract.SYNC_FAILED, 1);
     }
 
     @Override
     public void handleCannotSync(State finalState) {
       Logger.warn(LOG_TAG, "Cannot sync from state: " + finalState.getStateLabel());
       super.handleCannotSync(finalState);
     }
 
@@ -141,16 +141,79 @@ public class FxAccountSyncAdapter extend
       this.stageNamesToSync = Collections.unmodifiableCollection(stageNamesToSync);
     }
 
     public Collection<String> getStageNamesToSync() {
       return this.stageNamesToSync;
     }
   }
 
+  /**
+   * Locally broadcasts telemetry gathered by GlobalSession, so that it may be processed by
+   * Java Telemetry - specifically, {@link org.mozilla.gecko.telemetry.TelemetryBackgroundReceiver}.
+   * Due to how packages are built, we can not call into it directly from *.sync.
+   */
+  private static class InstrumentedSessionCallback extends SessionCallback {
+    private static final String ACTION_BACKGROUND_TELEMETRY = "org.mozilla.gecko.telemetry.BACKGROUND";
+
+    private final LocalBroadcastManager localBroadcastManager;
+    private final TelemetryCollector telemetryCollector;
+
+    InstrumentedSessionCallback(TelemetryCollector telemetryCollector, LocalBroadcastManager localBroadcastManager, SyncDelegate syncDelegate, SchedulePolicy schedulePolicy) {
+      super(syncDelegate, schedulePolicy);
+      this.telemetryCollector = telemetryCollector;
+      this.localBroadcastManager = localBroadcastManager;
+    }
+
+    @Override
+    public void handleSuccess(GlobalSession globalSession) {
+      super.handleSuccess(globalSession);
+      recordTelemetry();
+    }
+
+    @Override
+    public void handleError(GlobalSession globalSession, Exception ex, String reason) {
+      super.handleError(globalSession, ex, reason);
+      this.telemetryCollector.setError(TelemetryCollector.KEY_ERROR_INTERNAL, reason);
+      recordTelemetry();
+    }
+
+    @Override
+    public void handleError(GlobalSession globalSession, Exception e) {
+      super.handleError(globalSession, e);
+      if (e instanceof TokenServerException) {
+        this.telemetryCollector.setError(
+                TelemetryCollector.KEY_ERROR_TOKEN, e.getClass().getSimpleName());
+      } else {
+        this.telemetryCollector.setError(
+                TelemetryCollector.KEY_ERROR_INTERNAL, e.getClass().getSimpleName());
+      }
+      recordTelemetry();
+    }
+
+    @Override
+    public void handleAborted(GlobalSession globalSession, String reason) {
+      super.handleAborted(globalSession, reason);
+      // Note to future maintainers: while there are reasons, other than 'backoff', this method
+      // might be called, in practice that _is_ the only reason it gets called at the moment of
+      // writing this. If this changes, please do expand this telemetry handling.
+      this.telemetryCollector.setError(TelemetryCollector.KEY_ERROR_INTERNAL, "backoff");
+      recordTelemetry();
+    }
+
+    private void recordTelemetry() {
+      telemetryCollector.setFinished(SystemClock.elapsedRealtime());
+      final Intent telemetryIntent = new Intent();
+      telemetryIntent.setAction(ACTION_BACKGROUND_TELEMETRY);
+      telemetryIntent.putExtra(TelemetryContract.KEY_TYPE, TelemetryContract.KEY_TYPE_SYNC);
+      telemetryIntent.putExtra(TelemetryContract.KEY_TELEMETRY, this.telemetryCollector.build());
+      localBroadcastManager.sendBroadcast(telemetryIntent);
+    }
+  }
+
   protected static class SessionCallback implements GlobalSessionCallback {
     protected final SyncDelegate syncDelegate;
     protected final SchedulePolicy schedulePolicy;
     protected volatile BackoffHandler storageBackoffHandler;
 
     public SessionCallback(SyncDelegate syncDelegate, SchedulePolicy schedulePolicy) {
       this.syncDelegate = syncDelegate;
       this.schedulePolicy = schedulePolicy;
@@ -222,16 +285,21 @@ public class FxAccountSyncAdapter extend
         this.schedulePolicy.onSuccessfulSync(otherClientsCount);
       } finally {
         // Continue with the usual success flow.
         syncDelegate.handleSuccess();
       }
     }
 
     @Override
+    public void handleError(GlobalSession globalSession, Exception ex, String reason) {
+      this.handleError(globalSession, ex);
+    }
+
+    @Override
     public void handleError(GlobalSession globalSession, Exception e) {
       Logger.warn(LOG_TAG, "Global session failed."); // Exception will be dumped by delegate below.
       syncDelegate.handleError(e);
       // TODO: should we reduce the periodic sync interval?
     }
 
     @Override
     public void handleAborted(GlobalSession globalSession, String reason) {
@@ -349,17 +417,19 @@ public class FxAccountSyncAdapter extend
 
           final Context context = getContext();
           final SyncConfiguration syncConfig = new SyncConfiguration(token.uid, authHeaderProvider, sharedPrefs, syncKeyBundle);
 
           Collection<String> knownStageNames = SyncConfiguration.validEngineNames();
           syncConfig.stagesToSync = Utils.getStagesToSyncFromBundle(knownStageNames, extras);
           syncConfig.setClusterURL(storageServerURI);
 
-          globalSession = new GlobalSession(syncConfig, callback, context, clientsDataDelegate);
+          globalSession = new GlobalSession(syncConfig, callback, context, clientsDataDelegate, telemetryCollector);
+          telemetryCollector.setIDs(token.hashedFxaUid, clientsDataDelegate.getAccountGUID());
+          telemetryCollector.setStarted(SystemClock.elapsedRealtime());
           globalSession.start(syncDeadline);
         } catch (Exception e) {
           callback.handleError(globalSession, e);
           return;
         }
       }
 
       @Override
@@ -461,17 +531,16 @@ public class FxAccountSyncAdapter extend
 
     FirefoxAccounts.logSyncOptions(extras);
 
     if (this.lastSyncRealtimeMillis > 0L &&
         (this.lastSyncRealtimeMillis + MINIMUM_SYNC_DELAY_MILLIS) > SystemClock.elapsedRealtime() &&
             !extras.getBoolean(ContentResolver.SYNC_EXTRAS_IGNORE_BACKOFF, false)) {
       Logger.info(LOG_TAG, "Not syncing FxAccount " + Utils.obfuscateEmail(account.name) +
                            ": minimum interval not met.");
-      TelemetryWrapper.addToHistogram(TelemetryContract.SYNC_FAILED_BACKOFF, 1);
       return;
     }
 
     // Pickle in a background thread to avoid strict mode warnings.
     ThreadPool.run(new Runnable() {
       @Override
       public void run() {
         try {
@@ -541,18 +610,16 @@ public class FxAccountSyncAdapter extend
       try {
         state = fxAccount.getState();
       } catch (Exception e) {
         fxAccount.releaseSharedAccountStateLock();
         syncDelegate.handleError(e);
         return;
       }
 
-      TelemetryWrapper.addToHistogram(TelemetryContract.SYNC_STARTED, 1);
-
       final FxAccountLoginStateMachine stateMachine = new FxAccountLoginStateMachine();
       stateMachine.advance(state, StateLabel.Married, new FxADefaultLoginStateMachineDelegate(context, fxAccount) {
         @Override
         public void handleNotMarried(State notMarried) {
           Logger.info(LOG_TAG, "handleNotMarried: in " + notMarried.getStateLabel());
           schedulePolicy.onHandleFinal(notMarried.getNeededAction());
           syncDelegate.handleCannotSync(notMarried);
           if (notMarried.getStateLabel() == StateLabel.Engaged) {
@@ -596,17 +663,23 @@ public class FxAccountSyncAdapter extend
             if (!shouldRequestToken(tokenBackoffHandler, extras)) {
               Logger.info(LOG_TAG, "Not syncing (token server).");
               syncDelegate.postponeSync(tokenBackoffHandler.delayMilliseconds());
               return;
             }
 
             onSessionTokenStateReached(context, fxAccount);
 
-            final SessionCallback sessionCallback = new SessionCallback(syncDelegate, schedulePolicy);
+            final SessionCallback sessionCallback = new InstrumentedSessionCallback(
+                    telemetryCollector,
+                    LocalBroadcastManager.getInstance(context),
+                    syncDelegate,
+                    schedulePolicy
+            );
+
             final KeyBundle syncKeyBundle = married.getSyncKeyBundle();
             final String clientState = married.getClientState();
             syncWithAssertion(
                     assertion, tokenServerEndpointURI, tokenBackoffHandler, sharedPrefs,
                     syncKeyBundle, clientState, sessionCallback, extras, fxAccount, syncDeadline);
 
             // Force fetch the profile avatar information. (asynchronous, in another thread)
             Logger.info(LOG_TAG, "Fetching profile avatar information.");
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/GlobalSession.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/GlobalSession.java
@@ -1,15 +1,16 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.sync;
 
 import android.content.Context;
+import android.os.SystemClock;
 import android.support.annotation.VisibleForTesting;
 
 import org.json.simple.JSONArray;
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.sync.crypto.CryptoException;
 import org.mozilla.gecko.sync.crypto.KeyBundle;
 import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
 import org.mozilla.gecko.sync.delegates.FreshStartDelegate;
@@ -38,16 +39,18 @@ import org.mozilla.gecko.sync.stage.Fetc
 import org.mozilla.gecko.sync.stage.FetchMetaGlobalStage;
 import org.mozilla.gecko.sync.stage.FormHistoryServerSyncStage;
 import org.mozilla.gecko.sync.stage.GlobalSyncStage;
 import org.mozilla.gecko.sync.stage.GlobalSyncStage.Stage;
 import org.mozilla.gecko.sync.stage.NoSuchStageException;
 import org.mozilla.gecko.sync.stage.PasswordsServerSyncStage;
 import org.mozilla.gecko.sync.stage.SyncClientsEngineStage;
 import org.mozilla.gecko.sync.stage.UploadMetaGlobalStage;
+import org.mozilla.gecko.sync.telemetry.TelemetryCollector;
+import org.mozilla.gecko.sync.telemetry.TelemetryStageCollector;
 
 import java.io.IOException;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumMap;
@@ -79,16 +82,22 @@ public class GlobalSession implements Ht
   private long syncDeadline;
 
   /**
    * Map from engine name to new settings for an updated meta/global record.
    * Engines to remove will have <code>null</code> EngineSettings.
    */
   public final Map<String, EngineSettings> enginesToUpdate = new HashMap<String, EngineSettings>();
 
+  private final TelemetryCollector telemetryCollector;
+
+  public TelemetryCollector getTelemetryCollector() {
+    return telemetryCollector;
+  }
+
    /*
    * Key accessors.
    */
   public KeyBundle keyBundleForCollection(String collection) throws NoCollectionKeysSetException {
     return config.getCollectionKeys().keyBundleForCollection(collection);
   }
 
   /*
@@ -100,26 +109,28 @@ public class GlobalSession implements Ht
 
   public URI wboURI(String collection, String id) throws URISyntaxException {
     return config.wboURI(collection, id);
   }
 
   public GlobalSession(SyncConfiguration config,
                        GlobalSessionCallback callback,
                        Context context,
-                       ClientsDataDelegate clientsDelegate)
+                       ClientsDataDelegate clientsDelegate,
+                       TelemetryCollector telemetryCollector)
     throws SyncConfigurationException, IllegalArgumentException, IOException, NonObjectJSONException {
 
     if (callback == null) {
       throw new IllegalArgumentException("Must provide a callback to GlobalSession constructor.");
     }
 
     this.callback        = callback;
     this.context         = context;
     this.clientsDelegate = clientsDelegate;
+    this.telemetryCollector = telemetryCollector;
 
     this.config = config;
     registerCommands();
     prepareStages();
 
     if (config.stagesToSync == null) {
       Logger.info(LOG_TAG, "No stages to sync specified; defaulting to all valid engine names.");
       config.stagesToSync = Collections.unmodifiableCollection(SyncConfiguration.validEngineNames());
@@ -276,22 +287,33 @@ public class GlobalSession implements Ht
     try {
       nextStage = this.getSyncStageByName(next);
     } catch (NoSuchStageException e) {
       this.abort(e, "No such stage " + next);
       return;
     }
     this.currentState = next;
     Logger.info(LOG_TAG, "Running next stage " + next + " (" + nextStage + ")...");
+
+    // For named stages, use the repository name.
+    String collectorName = currentState.getRepositoryName();
+    final TelemetryStageCollector stageCollector = telemetryCollector.collectorFor(collectorName);
+    // Stage is responsible for setting the 'finished' timestamp when appropriate.
+    stageCollector.started = SystemClock.elapsedRealtime();
+
     try {
-      nextStage.execute(this);
+      nextStage.execute(this, stageCollector);
     } catch (Exception ex) {
       Logger.warn(LOG_TAG, "Caught exception " + ex + " running stage " + next);
+      // We're not setting stageCollector's error since there's a chance the stage already set it
+      // and we'll lose a root cause error by overriding it here. Call to `abort` will end up calling
+      // GlobalSession's callback handler which is instrumented and records global errors, so this
+      // error won't get lost.
+      stageCollector.finished = SystemClock.elapsedRealtime();
       this.abort(ex, "Uncaught exception in stage.");
-      return;
     }
   }
 
   public Context getContext() {
     return this.context;
   }
 
   /**
@@ -494,17 +516,17 @@ public class GlobalSession implements Ht
       callback.requestBackoff(existingBackoff);
     }
     if (!(e instanceof HTTPFailureException)) {
       //  e is null, or we aborted for a non-HTTP reason; okay to upload new meta/global record.
       if (this.hasUpdatedMetaGlobal()) {
         this.uploadUpdatedMetaGlobal(); // Only logs errors; does not call abort.
       }
     }
-    this.callback.handleError(this, e);
+    this.callback.handleError(this, e, reason);
   }
 
   public void handleIncompleteStage() {
     // Let our delegate know that current stage is incomplete and needs to be synced again.
     callback.handleIncompleteStage(this.currentState, this);
   }
 
   public void handleHTTPError(SyncStorageResponse response, String reason) {
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/GlobalSessionCallback.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/delegates/GlobalSessionCallback.java
@@ -30,16 +30,17 @@ public interface GlobalSessionCallback {
   /**
    * Called when a migration sentinel has been found and processed successfully.
    * <p>
    * This account should stop syncing immediately, and arrange to delete itself.
    */
   void informMigrated(GlobalSession session);
 
   void handleAborted(GlobalSession globalSession, String reason);
+  void handleError(GlobalSession globalSession, Exception ex, String reason);
   void handleError(GlobalSession globalSession, Exception ex);
   void handleSuccess(GlobalSession globalSession);
   void handleStageCompleted(Stage currentState, GlobalSession globalSession);
   void handleIncompleteStage(Stage currentState, GlobalSession globalSession);
   void handleFullSyncNecessary();
 
   /**
    * Called when a {@link GlobalSession} wants to know if it should continue
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AbstractSessionManagingSyncStage.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/AbstractSessionManagingSyncStage.java
@@ -1,28 +1,30 @@
 /* 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.sync.stage;
 
 import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.telemetry.TelemetryStageCollector;
 
 /**
  * A global sync stage that manages a <code>GlobalSession</code> instance. This
  * class is intended to be temporary: it should disappear as work to make
  * data-driven syncs progresses.
  * <p>
  * This class is inherently <b>thread-unsafe</b>: if <code>session</code> is
  * mutated after being set, all sorts of bad things could occur. At the time of
  * writing, every <code>GlobalSyncStage</code> created is executed (wiped,
  * reset) with the same <code>GlobalSession</code> argument.
  */
 public abstract class AbstractSessionManagingSyncStage implements GlobalSyncStage {
   protected GlobalSession session;
+  protected TelemetryStageCollector telemetryStageCollector;
 
   protected abstract void execute() throws NoSuchStageException;
   protected abstract void resetLocal();
   protected abstract void wipeLocal() throws Exception;
 
   @Override
   public void resetLocal(GlobalSession session) {
     this.session = session;
@@ -31,13 +33,15 @@ public abstract class AbstractSessionMan
 
   @Override
   public void wipeLocal(GlobalSession session) throws Exception {
     this.session = session;
     wipeLocal();
   }
 
   @Override
-  public void execute(GlobalSession session) throws NoSuchStageException {
+  public void execute(GlobalSession session, TelemetryStageCollector telemetryStageCollector) throws NoSuchStageException {
+    this.telemetryStageCollector = telemetryStageCollector;
     this.session = session;
+
     execute();
   }
 }
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/GlobalSyncStage.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/GlobalSyncStage.java
@@ -6,16 +6,17 @@ package org.mozilla.gecko.sync.stage;
 
 import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.Map;
 
 import org.mozilla.gecko.sync.GlobalSession;
+import org.mozilla.gecko.sync.telemetry.TelemetryStageCollector;
 
 
 public interface GlobalSyncStage {
   public static enum Stage {
     idle,                       // Start state.
     checkPreconditions,         // Preparation of the basics. TODO: clear status
     fetchInfoCollections,       // Take a look at timestamps.
     fetchInfoConfiguration,     // Fetch server upload limits
@@ -75,17 +76,17 @@ public interface GlobalSyncStage {
       this.repositoryName = null;
     }
 
     private Stage(final String name) {
       this.repositoryName = name;
     }
   }
 
-  public void execute(GlobalSession session) throws NoSuchStageException;
+  void execute(GlobalSession session, TelemetryStageCollector telemetryStageCollector) throws NoSuchStageException;
   public void resetLocal(GlobalSession session);
   public void wipeLocal(GlobalSession session) throws Exception;
 
   /**
    * What storage version number this engine supports.
    * <p>
    * Used to generate a fresh meta/global record for upload.
    * @return a version number or <code>null</code> to never include this engine in a fresh meta/global record.
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/ServerSyncStage.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/ServerSyncStage.java
@@ -37,16 +37,17 @@ import org.mozilla.gecko.sync.repositori
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate;
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate;
 import org.mozilla.gecko.sync.synchronizer.ServerLocalSynchronizer;
 import org.mozilla.gecko.sync.synchronizer.Synchronizer;
 import org.mozilla.gecko.sync.synchronizer.SynchronizerDelegate;
 import org.mozilla.gecko.sync.synchronizer.SynchronizerSession;
+import org.mozilla.gecko.sync.telemetry.TelemetryCollector;
 
 import java.io.IOException;
 import java.net.URISyntaxException;
 import java.util.Map;
 import java.util.concurrent.ExecutorService;
 
 /**
  * Fetch from a server collection into a local repository, encrypting
@@ -644,20 +645,39 @@ public abstract class ServerSyncStage ex
     if (newConfig != null) {
       persistConfig(newConfig);
     } else {
       Logger.warn(LOG_TAG, "Didn't get configuration from synchronizer after success.");
     }
 
     final SynchronizerSession synchronizerSession = synchronizer.getSynchronizerSession();
     int inboundCount = synchronizerSession.getInboundCount();
+    int inboundCountStored = synchronizerSession.getInboundCountStored();
+    int inboundCountFailed = synchronizerSession.getInboundCountFailed();
+    int inboundCountReconciled = synchronizerSession.getInboundCountReconciled();
     int outboundCount = synchronizerSession.getOutboundCount();
-    Logger.info(LOG_TAG, "Stage " + getEngineName() +
-        " received " + inboundCount + " and sent " + outboundCount +
-        " records in " + getStageDurationString() + ".");
+    int outboundCountStored = synchronizerSession.getOutboundCountStored();
+    int outboundCountFailed = synchronizerSession.getOutboundCountFailed();
+
+    telemetryStageCollector.finished = stageCompleteTimestamp;
+    telemetryStageCollector.inbound = inboundCount;
+    telemetryStageCollector.inboundStored = inboundCountStored;
+    telemetryStageCollector.inboundFailed = inboundCountFailed;
+    telemetryStageCollector.reconciled = inboundCountReconciled;
+    telemetryStageCollector.outbound = outboundCount;
+    telemetryStageCollector.outboundStored = outboundCountStored;
+    telemetryStageCollector.outboundFailed = outboundCountFailed;
+
+    Logger.info(LOG_TAG, "Stage " + getEngineName()
+            + " received " + inboundCount
+            + "; stored " + inboundCountStored + ", reconciling " + inboundCountReconciled
+            + " and failed to store " + inboundCountFailed
+            + ". Sent " + outboundCount
+            + "; server accepted " + outboundCountStored + " and rejected " + outboundCountFailed
+            + ". Duration: " + getStageDurationString() + ".");
     Logger.info(LOG_TAG, "Advancing session.");
     session.advance();
   }
 
   /**
    * We failed to sync this engine! Do not persist timestamps (which means that
    * the next sync will include this sync's data), but do advance the session
    * (if we didn't get a Retry-After header).
@@ -665,16 +685,26 @@ public abstract class ServerSyncStage ex
    * @param synchronizer the <code>Synchronizer</code> that failed.
    */
   @Override
   public void onSynchronizeFailed(Synchronizer synchronizer,
                                   Exception lastException, String reason) {
     stageCompleteTimestamp = SystemClock.elapsedRealtime();
     Logger.warn(LOG_TAG, "Synchronize failed: " + reason, lastException);
 
+    final SynchronizerSession synchronizerSession = synchronizer.getSynchronizerSession();
+
+    telemetryStageCollector.error = new TelemetryCollector.StageErrorBuilder()
+            .setLastException(lastException)
+            .setFetchException(synchronizerSession.getFetchFailedCauseException())
+            .setStoreException(synchronizerSession.getStoreFailedCauseException())
+            .build();
+
+    telemetryStageCollector.finished = stageCompleteTimestamp;
+
     // This failure could be due to a 503 or a 401 and it could have headers.
     // Interrogate the headers but only abort the global session if Retry-After header is set.
     if (lastException instanceof HTTPFailureException) {
       SyncStorageResponse response = ((HTTPFailureException)lastException).response;
       if (response.retryAfterInSeconds() > 0) {
         session.handleHTTPError(response, reason); // Calls session.abort().
         return;
       } else {
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SyncClientsEngineStage.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/stage/SyncClientsEngineStage.java
@@ -271,16 +271,17 @@ public class SyncClientsEngineStage exte
         if (clientsDelegate.isLocalGUID(r.guid)) {
           Logger.info(LOG_TAG, "Local client GUID exists on server and was downloaded.");
           localAccountGUIDDownloaded = true;
           handleDownloadedLocalRecord(r);
         } else {
           // Only need to store record if it isn't our local one.
           wipeAndStore(r);
           addCommands(r);
+          telemetryStageCollector.getSyncCollector().addDevice(r);
         }
         RepoUtils.logClient(r);
       } catch (Exception e) {
         session.abort(e, "Exception handling client WBO.");
         return;
       }
     }
 
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerSession.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/synchronizer/SynchronizerSession.java
@@ -117,16 +117,28 @@ implements RecordsChannelDelegate,
    * Valid only after first flow has completed.
    *
    * @return number of records, or -1 if not valid.
    */
   public int getInboundCount() {
     return numInboundRecords.get();
   }
 
+  public int getInboundCountStored() {
+    return numInboundRecordsStored.get();
+  }
+
+  public int getInboundCountFailed() {
+    return numInboundRecordsFailed.get();
+  }
+
+  public int getInboundCountReconciled() {
+    return numInboundRecordsReconciled.get();
+  }
+
   /**
    * Get the number of records fetched from the second repository (usually the
    * local store, hence outbound).
    * <p>
    * Valid only after second flow has completed.
    *
    * @return number of records, or -1 if not valid.
    */
@@ -155,17 +167,22 @@ implements RecordsChannelDelegate,
   protected RecordsChannel channelAToB;
   protected RecordsChannel channelBToA;
 
   /**
    * Please don't call this until you've been notified with onInitialized.
    */
   public synchronized void synchronize() {
     numInboundRecords.set(-1);
+    numInboundRecordsStored.set(-1);
+    numInboundRecordsFailed.set(-1);
+    numInboundRecordsReconciled.set(-1);
     numOutboundRecords.set(-1);
+    numOutboundRecordsStored.set(-1);
+    numOutboundRecordsFailed.set(-1);
 
     // First thing: decide whether we should.
     if (sessionA.shouldSkip() ||
         sessionB.shouldSkip()) {
       Logger.info(LOG_TAG, "Session requested skip. Short-circuiting sync.");
       sessionA.abort();
       sessionB.abort();
       this.delegate.onSynchronizeSkipped(this);
@@ -235,16 +252,19 @@ implements RecordsChannelDelegate,
    * @param storeEnd timestamp when stores completed.
    */
   public void onFirstFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd) {
     Logger.trace(LOG_TAG, "First RecordsChannel onFlowCompleted.");
     Logger.debug(LOG_TAG, "Fetch end is " + fetchEnd + ". Store end is " + storeEnd + ". Starting next.");
     pendingATimestamp = fetchEnd;
     storeEndBTimestamp = storeEnd;
     numInboundRecords.set(recordsChannel.getFetchCount());
+    numInboundRecordsStored.set(recordsChannel.getStoreAcceptedCount());
+    numInboundRecordsFailed.set(recordsChannel.getStoreFailureCount());
+    numInboundRecordsReconciled.set(recordsChannel.getStoreReconciledCount());
     flowAToBCompleted = true;
     channelBToA.flow();
   }
 
   /**
    * Called after the second flow completes.
    * <p>
    * By default, any fetch and store failures are ignored.
@@ -254,16 +274,18 @@ implements RecordsChannelDelegate,
    */
   public void onSecondFlowCompleted(RecordsChannel recordsChannel, long fetchEnd, long storeEnd) {
     Logger.trace(LOG_TAG, "Second RecordsChannel onFlowCompleted.");
     Logger.debug(LOG_TAG, "Fetch end is " + fetchEnd + ". Store end is " + storeEnd + ". Finishing.");
 
     pendingBTimestamp = fetchEnd;
     storeEndATimestamp = storeEnd;
     numOutboundRecords.set(recordsChannel.getFetchCount());
+    numOutboundRecordsStored.set(recordsChannel.getStoreAcceptedCount());
+    numOutboundRecordsFailed.set(recordsChannel.getStoreFailureCount());
     flowBToACompleted = true;
 
     // Finish the two sessions.
     try {
       this.sessionA.finish(this);
     } catch (InactiveSessionException e) {
       this.onFinishFailed(e);
       return;
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/telemetry/TelemetryCollector.java
@@ -0,0 +1,266 @@
+/* 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.sync.telemetry;
+
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.util.Log;
+
+import org.mozilla.gecko.sync.CollectionConcurrentModificationException;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.HTTPFailureException;
+import org.mozilla.gecko.sync.SyncDeadlineReachedException;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.repositories.FetchFailedException;
+import org.mozilla.gecko.sync.repositories.StoreFailedException;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+
+import java.io.Serializable;
+import java.io.UnsupportedEncodingException;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.HashMap;
+
+/**
+ * Gathers telemetry about a single run of sync.
+ * In light of sync restarts, rarely a "single sync" will actually include more than one sync.
+ * See {@link TelemetryStageCollector} for "stage telemetry".
+ *
+ * @author grisha
+ */
+public class TelemetryCollector {
+    private static final String LOG_TAG = "TelemetryCollector";
+
+    public static final String KEY_ERROR_INTERNAL = "internal";
+    public static final String KEY_ERROR_TOKEN = "token";
+
+    // Telemetry collected by individual stages is aggregated here. Stages run sequentially,
+    // and only access their own collectors.
+    private final HashMap<String, TelemetryStageCollector> stageCollectors = new HashMap<>();
+
+    // Data which is not specific to a single stage is aggregated in this object.
+    @VisibleForTesting protected ExtendedJSONObject error;
+    private String hashedUID;
+    private String hashedDeviceID;
+    private final ArrayList<Bundle> devices = new ArrayList<>();
+
+    @Nullable private Long started;
+    @Nullable private Long finished;
+
+    private boolean didRestart = false;
+
+    public TelemetryStageCollector collectorFor(@NonNull String stageName) {
+        if (stageCollectors.containsKey(stageName)) {
+            return stageCollectors.get(stageName);
+        }
+
+        final TelemetryStageCollector collector = new TelemetryStageCollector(this);
+        stageCollectors.put(stageName, collector);
+        return collector;
+    }
+
+    public void setIDs(@NonNull String uid, @NonNull String deviceID) {
+        // We use hashed_fxa_uid from the token server as our UID.
+        this.hashedUID = uid;
+        try {
+            this.hashedDeviceID = Utils.byte2Hex(Utils.sha256(
+                            deviceID.concat(uid).getBytes("UTF-8")
+                    ));
+        } catch (UnsupportedEncodingException | NoSuchAlgorithmException e) {
+            // Should not happen.
+            Log.e(LOG_TAG, "Either UTF-8 or SHA-256 are not supported", e);
+        }
+    }
+
+    public void setError(@NonNull String name, @NonNull String details) {
+        final ExtendedJSONObject error = new ExtendedJSONObject();
+        error.put("name", name);
+        error.put("error", details);
+        this.error = error;
+    }
+
+    public void setStarted(long time) {
+        this.started = time;
+    }
+
+    public void setFinished(long time) {
+        this.finished = time;
+    }
+
+    // In the current sync-ping parlance, "device" is really just a sync client.
+    // At some point we will start recording actual FxA devices.
+    public void addDevice(final ClientRecord client) {
+        if (this.hashedUID == null) {
+            throw new IllegalStateException("Must call setIDs before adding devices.");
+        }
+
+        final Bundle device = new Bundle();
+        device.putString(TelemetryContract.KEY_DEVICE_OS, client.os);
+        device.putString(TelemetryContract.KEY_DEVICE_VERSION, client.version);
+
+        final String clientAndUid = client.guid.concat(this.hashedUID);
+        try {
+            device.putString(
+                    TelemetryContract.KEY_DEVICE_ID,
+                    Utils.byte2Hex(Utils.sha256(clientAndUid.getBytes("UTF-8")))
+            );
+        } catch (UnsupportedEncodingException | NoSuchAlgorithmException e) {
+            // Should not happen.
+            Log.e(LOG_TAG, "Either UTF-8 or SHA-256 are not supported", e);
+        }
+        devices.add(device);
+    }
+
+    public Bundle build() {
+        if (this.started == null) {
+            throw new IllegalStateException("Telemetry missing 'started' timestamp");
+        }
+        if (this.finished == null) {
+            throw new IllegalStateException("Telemetry missing 'finished' timestamp");
+        }
+
+        final long took = this.finished - this.started;
+
+        final Bundle telemetry = new Bundle();
+        telemetry.putString(TelemetryContract.KEY_LOCAL_UID, this.hashedUID);
+        telemetry.putString(TelemetryContract.KEY_LOCAL_DEVICE_ID, this.hashedDeviceID);
+        telemetry.putParcelableArrayList(TelemetryContract.KEY_DEVICES, this.devices);
+        telemetry.putLong(TelemetryContract.KEY_TOOK, took);
+        telemetry.putSerializable(TelemetryContract.KEY_ERROR, (Serializable) this.error);
+        telemetry.putSerializable(TelemetryContract.KEY_STAGES, this.stageCollectors);
+        return telemetry;
+    }
+
+    /**
+     * Builder class which is responsible for mapping instances of exceptions thrown during sync
+     * stages into a JSON structure that may be submitted as part of a sync ping.
+     */
+    public static class StageErrorBuilder {
+        @Nullable private Exception lastException;
+        @Nullable Exception storeException;
+        @Nullable Exception fetchException;
+
+        @Nullable private final String name;
+        @Nullable private final String error;
+
+        public StageErrorBuilder() {
+            this(null, null);
+        }
+
+        public StageErrorBuilder(@Nullable String name, @Nullable String error) {
+            this.name = name;
+            this.error = error;
+        }
+
+        public StageErrorBuilder setLastException(Exception e) {
+            lastException = e;
+            return this;
+        }
+
+        public StageErrorBuilder setStoreException(Exception e) {
+            storeException = e;
+            return this;
+        }
+
+        public StageErrorBuilder setFetchException(Exception e) {
+            fetchException = e;
+            return this;
+        }
+
+        // Unlike the rest of TelemetryCollector, which only vaguely hints at the particulars of a
+        // sync ping, this method contains specific details - naming of keys/values, etc.
+        // This is done consciously and for simplicity's sake. The alternative is to either pack
+        // these key/values behind an interface and unpack them on the receiver end, or let the receiver
+        // figure out how to deal with exceptions directly. Either way, we'll have a strong coupling.
+        public ExtendedJSONObject build() {
+            final ExtendedJSONObject errorJSON = new ExtendedJSONObject();
+
+            // Process manually set name, error and optional exception.
+            if (name != null && error != null) {
+                errorJSON.put("name", name);
+                errorJSON.put("error", error);
+
+                if (lastException != null && lastException instanceof HTTPFailureException) {
+                    final SyncStorageResponse response = ((HTTPFailureException)lastException).response;
+                    errorJSON.put("code", response.getStatusCode());
+                }
+
+                return errorJSON;
+            }
+
+            // Process set exceptions.
+            if (lastException instanceof CollectionConcurrentModificationException) {
+                errorJSON.put("name", "httperror");
+                errorJSON.put("code", 412);
+
+            } else if (lastException instanceof SyncDeadlineReachedException) {
+                errorJSON.put("name", "unexpected");
+                errorJSON.put("error", "syncdeadline");
+
+            } else if (lastException instanceof FetchFailedException) {
+                if (isNetworkError(fetchException)) {
+                    errorJSON.put("name", "networkerror");
+                    errorJSON.put("error", "fetch:" + fetchException.getClass().getSimpleName());
+                } else {
+                    errorJSON.put("name", "othererror");
+                    if (fetchException != null) {
+                        errorJSON.put("error", "fetch:" + fetchException.getClass().getSimpleName());
+                    } else {
+                        errorJSON.put("error", "fetch:unknown");
+                    }
+                }
+
+            } else if (lastException instanceof StoreFailedException) {
+                if (isNetworkError(storeException)) {
+                    errorJSON.put("name", "networkerror");
+                    errorJSON.put("error", "store:" + storeException.getClass().getSimpleName());
+
+                // Currently we only have access to one exception, the last one that happened. However, there
+                // could have been multiple errors that we're currently ignoring. See Bug 1362208.
+                // Local store failures are ignored (but will get recorded in the incoming failure count).
+                // Remote store failures generally do not abort the session, but will bubble up as an error.
+                // See Bug 1362206.
+                } else {
+                    errorJSON.put("name", "othererror");
+                    if (storeException != null) {
+                        errorJSON.put("error", "store:" + storeException.getClass().getSimpleName());
+                    } else {
+                        errorJSON.put("error", "store:unknown");
+                    }
+                }
+
+            } else if (lastException instanceof HTTPFailureException) {
+                final SyncStorageResponse response = ((HTTPFailureException)lastException).response;
+
+                // Is it an auth error? This could be a password change or a node re-assignment, and
+                // we can't distinguish between the two until we fetch new cluster URL during the
+                // next sync session.
+                if (response.getStatusCode() == 401) {
+                    errorJSON.put("name", "autherror");
+                    // Desktop clients differentiate between "tokenserver", "hawkclient", and "fxaccounts".
+                    // We will encounter this error during a sync stage run, and so it will come
+                    // from a sync storage node.
+                    errorJSON.put("from", "storage");
+                } else {
+                    errorJSON.put("name", "httperror");
+                    errorJSON.put("code", response.getStatusCode());
+                }
+
+            } else if (lastException != null) {
+                errorJSON.put("name", "unexpected");
+                errorJSON.put("error", lastException.getClass().getSimpleName());
+            }
+
+            return errorJSON;
+        }
+
+        private static boolean isNetworkError(@Nullable Exception e) {
+            return e instanceof java.net.SocketException;
+        }
+    }
+}
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/telemetry/TelemetryContract.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/telemetry/TelemetryContract.java
@@ -1,56 +1,27 @@
 /* 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.sync.telemetry;
 
+/**
+ * Establishes a common interface between {@link TelemetryCollector} and
+ * {@link org.mozilla.gecko.telemetry.TelemetryBackgroundReceiver}.
+ */
 public class TelemetryContract {
-  /**
-   * We are a Sync 1.1 (legacy) client, and we downloaded a migration sentinel.
-   */
-  public static final String SYNC11_MIGRATION_SENTINELS_SEEN = "FENNEC_SYNC11_MIGRATION_SENTINELS_SEEN";
-
-  /**
-   * We are a Sync 1.1 (legacy) client and we have downloaded a migration
-   * sentinel, but there was an error creating a Firefox Account from that
-   * sentinel.
-   * <p>
-   * We have logged the error and are ignoring that sentinel.
-   */
-  public static final String SYNC11_MIGRATIONS_FAILED = "FENNEC_SYNC11_MIGRATIONS_FAILED";
-
-  /**
-   * We are a Sync 1.1 (legacy) client and we have downloaded a migration
-   * sentinel, and there was no reported error creating a Firefox Account from
-   * that sentinel.
-   * <p>
-   * We have created a Firefox Account corresponding to the sentinel and have
-   * queued the existing Old Sync account for removal.
-   */
-  public static final String SYNC11_MIGRATIONS_SUCCEEDED = "FENNEC_SYNC11_MIGRATIONS_SUCCEEDED";
+  public final static String KEY_TELEMETRY = "telemetry";
+  public final static String KEY_STAGES = "stages";
+  public final static String KEY_ERROR = "error";
+  public final static String KEY_LOCAL_UID = "uid";
+  public final static String KEY_LOCAL_DEVICE_ID = "deviceID";
+  public final static String KEY_DEVICES = "devices";
+  public final static String KEY_TOOK = "took";
 
-  /**
-   * We are (now) a Sync 1.5 (Firefox Accounts-based) client that migrated from
-   * Sync 1.1. We have presented the user the "complete upgrade" notification.
-   * <p>
-   * We will offer every time a sync is triggered, including when a notification
-   * is already pending.
-   */
-  public static final String SYNC11_MIGRATION_NOTIFICATIONS_OFFERED = "FENNEC_SYNC11_MIGRATION_NOTIFICATIONS_OFFERED";
+  public static final String KEY_TYPE = "type";
+  public static final String KEY_TYPE_SYNC = "sync";
+  public static final String KEY_TYPE_EVENT = "event";
 
-  /**
-   * We are (now) a Sync 1.5 (Firefox Accounts-based) client that migrated from
-   * Sync 1.1. We have presented the user the "complete upgrade" notification
-   * and they have successfully completed the upgrade process by entering their
-   * Firefox Account credentials.
-   */
-  public static final String SYNC11_MIGRATIONS_COMPLETED = "FENNEC_SYNC11_MIGRATIONS_COMPLETED";
-
-  public static final String SYNC_STARTED = "FENNEC_SYNC_NUMBER_OF_SYNCS_STARTED";
-
-  public static final String SYNC_COMPLETED = "FENNEC_SYNC_NUMBER_OF_SYNCS_COMPLETED";
-
-  public static final String SYNC_FAILED = "FENNEC_SYNC_NUMBER_OF_SYNCS_FAILED";
-
-  public static final String SYNC_FAILED_BACKOFF = "FENNEC_SYNC_NUMBER_OF_SYNCS_FAILED_BACKOFF";
+  public static final String KEY_DEVICE_OS = "os";
+  public static final String KEY_DEVICE_VERSION = "version";
+  public static final String KEY_DEVICE_ID = "id";
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/telemetry/TelemetryStageCollector.java
@@ -0,0 +1,37 @@
+/* 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.sync.telemetry;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+/**
+ * Gathers telemetry details about an individual sync stage.
+ * Implementation note: there are no getters/setters to avoid unnecessary verboseness.
+ * This data expected to be write-only from within SyncStages, and read-only from TelemetryCollector.
+ * Although there shouldn't be concurrent access, it's possible that we'll be reading/writing these
+ * values from different threads - hence `volatile` to ensure visibility.
+ */
+public class TelemetryStageCollector {
+    private final TelemetryCollector syncCollector;
+
+    public volatile long started = 0L;
+    public volatile long finished = 0L;
+    public volatile int inbound = 0;
+    public volatile int inboundStored = 0;
+    public volatile int inboundFailed = 0;
+    public volatile int outbound = 0;
+    public volatile int outboundStored = 0;
+    public volatile int outboundFailed = 0;
+    public volatile int reconciled = 0;
+    public volatile ExtendedJSONObject error = null;
+
+    public TelemetryStageCollector(TelemetryCollector syncCollector) {
+        this.syncCollector = syncCollector;
+    }
+
+    public TelemetryCollector getSyncCollector() {
+        return this.syncCollector;
+    }
+}
--- a/mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/sync/TestClientsStage.java
+++ b/mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/sync/TestClientsStage.java
@@ -13,16 +13,17 @@ import org.mozilla.gecko.sync.SyncConfig
 import org.mozilla.gecko.sync.crypto.KeyBundle;
 import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
 import org.mozilla.gecko.sync.delegates.GlobalSessionCallback;
 import org.mozilla.gecko.sync.net.AuthHeaderProvider;
 import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
 import org.mozilla.gecko.sync.repositories.android.ClientsDatabaseAccessor;
 import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
 import org.mozilla.gecko.sync.stage.SyncClientsEngineStage;
+import org.mozilla.gecko.sync.telemetry.TelemetryCollector;
 
 import android.content.Context;
 import android.content.SharedPreferences;
 
 public class TestClientsStage extends AndroidSyncTestCase {
   private static final String TEST_USERNAME    = "johndoe";
   private static final String TEST_PASSWORD    = "password";
   private static final String TEST_SYNC_KEY    = "abcdeabcdeabcdeabcdeabcdea";
@@ -45,17 +46,17 @@ public class TestClientsStage extends An
     final GlobalSessionCallback callback = new DefaultGlobalSessionCallback();
     final ClientsDataDelegate delegate = new MockClientsDataDelegate();
 
     final KeyBundle keyBundle = new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY);
     final AuthHeaderProvider authHeaderProvider = new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD);
     final SharedPreferences prefs = new MockSharedPreferences();
     final SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, authHeaderProvider, prefs);
     config.syncKeyBundle = keyBundle;
-    GlobalSession session = new GlobalSession(config, callback, context, delegate);
+    GlobalSession session = new GlobalSession(config, callback, context, delegate, new TelemetryCollector());
 
     SyncClientsEngineStage stage = new SyncClientsEngineStage() {
 
       @Override
       public synchronized ClientsDatabaseAccessor getClientsDatabaseAccessor() {
         if (db == null) {
           db = dataAccessor;
         }
--- a/mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/sync/TestResetting.java
+++ b/mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/sync/TestResetting.java
@@ -19,16 +19,18 @@ import org.mozilla.gecko.sync.SyncConfig
 import org.mozilla.gecko.sync.SynchronizerConfiguration;
 import org.mozilla.gecko.sync.crypto.KeyBundle;
 import org.mozilla.gecko.sync.delegates.GlobalSessionCallback;
 import org.mozilla.gecko.sync.net.AuthHeaderProvider;
 import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
 import org.mozilla.gecko.sync.repositories.domain.Record;
 import org.mozilla.gecko.sync.stage.NoSuchStageException;
 import org.mozilla.gecko.sync.synchronizer.Synchronizer;
+import org.mozilla.gecko.sync.telemetry.TelemetryCollector;
+import org.mozilla.gecko.sync.telemetry.TelemetryStageCollector;
 
 /**
  * Test the on-device side effects of reset operations on a stage.
  *
  * See also "TestResetCommands" in the unit test suite.
  */
 public class TestResetting extends AndroidSyncTestCase {
   private static final String TEST_USERNAME    = "johndoe";
@@ -136,32 +138,32 @@ public class TestResetting extends Andro
      * Run this stage synchronously.
      */
     public void executeSynchronously(final GlobalSession session) {
       final BaseMockServerSyncStage self = this;
       performWait(new Runnable() {
         @Override
         public void run() {
           try {
-            self.execute(session);
+            self.execute(session, new TelemetryStageCollector(new TelemetryCollector()));
           } catch (NoSuchStageException e) {
             performNotify(e);
           }
         }
       });
     }
   }
 
   private GlobalSession createDefaultGlobalSession(final GlobalSessionCallback callback) throws Exception {
     final KeyBundle keyBundle = new KeyBundle(TEST_USERNAME, TEST_SYNC_KEY);
     final AuthHeaderProvider authHeaderProvider = new BasicAuthHeaderProvider(TEST_USERNAME, TEST_PASSWORD);
     final SharedPreferences prefs = new MockSharedPreferences();
     final SyncConfiguration config = new SyncConfiguration(TEST_USERNAME, authHeaderProvider, prefs);
     config.syncKeyBundle = keyBundle;
-    return new GlobalSession(config, callback, getApplicationContext(), null) {
+    return new GlobalSession(config, callback, getApplicationContext(), null, new TelemetryCollector()) {
       @Override
       public boolean isEngineRemotelyEnabled(String engineName,
                                      EngineSettings engineSettings)
         throws MetaGlobalException {
         return true;
       }
 
       @Override
--- a/mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/testhelpers/DefaultGlobalSessionCallback.java
+++ b/mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/testhelpers/DefaultGlobalSessionCallback.java
@@ -43,16 +43,20 @@ public class DefaultGlobalSessionCallbac
   public void handleAborted(GlobalSession globalSession, String reason) {
   }
 
   @Override
   public void handleError(GlobalSession globalSession, Exception ex) {
   }
 
   @Override
+  public void handleError(GlobalSession globalSession, Exception ex, String reason) {
+  }
+
+  @Override
   public void handleSuccess(GlobalSession globalSession) {
   }
 
   @Override
   public void handleStageCompleted(Stage currentState,
                                    GlobalSession globalSession) {
   }
 
--- a/mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/testhelpers/MockPrefsGlobalSession.java
+++ b/mobile/android/tests/background/junit3/src/org/mozilla/gecko/background/testhelpers/MockPrefsGlobalSession.java
@@ -10,32 +10,33 @@ import org.mozilla.gecko.sync.GlobalSess
 import org.mozilla.gecko.sync.NonObjectJSONException;
 import org.mozilla.gecko.sync.SyncConfiguration;
 import org.mozilla.gecko.sync.SyncConfigurationException;
 import org.mozilla.gecko.sync.crypto.KeyBundle;
 import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
 import org.mozilla.gecko.sync.delegates.GlobalSessionCallback;
 import org.mozilla.gecko.sync.net.AuthHeaderProvider;
 import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.telemetry.TelemetryCollector;
 
 import java.io.IOException;
 
 /**
  * GlobalSession touches the Android prefs system. Stub that out.
  */
 public class MockPrefsGlobalSession extends GlobalSession {
 
   public MockSharedPreferences prefs;
 
   public MockPrefsGlobalSession(
       SyncConfiguration config, GlobalSessionCallback callback, Context context,
       ClientsDataDelegate clientsDelegate)
       throws SyncConfigurationException, IllegalArgumentException, IOException,
       NonObjectJSONException {
-    super(config, callback, context, clientsDelegate);
+    super(config, callback, context, clientsDelegate, new TelemetryCollector());
   }
 
   public static MockPrefsGlobalSession getSession(
       String username, String password,
       KeyBundle syncKeyBundle, GlobalSessionCallback callback, Context context,
       ClientsDataDelegate clientsDelegate)
       throws SyncConfigurationException, IllegalArgumentException, IOException,
       NonObjectJSONException {
--- a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestClientsEngineStage.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/net/test/TestClientsEngineStage.java
@@ -35,16 +35,18 @@ import org.mozilla.gecko.sync.crypto.Key
 import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
 import org.mozilla.gecko.sync.delegates.GlobalSessionCallback;
 import org.mozilla.gecko.sync.net.BaseResource;
 import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
 import org.mozilla.gecko.sync.net.SyncStorageResponse;
 import org.mozilla.gecko.sync.repositories.NullCursorException;
 import org.mozilla.gecko.sync.repositories.android.ClientsDatabaseAccessor;
 import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+import org.mozilla.gecko.sync.telemetry.TelemetryCollector;
+import org.mozilla.gecko.sync.telemetry.TelemetryStageCollector;
 import org.simpleframework.http.Request;
 import org.simpleframework.http.Response;
 
 import java.io.IOException;
 import java.io.PrintStream;
 import java.math.BigDecimal;
 import java.net.URI;
 import java.net.URISyntaxException;
@@ -67,16 +69,18 @@ import static org.junit.Assert.fail;
  */
 @RunWith(TestRunner.class)
 public class TestClientsEngineStage extends MockSyncClientsEngineStage {
   public final static String LOG_TAG = "TestClientsEngSta";
 
   public TestClientsEngineStage() throws SyncConfigurationException, IllegalArgumentException, NonObjectJSONException, IOException, CryptoException, URISyntaxException {
     super();
     session = initializeSession();
+    telemetryStageCollector = new TelemetryStageCollector(new TelemetryCollector());
+    telemetryStageCollector.getSyncCollector().setIDs("mockUID", "mockDeviceID");
   }
 
   // Static so we can set it during the constructor. This is so evil.
   private static MockGlobalSessionCallback callback;
   private static GlobalSession initializeSession() throws SyncConfigurationException, IllegalArgumentException, NonObjectJSONException, IOException, CryptoException, URISyntaxException {
     callback = new MockGlobalSessionCallback();
     SyncConfiguration config = new SyncConfiguration(USERNAME, new BasicAuthHeaderProvider(USERNAME, PASSWORD), new MockSharedPreferences());
     config.syncKeyBundle = new KeyBundle(USERNAME, SYNC_KEY);
--- a/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockGlobalSessionCallback.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/android/sync/test/helpers/MockGlobalSessionCallback.java
@@ -48,16 +48,21 @@ public class MockGlobalSessionCallback i
 
   @Override
   public void handleAborted(GlobalSession globalSession, String reason) {
     this.calledAborted = true;
     this.testWaiter().performNotify();
   }
 
   @Override
+  public void handleError(GlobalSession globalSession, Exception ex, String reason) {
+    this.handleError(globalSession, ex);
+  }
+
+  @Override
   public void handleError(GlobalSession globalSession, Exception ex) {
     this.calledError = true;
     this.calledErrorException = ex;
     this.testWaiter().performNotify();
   }
 
   @Override
   public void handleIncompleteStage(Stage currentState,
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/DefaultGlobalSessionCallback.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/DefaultGlobalSessionCallback.java
@@ -27,16 +27,20 @@ public class DefaultGlobalSessionCallbac
   public void informMigrated(GlobalSession globalSession) {
   }
 
   @Override
   public void handleAborted(GlobalSession globalSession, String reason) {
   }
 
   @Override
+  public void handleError(GlobalSession globalSession, Exception ex, String reason) {
+  }
+
+  @Override
   public void handleError(GlobalSession globalSession, Exception ex) {
   }
 
   @Override
   public void handleSuccess(GlobalSession globalSession) {
   }
 
   @Override
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockPrefsGlobalSession.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/background/testhelpers/MockPrefsGlobalSession.java
@@ -10,31 +10,32 @@ import org.mozilla.gecko.sync.GlobalSess
 import org.mozilla.gecko.sync.NonObjectJSONException;
 import org.mozilla.gecko.sync.SyncConfiguration;
 import org.mozilla.gecko.sync.SyncConfigurationException;
 import org.mozilla.gecko.sync.crypto.KeyBundle;
 import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
 import org.mozilla.gecko.sync.delegates.GlobalSessionCallback;
 import org.mozilla.gecko.sync.net.AuthHeaderProvider;
 import org.mozilla.gecko.sync.net.BasicAuthHeaderProvider;
+import org.mozilla.gecko.sync.telemetry.TelemetryCollector;
 
 import java.io.IOException;
 
 /**
  * GlobalSession touches the Android prefs system. Stub that out.
  */
 public class MockPrefsGlobalSession extends GlobalSession {
 
   public MockSharedPreferences prefs;
 
   public MockPrefsGlobalSession(
       SyncConfiguration config, GlobalSessionCallback callback, Context context,
       ClientsDataDelegate clientsDelegate)
       throws SyncConfigurationException, IllegalArgumentException, IOException, NonObjectJSONException {
-    super(config, callback, context, clientsDelegate);
+    super(config, callback, context, clientsDelegate, new TelemetryCollector());
   }
 
   public static MockPrefsGlobalSession getSession(
       String username, String password,
       KeyBundle syncKeyBundle, GlobalSessionCallback callback, Context context,
       ClientsDataDelegate clientsDelegate)
       throws SyncConfigurationException, IllegalArgumentException, IOException, NonObjectJSONException {
     return getSession(username, new BasicAuthHeaderProvider(username, password), null,
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/sync/telemetry/TelemetryCollectorTest.java
@@ -0,0 +1,312 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.telemetry;
+
+import android.os.Bundle;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.CollectionConcurrentModificationException;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.HTTPFailureException;
+import org.mozilla.gecko.sync.SyncDeadlineReachedException;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.net.SyncStorageResponse;
+import org.mozilla.gecko.sync.repositories.FetchFailedException;
+import org.mozilla.gecko.sync.repositories.StoreFailedException;
+import org.mozilla.gecko.sync.repositories.domain.ClientRecord;
+
+import java.util.ArrayList;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.doReturn;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+public class TelemetryCollectorTest {
+    private TelemetryCollector collector;
+
+    @Before
+    public void setUp() throws Exception {
+        collector = new TelemetryCollector();
+    }
+
+    @Test
+    public void testSetIDs() throws Exception {
+        final String deviceID = "random-device-guid";
+        final String uid = "already-hashed-test-uid";
+
+        collector.setIDs(uid, deviceID);
+        collector.setStarted(5L);
+        collector.setFinished(10L);
+        final Bundle bundle = collector.build();
+
+        // Setting UID is a pass-through, since we're expecting it to be hashed already.
+        assertEquals(uid, bundle.get("uid"));
+        // Expect device ID to be hashed with the UID.
+        assertEquals(
+                Utils.byte2Hex(Utils.sha256(deviceID.concat(uid).getBytes("UTF-8"))),
+                bundle.get("deviceID")
+        );
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testAddingDevicesBeforeSettingUID() throws Exception {
+        collector.addDevice(new ClientRecord("client1"));
+    }
+
+    @Test
+    public void testAddingDevices() throws Exception {
+        ClientRecord client1 = new ClientRecord("client1-guid");
+        client1.os = "iOS";
+        client1.version = "1.33.7";
+
+        collector.setStarted(5L);
+        collector.setFinished(10L);
+
+        collector.setIDs("hashed-uid", "deviceID");
+
+        // Test that client device ID is hashed together with UID
+        collector.addDevice(client1);
+
+        Bundle data = collector.build();
+        ArrayList<Bundle> devices = data.getParcelableArrayList("devices");
+        assertEquals(1, devices.size());
+        assertEquals(
+                Utils.byte2Hex(Utils.sha256("client1-guid".concat("hashed-uid").getBytes("UTF-8"))),
+                devices.get(0).getString("id")
+        );
+        assertEquals("iOS", devices.get(0).getString("os"));
+        assertEquals("1.33.7", devices.get(0).getString("version"));
+
+        // Test that we can add more than just one device
+        ClientRecord client2 = new ClientRecord("client2-guid");
+        client2.os = "Android";
+        client2.version = "55.0a1";
+        collector.addDevice(client2);
+
+        data = collector.build();
+        devices = data.getParcelableArrayList("devices");
+        assertEquals(2, devices.size());
+
+        assertEquals("iOS", devices.get(0).getString("os"));
+        assertEquals("1.33.7", devices.get(0).getString("version"));
+        assertEquals(
+                Utils.byte2Hex(Utils.sha256("client1-guid".concat("hashed-uid").getBytes("UTF-8"))),
+                devices.get(0).getString("id")
+        );
+
+        assertEquals("Android", devices.get(1).getString("os"));
+        assertEquals("55.0a1", devices.get(1).getString("version"));
+        assertEquals(
+                Utils.byte2Hex(Utils.sha256("client2-guid".concat("hashed-uid").getBytes("UTF-8"))),
+                devices.get(1).getString("id")
+        );
+    }
+
+    @Test
+    public void testDuration() throws Exception {
+        collector.setStarted(5L);
+        collector.setFinished(11L);
+        Bundle data = collector.build();
+
+        assertEquals(6L, data.getLong("took"));
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testDurationMissingStarted() throws Exception {
+        collector.setFinished(10L);
+        collector.build();
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testDurationMissingFinished() throws Exception {
+        collector.setStarted(10L);
+        collector.build();
+    }
+
+    @Test
+    public void testError() throws Exception {
+        collector.setError("testError", "unexpectedStuff");
+        assertEquals("testError", collector.error.getString("name"));
+        assertEquals("unexpectedStuff", collector.error.getString("error"));
+    }
+
+    @Test
+    public void testCollectorFor() throws Exception {
+        // Test that we'll get the same stage collector for the same stage name
+        TelemetryStageCollector stageCollector = collector.collectorFor("test");
+        TelemetryStageCollector stageCollector2 = collector.collectorFor("test");
+        assertEquals(stageCollector, stageCollector2);
+
+        // Test that we won't just keep getting the same stage collector for different stages
+        TelemetryStageCollector stageCollector3 = collector.collectorFor("another");
+        assertNotEquals(stageCollector, stageCollector3);
+    }
+
+    @Test
+    public void testStageErrorBuilder() throws Exception {
+        TelemetryCollector.StageErrorBuilder builder = new TelemetryCollector.StageErrorBuilder("test", "sampleerror");
+        ExtendedJSONObject error = builder.build();
+
+        // Test setting error name/details manually.
+        assertEquals("test", error.getString("name"));
+        assertEquals("sampleerror", error.getString("error"));
+
+        SyncStorageResponse response400 = mock(SyncStorageResponse.class);
+        doReturn(400).when(response400).getStatusCode();
+
+        // Test setting an optional HTTP exception while manually setting error/details.
+        builder.setLastException(new HTTPFailureException(response400));
+
+        error = builder.build();
+        assertEquals("test", error.getString("name"));
+        assertEquals("sampleerror", error.getString("error"));
+        assertEquals(Integer.valueOf(400), error.getIntegerSafely("code"));
+
+        // Test deadline reached errors
+        builder = new TelemetryCollector.StageErrorBuilder();
+        builder.setLastException(mock(SyncDeadlineReachedException.class));
+
+        error = builder.build();
+        assertEquals("unexpected", error.getString("name"));
+        assertEquals("syncdeadline", error.getString("error"));
+
+        // Test that internal concurrent modification exceptions are treated as HTTP 412
+        builder = new TelemetryCollector.StageErrorBuilder();
+        builder.setLastException(mock(CollectionConcurrentModificationException.class));
+
+        error = builder.build();
+        assertEquals("httperror", error.getString("name"));
+        assertEquals(Integer.valueOf(412), error.getIntegerSafely("code"));
+
+        // Test a non-401 HTTP exception
+        builder = new TelemetryCollector.StageErrorBuilder();
+        builder.setLastException(new HTTPFailureException(response400));
+
+        error = builder.build();
+        assertEquals("httperror", error.getString("name"));
+        assertEquals(Integer.valueOf(400), error.getIntegerSafely("code"));
+
+        // Test 401 HTTP exceptions
+        SyncStorageResponse response401 = mock(SyncStorageResponse.class);
+        doReturn(401).when(response401).getStatusCode();
+
+        builder = new TelemetryCollector.StageErrorBuilder();
+        builder.setLastException(new HTTPFailureException(response401));
+
+        error = builder.build();
+        assertEquals("autherror", error.getString("name"));
+        assertEquals("storage", error.getString("from"));
+
+        // Test generic exception handling
+        builder = new TelemetryCollector.StageErrorBuilder();
+        builder.setLastException(new NullPointerException());
+
+        error = builder.build();
+        assertEquals("unexpected", error.getString("name"));
+        assertEquals("NullPointerException", error.getString("error"));
+
+        // Test exceptions encountered during a fetch phase of a synchronizer
+        // Generic network error
+        builder = new TelemetryCollector.StageErrorBuilder();
+        builder.setLastException(mock(FetchFailedException.class))
+                .setFetchException(new java.net.SocketException());
+
+        error = builder.build();
+        assertEquals("networkerror", error.getString("name"));
+        assertEquals("fetch:SocketException", error.getString("error"));
+
+        // Non-network error
+        builder = new TelemetryCollector.StageErrorBuilder();
+        builder.setLastException(mock(FetchFailedException.class))
+                .setFetchException(new IllegalStateException());
+
+        error = builder.build();
+        assertEquals("othererror", error.getString("name"));
+        assertEquals("fetch:IllegalStateException", error.getString("error"));
+
+        // Missing specific error
+        // Non-network error
+        builder = new TelemetryCollector.StageErrorBuilder();
+        builder.setLastException(mock(FetchFailedException.class));
+
+        error = builder.build();
+        assertEquals("othererror", error.getString("name"));
+        assertEquals("fetch:unknown", error.getString("error"));
+
+        // Test exceptions encountered during a store phase of a synchronizer
+        // Generic network error
+        builder = new TelemetryCollector.StageErrorBuilder();
+        builder.setLastException(mock(StoreFailedException.class))
+                .setStoreException(new java.net.SocketException());
+
+        error = builder.build();
+        assertEquals("networkerror", error.getString("name"));
+        assertEquals("store:SocketException", error.getString("error"));
+
+        // Non-network error
+        builder = new TelemetryCollector.StageErrorBuilder();
+        builder.setLastException(mock(StoreFailedException.class))
+                .setStoreException(new IllegalStateException());
+
+        error = builder.build();
+        assertEquals("othererror", error.getString("name"));
+        assertEquals("store:IllegalStateException", error.getString("error"));
+
+        // Missing specific error
+        // Non-network error
+        builder = new TelemetryCollector.StageErrorBuilder();
+        builder.setLastException(mock(StoreFailedException.class));
+
+        error = builder.build();
+        assertEquals("othererror", error.getString("name"));
+        assertEquals("store:unknown", error.getString("error"));
+
+        // Test ability to blindly set both types of exceptions (store & fetch)
+        // one of them being null. Just as the ServerSyncStage does on failures.
+        // Store exceptions
+        builder = new TelemetryCollector.StageErrorBuilder();
+        builder.setLastException(mock(StoreFailedException.class))
+                .setFetchException(null)
+                .setStoreException(new IllegalArgumentException());
+
+        error = builder.build();
+        assertEquals("othererror", error.getString("name"));
+        assertEquals("store:IllegalArgumentException", error.getString("error"));
+
+        // Fetch exceptions
+        builder = new TelemetryCollector.StageErrorBuilder();
+        builder.setLastException(mock(FetchFailedException.class))
+                .setFetchException(new java.net.SocketException())
+                .setStoreException(null);
+
+        error = builder.build();
+        assertEquals("networkerror", error.getString("name"));
+        assertEquals("fetch:SocketException", error.getString("error"));
+
+        // Both types, but indicating that last one was a fetch exception
+        builder = new TelemetryCollector.StageErrorBuilder();
+        builder.setLastException(mock(FetchFailedException.class))
+                .setFetchException(new java.net.SocketException())
+                .setStoreException(new IllegalStateException());
+
+        error = builder.build();
+        assertEquals("networkerror", error.getString("name"));
+        assertEquals("fetch:SocketException", error.getString("error"));
+
+        // Both types, but indicating that last one was a store exception
+        builder = new TelemetryCollector.StageErrorBuilder();
+        builder.setLastException(mock(StoreFailedException.class))
+                .setFetchException(new java.net.SocketException())
+                .setStoreException(new IllegalStateException());
+
+        error = builder.build();
+        assertEquals("othererror", error.getString("name"));
+        assertEquals("store:IllegalStateException", error.getString("error"));
+    }
+}
\ No newline at end of file