Bug 1308337 - Part 8: Receive sync telemetry, construct and upload sync pings r=nalexander
authorGrigory Kruglov <gkruglov@mozilla.com>
Thu, 01 Jun 2017 17:16:09 -0400
changeset 412584 bae470a1d3973491f713fdd5d466001abe1c7b79
parent 412583 4acec427f8b4bc4730692ba08cfefffdea32d047
child 412585 72a455b224e63335c4cf6cce7588d2b9c9de7a5f
push id1490
push usermtabara@mozilla.com
push dateMon, 31 Jul 2017 14:08:16 +0000
treeherdermozilla-release@70e32e6bf15e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnalexander
bugs1308337
milestone55.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 1308337 - Part 8: Receive sync telemetry, construct and upload sync pings r=nalexander This patch includes some "pre" work, which should have been a separate patch (my apologies!): - telemetry ping is (needlessly) coupled with information about where it should be uploaded. It wasn't a problem before, since core pings are "upload right away", and are never bundled together during an upload. However, for sync ping, we need to bundle a bunch of "syncs" and "events" (down the road) into one single "sync ping", and as such we need a separate representation for a "ping that is not meant to be uploaded directly". - instead of dealing with the coupling directly, a simpler approach is taken: - a "ping" is split into two types of pings: local and outgoing - outgoing ping is what the old "ping" was - a data bundle that is ready to be uploaded - local ping is not meant to be uploaded directly, but is intended to be a part of an outgoing ping, along with other local pings - the main difference between local and outgoing pings is the URL: local pings don't have it while outgoing pings do have it. As background telemetry is received via LocalBroadcastManager, it is processed as follows: - telemetry data is processed into "local pings" which are stored on disk - as enough telemetry is gathered, or we hit one of "let's upload now" conditions, the persisted local pings are gathered into an "outgoing ping" via a SyncPingBundleBuilder, which is persisted on disk. - upload of the bundled "sync ping" is attempted - as individual "local pings" are processed into outgoing "Sync ping" bundles, they are removed from disk Hooks for the upcoming event telemetry data are in place to make that follow-up work easier. MozReview-Commit-ID: 6uXi6pjXiSv
mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryBackgroundReceiver.java
mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryDispatcher.java
mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryLocalPing.java
mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryOutgoingPing.java
mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPing.java
mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryUploadService.java
mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryCorePingBuilder.java
mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryLocalPingBuilder.java
mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryPingBuilder.java
mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncEventPingBuilder.java
mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncPingBuilder.java
mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncPingBundleBuilder.java
mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadAllPingsImmediatelyScheduler.java
mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadScheduler.java
mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryJSONFilePingStore.java
mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryPingStore.java
mobile/android/base/moz.build
mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncPingBuilderTest.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncPingBundleBuilderTest.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/schedulers/TestTelemetryUploadAllPingsImmediatelyScheduler.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/stores/TestTelemetryJSONFilePingStore.java
--- a/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
@@ -39,23 +39,23 @@ import org.mozilla.gecko.icons.Icons;
 import org.mozilla.gecko.lwt.LightweightTheme;
 import org.mozilla.gecko.mdns.MulticastDNSManager;
 import org.mozilla.gecko.media.AudioFocusAgent;
 import org.mozilla.gecko.media.RemoteManager;
 import org.mozilla.gecko.notifications.NotificationClient;
 import org.mozilla.gecko.notifications.NotificationHelper;
 import org.mozilla.gecko.preferences.DistroSharedPrefsImport;
 import org.mozilla.gecko.util.ActivityUtils;
+import org.mozilla.gecko.telemetry.TelemetryBackgroundReceiver;
 import org.mozilla.gecko.util.BundleEventListener;
 import org.mozilla.gecko.util.EventCallback;
 import org.mozilla.gecko.util.GeckoBundle;
 import org.mozilla.gecko.util.HardwareUtils;
 import org.mozilla.gecko.util.PRNGFixes;
 import org.mozilla.gecko.util.ThreadUtils;
