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