Bug 1363924 p3 - Record commands telemetry events. r?grisha, tcsc draft
authorEdouard Oger <eoger@fastmail.com>
Fri, 02 Feb 2018 13:58:43 -0500
changeset 759865 c02a92c88df105ff8e380a886575c3d59277bd99
parent 759832 5cf1fabfee63c4f47133f1488d59e898de67a030
push id100498
push userbmo:eoger@fastmail.com
push dateMon, 26 Feb 2018 19:26:36 +0000
reviewersgrisha, tcsc
bugs1363924
milestone60.0a1
Bug 1363924 p3 - Record commands telemetry events. r?grisha, tcsc MozReview-Commit-ID: 2IRAYdN7BI4
mobile/android/app/src/test/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncEventPingBuilderTest.java
mobile/android/app/src/test/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncPingBundleBuilderTest.java
mobile/android/base/android-services.mozbuild
mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryBackgroundReceiver.java
mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncEventPingBuilder.java
mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncPingBundleBuilder.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java
mobile/android/services/src/main/java/org/mozilla/gecko/fxa/sync/FxAccountSyncAdapter.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandProcessor.java
mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResource.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/TelemetryEventCollector.java
mobile/android/services/src/test/java/org/mozilla/gecko/sync/telemetry/TelemetryEventCollectorTest.java
toolkit/components/telemetry/docs/data/sync-ping.rst
new file mode 100644
--- /dev/null
+++ b/mobile/android/app/src/test/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncEventPingBuilderTest.java
@@ -0,0 +1,76 @@
+/* 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 org.json.simple.JSONArray;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+import org.mozilla.gecko.sync.telemetry.TelemetryContract;
+
+@RunWith(TestRunner.class)
+public class TelemetrySyncEventPingBuilderTest {
+
+    @Test
+    public void testGeneralShape() throws Exception {
+        JSONArray payload = buildPayloadArray(123456L, "sync", "object", "method", null, null);
+        Assert.assertArrayEquals(new Object[] {123456L, "sync", "method", "object"}, payload.toArray());
+
+        payload = buildPayloadArray(123456L, "sync", "object", "method", "value", null);
+        Assert.assertArrayEquals(new Object[] {123456L, "sync", "method", "object", "value"}, payload.toArray());
+
+        Bundle extra = new Bundle();
+        extra.putString("extra-key", "extra-value");
+
+        payload = buildPayloadArray(123456L, "sync", "object", "method", null, extra);
+        Assert.assertEquals("[123456,\"sync\",\"method\",\"object\",null,{\"extra-key\":\"extra-value\"}]",
+                payload.toJSONString());
+
+        payload = buildPayloadArray(123456L, "sync", "object", "method", "value", extra);
+        Assert.assertEquals("[123456,\"sync\",\"method\",\"object\",\"value\",{\"extra-key\":\"extra-value\"}]",
+                payload.toJSONString());
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testNullTimestamp() throws Exception {
+        buildPayloadArray(null, "category", "object", "method", null, null);
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testNullCategory() throws Exception {
+        buildPayloadArray(123456L, null, "object", "method", null, null);
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testNullObject() throws Exception {
+        buildPayloadArray(123456L, "category", null, "method", null, null);
+    }
+
+    @Test(expected = IllegalStateException.class)
+    public void testNullMethod() throws Exception {
+        buildPayloadArray(123456L, "category", "object", null, null, null);
+    }
+
+    private JSONArray buildPayloadArray(Long ts, String category, String object, String method,
+                                        String value, Bundle extra) throws Exception {
+        Bundle bundle = new Bundle();
+        if (ts != null) {
+            bundle.putLong(TelemetryContract.KEY_EVENT_TIMESTAMP, ts);
+        }
+        bundle.putString(TelemetryContract.KEY_EVENT_CATEGORY, category);
+        bundle.putString(TelemetryContract.KEY_EVENT_OBJECT, object);
+        bundle.putString(TelemetryContract.KEY_EVENT_METHOD, method);
+        if (value != null) {
+            bundle.putString(TelemetryContract.KEY_EVENT_VALUE, value);
+        }
+        if (extra != null) {
+            bundle.putBundle(TelemetryContract.KEY_EVENT_EXTRA, extra);
+        }
+        return new TelemetrySyncEventPingBuilder().fromEventTelemetry(bundle)
+                .build().getPayload().getArray("event");
+    }
+}
--- a/mobile/android/app/src/test/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncPingBundleBuilderTest.java
+++ b/mobile/android/app/src/test/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncPingBundleBuilderTest.java
@@ -1,23 +1,26 @@
 /* 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.Parcel;
 import android.os.Parcelable;
 
 import org.json.JSONException;
 import org.json.simple.JSONArray;
+import org.junit.Assert;
 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.sync.telemetry.TelemetryContract;
 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.LinkedHashMap;
@@ -124,28 +127,25 @@ public class TelemetrySyncPingBundleBuil
         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. Expecting {"syncs":[],"why":"schedule", "version": 1,
+        // Test general shape of payload. Expecting {"why":"schedule", "version": 1,
         // "os": {"name": "Android", "version": "<version>", "locale": "<locale>"},
         // "deviceID": <Hashed Device ID>, "uid": <Hashed UID>}.
-        // 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(6, payload.keySet().size());
+        assertEquals(5, payload.keySet().size());
         assertEquals("schedule", payload.getString("why"));
         assertEquals(Integer.valueOf(1), payload.getIntegerSafely("version"));
         assertEquals(payload.getString("uid"), "uid-1");
         assertEquals(payload.getString("deviceID"), "device-id-1");
-        assertEquals(0, payload.getArray("syncs").size());
         // Test os key.
         ExtendedJSONObject os = payload.getObject("os");
         assertEquals(3, os.keySet().size());
         assertEquals("Android", os.getString("name"));
         // Going to be different depending on the test environment.
         // Test for presence and type to void random failures.
         assertTrue(os.getIntegerSafely("version") != null);
         // Likely "en-US" in tests, but let's test for presence and type to avoid random failures.
@@ -182,16 +182,41 @@ public class TelemetrySyncPingBundleBuil
         // We should have two pings now.
         outgoingPing = builder.build();
         syncs = outgoingPing.getPayload()
                 .getObject("payload")
                 .getArray("syncs");
         assertEquals(2, syncs.size());
         assertSync((ExtendedJSONObject) syncs.get(0), 123L, true);
         assertSync((ExtendedJSONObject) syncs.get(1), 321L, false);
+
+        // And add an event ping!
+        Bundle event = new Bundle();
+        event.putLong(TelemetryContract.KEY_EVENT_TIMESTAMP, 123456L);
+        event.putString(TelemetryContract.KEY_EVENT_CATEGORY, "sync");
+        event.putString(TelemetryContract.KEY_EVENT_OBJECT, "object");
+        event.putString(TelemetryContract.KEY_EVENT_METHOD, "method");
+        event.putString(TelemetryContract.KEY_EVENT_VALUE, "value");
+        Bundle extra = new Bundle();
+        extra.putString("extra-key", "extra-value");
+        event.putBundle(TelemetryContract.KEY_EVENT_EXTRA, extra);
+        eventPings.storePing(new TelemetrySyncEventPingBuilder()
+                .fromEventTelemetry(event)
+                .build()
+        );
+        builder.setSyncEventStore(eventPings);
+
+        // We should have three pings now.
+        outgoingPing = builder.build();
+        JSONArray events = outgoingPing.getPayload()
+                .getObject("payload")
+                .getArray("events");
+        assertEquals(1, events.size());
+        Assert.assertEquals("[[123456,\"sync\",\"method\",\"object\",\"value\",{\"extra-key\":\"extra-value\"}]]",
+                events.toJSONString());
     }
 
     private void assertSync(ExtendedJSONObject sync, long took, boolean restarted) throws JSONException {
         assertEquals(Long.valueOf(took), sync.getLong("took"));
 
         // Test that 'when' timestamp looks generally sane.
         final long now = System.currentTimeMillis();
         final long yearAgo = now - 1000L * 60 * 60 * 24 * 365;
--- a/mobile/android/base/android-services.mozbuild
+++ b/mobile/android/base/android-services.mozbuild
@@ -1082,16 +1082,17 @@ sync_java_files = [TOPSRCDIR + '/mobile/
     '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/TelemetryEventCollector.java',
     'sync/telemetry/TelemetryStageCollector.java',
     'sync/ThreadPool.java',
     'sync/UnexpectedJSONException.java',
     'sync/UnknownSynchronizerConfigurationVersionException.java',
     'sync/Utils.java',
     'sync/validation/BookmarkValidationResults.java',
     'sync/validation/BookmarkValidator.java',
     'sync/validation/CollectionValidator.java',
--- a/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryBackgroundReceiver.java
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/TelemetryBackgroundReceiver.java
@@ -166,19 +166,17 @@ public class TelemetryBackgroundReceiver
                         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))
+                                .fromEventTelemetry(telemetryBundle)
                                 .build();
                         break;
                     default:
                         throw new IllegalArgumentException("Unknown background telemetry type.");
                 }
 
                 // Persist the incoming telemetry data.
                 try {
--- a/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncEventPingBuilder.java
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncEventPingBuilder.java
@@ -1,24 +1,58 @@
 /* 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.json.simple.JSONArray;
+import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.telemetry.TelemetryContract;
 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 {
+
+    @SuppressWarnings("unchecked")
     public TelemetrySyncEventPingBuilder fromEventTelemetry(Bundle data) {
+        final long timestamp = data.getLong(TelemetryContract.KEY_EVENT_TIMESTAMP, -1L);
+        final String category = data.getString(TelemetryContract.KEY_EVENT_CATEGORY);
+        final String object = data.getString(TelemetryContract.KEY_EVENT_OBJECT);
+        final String method = data.getString(TelemetryContract.KEY_EVENT_METHOD);
+        final String value = data.getString(TelemetryContract.KEY_EVENT_VALUE);
+        final Bundle extra = data.getBundle(TelemetryContract.KEY_EVENT_EXTRA);
+
+        if (timestamp == -1L || category == null || object == null || method == null) {
+            throw new IllegalStateException("Bundle should be well formed.");
+        }
+
+        final JSONArray event = new JSONArray();
+        // Events are serialized as arrays when sending the sync ping. The order of the following
+        // statements SHOULD NOT be changed unless the telemetry server specification changes.
+        event.add(timestamp);
+        event.add(category);
+        event.add(method);
+        event.add(object);
+        if (value != null || extra != null) {
+            event.add(value);
+            if (extra != null) {
+                final ExtendedJSONObject extraJSON = new ExtendedJSONObject();
+                for (final String k : extra.keySet()) {
+                    extraJSON.put(k, extra.getString(k));
+                }
+                event.add(extraJSON);
+            }
+        }
+        /**
+         * Note: {@link org.mozilla.gecko.telemetry.TelemetryOutgoingPing#getPayload()}
+         * returns ExtendedJSONObject. Wrap our JSONArray into the payload JSON object.
+         */
+        payload.put("event", event);
         return this;
     }
 
     @Override
     public TelemetryLocalPing build() {
-        throw new UnsupportedOperationException();
+        return new TelemetryLocalPing(payload, docID);
     }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncPingBundleBuilder.java
