Bug 1243585 - Revise TelemetryUploadService for new store. r=sebastian

Note: this is still not expected to compile.

MozReview-Commit-ID: nadP0VfCG7

--HG--
extra : rebase_source : ca957fd03140ebd7132410d80cc53abb111dbcde
This commit is contained in:
Michael Comella 2016-04-28 17:51:47 -07:00
parent daf1884121
commit b50372bc52
2 changed files with 150 additions and 145 deletions

View File

@ -4,102 +4,127 @@
package org.mozilla.gecko.telemetry;
import android.app.IntentService;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.WorkerThread;
import android.text.TextUtils;
import android.util.Log;
import ch.boye.httpclientandroidlib.HttpResponse;
import ch.boye.httpclientandroidlib.client.ClientProtocolException;
import org.mozilla.gecko.GeckoProfile;
import org.mozilla.gecko.GeckoSharedPrefs;
import org.mozilla.gecko.background.BackgroundService;
import org.mozilla.gecko.distribution.DistributionStoreCallback;
import org.mozilla.gecko.preferences.GeckoPreferences;
import org.mozilla.gecko.sync.net.BaseResource;
import org.mozilla.gecko.sync.net.BaseResourceDelegate;
import org.mozilla.gecko.sync.net.Resource;
import org.mozilla.gecko.telemetry.core.TelemetryCorePingBuilder;
import org.mozilla.gecko.telemetry.stores.TelemetryPingStore;
import org.mozilla.gecko.util.NetworkUtils;
import org.mozilla.gecko.util.StringUtils;
import java.io.IOException;
import java.net.URISyntaxException;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.concurrent.TimeUnit;
/**
* The service that handles uploading telemetry payloads to the server.
*
* Note that we'll fail to upload if the network is off or background uploads are disabled but the caller is still
* expected to increment the sequence number.
* The service that handles retrieving a list of telemetry pings to upload from the given
* {@link TelemetryPingStore}, uploading those payloads to the associated server, and reporting
* back to the Store which uploads were a success.
*/
public class TelemetryUploadService extends BackgroundService {
public class TelemetryUploadService extends IntentService {
private static final String LOGTAG = StringUtils.safeSubstring("Gecko" + TelemetryUploadService.class.getSimpleName(), 0, 23);
private static final String WORKER_THREAD_NAME = LOGTAG + "Worker";
public static final String ACTION_UPLOAD_CORE = "uploadCore";
public static final String EXTRA_DEFAULT_SEARCH_ENGINE = "defaultSearchEngine";
public static final String EXTRA_DOC_ID = "docId";
public static final String EXTRA_PROFILE_NAME = "geckoProfileName";
public static final String EXTRA_PROFILE_PATH = "geckoProfilePath";
public static final String EXTRA_SEQ = "seq";
private static final int MILLIS_IN_DAY = 1000 * 60 * 60 * 24;
public static final String ACTION_UPLOAD = "upload";
public static final String EXTRA_STORE = "store";
public TelemetryUploadService() {
super(WORKER_THREAD_NAME);
// Intent redelivery can fail hard (e.g. we OOM as we try to upload, the Intent gets redelivered, repeat) so for
// simplicity, we avoid it for now. In the unlikely event that Android kills our upload service, we'll thus fail
// to upload the document with a specific sequence number. Furthermore, we never attempt to re-upload it.
//
// We'll fix this issue in bug 1243585.
// Intent redelivery can fail hard (e.g. we OOM as we try to upload, the Intent gets redelivered, repeat)
// so for simplicity, we avoid it. We expect the upload service to eventually get called again by the caller.
setIntentRedelivery(false);
}
/**
* Handles a core ping with the mandatory extras:
* EXTRA_DOC_ID: a unique document ID.
* EXTRA_SEQ: a sequence number for this upload.
* EXTRA_PROFILE_NAME: the gecko profile name.
* EXTRA_PROFILE_PATH: the gecko profile path.
*
* Note that for a given doc ID, seq should always be identical because these are the tools the server uses to
* de-duplicate documents. In order to maintain this consistency, we receive the doc ID and seq from the Intent and
* rely on the caller to update the values. The Service can be killed at any time so we can't ensure seq could be
* incremented properly if we tried to do so in the Service.
* Handles a ping with the mandatory extras:
* * EXTRA_STORE: A {@link TelemetryPingStore} where the pings to upload are located
*/
@Override
public void onHandleIntent(final Intent intent) {
Log.d(LOGTAG, "Service started");
if (!isReadyToUpload(this, intent)) {
return;
}
final TelemetryPingStore store = intent.getParcelableExtra(EXTRA_STORE);
uploadPendingPingsFromStore(store);
store.maybePrunePings();
Log.d(LOGTAG, "Service finished: upload and prune attempts completed");
}
private static void uploadPendingPingsFromStore(final TelemetryPingStore store) {
final ArrayList<TelemetryPingFromStore> pingsToUpload = store.getAllPings();
final HashSet<Integer> successfulUploadIDs = new HashSet<>(pingsToUpload.size()); // used for side effects.
final PingResultDelegate delegate = new PingResultDelegate(successfulUploadIDs);
for (final TelemetryPingFromStore ping : pingsToUpload) {
// There are minimal gains in trying to upload if we already failed one attempt.
if (delegate.hadConnectionError()) {
break;
}
// TODO: It'd be great to re-use the same HTTP connection for each upload request.
delegate.setPingID(ping.getUniqueID());
uploadPing(ping, delegate);
}
if (!delegate.hadConnectionError()) {
// We don't log individual successful uploads to avoid log spam.
Log.d(LOGTAG, "Telemetry upload success!");
}
store.onUploadAttemptComplete(successfulUploadIDs);
}
private static void uploadPing(final TelemetryPing ping, final ResultDelegate delegate) {
final BaseResource resource;
try {
resource = new BaseResource(ping.getURL());
} catch (final URISyntaxException e) {
Log.w(LOGTAG, "URISyntaxException for server URL when creating BaseResource: returning.");
return;
}
delegate.setResource(resource);
resource.delegate = delegate;
resource.setShouldCompressUploadedEntity(true);
resource.setShouldChunkUploadsHint(false); // Telemetry servers don't support chunking.
// We're in a background thread so we don't have any reason to do this asynchronously.
// If we tried, onStartCommand would return and IntentService might stop itself before we finish.
resource.postBlocking(ping.getPayload());
}
private static boolean isReadyToUpload(final Context context, final Intent intent) {
// Sanity check: is upload enabled? Generally, the caller should check this before starting the service.
// Since we don't have the profile here, we rely on the caller to check the enabled state for the profile.
if (!isUploadEnabledByAppConfig(this)) {
if (!isUploadEnabledByAppConfig(context)) {
Log.w(LOGTAG, "Upload is not available by configuration; returning");
return;
return false;
}
if (!isIntentValid(intent)) {
Log.w(LOGTAG, "Received invalid Intent; returning");
return;
return false;
}
if (!ACTION_UPLOAD_CORE.equals(intent.getAction())) {
if (!ACTION_UPLOAD.equals(intent.getAction())) {
Log.w(LOGTAG, "Unknown action: " + intent.getAction() + ". Returning");
return;
return false;
}
final String defaultSearchEngine = intent.getStringExtra(EXTRA_DEFAULT_SEARCH_ENGINE);
final String docId = intent.getStringExtra(EXTRA_DOC_ID);
final int seq = intent.getIntExtra(EXTRA_SEQ, -1);
final String profileName = intent.getStringExtra(EXTRA_PROFILE_NAME);
final String profilePath = intent.getStringExtra(EXTRA_PROFILE_PATH);
uploadCorePing(docId, seq, profileName, profilePath, defaultSearchEngine);
return true;
}
/**
@ -120,7 +145,7 @@ public class TelemetryUploadService extends BackgroundService {
return false;
}
if (!backgroundDataIsEnabled(context)) {
if (!NetworkUtils.isBackgroundDataEnabled(context)) {
Log.d(LOGTAG, "Background data is disabled");
return false;
}
@ -143,109 +168,70 @@ public class TelemetryUploadService extends BackgroundService {
return isUploadEnabledByAppConfig(context);
}
private boolean isIntentValid(final Intent intent) {
private static boolean isIntentValid(final Intent intent) {
// Intent can be null. Bug 1025937.
if (intent == null) {
Log.d(LOGTAG, "Received null intent");
return false;
}
if (intent.getStringExtra(EXTRA_DOC_ID) == null) {
Log.d(LOGTAG, "Received invalid doc ID in Intent");
return false;
}
if (!intent.hasExtra(EXTRA_SEQ)) {
Log.d(LOGTAG, "Received Intent without sequence number");
return false;
}
if (intent.getStringExtra(EXTRA_PROFILE_NAME) == null) {
Log.d(LOGTAG, "Received invalid profile name in Intent");
return false;
}
// GeckoProfile can use the name to get the path so this isn't strictly necessary.
// However, getting the path requires parsing an ini file so we optimize by including it here.
if (intent.getStringExtra(EXTRA_PROFILE_PATH) == null) {
Log.d(LOGTAG, "Received invalid profile path in Intent");
if (intent.getParcelableExtra(EXTRA_STORE) == null) {
Log.d(LOGTAG, "Received invalid store in Intent");
return false;
}
return true;
}
@WorkerThread
private void uploadCorePing(@NonNull final String docId, final int seq, @NonNull final String profileName,
@NonNull final String profilePath, @Nullable final String defaultSearchEngine) {
final GeckoProfile profile = GeckoProfile.get(this, profileName, profilePath);
final String clientId;
try {
clientId = profile.getClientId();
} catch (final IOException e) {
Log.w(LOGTAG, "Unable to get client ID to generate core ping: returning.", e);
return;
}
// Each profile can have different telemetry data so we intentionally grab the shared prefs for the profile.
final SharedPreferences sharedPrefs = GeckoSharedPrefs.forProfileName(this, profileName);
// TODO (bug 1241685): Sync this preference with the gecko preference.
final String serverURLSchemeHostPort =
sharedPrefs.getString(TelemetryConstants.PREF_SERVER_URL, TelemetryConstants.DEFAULT_SERVER_URL);
final long profileCreationDate = getProfileCreationDate(profile);
final TelemetryCorePingBuilder builder = new TelemetryCorePingBuilder(this, serverURLSchemeHostPort)
.setClientID(clientId)
.setDefaultSearchEngine(TextUtils.isEmpty(defaultSearchEngine) ? null : defaultSearchEngine)
.setProfileCreationDate(profileCreationDate < 0 ? null : profileCreationDate)
.setSequenceNumber(seq);
final String distributionId = sharedPrefs.getString(DistributionStoreCallback.PREF_DISTRIBUTION_ID, null);
if (distributionId != null) {
builder.setOptDistributionID(distributionId);
}
final TelemetryPing corePing = builder.build();
final CorePingResultDelegate resultDelegate = new CorePingResultDelegate();
uploadPing(corePing, resultDelegate);
}
private void uploadPing(final TelemetryPing ping, final ResultDelegate delegate) {
final BaseResource resource;
try {
resource = new BaseResource(ping.getURL());
} catch (final URISyntaxException e) {
Log.w(LOGTAG, "URISyntaxException for server URL when creating BaseResource: returning.");
return;
}
delegate.setResource(resource);
resource.delegate = delegate;
resource.setShouldCompressUploadedEntity(true);
resource.setShouldChunkUploadsHint(false); // Telemetry servers don't support chunking.
// We're in a background thread so we don't have any reason to do this asynchronously.
// If we tried, onStartCommand would return and IntentService might stop itself before we finish.
resource.postBlocking(ping.getPayload());
}
/**
* @return the profile creation date in the format expected by TelemetryPingGenerator.
* Logs on success & failure and appends the set ID to the given ArrayList on success.
*
* Note: you *must* set the ping ID before attempting upload or we'll throw!
*
* We use mutation on the set ID and the successful upload array to avoid object allocation.
*/
@WorkerThread
private long getProfileCreationDate(final GeckoProfile profile) {
final long profileMillis = profile.getAndPersistProfileCreationDate(this);
// getAndPersistProfileCreationDate can return -1,
// and we don't want to truncate (-1 / MILLIS) to 0.
if (profileMillis < 0) {
return profileMillis;
}
return (long) Math.floor((double) profileMillis / MILLIS_IN_DAY);
}
private static class PingResultDelegate extends ResultDelegate {
// Since an IntentService handles one Intent at a time and queues subsequent Intents,
// an upload attempt can block the next upload attempt. This is problematic in the case
// that we don't have a connection e.g. if we block for 2 minutes on each upload attempt,
// then each Intent will keep the radio active for two minutes and our users will have a
// bad day. Therefore, keep these durations short - we will try to upload these pings later
// and will generally not lose data.
//
// We can prevent this issue with better upload scheduling (bug 1268730).
private static final int SOCKET_TIMEOUT_MILLIS = (int) TimeUnit.SECONDS.toMillis(20);
private static final int CONNECTION_TIMEOUT_MILLIS = (int) TimeUnit.SECONDS.toMillis(15);
private static class CorePingResultDelegate extends ResultDelegate {
public CorePingResultDelegate() {
/** The store ID of the ping currently being uploaded. Use {@link #getPingID()} to access it. */
private int pingID = -1;
private final HashSet<Integer> successfulUploadIDs;
private boolean hadConnectionError = false;
public PingResultDelegate(final HashSet<Integer> successfulUploadIDs) {
super();
this.successfulUploadIDs = successfulUploadIDs;
}
@Override
public int socketTimeout() {
return SOCKET_TIMEOUT_MILLIS;
}
@Override
public int connectionTimeout() {
return CONNECTION_TIMEOUT_MILLIS;
}
private int getPingID() {
if (pingID < 0) {
throw new IllegalStateException("Expected ping ID to have been updated before retrieval");
}
return pingID;
}
public void setPingID(final int id) {
pingID = id;
}
@Override
@ -259,10 +245,11 @@ public class TelemetryUploadService extends BackgroundService {
switch (status) {
case 200:
case 201:
Log.d(LOGTAG, "Telemetry upload success.");
successfulUploadIDs.add(getPingID());
break;
default:
Log.w(LOGTAG, "Telemetry upload failure. HTTP status: " + status);
hadConnectionError = true;
}
}
@ -270,18 +257,25 @@ public class TelemetryUploadService extends BackgroundService {
public void handleHttpProtocolException(final ClientProtocolException e) {
// We don't log the exception to prevent leaking user data.
Log.w(LOGTAG, "HttpProtocolException when trying to upload telemetry");
hadConnectionError = true;
}
@Override
public void handleHttpIOException(final IOException e) {
// We don't log the exception to prevent leaking user data.
Log.w(LOGTAG, "HttpIOException when trying to upload telemetry");
hadConnectionError = true;
}
@Override
public void handleTransportException(final GeneralSecurityException e) {
// We don't log the exception to prevent leaking user data.
Log.w(LOGTAG, "Transport exception when trying to upload telemetry");
hadConnectionError = true;
}
private boolean hadConnectionError() {
return hadConnectionError;
}
}

View File

@ -8,6 +8,7 @@ package org.mozilla.gecko.util;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.support.annotation.Nullable;
public class NetworkUtils {
@ -15,14 +16,24 @@ public class NetworkUtils {
* Indicates whether network connectivity exists and it is possible to establish connections and pass data.
*/
public static boolean isConnected(Context context) {
final NetworkInfo networkInfo = getActiveNetworkInfo(context);
return networkInfo != null &&
networkInfo.isConnected();
}
public static boolean isBackgroundDataEnabled(final Context context) {
final NetworkInfo networkInfo = getActiveNetworkInfo(context);
return networkInfo != null &&
networkInfo.isAvailable() &&
networkInfo.isConnectedOrConnecting();
}
@Nullable
private static NetworkInfo getActiveNetworkInfo(final Context context) {
final ConnectivityManager connectivity = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
if (connectivity == null) {
return false;
return null;
}
final NetworkInfo networkInfo = connectivity.getActiveNetworkInfo();
if (networkInfo == null) {
return false;
}
return networkInfo.isConnected();
return connectivity.getActiveNetworkInfo(); // can return null.
}
}