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.