+++ b/mobile/android/base/java/org/mozilla/gecko/telemetry/pingbuilders/TelemetrySyncPingBundleBuilder.java
@@ -3,21 +3,23 @@
  * 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 android.util.Log;
 
 import org.json.simple.JSONArray;
 import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.Locales;
 import org.mozilla.gecko.sync.ExtendedJSONObject;
+import org.mozilla.gecko.sync.NonArrayJSONException;
 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;
@@ -28,25 +30,25 @@ import java.util.TimeZone;
  * Responsible for building a Sync Ping, based on the telemetry docs:
  * https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/data/sync-ping.html
  *
  * Fields common to all pings are documented here:
  * https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/data/common-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 {
+    public static final String LOG_TAG = "SyncPingBundleBuilder";
+
     private static final String PING_TYPE = "sync";
     private static final int PING_BUNDLE_VERSION = 4; // Bug 1410145
     private static final int PING_SYNC_DATA_FORMAT_VERSION = 1; // Bug 1374758
 
     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";
@@ -123,17 +125,32 @@ public class TelemetrySyncPingBundleBuil
 
         // 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);
+        if (syncs.size() > 0) {
+            pingData.put("syncs", syncs);
+        }
         return this;
     }
+    @SuppressWarnings("unchecked")
+    public TelemetrySyncPingBundleBuilder setSyncEventStore(TelemetryPingStore store) {
+        final JSONArray events = new JSONArray();
+        List<TelemetryPing> pings = store.getAllPings();
 
-    // Event telemetry will be implemented in Bug 1363924.
-    public TelemetrySyncPingBundleBuilder setSyncEventStore(TelemetryPingStore store) {
+        for (TelemetryPing ping : pings) {
+            try {
+                events.add(ping.getPayload().getArray("event"));
+            } catch (NonArrayJSONException ex) {
+                Log.e(LOG_TAG, "Invalid state: Non JSONArray for event payload.");
+            }
+        }
+
+        if (events.size() > 0) {
+            pingData.put("events", events);
+        }
         return this;
     }
 }
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/fxa/authenticator/AndroidFxAccount.java
@@ -105,16 +105,23 @@ public class AndroidFxAccount {
   private static final String BUNDLE_KEY_PROFILE_JSON = "profile";
 
   private static final String ACCOUNT_KEY_DEVICE_ID = "deviceId";
   private static final String ACCOUNT_KEY_DEVICE_REGISTRATION_VERSION = "deviceRegistrationVersion";
   private static final String ACCOUNT_KEY_DEVICE_REGISTRATION_TIMESTAMP = "deviceRegistrationTimestamp";
   private static final String ACCOUNT_KEY_DEVICE_PUSH_REGISTRATION_ERROR = "devicePushRegistrationError";
   private static final String ACCOUNT_KEY_DEVICE_PUSH_REGISTRATION_ERROR_TIME = "devicePushRegistrationErrorTime";
 
+  // We only see the hashed FxA UID once every sync.
+  // We might need it later for telemetry purposes outside of the context of a sync, which
+  // is why it is persisted.
+  // It is not expected to change during the lifetime of an account, but we set
+  // that value every time we see an FxA token nonetheless.
+  private static final String ACCOUNT_KEY_HASHED_FXA_UID = "hashedFxAUID";
+
   // Account authentication token type for fetching account profile.
   private static final String PROFILE_OAUTH_TOKEN_TYPE = "oauth::profile";
 
   // Services may request OAuth tokens from the Firefox Account dynamically.
   // Each such token is prefixed with "oauth::" and a service-dependent scope.
   // Such tokens should be destroyed when the account is removed from the device.
   // This list collects all the known "oauth::" token types in order to delete them when necessary.
   private static final List<String> KNOWN_OAUTH_TOKEN_TYPES;
@@ -1033,16 +1040,24 @@ public class AndroidFxAccount {
     accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_PUSH_REGISTRATION_ERROR, Long.toString(error));
     accountManager.setUserData(account, ACCOUNT_KEY_DEVICE_PUSH_REGISTRATION_ERROR_TIME, Long.toString(errorTimeMs));
   }
 
   public synchronized void resetDevicePushRegistrationError() {
     setDevicePushRegistrationError(0L, 0l);
   }
 