-import org.mozilla.gecko.util.UUIDUtil;
 
 import java.io.File;
 import java.lang.reflect.Method;
 import java.util.UUID;
 
 public class GeckoApplication extends Application
     implements ContextGetter {
     private static final String LOG_TAG = "GeckoApplication";
@@ -273,16 +273,18 @@ public class GeckoApplication extends Ap
 
         HardwareUtils.init(context);
         FilePicker.init(context);
         DownloadsIntegration.init();
         HomePanelsManager.getInstance().init(context);
 
         GlobalPageMetadata.getInstance().init();
 
+        TelemetryBackgroundReceiver.getInstance().init(context);
+
         // We need to set the notification client before launching Gecko, since Gecko could start
         // sending notifications immediately after startup, which we don't want to lose/crash on.
         GeckoAppShell.setNotificationListener(new NotificationClient(context));
         // This getInstance call will force initialization of the NotificationHelper, but does nothing with the result
         NotificationHelper.getInstance(context).init();
 
         MulticastDNSManager.getInstance(context).init();
 
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryBackgroundReceiver.java
@@ -0,0 +1,352 @@
+/* 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.telemetry;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.support.annotation.Nullable;
+import android.support.v4.content.LocalBroadcastManager;
+import android.util.Log;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.sync.telemetry.TelemetryContract;
+import org.mozilla.gecko.telemetry.pingbuilders.TelemetrySyncEventPingBuilder;
+import org.mozilla.gecko.telemetry.pingbuilders.TelemetrySyncPingBuilder;
+import org.mozilla.gecko.telemetry.pingbuilders.TelemetrySyncPingBundleBuilder;
+import org.mozilla.gecko.telemetry.schedulers.TelemetryUploadAllPingsImmediatelyScheduler;
+import org.mozilla.gecko.telemetry.schedulers.TelemetryUploadScheduler;
+import org.mozilla.gecko.telemetry.stores.TelemetryJSONFilePingStore;
+import org.mozilla.gecko.telemetry.stores.TelemetryPingStore;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Set;
+
+/**
+ * Receives and processes telemetry broadcasts from background services, namely Sync.
+ * Nomenclature:
+ * - Bundled Sync Ping: a Sync Ping as documented at http://gecko.readthedocs.io/en/latest/toolkit/components/telemetry/telemetry/data/sync-ping.html
+ *   as of commit https://github.com/mozilla-services/docs/commit/7eb4b412d3ab5ec46b280eff312ace32e7cf27e6
+ * - Telemetry data: incoming background telemetry, of two types: "sync" and "sync event"
+ * - Local Sync Ping: a persistable representation of incoming telemetry data. Not intended for upload.
+ *   See {@link TelemetryLocalPing}
+ *
+ * General flow:
+ * - background telemetry bundles come in, describing syncs or events that happened
+ * - telemetry bundles are transformed into a local pings and persisted
+ * - once there are enough local pings, or another upload condition kicks in, all of the persisted
+ *   local pings are bundled into a single outgoing Sync Ping, removed from the local store,
+ *   and Sync Ping is persisted and uploaded.
+ *
+ *   @author grisha
+ */
+public class TelemetryBackgroundReceiver extends BroadcastReceiver {
+    // NB: spelling is to appease logger's limitation on sizes of tags.
+    private final static String LOG_TAG = "TelemetryBgReceiver";
+
+    private static final String ACTION_BACKGROUND_TELEMETRY = "org.mozilla.gecko.telemetry.BACKGROUND";
+    private static final String SYNC_BUNDLE_STORE_DIR = "sync-ping-data";
+    private static final String SYNC_STORE_DIR = "sync-data";
+    private static final String SYNC_EVENT_STORE_DIR = "sync-event-data";
+    private static final int LOCAL_SYNC_EVENT_PING_THRESHOLD = 100;
+    private static final int LOCAL_SYNC_PING_THRESHOLD = 100;
+    private static final long MAX_TIME_BETWEEN_UPLOADS = 12 * 60 * 60 * 1000; // 12 hours
+
+    private static final String PREF_FILE_BACKGROUND_TELEMETRY = AppConstants.ANDROID_PACKAGE_NAME + ".telemetry.background";
+    private static final String PREF_IDS = "ids";
+    private static final String PREF_LAST_ATTEMPTED_UPLOADED = "last_attempted_upload";
+
+    // We don't currently support passing profile along with background telemetry. Profile is used to
+    // identify where pings are persisted locally.
+    private static final String DEFAULT_PROFILE = "default";
+
+    private static final TelemetryBackgroundReceiver instance = new TelemetryBackgroundReceiver();
+
+    public static TelemetryBackgroundReceiver getInstance() {
+        return instance;
+    }
+
+    public void init(Context context) {
+        LocalBroadcastManager.getInstance(context).registerReceiver(
+                this, new IntentFilter(ACTION_BACKGROUND_TELEMETRY));
+    }
+
+    @Override
+    public void onReceive(final Context context, final Intent intent) {
+        Log.i(LOG_TAG, "Handling background telemetry broadcast");
+
+        if (!intent.hasExtra(TelemetryContract.KEY_TELEMETRY)) {
+            throw new IllegalStateException("Received a background telemetry broadcast without data.");
+        }
+
+        if (!intent.hasExtra(TelemetryContract.KEY_TYPE)) {
+            throw new IllegalStateException("Received a background telemetry broadcast without type.");
+        }
+
+        // We want to know if any of the below code is faulty in non-obvious ways, and as such there
+        // isn't an overarching try/catch to silence the errors.
+        // That is, let's crash here if something goes really wrong, and hope that we'll spot the
+        // error in the crash stats.
+        ThreadUtils.postToBackgroundThread(new Runnable() {
+            @Override
+            public void run() {
+                final String type = intent.getStringExtra(TelemetryContract.KEY_TYPE);
+
+                // Setup local telemetry stores.
+                final TelemetryJSONFilePingStore syncTelemetryStore = new TelemetryJSONFilePingStore(
+                        context.getFileStreamPath(SYNC_STORE_DIR), DEFAULT_PROFILE);
+                final TelemetryJSONFilePingStore syncEventTelemetryStore = new TelemetryJSONFilePingStore(
+                        context.getFileStreamPath(SYNC_EVENT_STORE_DIR), DEFAULT_PROFILE);
+
+                // Process incoming telemetry.
+                final Bundle telemetryBundle = intent.getParcelableExtra(TelemetryContract.KEY_TELEMETRY);
+                final String uid = telemetryBundle.getString(TelemetryContract.KEY_LOCAL_UID);
+                final String deviceID = telemetryBundle.getString(TelemetryContract.KEY_LOCAL_DEVICE_ID);
+
+                // Transform incoming telemetry into a local ping of correct type (sync vs event).
+                final TelemetryLocalPing localPing;
+                final TelemetryPingStore telemetryStore;
+                switch (type) {
+                    case TelemetryContract.KEY_TYPE_SYNC:
+                        final ArrayList<Parcelable> devices = telemetryBundle.getParcelableArrayList(TelemetryContract.KEY_DEVICES);
+                        final Serializable error = telemetryBundle.getSerializable(TelemetryContract.KEY_ERROR);
+                        final Serializable stages = telemetryBundle.getSerializable(TelemetryContract.KEY_STAGES);
+                        final long took = telemetryBundle.getLong(TelemetryContract.KEY_TOOK);
+                        final boolean didRestart = telemetryBundle.getBoolean(TelemetryContract.KEY_RESTARTED);
+
+                        telemetryStore = syncTelemetryStore;
+                        TelemetrySyncPingBuilder localPingBuilder = new TelemetrySyncPingBuilder();
+
+                        if (uid != null) {
+                            localPingBuilder.setUID(uid);
+                        }
+
+                        if (deviceID != null) {
+                            localPingBuilder.setDeviceID(deviceID);
+                        }
+
+                        if (devices != null) {
+                            localPingBuilder.setDevices(devices);
+                        }
+
+                        if (stages != null) {
+                            localPingBuilder.setStages(stages);
+                        }
+
+                        if (error != null) {
+                            localPingBuilder.setError(error);
+                        }
+
+                        localPing = localPingBuilder
+                                .setRestarted(didRestart)
+                                .setTook(took)
+                                .build();
+                        break;
+                    case TelemetryContract.KEY_TYPE_EVENT:
+                        telemetryStore = syncEventTelemetryStore;
+                        localPing = new TelemetrySyncEventPingBuilder()
+                                .fromEventTelemetry(
+                                        (Bundle) intent.getParcelableExtra(
+                                                TelemetryContract.KEY_TELEMETRY))
+                                .build();
+                        break;
+                    default:
+                        throw new IllegalArgumentException("Unknown background telemetry type.");
+                }
+
+                // Persist the incoming telemetry data.
+                try {
+                    telemetryStore.storePing(localPing);
+                } catch (IOException e) {
+                    Log.e(LOG_TAG, "Could not store incoming telemetry. Attempting to upload already stored telemetry.", e);
+                }
+
+                // Determine if we should try uploading at this point, and attempt to do so.
+                final SharedPreferences sharedPreferences = context.getSharedPreferences(
+                        PREF_FILE_BACKGROUND_TELEMETRY, Context.MODE_PRIVATE);
+                final TelemetryPingStore syncPingStore = new TelemetryJSONFilePingStore(
+                        context.getFileStreamPath(SYNC_BUNDLE_STORE_DIR), DEFAULT_PROFILE);
+
+                long lastAttemptedSyncPingUpload = sharedPreferences.getLong(PREF_LAST_ATTEMPTED_UPLOADED, 0L);
+                boolean idsChanged = setOrUpdateIDsIfChanged(sharedPreferences, uid, deviceID);
+
+                // Is there a good reason to upload at this time?
+                final String reasonToUpload = reasonToUpload(
+                        idsChanged,
+                        syncTelemetryStore.getCount(),
+                        syncEventTelemetryStore.getCount(),
+                        lastAttemptedSyncPingUpload
+                );
+
+                // If we have a reason to upload at this point, bundle up sync and event telemetry
+                // into a Sync Ping.
+                if (reasonToUpload != null) {
+                    // Get the IDs of telemetry objects we're about to bundle up.
+                    // Note that this may race with other incoming background telemetry.
+                    // We may accidentally drop non-bundled local telemetry if it was emitted while
+                    // we're processing the current telemetry.
+                    // Chances of that happening are very small due to how often background telemetry
+                    // is actually emitted: very infrequently on a timescale of disk access.
+                    final Set<String> localSyncTelemetryToRemove = syncTelemetryStore.getStoredIDs();
+                    final Set<String> localSyncEventTelemetryToRemove = syncEventTelemetryStore.getStoredIDs();
+
+                    // Bundle up all that we have in our telemetry stores.
+                    final TelemetryOutgoingPing syncPing = new TelemetrySyncPingBundleBuilder()
+                            .setSyncStore(syncTelemetryStore)
+                            .setSyncEventStore(syncEventTelemetryStore)
+                            .setReason(reasonToUpload)
+                            .build();
+
+                    // Persist a Sync Ping Bundle.
+                    boolean bundledSyncPingPersisted = true;
+                    try {
+                        syncPingStore.storePing(syncPing);
+                    } catch (IOException e) {
+                        // If we fail to persist a bundled sync ping, we can either attempt to upload it,
+                        // or skip the upload. Each choice has its own set of trade-offs.
+                        // In short, current approach is to skip an upload. See Bug 1369186.
+                        //
+                        // If we choose to upload a Sync Ping that failed to persist locally, it becomes
+                        // possible to upload the same telemetry multiple times. Since currently we do
+                        // not have IDs as part of our sync and event telemetry objects, it is impossible
+                        // to guarantee idempotence on the receiver's end. As such, we care to not
+                        // upload the same thing multiple times. One way to achieve this involves
+                        // creating an additional mapping of Bundled Sync Ping ID to two sets,
+                        // {sync telemetry ids} and {event telemetry ids}, and taking care to only include
+                        // telemetry in the subsequent pings which has not yet been associated with a
+                        // Sync Ping Bundle. Given the fact that this will likely use a SharedPreference
+                        // and that we inherently have a concurrent telemetry pipeline, it's quite possible
+                        // that we'll get this wrong in some subtle way after considerable effort.
+                        //
+                        // An alternative is to simply not upload if we failed to persist a Sync Ping.
+                        // This side-steps issues of idempotancy, but comes at a risk of taking longer
+                        // to upload telemetry, or sometimes not uploading it at all - see Bug 1366045.
+                        // However, those issues become likely only if our local JSON storage is failing
+                        // frequently. This should not happen under most circumstances, and if it does
+                        // happen frequently, we're likely to have a host of other problems.
+                        //
+                        // Yet another solution is to alter server-side processing of this data such that
+                        // a unique ID may be included alongside each sync/event telemetry object. This
+                        // will result in a very straightforward client implementation with much better
+                        // consistency guarantees.
+                        //
+                        // See Bug 1368579 for exploring possible "runaway storage" implications of
+                        // the current approach.
+                        //
+                        // Also note that a core assumption here is that storePing never successfully writes
+                        // to disk if it throws.
+                        Log.e(LOG_TAG, "Unable to write bundled sync ping to disk. Skipping upload.", e);
+                        bundledSyncPingPersisted = false;
+                    }
+
+                    if (bundledSyncPingPersisted) {
+                        // It is now safe to delete persisted telemetry which we just bundled up.
+                        syncTelemetryStore.onUploadAttemptComplete(localSyncTelemetryToRemove);
+                        syncEventTelemetryStore.onUploadAttemptComplete(localSyncEventTelemetryToRemove);
+                    }
+                }
+
+                // Kick-off ping upload. If this succeeds, the uploader service will remove persisted
+                // Sync Ping Bundles. Otherwise, we'll attempt another upload next time telemetry is
+                // processed.
+                // If we already have some persisted Sync Pings, that means a previous upload
+                // failed - or, less likely, is in progress and did not yet succeed. It should be safe to
+                // upload. Even if we raced with ourselves and uploaded some of the bundled sync pings more
+                // than once, it's possible to guarantee idempotence on the receiver's end since we
+                // include a unique ID with each ping. However, this depends on the telemetry pipeline
+                // successfully de-duplicating submitted pings. As of Q2 2017, success rate of de-duping
+                // sits around 45%, and is anticipated to be improved to 80-90% sometime by the end of 2017.
+                // Relevant bugs are 1369512, 1357275.
+                // Not uploading here means possibly delaying an already once-failed upload for a long
+                // time. See Bug 1366045 for exploring scheduling options.
+                if (reasonToUpload != null || syncPingStore.getCount() > 0) {
+                    // Bump the "last-attempted-uploaded" timestamp, even though we might still fail
+                    // to upload. Since we check for presence of pending pings above, if this upload
+                    // fails we'll try again whenever next telemetry event happens.
+                    sharedPreferences
+                            .edit()
+                            .putLong(PREF_LAST_ATTEMPTED_UPLOADED, System.currentTimeMillis())
+                            .apply();
+
+                    final TelemetryUploadScheduler scheduler = new TelemetryUploadAllPingsImmediatelyScheduler();
+                    if (scheduler.isReadyToUpload(context, syncPingStore)) {
+                        scheduler.scheduleUpload(context, syncPingStore);
+                    }
+                }
+            }
+        });
+    }
+
+    // There's no "scheduler" in a classic sense, and so we might end up not uploading pings at all
+    // if there has been no new incoming telemetry data. See Bug 1366045.
+    @Nullable
+    protected static String reasonToUpload(boolean idsChanged, int syncCount, int eventCount, long lastUploadAttempt) {
+        // Whenever any IDs change, upload.
+        if (idsChanged) {
+            return TelemetrySyncPingBundleBuilder.UPLOAD_REASON_IDCHANGE;
+        }
+
+        // Whenever we hit a certain threshold of local persisted telemetry, upload.
+        if (syncCount > LOCAL_SYNC_PING_THRESHOLD || eventCount > LOCAL_SYNC_EVENT_PING_THRESHOLD) {
+            return TelemetrySyncPingBundleBuilder.UPLOAD_REASON_COUNT;
+        }
+
+        final long now = System.currentTimeMillis();
+
+        // If it's the first time we're processing telemetry data, upload ahead of schedule as a way
+        // of saying "we're alive". This might often correspond to sending data about the first sync.
+        if (lastUploadAttempt == 0L) {
+            return TelemetrySyncPingBundleBuilder.UPLOAD_REASON_FIRST;
+        }
+
+        // Wall clock changed significantly; upload because we can't be sure of our timing anymore.
+        // Allow for some wiggle room to account for clocks jumping around insignificantly.
+        final long DRIFT_BUFFER_IN_MS = 60 * 1000L;
+        if ((lastUploadAttempt - now) > DRIFT_BUFFER_IN_MS) {
+            return TelemetrySyncPingBundleBuilder.UPLOAD_REASON_CLOCK_DRIFT;
+        }
+
+        // Upload if we haven't uploaded for some time.
+        if ((now - lastUploadAttempt) >= MAX_TIME_BETWEEN_UPLOADS) {
+            return TelemetrySyncPingBundleBuilder.UPLOAD_REASON_SCHEDULE;
+        }
+
+        // No reason to upload.
+        return null;
+    }
+
+    // This has storage side-effects.
+    private boolean setOrUpdateIDsIfChanged(SharedPreferences prefs, String uid, String deviceID) {
+        final String currentIDsCombined = uid.concat(deviceID);
+        final String previousIDsHash = prefs.getString(PREF_IDS, "");
+
+        // Persist IDs for the first time, declare them as "not changed".
+        if (previousIDsHash.equals("")) {
+            final SharedPreferences.Editor prefsEditor = prefs.edit();
+            prefsEditor.putString(PREF_IDS, currentIDsCombined);
+            prefsEditor.apply();
+            return false;
+        }
+
+        // If IDs are different update local cache and declare them as "changed".
+        if (!previousIDsHash.equals(currentIDsCombined)) {
+            final SharedPreferences.Editor prefsEditor = prefs.edit();
+            prefsEditor.putString(PREF_IDS, currentIDsCombined);
+            prefsEditor.apply();
+            return true;
+        }
+
+        // Nothing changed, and no side-effects took place.
+        return false;
+    }
+
+}
--- a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryDispatcher.java
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryDispatcher.java
@@ -67,52 +67,52 @@ public class TelemetryDispatcher {
         // There are measurements in the core ping (e.g. seq #) that would ideally be atomically updated
         // when the ping is stored. However, for simplicity, we use the json store and accept the possible
         // loss of data (see bug 1243585 comment 16+ for more).
         coreStore = new TelemetryJSONFilePingStore(new File(storePath, CORE_STORE_DIR_NAME), profileName);
 
         uploadAllPingsImmediatelyScheduler = new TelemetryUploadAllPingsImmediatelyScheduler();
     }
 
-    private void queuePingForUpload(final Context context, final TelemetryPing ping, final TelemetryPingStore store,
-            final TelemetryUploadScheduler scheduler) {
+    private void queuePingForUpload(final Context context, final TelemetryOutgoingPing ping, final TelemetryPingStore store,
+                                    final TelemetryUploadScheduler scheduler) {
         final QueuePingRunnable runnable = new QueuePingRunnable(context, ping, store, scheduler);
         ThreadUtils.postToBackgroundThread(runnable); // TODO: Investigate how busy this thread is. See if we want another.
     }
 
     /**
      * Queues the given ping for upload and potentially schedules upload. This method can be called from any thread.
      */
     public void queuePingForUpload(final Context context, final TelemetryCorePingBuilder pingBuilder) {
-        final TelemetryPing ping = pingBuilder.build();
+        final TelemetryOutgoingPing ping = pingBuilder.build();
         queuePingForUpload(context, ping, coreStore, uploadAllPingsImmediatelyScheduler);
     }
 
-    private static class QueuePingRunnable implements Runnable {
+    /* package-private */ static class QueuePingRunnable implements Runnable {
         private final Context applicationContext;
-        private final TelemetryPing ping;
+        private final TelemetryOutgoingPing ping;
         private final TelemetryPingStore store;
         private final TelemetryUploadScheduler scheduler;
 
-        public QueuePingRunnable(final Context context, final TelemetryPing ping, final TelemetryPingStore store,
-                final TelemetryUploadScheduler scheduler) {
+        /* package-private */ QueuePingRunnable(final Context context, final TelemetryOutgoingPing ping, final TelemetryPingStore store,
+                                                final TelemetryUploadScheduler scheduler) {
             this.applicationContext = context.getApplicationContext();
             this.ping = ping;
             this.store = store;
             this.scheduler = scheduler;
         }
 
         @Override
         public void run() {
             // We block while storing the ping so the scheduled upload is guaranteed to have the newly-stored value.
             try {
                 store.storePing(ping);
             } catch (final IOException e) {
                 // Don't log exception to avoid leaking profile path.
                 Log.e(LOGTAG, "Unable to write ping to disk. Continuing with upload attempt");
             }
 
-            if (scheduler.isReadyToUpload(store)) {
+            if (scheduler.isReadyToUpload(applicationContext, store)) {
                 scheduler.scheduleUpload(applicationContext, store);
             }
         }
     }
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryLocalPing.java
@@ -0,0 +1,34 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * 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.telemetry;
+
+import android.support.annotation.Nullable;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+
+/**
+ * A "local" ping, which is not intended to be uploaded, but simply stored for later processing.
+ * Currently, many instances of local pings are bundled into a Sync Ping at appropriate moments.
+ */
+public class TelemetryLocalPing implements TelemetryPing {
+    private final ExtendedJSONObject payload;
+    private final String docID;
+
+    public TelemetryLocalPing(final ExtendedJSONObject payload, final String docID) {
+        this.payload = payload;
+        this.docID = docID;
+    }
+
+    public ExtendedJSONObject getPayload() { return payload; }
+    public String getDocID() { return docID; }
+
+    // Following the path of least resistance to avoid decoupling a ping from where it should
+    // be uploaded, for a local ping we declare that the path is nullable, and in fact it's always null.
+    @Nullable
+    public String getURLPath() {
+        return null;
+    }
+}
copy from mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPing.java
copy to mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryOutgoingPing.java
--- a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPing.java
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryOutgoingPing.java
@@ -12,23 +12,31 @@ import org.mozilla.gecko.sync.ExtendedJS
  *
  * The doc ID is used by a Store to manipulate its internal pings and should
  * be the same value found in the urlPath.
  *
  * If you want to create one of these, consider extending
  * {@link org.mozilla.gecko.telemetry.pingbuilders.TelemetryPingBuilder}
  * or one of its descendants.
  */
-public class TelemetryPing {
-    private final String urlPath;
+public class TelemetryOutgoingPing implements TelemetryPing {
     private final ExtendedJSONObject payload;
     private final String docID;
+    private final String urlPath;
 
-    public TelemetryPing(final String urlPath, final ExtendedJSONObject payload, final String docID) {
-        this.urlPath = urlPath;
+    public TelemetryOutgoingPing(final String urlPath, final ExtendedJSONObject payload, final String docID) {
         this.payload = payload;
         this.docID = docID;
+        this.urlPath = urlPath;
     }
 
     public String getURLPath() { return urlPath; }
-    public ExtendedJSONObject getPayload() { return payload; }
-    public String getDocID() { return docID; }
+
+    @Override
+    public ExtendedJSONObject getPayload() {
+        return payload;
+    }
+
+    @Override
+    public String getDocID() {
+        return docID;
+    }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPing.java
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryPing.java
@@ -2,33 +2,13 @@
  * 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.telemetry;
 
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 
-/**
- * Container for telemetry data and the data necessary to upload it.
- *
- * The doc ID is used by a Store to manipulate its internal pings and should
- * be the same value found in the urlPath.
- *
- * If you want to create one of these, consider extending
- * {@link org.mozilla.gecko.telemetry.pingbuilders.TelemetryPingBuilder}
- * or one of its descendants.
- */
-public class TelemetryPing {
-    private final String urlPath;
-    private final ExtendedJSONObject payload;
-    private final String docID;
-
-    public TelemetryPing(final String urlPath, final ExtendedJSONObject payload, final String docID) {
-        this.urlPath = urlPath;
-        this.payload = payload;
-        this.docID = docID;
-    }
-
-    public String getURLPath() { return urlPath; }
-    public ExtendedJSONObject getPayload() { return payload; }
-    public String getDocID() { return docID; }
+public interface TelemetryPing {
+    ExtendedJSONObject getPayload();
+    String getDocID();
+    String getURLPath();
 }
--- a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryUploadService.java
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryUploadService.java
@@ -9,16 +9,17 @@ import android.content.Context;
 import android.content.Intent;
 import android.util.Log;
 import ch.boye.httpclientandroidlib.HttpHeaders;
 import ch.boye.httpclientandroidlib.HttpResponse;
 import ch.boye.httpclientandroidlib.client.ClientProtocolException;
 import ch.boye.httpclientandroidlib.client.methods.HttpRequestBase;
 import ch.boye.httpclientandroidlib.impl.client.DefaultHttpClient;
 import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.Telemetry;
 import org.mozilla.gecko.preferences.GeckoPreferences;
 import org.mozilla.gecko.restrictions.Restrictable;
 import org.mozilla.gecko.restrictions.Restrictions;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.net.BaseResource;
 import org.mozilla.gecko.sync.net.BaseResourceDelegate;
 import org.mozilla.gecko.sync.net.Resource;
 import org.mozilla.gecko.telemetry.stores.TelemetryPingStore;
@@ -99,16 +100,19 @@ public class TelemetryUploadService exte
         if (pingsToUpload.isEmpty()) {
             return true;
         }
 
         final String serverSchemeHostPort = TelemetryPreferences.getServerSchemeHostPort(context, store.getProfileName());
         final HashSet<String> successfulUploadIDs = new HashSet<>(pingsToUpload.size()); // used for side effects.
         final PingResultDelegate delegate = new PingResultDelegate(successfulUploadIDs);
         for (final TelemetryPing ping : pingsToUpload) {
+            if (!(ping instanceof TelemetryOutgoingPing)) {
+                throw new IllegalStateException("Tried uploading a non-outgoing ping.");
+            }
             // TODO: It'd be great to re-use the same HTTP connection for each upload request.
             delegate.setDocID(ping.getDocID());
             final String url = serverSchemeHostPort + "/" + ping.getURLPath();
             uploadPayload(url, ping.getPayload(), delegate);
 
             // There are minimal gains in trying to upload if we already failed one attempt.
             if (delegate.hadConnectionError()) {
                 break;
--- a/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryCorePingBuilder.java
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryCorePingBuilder.java
@@ -10,36 +10,36 @@ import android.content.Context;
 import android.content.SharedPreferences;
 import android.os.Build;
 import android.support.annotation.NonNull;
 import android.support.annotation.Nullable;
 import android.support.annotation.WorkerThread;
 import android.text.TextUtils;
 
 import android.util.Log;
-import org.mozilla.gecko.AppConstants;
+
 import org.mozilla.gecko.GeckoApp;
 import org.mozilla.gecko.GeckoProfile;
 import org.mozilla.gecko.GeckoSharedPrefs;
 import org.mozilla.gecko.Locales;
 import org.mozilla.gecko.search.SearchEngine;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
-import org.mozilla.gecko.telemetry.TelemetryPing;
+import org.mozilla.gecko.telemetry.TelemetryOutgoingPing;
 import org.mozilla.gecko.util.DateUtil;
 import org.mozilla.gecko.Experiments;
 import org.mozilla.gecko.util.StringUtils;
 
 import java.text.DateFormat;
 import java.text.SimpleDateFormat;
 import java.util.Calendar;
 import java.util.Locale;
 import java.util.concurrent.TimeUnit;
 
 /**
- * Builds a {@link TelemetryPing} representing a core ping.
+ * Builds a {@link TelemetryOutgoingPing} representing a core ping.
  *
  * See https://gecko.readthedocs.org/en/latest/toolkit/components/telemetry/telemetry/core-ping.html
  * for details on the core ping.
  */
 public class TelemetryCorePingBuilder extends TelemetryPingBuilder {
     private static final String LOGTAG = StringUtils.safeSubstring(TelemetryCorePingBuilder.class.getSimpleName(), 0, 23);
 
     // For legacy reasons, this preference key is not namespaced with "core".
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryLocalPingBuilder.java
@@ -0,0 +1,17 @@
+/* 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.telemetry.pingbuilders;
+
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.telemetry.TelemetryLocalPing;
+
+import java.util.UUID;
+
+abstract class TelemetryLocalPingBuilder {
+    final ExtendedJSONObject payload = new ExtendedJSONObject();
+    final String docID = UUID.randomUUID().toString();
+
+    abstract TelemetryLocalPing build();
+}
--- a/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryPingBuilder.java
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetryPingBuilder.java
@@ -3,36 +3,36 @@
  * 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.telemetry.pingbuilders;
 
 import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
-import org.mozilla.gecko.telemetry.TelemetryPing;
+import org.mozilla.gecko.telemetry.TelemetryOutgoingPing;
 
 import java.util.Set;
 import java.util.UUID;
 
 /**
- * A generic Builder for {@link TelemetryPing} instances. Each overriding class is
+ * A generic Builder for {@link TelemetryOutgoingPing} instances. Each overriding class is
  * expected to create a specific type of ping (e.g. "core").
  *
  * This base class handles the common ping operations under the hood:
  *   * Validating mandatory fields
  *   * Forming the server url
  */
 abstract class TelemetryPingBuilder {
     // In the server url, the initial path directly after the "scheme://host:port/"
     private static final String SERVER_INITIAL_PATH = "submit/telemetry";
 
     private final String serverPath;
     protected final ExtendedJSONObject payload;
-    private final String docID;
+    protected final String docID;
 
     public TelemetryPingBuilder() {
         docID = UUID.randomUUID().toString();
         serverPath = getTelemetryServerPath(getDocType(), docID);
         payload = new ExtendedJSONObject();
     }
 
     /**
@@ -41,19 +41,19 @@ abstract class TelemetryPingBuilder {
     public abstract String getDocType();
 
     /**
      * @return the fields that are mandatory for the resultant ping to be uploaded to
      *         the server. These will be validated before the ping is built.
      */
     public abstract String[] getMandatoryFields();
 
-    public TelemetryPing build() {
+    public TelemetryOutgoingPing build() {
         validatePayload();
-        return new TelemetryPing(serverPath, payload, docID);
+        return new TelemetryOutgoingPing(serverPath, payload, docID);
     }
 
     private void validatePayload() {
         final Set<String> keySet = payload.keySet();
         for (final String mandatoryField : getMandatoryFields()) {
             if (!keySet.contains(mandatoryField)) {
                 throw new IllegalArgumentException("Builder does not contain mandatory field: " +
                         mandatoryField);
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncEventPingBuilder.java
@@ -0,0 +1,24 @@
+/* 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.telemetry.pingbuilders;
+
+import android.os.Bundle;
+
+import org.mozilla.gecko.telemetry.TelemetryLocalPing;
+
+/**
+ * Local ping builder which understands how to process event data.
+ * This is a placeholder, to be implemented in Bug 1363924.
+ */
+public class TelemetrySyncEventPingBuilder extends TelemetryLocalPingBuilder {
+    public TelemetrySyncEventPingBuilder fromEventTelemetry(Bundle data) {
+        return this;
+    }
+
+    @Override
+    public TelemetryLocalPing build() {
+        throw new UnsupportedOperationException();
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncPingBuilder.java
@@ -0,0 +1,162 @@
+/*
+ * 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.telemetry.pingbuilders;
+
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.support.annotation.NonNull;
+
+import org.json.simple.JSONArray;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.telemetry.TelemetryContract;
+import org.mozilla.gecko.sync.telemetry.TelemetryStageCollector;
+import org.mozilla.gecko.telemetry.TelemetryLocalPing;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.HashMap;
+
+/**
+ * Local ping builder which understands how to process sync data.
+ * Whenever hashing of data is involved, we expect it to be performed at the time of collection,
+ * somewhere in {@link org.mozilla.gecko.sync.telemetry.TelemetryCollector} and friends.
+ */
+public class TelemetrySyncPingBuilder extends TelemetryLocalPingBuilder {
+    private static final int DATA_FORMAT_VERSION = 1;
+
+    public TelemetrySyncPingBuilder setStages(@NonNull final Serializable data) {
+        HashMap<String, TelemetryStageCollector> stages = castSyncData(data);
+
+        final JSONArray engines = new JSONArray();
+        for (String stageName : stages.keySet()) {
+            final TelemetryStageCollector stage = stages.get(stageName);
+
+            // Skip stages that did nothing.
+            if (stage.inbound == 0 && stage.outbound == 0) {
+                continue;
+            }
+
+            final ExtendedJSONObject stageJSON = new ExtendedJSONObject();
+
+            stageJSON.put("name", stageName);
+            stageJSON.put("took", stage.finished - stage.started);
+
+            // Desktop also includes a "status" field with internal constants as possible values.
+            // Status may be deducted by inspecting 'failureReason', and as such it is omitted here.
+            // Absence of 'failureReason' means that stage succeeded.
+
+            if (stage.inbound > 0) {
+                final ExtendedJSONObject incomingJSON = new ExtendedJSONObject();
+                incomingJSON.put("applied", stage.inbound);
+                if (stage.inboundStored > 0) {
+                    incomingJSON.put("succeeded", stage.inboundStored);
+                }
+                if (stage.inboundFailed > 0) {
+                    incomingJSON.put("failed", stage.inboundFailed);
+                }
+                if (stage.reconciled > 0) {
+                    incomingJSON.put("reconciled", stage.reconciled);
+                }
+                stageJSON.put("incoming", incomingJSON);
+            }
+
+            if (stage.outbound > 0) {
+                final ExtendedJSONObject outgoingJSON = new ExtendedJSONObject();
+                // We specifically do not check if `outboundStored` is greater than zero.
+                // `outbound` schema is simpler than `inbound`, namely there isn't an "attempted
+                // to send" count.
+                // Stage telemetry itself has that data (outbound = outboundStored + outboundFailed),
+                // and so this is our way to relay slightly more information.
+                // e.g. we'll know there's something wrong if `sent = 0` and `failed` is missing.
+                outgoingJSON.put("sent", stage.outboundStored);
+                if (stage.outboundFailed > 0) {
+                    outgoingJSON.put("failed", stage.outboundFailed);
+                }
+                stageJSON.put("outgoing", outgoingJSON);
+            }
+
+            // We depend on the error builder from TelemetryCollector to produce the right schema.
+            // Spreading around our schema definition like that is awkward, but, alas, here we are.
+            if (stage.error != null) {
+                stageJSON.put("failureReason", stage.error);
+            }
+
+            addUnchecked(engines, stageJSON);
+        }
+        payload.put("engines", engines);
+        return this;
+    }
+
+    public TelemetrySyncPingBuilder setUID(@NonNull String uid) {
+        payload.put("uid", uid);
+        return this;
+    }
+
+    public TelemetrySyncPingBuilder setDeviceID(@NonNull String deviceID) {
+        payload.put("deviceID", deviceID);
+        return this;
+    }
+
+    public TelemetrySyncPingBuilder setRestarted(boolean didRestart) {
+        if (!didRestart) {
+            return this;
+        }
+
+        payload.put("restarted", true);
+        return this;
+    }
+
+    public TelemetrySyncPingBuilder setDevices(ArrayList<Parcelable> devices) {
+        final JSONArray devicesJSON = new JSONArray();
+
+        for (Parcelable device : devices) {
+            final Bundle deviceBundle = (Bundle) device;
+            final ExtendedJSONObject deviceJSON = new ExtendedJSONObject();
+
+            deviceJSON.put("os", deviceBundle.getString(TelemetryContract.KEY_DEVICE_OS));
+            deviceJSON.put("version", deviceBundle.getString(TelemetryContract.KEY_DEVICE_VERSION));
+            deviceJSON.put("id", deviceBundle.getString(TelemetryContract.KEY_DEVICE_ID));
+
+            addUnchecked(devicesJSON, deviceJSON);
+        }
+
+        if (devicesJSON.size() > 0) {
+            payload.put("devices", devicesJSON);
+        }
+        return this;
+    }
+
+    public TelemetrySyncPingBuilder setError(@NonNull Serializable error) {
+        payload.put("failureReason", (ExtendedJSONObject) error);
+        return this;
+    }
+
+    public TelemetrySyncPingBuilder setTook(long took) {
+        payload.put("took", took);
+        return this;
+    }
+
+    @Override
+    public TelemetryLocalPing build() {
+        payload.put("version", DATA_FORMAT_VERSION);
+        return new TelemetryLocalPing(payload, docID);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static void addUnchecked(final JSONArray list, final ExtendedJSONObject obj) {
+        list.add(obj);
+    }
+
+    /**
+     * We broadcast this data via LocalBroadcastManager and control both sides of this code, so it
+     * is acceptable to do an unchecked cast.
+     */
+    @SuppressWarnings("unchecked")
+    private static HashMap<String, TelemetryStageCollector> castSyncData(final Serializable data) {
+        return (HashMap<String, TelemetryStageCollector>) data;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncPingBundleBuilder.java
@@ -0,0 +1,114 @@
+/*
+ * 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.telemetry.pingbuilders;
+
+import android.os.Build;
+import android.support.annotation.NonNull;
+
+import org.json.simple.JSONArray;
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.telemetry.TelemetryOutgoingPing;
+import org.mozilla.gecko.telemetry.TelemetryPing;
+import org.mozilla.gecko.telemetry.stores.TelemetryPingStore;
+
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.TimeZone;
+
+/**
+ * Responsible for building a Sync Ping, based on the telemetry docs:
+ * http://gecko.readthedocs.io/en/latest/toolkit/components/telemetry/telemetry/data/sync-ping.html
+ *
+ * This builder takes two stores ('sync' and 'event') and produces a single "sync ping".
+ *
+ * Note that until Bug 1363924, event telemetry will be ignored.
+ *
+ * Sample result will look something like:
+ * {
+ *     "syncs": [list of syncs, as produced by the SyncBuilder],
+ *     "events": [list of events, as produced by the EventBuilder]
+ * }
+ */
+public class TelemetrySyncPingBundleBuilder extends TelemetryPingBuilder {
+    private static final String PING_TYPE = "sync";
+    private static final int PING_VERSION = 4;
+
+    public static final String UPLOAD_REASON_FIRST = "first";
+    public static final String UPLOAD_REASON_CLOCK_DRIFT = "clockdrift";
+    public static final String UPLOAD_REASON_SCHEDULE = "schedule";
+    public static final String UPLOAD_REASON_IDCHANGE = "idchange";
+    public static final String UPLOAD_REASON_COUNT = "count";
+
+    private final ExtendedJSONObject pingData = new ExtendedJSONObject();
+
+    @Override
+    public String getDocType() {
+        return "sync";
+    }
+
+    @Override
+    public String[] getMandatoryFields() {
+        return new String[0];
+    }
+
+    public TelemetrySyncPingBundleBuilder setReason(@NonNull String reason) {
+        pingData.put("why", reason);
+        return this;
+    }
+
+    @Override
+    public TelemetryOutgoingPing build() {
+        final DateFormat pingCreationDateFormat = new SimpleDateFormat(
+                "yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US);
+        pingCreationDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
+
+        payload.put("type", PING_TYPE);
+        payload.put("version", PING_VERSION);
+        payload.put("id", docID);
+        payload.put("creationDate", pingCreationDateFormat.format(new Date()));
+
+        final ExtendedJSONObject application = new ExtendedJSONObject();
+        application.put("architecture", Build.CPU_ABI);
+        application.put("buildID", AppConstants.MOZ_APP_BUILDID);
+        application.put("platformVersion", AppConstants.MOZ_APP_VERSION);
+        application.put("name", AppConstants.MOZ_APP_BASENAME);
+        application.put("version", AppConstants.MOZ_APP_VERSION);
+        application.put("displayVersion", AppConstants.MOZ_APP_VERSION);
+        application.put("vendor", AppConstants.MOZ_APP_VENDOR);
+        application.put("xpcomAbi", AppConstants.MOZ_APP_ABI);
+        application.put("channel", AppConstants.MOZ_UPDATE_CHANNEL);
+
+        payload.put("application", application);
+        payload.put("payload", pingData);
+        return super.build();
+    }
+
+    @SuppressWarnings("unchecked")
+    public TelemetrySyncPingBundleBuilder setSyncStore(TelemetryPingStore store) {
+        final JSONArray syncs = new JSONArray();
+        List<TelemetryPing> pings = store.getAllPings();
+
+        // Please note how we're not including constituent ping's docID in the final payload. This is
+        // unfortunate and causes some grief when managing local ping storage and uploads, but needs
+        // to be resolved beyond this individual client. See Bug 1369186.
+        for (TelemetryPing ping : pings) {
+            syncs.add(ping.getPayload());
+        }
+
+        pingData.put("syncs", syncs);
+        return this;
+    }
+
+    // Event telemetry will be implemented in Bug 1363924.
+    public TelemetrySyncPingBundleBuilder setSyncEventStore(TelemetryPingStore store) {
+        return this;
+    }
+}
--- a/mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadAllPingsImmediatelyScheduler.java
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadAllPingsImmediatelyScheduler.java
@@ -12,17 +12,17 @@ import org.mozilla.gecko.telemetry.store
 import org.mozilla.gecko.telemetry.TelemetryUploadService;
 
 /**
  * Schedules an upload with all pings to be sent immediately.
  */
 public class TelemetryUploadAllPingsImmediatelyScheduler implements TelemetryUploadScheduler {
 
     @Override
-    public boolean isReadyToUpload(final TelemetryPingStore store) {
+    public boolean isReadyToUpload(final Context applicationContext, final TelemetryPingStore store) {
         // We're ready since we don't have any conditions to wait on (e.g. on wifi, accumulated X pings).
         return true;
     }
 
     @Override
     public void scheduleUpload(final Context applicationContext, final TelemetryPingStore store) {
         final Intent i = new Intent(TelemetryUploadService.ACTION_UPLOAD);
         i.setClass(applicationContext, TelemetryUploadService.class);
--- a/mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadScheduler.java
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/schedulers/TelemetryUploadScheduler.java
@@ -16,11 +16,11 @@ import org.mozilla.gecko.telemetry.store
  * scheduled by sending an {@link android.content.Intent} to the
  * {@link org.mozilla.gecko.telemetry.TelemetryUploadService}, either immediately or
  * via an external scheduler (e.g. {@link android.app.job.JobScheduler}).
  *
  * N.B.: If the Store is not ready to upload, an implementation *should not* try to reschedule
  * the check to see if it's time to upload - this is expected to be handled by the caller.
  */
 public interface TelemetryUploadScheduler {
-    boolean isReadyToUpload(TelemetryPingStore store);
+    boolean isReadyToUpload(Context applicationContext, TelemetryPingStore store);
     void scheduleUpload(Context applicationContext, TelemetryPingStore store);
 }
--- a/mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryJSONFilePingStore.java
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryJSONFilePingStore.java
@@ -3,23 +3,26 @@
  * 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.telemetry.stores;
 
 import android.os.Parcel;
 import android.os.Parcelable;
+import android.support.annotation.Nullable;
 import android.support.annotation.VisibleForTesting;
 import android.support.annotation.WorkerThread;
 import android.util.Log;
 import org.json.JSONException;
 import org.json.JSONObject;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
 import org.mozilla.gecko.sync.NonObjectJSONException;
+import org.mozilla.gecko.telemetry.TelemetryLocalPing;
+import org.mozilla.gecko.telemetry.TelemetryOutgoingPing;
 import org.mozilla.gecko.telemetry.TelemetryPing;
 import org.mozilla.gecko.util.FileUtils;
 import org.mozilla.gecko.util.FileUtils.FileLastModifiedComparator;
 import org.mozilla.gecko.util.FileUtils.FilenameRegexFilter;
 import org.mozilla.gecko.util.FileUtils.FilenameWhitelistFilter;
 import org.mozilla.gecko.util.StringUtils;
 import org.mozilla.gecko.util.UUIDUtil;
 
@@ -28,21 +31,20 @@ import java.io.FileInputStream;
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.FilenameFilter;
 import java.io.IOException;
 import java.nio.channels.FileLock;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
-import java.util.SortedSet;
-import java.util.TreeSet;
 
 /**
  * An implementation of TelemetryPingStore that is backed by JSON files.
  *
  * This implementation seeks simplicity. Each ping to upload is stored in its own file with its doc ID
  * as the filename. The doc ID is sent with a ping to be uploaded and is expected to be returned with
  * {@link #onUploadAttemptComplete(Set)} so the associated file can be removed.
  *
@@ -95,29 +97,34 @@ public class TelemetryJSONFilePingStore 
     }
 
     @VisibleForTesting File getPingFile(final String docID) {
         return new File(storeDir, docID);
     }
 
     @Override
     public void storePing(final TelemetryPing ping) throws IOException {
-        final String output;
+        storePing(ping.getPayload(), ping.getDocID(), ping.getURLPath());
+    }
+
+    public void storePing(final ExtendedJSONObject payload, String docID, @Nullable String urlPath) throws IOException {
+        final JSONObject output;
         try {
-            output = new JSONObject()
-                    .put(KEY_PAYLOAD, ping.getPayload())
-                    .put(KEY_URL_PATH, ping.getURLPath())
-                    .toString();
+            output = new JSONObject().put(KEY_PAYLOAD, payload);
+
+            if (urlPath != null) {
+                output.put(KEY_URL_PATH, urlPath);
+            }
         } catch (final JSONException e) {
             // Do not log the exception to avoid leaking personal data.
             throw new IOException("Unable to create JSON to store to disk");
         }
 
-        final FileOutputStream outputStream = new FileOutputStream(getPingFile(ping.getDocID()), false);
-        blockForLockAndWriteFileAndCloseStream(outputStream, output);
+        final FileOutputStream outputStream = new FileOutputStream(getPingFile(docID), false);
+        blockForLockAndWriteFileAndCloseStream(outputStream, output.toString());
     }
 
     @Override
     public void maybePrunePings() {
         final File[] files = storeDir.listFiles(uuidFilenameFilter);
         if (files == null) {
             return;
         }
@@ -162,28 +169,59 @@ public class TelemetryJSONFilePingStore 
         final ArrayList<TelemetryPing> out = new ArrayList<>(files.size());
         for (final File file : files) {
             final JSONObject obj = lockAndReadJSONFromFile(file);
             if (obj == null) {
                 // We log in the method to get the JSONObject if we return null.
                 continue;
             }
 
+            final ExtendedJSONObject payload;
             try {
-                final String url = obj.getString(KEY_URL_PATH);
-                final ExtendedJSONObject payload = new ExtendedJSONObject(obj.getString(KEY_PAYLOAD));
-                out.add(new TelemetryPing(url, payload, file.getName()));
-            } catch (final IOException | JSONException | NonObjectJSONException e) {
+                payload = new ExtendedJSONObject(obj.getString(KEY_PAYLOAD));
+            } catch (IOException | JSONException | NonObjectJSONException e) {
                 Log.w(LOGTAG, "Bad json in ping. Ignoring.");
                 continue;
             }
+
+            try {
+                final String url = obj.getString(KEY_URL_PATH);
+                out.add(new TelemetryOutgoingPing(url, payload, file.getName()));
+            } catch (JSONException e) {
+                out.add(new TelemetryLocalPing(payload, file.getName()));
+            }
         }
         return out;
     }
 
+    @Override
+    public int getCount() {
+        final File[] fileArray = storeDir.listFiles(uuidFilenameFilter);
+        if (fileArray == null) {
+            Log.w(LOGTAG, "listFiles unexpectedly returned null - unable to retrieve pings. Assuming 0. " +
+                    "Debug: exists? " + storeDir.exists() + "; directory? " + storeDir.isDirectory());
+            return 0;
+        }
+        return fileArray.length;
+    }
+
+    @Override
+    public Set<String> getStoredIDs() {
+        final Set<String> ids = new HashSet<>();
+        final File[] fileArray = storeDir.listFiles(uuidFilenameFilter);
+        if (fileArray == null) {
+            return ids;
+        }
+        // Map list of files to a set of IDs.
+        for (File file : fileArray) {
+            ids.add(file.getName());
+        }
+        return ids;
+    }
+
     /**
      * Logs if there is an error.
      *
      * @return the JSON object from the given file or null if there is an error.
      */
     private JSONObject lockAndReadJSONFromFile(final File file) {
         // lockAndReadFileAndCloseStream doesn't handle file size of 0.
         if (file.length() == 0) {
--- a/mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryPingStore.java
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/stores/TelemetryPingStore.java
@@ -2,16 +2,20 @@
  * 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.telemetry.stores;
 
 import android.os.Parcelable;
+import android.support.annotation.VisibleForTesting;
+
+import org.mozilla.gecko.telemetry.TelemetryLocalPing;
+import org.mozilla.gecko.telemetry.TelemetryOutgoingPing;
 import org.mozilla.gecko.telemetry.TelemetryPing;
 
 import java.io.IOException;
 import java.util.List;
 import java.util.Set;
 
 /**
  * Persistent storage for TelemetryPings that are queued for upload.
@@ -21,33 +25,40 @@ import java.util.Set;
  * to synchronize state (or be stateless!).
  *
  * The pings in {@link #getAllPings()} and {@link #maybePrunePings()} are returned in the
  * same order in order to guarantee consistent results.
  */
 public abstract class TelemetryPingStore implements Parcelable {
     private final String profileName;
 
+    @VisibleForTesting
     public TelemetryPingStore(final String profileName) {
         this.profileName = profileName;
     }
 
     /**
      * @return the profile name associated with this store.
      */
     public String getProfileName() {
         return profileName;
     }
 
     /**
-     * @return a list of all the telemetry pings in the store that are ready for upload, ascending oldest to newest.
+     * @return a list of all the telemetry pings in the store, ascending oldest to newest. Depending
+     * on the store.
      */
     public abstract List<TelemetryPing> getAllPings();
 
     /**
+     * @return a number of all currently stored pings.
+     */
+    public abstract int getCount();
+
+    /**
      * Save a ping to the store.
      *
      * @param ping the ping to store
      * @throws IOException for underlying store access errors
      */
     public abstract void storePing(TelemetryPing ping) throws IOException;
 
     /**
@@ -58,9 +69,14 @@ public abstract class TelemetryPingStore
 
     /**
      * Removes the successfully uploaded pings from the database and performs another other actions necessary
      * for when upload is completed.
      *
      * @param successfulRemoveIDs doc ids of pings that were successfully uploaded
      */
     public abstract void onUploadAttemptComplete(Set<String> successfulRemoveIDs);
+
+    /**
+     * Returns a set of currently stored IDs.
+     */
+    public abstract Set<String> getStoredIDs();
 }
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -894,24 +894,31 @@ gbjar.sources += ['java/org/mozilla/geck
     'tabs/TabsPanel.java',
     'tabs/TabsPanelThumbnailView.java',
     'tabs/TabsTouchHelperCallback.java',
     'Telemetry.java',
     'telemetry/measurements/CampaignIdMeasurements.java',
     'telemetry/measurements/SearchCountMeasurements.java',
     'telemetry/measurements/SessionMeasurements.java',
     'telemetry/pingbuilders/TelemetryCorePingBuilder.java',
+    'telemetry/pingbuilders/TelemetryLocalPingBuilder.java',
     'telemetry/pingbuilders/TelemetryPingBuilder.java',
+    'telemetry/pingbuilders/TelemetrySyncEventPingBuilder.java',
+    'telemetry/pingbuilders/TelemetrySyncPingBuilder.java',
+    'telemetry/pingbuilders/TelemetrySyncPingBundleBuilder.java',
     'telemetry/schedulers/TelemetryUploadAllPingsImmediatelyScheduler.java',
     'telemetry/schedulers/TelemetryUploadScheduler.java',
     'telemetry/stores/TelemetryJSONFilePingStore.java',
     'telemetry/stores/TelemetryPingStore.java',
+    'telemetry/TelemetryBackgroundReceiver.java',
     'telemetry/TelemetryConstants.java',
     'telemetry/TelemetryCorePingDelegate.java',
     'telemetry/TelemetryDispatcher.java',
+    'telemetry/TelemetryLocalPing.java',
+    'telemetry/TelemetryOutgoingPing.java',
     'telemetry/TelemetryPing.java',
     'telemetry/TelemetryPreferences.java',
     'telemetry/TelemetryUploadService.java',
     'TelemetryContract.java',
     'text/FloatingActionModeCallback.java',
     'text/FloatingToolbarTextSelection.java',
     'text/TextAction.java',
     'text/TextSelection.java',
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncPingBuilderTest.java
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.telemetry.pingbuilders;
+
+import android.os.Bundle;
+import android.os.Parcelable;
+
+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.ExtendedJSONObject;
+import org.mozilla.gecko.telemetry.TelemetryLocalPing;
+
+import java.util.ArrayList;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+public class TelemetrySyncPingBuilderTest {
+    private TelemetrySyncPingBundleBuilderTest.MockTelemetryPingStore pingStore;
+    private TelemetrySyncPingBuilder builder;
+
+    @Before
+    public void setUp() throws Exception {
+        pingStore = new TelemetrySyncPingBundleBuilderTest.MockTelemetryPingStore();
+        builder = new TelemetrySyncPingBuilder();
+    }
+
+    @Test
+    public void testGeneralShape() throws Exception {
+        TelemetryLocalPing localPing = builder
+                .setDeviceID("device-id")
+                .setUID("uid")
+                .setTook(123L)
+                .setRestarted(false)
+                .build();
+        ExtendedJSONObject payload = localPing.getPayload();
+        assertEquals(
+                "{\"uid\":\"uid\",\"took\":123,\"deviceID\":\"device-id\",\"version\":1}",
+                payload.toString()
+        );
+    }
+
+    @Test
+    public void testDevices() throws Exception {
+        ArrayList<Parcelable> devices = new ArrayList<>();
+
+        TelemetryLocalPing localPing = builder
+                .setDevices(devices)
+                .build();
+        ExtendedJSONObject payload = localPing.getPayload();
+
+        // Empty list isn't part of the JSON.
+        assertEquals(
+                "{\"version\":1}",
+                payload.toString()
+        );
+
+        Bundle device = new Bundle();
+        device.putString("os", "Android");
+        device.putString("version", "53.0a1");
+        device.putString("id", "80daf12dsadsa4236914cff2cc6e9d0f80a965380e2cf8e976e4004ead887521b5d9");
+        devices.add(device);
+
+        // Test with only one device
+        payload = builder
+                .setDevices(devices)
+                .build()
+                .getPayload();
+        assertEquals(
+                "{\"devices\":[{\"os\":\"Android\",\"id\":\"80daf12dsadsa4236914cff2cc6e9d0f80a965380e2cf8e976e4004ead887521b5d9\",\"version\":\"53.0a1\"}],\"version\":1}",
+                payload.toString()
+        );
+
+        device = new Bundle();
+        device.putString("os", "iOS");
+        device.putString("version", "8.0");
+        device.putString("id", "fa813452774b3cdc8f5f73290b5346df800f644b7b92a1ab94b6e2af748d261362");
+        devices.add(device);
+
+        // Test with more than one device
+        payload = builder
+                .setDevices(devices)
+                .build()
+                .getPayload();
+        assertEquals(
+                "{\"devices\":[{\"os\":\"Android\",\"id\":\"80daf12dsadsa4236914cff2cc6e9d0f80a965380e2cf8e976e4004ead887521b5d9\",\"version\":\"53.0a1\"},{\"os\":\"iOS\",\"id\":\"fa813452774b3cdc8f5f73290b5346df800f644b7b92a1ab94b6e2af748d261362\",\"version\":\"8.0\"}],\"version\":1}",
+                payload.toString()
+        );
+    }
+}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncPingBundleBuilderTest.java
@@ -0,0 +1,178 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.telemetry.pingbuilders;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import org.json.simple.JSONArray;
+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.ExtendedJSONObject;
+import org.mozilla.gecko.telemetry.TelemetryOutgoingPing;
+import org.mozilla.gecko.telemetry.TelemetryPing;
+import org.mozilla.gecko.telemetry.stores.TelemetryJSONFilePingStore;
+import org.mozilla.gecko.telemetry.stores.TelemetryPingStore;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.Assert.*;
+
+@RunWith(TestRunner.class)
+public class TelemetrySyncPingBundleBuilderTest {
+    public static class MockTelemetryPingStore extends TelemetryPingStore {
+        public MockTelemetryPingStore() {
+            super("default");
+        }
+
+        private HashMap<String, TelemetryPing> pings = new HashMap<>();
+
+        @Override
+        public List<TelemetryPing> getAllPings() {
+            return new ArrayList<>(pings.values());
+        }
+
+        @Override
+        public int getCount() {
+            return pings.size();
+        }
+
+        @Override
+        public void storePing(TelemetryPing ping) throws IOException {
+            pings.put(ping.getDocID(), ping);
+        }
+
+        @Override
+        public void maybePrunePings() {}
+
+        @Override
+        public void onUploadAttemptComplete(Set<String> successfulRemoveIDs) {
+            for (String id : successfulRemoveIDs) {
+                pings.remove(id);
+            }
+        }
+
+        @Override
+        public Set<String> getStoredIDs() {
+            return pings.keySet();
+        }
+
+        @Override
+        public int describeContents() {
+            return 0;
+        }
+
+        @Override
+        public void writeToParcel(Parcel dest, int flags) {
+
+        }
+
+        public final Parcelable.Creator<TelemetryJSONFilePingStore> CREATOR = new Parcelable.Creator<TelemetryJSONFilePingStore>() {
+            @Override
+            public TelemetryJSONFilePingStore createFromParcel(Parcel source) {
+                return null;
+            }
+
+            @Override
+            public TelemetryJSONFilePingStore[] newArray(int size) {
+                return new TelemetryJSONFilePingStore[0];
+            }
+        };
+    }
+
+    private TelemetrySyncPingBundleBuilder builder;
+    private MockTelemetryPingStore syncPings = new MockTelemetryPingStore();
+    private MockTelemetryPingStore eventPings = new MockTelemetryPingStore();
+
+    @Before
+    public void setUp() throws Exception {
+        builder = new TelemetrySyncPingBundleBuilder();
+        builder.setReason(TelemetrySyncPingBundleBuilder.UPLOAD_REASON_SCHEDULE);
+        syncPings = new MockTelemetryPingStore();
+        eventPings = new MockTelemetryPingStore();
+    }
+
+    @Test
+    public void testGeneralShape() throws Exception {
+        builder.setSyncStore(syncPings);
+        builder.setSyncEventStore(eventPings);
+
+        TelemetryOutgoingPing outgoingPing = builder.build();
+
+        assertTrue(outgoingPing.getPayload().containsKey("application"));
+        assertTrue(outgoingPing.getPayload().containsKey("payload"));
+        assertTrue(outgoingPing.getPayload().containsKey("id"));
+        assertEquals("sync", outgoingPing.getPayload().getString("type"));
+        assertEquals(Integer.valueOf(4), outgoingPing.getPayload().getIntegerSafely("version"));
+
+        // Test application key.
+        ExtendedJSONObject application = outgoingPing.getPayload().getObject("application");
+        assertEquals("Mozilla", application.getString("vendor"));
+        assertTrue(application.containsKey("architecture"));
+        assertTrue(application.containsKey("platformVersion"));
+        assertTrue(application.containsKey("displayVersion"));
+        assertTrue(application.containsKey("version"));
+        assertTrue(application.containsKey("name"));
+        assertTrue(application.containsKey("channel"));
+        assertTrue(application.containsKey("buildID"));
+        assertTrue(application.containsKey("xpcomAbi"));
+
+        // Test general shape of payload.
+        // NB that even though we set an empty sync event store, it's not in the json string.
+        // That's because sync events are not yet instrumented.
+        ExtendedJSONObject payload = outgoingPing.getPayload().getObject("payload");
+        assertEquals("{\"syncs\":[],\"why\":\"schedule\"}", payload.toJSONString());
+    }
+
+    @Test
+    public void testBundlingOfMultiplePings() throws Exception {
+        // Try just one ping first.
+        syncPings.storePing(new TelemetrySyncPingBuilder()
+                .setDeviceID("test-device-id")
+                .setRestarted(true)
+                .setTook(123L)
+                .setUID("test-uid")
+                .build()
+        );
+        builder.setSyncStore(syncPings);
+
+        TelemetryOutgoingPing outgoingPing = builder.build();
+
+        // Ensure we have that one ping.
+        ExtendedJSONObject payload = outgoingPing.getPayload().getObject("payload");
+        assertEquals(
+                "{\"syncs\":[{\"took\":123,\"uid\":\"test-uid\",\"restarted\":true,\"deviceID\":\"test-device-id\",\"version\":1}],\"why\":\"schedule\"}",
+                payload.toString()
+        );
+
+        // Add another ping.
+        syncPings.storePing(new TelemetrySyncPingBuilder()
+                .setDeviceID("test-device-id")
+                .setRestarted(true)
+                .setTook(321L)
+                .setUID("test-uid")
+                .build()
+        );
+        builder.setSyncStore(syncPings);
+
+        // We should have two pings now.
+        outgoingPing = builder.build();
+        JSONArray syncs = outgoingPing.getPayload()
+                .getObject("payload")
+                .getArray("syncs");
+        assertEquals(2, syncs.size());
+        assertTrue(
+                syncs.toString().contains("{\"took\":123,\"uid\":\"test-uid\",\"restarted\":true,\"deviceID\":\"test-device-id\",\"version\":1}")
+        );
+        assertTrue(
+                syncs.toString().contains("{\"took\":321,\"uid\":\"test-uid\",\"restarted\":true,\"deviceID\":\"test-device-id\",\"version\":1}")
+        );
+    }
+}
\ No newline at end of file
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/schedulers/TestTelemetryUploadAllPingsImmediatelyScheduler.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/schedulers/TestTelemetryUploadAllPingsImmediatelyScheduler.java
@@ -34,17 +34,18 @@ public class TestTelemetryUploadAllPings
     @Before
     public void setUp() {
         testScheduler = new TelemetryUploadAllPingsImmediatelyScheduler();
         testStore = mock(TelemetryPingStore.class);
     }
 
     @Test
     public void testReadyToUpload() {
-        assertTrue("Scheduler is always ready to upload", testScheduler.isReadyToUpload(testStore));
+        assertTrue("Scheduler is always ready to upload", testScheduler.isReadyToUpload(
+                mock(Context.class), testStore));
     }
 
     @Test
     public void testScheduleUpload() {
         final Context context = mock(Context.class);
 
         testScheduler.scheduleUpload(context, testStore);
 
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/stores/TestTelemetryJSONFilePingStore.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/telemetry/stores/TestTelemetryJSONFilePingStore.java
@@ -9,22 +9,22 @@ package org.mozilla.gecko.telemetry.stor
 import org.json.JSONObject;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
 import org.junit.runner.RunWith;
 import org.mozilla.gecko.background.testhelpers.TestRunner;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.telemetry.TelemetryOutgoingPing;
 import org.mozilla.gecko.telemetry.TelemetryPing;
 import org.mozilla.gecko.util.FileUtils;
 
 import java.io.File;
 import java.io.FileOutputStream;
-import java.io.FilenameFilter;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 import java.util.UUID;
 
 import static org.junit.Assert.*;
@@ -98,40 +98,40 @@ public class TestTelemetryJSONFilePingSt
         new TelemetryJSONFilePingStore(dir, "profileName"); // expected to throw.
     }
 
     @Test
     public void testStorePingStoresCorrectData() throws Exception {
         assertStoreFileCount(0);
 
         final String expectedID = getDocID();
-        final TelemetryPing expectedPing = new TelemetryPing("a/server/url", generateTelemetryPayload(), expectedID);
+        final TelemetryOutgoingPing expectedPing = new TelemetryOutgoingPing("a/server/url", generateTelemetryPayload(), expectedID);
         testStore.storePing(expectedPing);
 
         assertStoreFileCount(1);
         final String filename = testDir.list()[0];
         assertTrue("Filename contains expected ID", filename.equals(expectedID));
         final JSONObject actual = FileUtils.readJSONObjectFromFile(new File(testDir, filename));
         assertEquals("Ping url paths are equal", expectedPing.getURLPath(), actual.getString(TelemetryJSONFilePingStore.KEY_URL_PATH));
         assertIsGeneratedPayload(new ExtendedJSONObject(actual.getString(TelemetryJSONFilePingStore.KEY_PAYLOAD)));
     }
 
     @Test
     public void testStorePingMultiplePingsStoreSeparateFiles() throws Exception {
         assertStoreFileCount(0);
         for (int i = 1; i < 10; ++i) {
-            testStore.storePing(new TelemetryPing("server", generateTelemetryPayload(), getDocID()));
+            testStore.storePing(new TelemetryOutgoingPing("server", generateTelemetryPayload(), getDocID()));
             assertStoreFileCount(i);
         }
     }
 
     @Test
     public void testStorePingReleasesFileLock() throws Exception {
         assertStoreFileCount(0);
-        testStore.storePing(new TelemetryPing("server", generateTelemetryPayload(), getDocID()));
+        testStore.storePing(new TelemetryOutgoingPing("server", generateTelemetryPayload(), getDocID()));
         assertStoreFileCount(1);
         final File file = new File(testDir, testDir.list()[0]);
         final FileOutputStream stream = new FileOutputStream(file);
         try {
             assertNotNull("File lock is released after store write", stream.getChannel().tryLock());
         } finally {
             stream.close(); // releases lock
         }