+  public synchronized void setCachedHashedFxAUID(final String newHashedFxAUID) {
+    accountManager.setUserData(account, ACCOUNT_KEY_HASHED_FXA_UID, newHashedFxAUID);
+  }
+
+  public synchronized String getCachedHashedFxAUID() {
+    return accountManager.getUserData(account, ACCOUNT_KEY_HASHED_FXA_UID);
+  }
+
   @SuppressLint("ParcelCreator") // The CREATOR field is defined in the super class.
   private class ProfileResultReceiver extends ResultReceiver {
     /* package-private */ ProfileResultReceiver(Handler handler) {
       super(handler);
     }
 
     @Override
     protected void onReceiveResult(int resultCode, Bundle bundle) {
--- 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
@@ -343,16 +343,17 @@ public class FxAccountSyncAdapter extend
       @Override
       public String getUserAgent() {
         return FxAccountConstants.USER_AGENT;
       }
 
       @Override
       public void handleSuccess(final TokenServerToken token) {
         FxAccountUtils.pii(LOG_TAG, "Got token! uid is " + token.uid + " and endpoint is " + token.endpoint + ".");
+        fxAccount.setCachedHashedFxAUID(token.hashedFxaUid);
         fxAccount.releaseSharedAccountStateLock();
 
         if (!didReceiveBackoff) {
           // We must be OK to touch this token server.
           tokenBackoffHandler.setEarliestNextRequest(0L);
         }
 
         final URI storageServerURI;
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandProcessor.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/CommandProcessor.java
@@ -4,25 +4,33 @@
 
 package org.mozilla.gecko.sync;
 
 import android.content.ComponentName;
 import android.content.Context;
 import android.content.Intent;
 import android.net.Uri;
 import android.support.annotation.Nullable;
+import android.text.TextUtils;
+import android.util.Log;
+
 import org.json.simple.JSONArray;
 import org.json.simple.JSONObject;
 import org.mozilla.gecko.background.common.log.Logger;
 import org.mozilla.gecko.db.BrowserContract;
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
 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.TelemetryEventCollector;
 
+import java.io.UnsupportedEncodingException;
+import java.security.NoSuchAlgorithmException;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicInteger;
 
 /**
  * Process commands received from Sync clients.
  * <p>
@@ -128,18 +136,30 @@ public class CommandProcessor {
       return;
     }
 
     CommandRunner executableCommand = commands.get(command.commandType);
     if (executableCommand == null) {
       Logger.debug(LOG_TAG, "Command \"" + command.commandType + "\" not registered and will not be processed.");
       return;
     }
+    try {
+      recordProcessCommandTelemetryEvent(session.getContext(), command);
+    } catch (Exception e) {
+      Log.e(LOG_TAG, "Could not record telemetry event.");
+    }
+    executableCommand.executeCommand(session, command.getArgsList());
+  }
 
-    executableCommand.executeCommand(session, command.getArgsList());
+  private static void recordProcessCommandTelemetryEvent(Context context, Command command) {
+    final HashMap<String, String> extra = new HashMap<>();
+    if (command.flowID != null) {
+      extra.put("flowID", command.flowID);
+    }
+    TelemetryEventCollector.recordEvent(context, "processcommand", command.commandType, null, extra);
   }
 
   /**
    * Parse a JSON command into a ParsedCommand object for easier handling.
    *
    * @param unparsedCommand - command as ExtendedJSONObject
    * @return - null if command is invalid, else return ParsedCommand with
    *           no null attributes.
@@ -225,18 +245,49 @@ public class CommandProcessor {
       }
     } catch (NullCursorException e) {
       Logger.error(LOG_TAG, "NullCursorException when fetching all GUIDs");
     } finally {
       db.close();
     }
   }
 
+  private static void recordSendCommandTelemetryEvent(Context context, Command command, String clientID) {
+    final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context);
+    if (fxAccount == null) {
+      Log.e(LOG_TAG, "Can't record telemetry event: FxAccount doesn't exist.");
+      return;
+    }
+    final String hashedFxAUID = fxAccount.getCachedHashedFxAUID();
+    if (TextUtils.isEmpty(hashedFxAUID)) {
+      Log.e(LOG_TAG, "Can't record telemetry event: The hashed FxA UID is empty");
+      return;
+    }
+
+    HashMap<String, String> extra = new HashMap<>();
+    if (!TextUtils.isEmpty(command.flowID)) {
+      extra.put("flowID", command.flowID);
+    }
+    try {
+      extra.put("deviceID", Utils.byte2Hex(Utils.sha256(clientID.concat(hashedFxAUID).getBytes("UTF-8"))));
+    } catch (UnsupportedEncodingException | NoSuchAlgorithmException e) {
+      // Should not happen.
+      Log.e(LOG_TAG, "Either UTF-8 or SHA-256 are not supported", e);
+    }
+
+    TelemetryEventCollector.recordEvent(context, "sendcommand", command.commandType, null, extra);
+  }
+
   protected void sendCommandToClient(String clientID, Command command, Context context) {
     Logger.info(LOG_TAG, "Sending " + command.commandType + " to " + clientID);
+    try {
+      recordSendCommandTelemetryEvent(context, command, clientID);
+    } catch (Exception e) {
+      Log.e(LOG_TAG, "Could not record telemetry event.");
+    }
 
     ClientsDatabaseAccessor db = new ClientsDatabaseAccessor(context);
     try {
       db.store(clientID, command);
     } catch (NullCursorException e) {
       Logger.error(LOG_TAG, "NullCursorException: Unable to send command.");
     } finally {
       db.close();
--- a/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResource.java
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/net/BaseResource.java
@@ -74,17 +74,17 @@ public class BaseResource implements Res
 
   private static final String LOG_TAG = "BaseResource";
 
   protected final URI uri;
   protected BasicHttpContext context;
   protected DefaultHttpClient client;
   public    ResourceDelegate delegate;
   protected HttpRequestBase request;
-  public final String charset = "utf-8";
+  public static final String charset = "utf-8";
 
   private boolean shouldGzipCompress = false;
   // A hint whether uploaded payloads are chunked. Default true to use GzipCompressingEntity, which is built-in functionality.
   private boolean shouldChunkUploadsHint = true;
 
   /**
    * We have very few writes (observers tend to be installed around sync
    * sessions) and many iterations (every HTTP request iterates observers), so
--- 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
@@ -20,9 +20,16 @@ public class TelemetryContract {
 
   public static final String KEY_TYPE = "type";
   public static final String KEY_TYPE_SYNC = "sync";
   public static final String KEY_TYPE_EVENT = "event";
 
   public static final String KEY_DEVICE_OS = "os";
   public static final String KEY_DEVICE_VERSION = "version";
   public static final String KEY_DEVICE_ID = "id";
+
+  public static final String KEY_EVENT_TIMESTAMP = "ts";
+  public static final String KEY_EVENT_CATEGORY = "category";
+  public static final String KEY_EVENT_METHOD = "method";
+  public static final String KEY_EVENT_OBJECT = "object";
+  public static final String KEY_EVENT_VALUE = "value";
+  public static final String KEY_EVENT_EXTRA = "extra";
 }
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/src/main/java/org/mozilla/gecko/sync/telemetry/TelemetryEventCollector.java
@@ -0,0 +1,141 @@
+/* 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.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.os.SystemClock;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.support.v4.content.LocalBroadcastManager;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.mozilla.gecko.fxa.authenticator.AndroidFxAccount;
+import org.mozilla.gecko.sync.SharedPreferencesClientsDataDelegate;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.sync.delegates.ClientsDataDelegate;
+import org.mozilla.gecko.sync.net.BaseResource;
+
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.Charset;
+import java.security.GeneralSecurityException;
+import java.security.NoSuchAlgorithmException;
+import java.util.HashMap;
+import java.util.Map;
+
+public class TelemetryEventCollector {
+    private static final String LOG_TAG = "TelemetryEventCollector";
+
+    private static final String ACTION_BACKGROUND_TELEMETRY = "org.mozilla.gecko.telemetry.BACKGROUND";
+    private static final String EVENT_CATEGORY_SYNC = "sync"; // Max byte length: 30.
+
+    public static void recordEvent(final Context context, final String object, final String method,
+                                   @Nullable final String value,
+                                   @Nullable final HashMap<String, String> extra) {
+        if (!validateTelemetryEvent(object, method, value, extra)) {
+            throw new IllegalArgumentException("Could not validate telemetry event.");
+        }
+
+        Log.d(LOG_TAG, "Recording event {" + object + ", " + method + ", " + value + ", " + extra);
+        final Bundle event = new Bundle();
+        event.putLong(TelemetryContract.KEY_EVENT_TIMESTAMP, SystemClock.elapsedRealtime());
+        event.putString(TelemetryContract.KEY_EVENT_CATEGORY, EVENT_CATEGORY_SYNC);
+        event.putString(TelemetryContract.KEY_EVENT_OBJECT, object);
+        event.putString(TelemetryContract.KEY_EVENT_METHOD, method);
+        if (value != null) {
+            event.putString(TelemetryContract.KEY_EVENT_VALUE, value);
+        }
+        if (extra != null) {
+            final Bundle extraBundle = new Bundle();
+            for (Map.Entry<String, String> e : extra.entrySet()) {
+                extraBundle.putString(e.getKey(), e.getValue());
+            }
+            event.putBundle(TelemetryContract.KEY_EVENT_EXTRA, extraBundle);
+        }
+        if (!setIDs(context, event)) {
+            throw new IllegalStateException("UID and deviceID need to be set.");
+        }
+
+        final Intent telemetryIntent = new Intent();
+        telemetryIntent.setAction(ACTION_BACKGROUND_TELEMETRY);
+        telemetryIntent.putExtra(TelemetryContract.KEY_TYPE, TelemetryContract.KEY_TYPE_EVENT);
+        telemetryIntent.putExtra(TelemetryContract.KEY_TELEMETRY, event);
+        LocalBroadcastManager.getInstance(context).sendBroadcast(telemetryIntent);
+    }
+
+    // The Firefox Telemetry pipeline imposes size limits.
+    // See toolkit/components/telemetry/docs/collection/events.rst
+    @VisibleForTesting
+    static boolean validateTelemetryEvent(final String object, final String method,
+                                          @Nullable final String value, @Nullable final HashMap<String, String> extra) {
+        // The Telemetry Sender uses BaseResource under the hood.
+        final Charset charset = Charset.forName(BaseResource.charset);
+        // Length checks.
+        if (method.getBytes(charset).length > 20 ||
+                object.getBytes(charset).length > 20 ||
+                (value != null && value.getBytes(charset).length > 80)) {
+            Log.w(LOG_TAG, "Invalid event parameters - wrong lengths: " + method + " " +
+                    object + " " + value);
+            return false;
+        }
+
+        if (extra != null) {
+            if (extra.size() > 10) {
+                Log.w(LOG_TAG, "Invalid event parameters - too many extra keys: " + extra);
+                return false;
+            }
+            for (Map.Entry<String, String> e : extra.entrySet()) {
+                if (e.getKey().getBytes(charset).length > 15 ||
+                    e.getValue().getBytes(charset).length > 80) {
+                    Log.w(LOG_TAG, "Invalid event parameters: extra item \"" + e.getKey() +
+                            "\" is invalid: " + extra);
+                    return false;
+                }
+            }
+        }
+
+        return true;
+    }
+
+    private static boolean setIDs(final Context context, final Bundle event) {
+        final SharedPreferences sharedPrefs;
+        final AndroidFxAccount fxAccount = AndroidFxAccount.fromContext(context);
+        if (fxAccount == null) {
+            Log.e(LOG_TAG, "Can't record telemetry event: FxAccount doesn't exist.");
+            return false;
+        }
+        try {
+            sharedPrefs = fxAccount.getSyncPrefs();
+        }  catch (UnsupportedEncodingException | GeneralSecurityException e) {
+            Log.e(LOG_TAG, "Can't record telemetry event: Could not retrieve Sync Prefs", e);
+            return false;
+        }
+
+        final String hashedFxAUID = fxAccount.getCachedHashedFxAUID();
+        if (TextUtils.isEmpty(hashedFxAUID)) {
+            Log.e(LOG_TAG, "Can't record telemetry event: The hashed FxA UID is empty");
+            return false;
+        }
+
+        final ClientsDataDelegate clientsDataDelegate = new SharedPreferencesClientsDataDelegate(sharedPrefs, context);
+        try {
+            final String hashedDeviceID = Utils.byte2Hex(Utils.sha256(
+                    clientsDataDelegate.getAccountGUID().concat(hashedFxAUID).getBytes("UTF-8")
+            ));
+            event.putString(TelemetryContract.KEY_LOCAL_DEVICE_ID, hashedDeviceID);
+        } catch (UnsupportedEncodingException | NoSuchAlgorithmException e) {
+            // Should not happen.
+            Log.e(LOG_TAG, "Either UTF-8 or SHA-256 are not supported", e);
+            return false;
+        }
+
+        event.putString(TelemetryContract.KEY_LOCAL_UID, hashedFxAUID);
+        return true;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/services/src/test/java/org/mozilla/gecko/sync/telemetry/TelemetryEventCollectorTest.java
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+package org.mozilla.gecko.sync.telemetry;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mozilla.gecko.background.testhelpers.TestRunner;
+
+import java.util.HashMap;
+
+@RunWith(TestRunner.class)
+public class TelemetryEventCollectorTest {
+
+    @Test
+    public void testValidateTelemetryEvent() {
+        // object arg bytes len > 20.
+        Assert.assertFalse(TelemetryEventCollector.validateTelemetryEvent(repeat("c", 21), "met", null, null));
+        Assert.assertFalse(TelemetryEventCollector.validateTelemetryEvent(repeat("©", 11), "met", null, null));
+
+        // method arg bytes len > 20.
+        Assert.assertFalse(TelemetryEventCollector.validateTelemetryEvent("obj", repeat("c", 21), null, null));
+        Assert.assertFalse(TelemetryEventCollector.validateTelemetryEvent("obj", repeat("©", 11), null, null));
+
+        // val arg bytes len > 80.
+        Assert.assertFalse(TelemetryEventCollector.validateTelemetryEvent("obj", "met", repeat("c", 81), null));
+        Assert.assertFalse(TelemetryEventCollector.validateTelemetryEvent("obj", "met", repeat("©", 41), null));
+
+        // extra arg len > 10.
+        HashMap<String, String> extra = new HashMap<>();
+        for (int i = 0; i < 11; i++) {
+            extra.put("" + i, "" + i);
+        }
+        Assert.assertFalse(TelemetryEventCollector.validateTelemetryEvent("obj", "met", "val", extra));
+
+        // extra arg key len > 15.
+        extra.clear();
+        for (int i = 0; i < 9; i++) {
+            extra.put("" + i, "" + i);
+        }
+        extra.put("HashMapKeyInstanceFactory", "val");
+        Assert.assertFalse(TelemetryEventCollector.validateTelemetryEvent("obj", "met", "val", extra));
+
+        // extra arg val bytes len > 80.
+        extra.clear();
+        for (int i = 0; i < 8; i++) {
+            extra.put("" + i, "" + i);
+        }
+        extra.put("key", repeat("©", 41));
+        Assert.assertFalse(TelemetryEventCollector.validateTelemetryEvent("obj", "met", "val", extra));
+
+        // Happy case.
+        extra.clear();
+        for (int i = 0; i < 10; i++) {
+            extra.put(repeat(i + "", 15), repeat("" + i, 80));
+        }
+        Assert.assertTrue(TelemetryEventCollector.validateTelemetryEvent(repeat("c", 20), repeat("c", 20), repeat("c", 80), extra));
+    }
+
+    private static String repeat(String c, int times) {
+        return new String(new char[times]).replace("\0", c);
+    }
+}
--- a/toolkit/components/telemetry/docs/data/sync-ping.rst
+++ b/toolkit/components/telemetry/docs/data/sync-ping.rst
@@ -224,13 +224,12 @@ processcommand
 
 Records that Sync processed a remote "command" previously sent by another
 client. This is logically the "other end" of ``sendcommand``.
 
 - object: The specific command being processed.
 - value: Not used (ie, ``null``)
 - extra: An object with the following attributes:
 
-  - deviceID: A GUID which identifies the device the command is being sent to.
   - flowID: A GUID which uniquely identifies this command invocation. The value
             for this GUID will be the same as the flowID sent to the client via
             ``sendcommand``.
   - serverTime: (optional) Most recent server timestamp, as described above.