Skip to content

Commit f15b7dc

Browse files
Add MixpanelNetworkErrorListener (#857)
* add MixpanelNetworkErrorListener * pass to AnalyticsMessages and add to mixpaneldemo * rename props * make helper * remove dupe calls from AnalyticsMessages add catch Exception * update SimpleLoggingErrorListener example * newline * add ip and duration * revert MIXPANEL_API test change * add body size and response * reformat * cleanup * move endTimeNanos up
1 parent 309d58e commit f15b7dc

File tree

6 files changed

+215
-17
lines changed

6 files changed

+215
-17
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package com.mixpanel.mixpaneldemo
2+
3+
import android.util.Log
4+
import com.mixpanel.android.util.MixpanelNetworkErrorListener
5+
import java.io.EOFException
6+
import java.io.IOException
7+
import java.net.ConnectException
8+
import java.net.SocketException
9+
import java.net.SocketTimeoutException
10+
import java.net.UnknownHostException
11+
import javax.net.ssl.SSLException
12+
import javax.net.ssl.SSLHandshakeException
13+
import javax.net.ssl.SSLPeerUnverifiedException
14+
15+
/**
16+
* A simple implementation of MixpanelNetworkErrorListener that logs
17+
* the encountered network errors using Android's Logcat.
18+
*/
19+
class SimpleLoggingErrorListener : MixpanelNetworkErrorListener {
20+
21+
companion object {
22+
private const val TAG = "MixpanelNetworkError"
23+
}
24+
25+
override fun onNetworkError(
26+
endpointUrl: String,
27+
ipAddress: String,
28+
durationMillis: Long,
29+
uncompressedBodySize: Long,
30+
compressedBodySize: Long,
31+
responseCode: Int,
32+
responseMessage: String,
33+
exception: Exception
34+
) {
35+
Log.w(
36+
TAG,
37+
"Mixpanel network error for endpoint: $endpointUrl (IP: $ipAddress, duration: $durationMillis ms, uncompressed body size: $uncompressedBodySize, compressed body size: $compressedBodySize, response: $responseCode $responseMessage)"
38+
)
39+
Log.w(TAG, "Exception: ${exception.toString()} - Message: ${exception.message}", exception)
40+
41+
when (exception) {
42+
// --- Specific SSL/TLS Issues ---
43+
is SSLPeerUnverifiedException -> {
44+
Log.e(
45+
TAG,
46+
"--> SSLPeerUnverifiedException occurred (Certificate validation issue?).",
47+
exception
48+
)
49+
}
50+
51+
is SSLHandshakeException -> {
52+
Log.e(
53+
TAG,
54+
"--> SSLHandshakeException occurred (Handshake phase failure).",
55+
exception
56+
)
57+
}
58+
59+
is SSLException -> {
60+
Log.e(TAG, "--> General SSLException occurred.", exception)
61+
}
62+
63+
// --- Specific Connection/Network Issues ---
64+
is ConnectException -> {
65+
// TCP connection attempt failure (e.g., connection refused)
66+
Log.e(
67+
TAG,
68+
"--> ConnectException occurred (Connection refused/TCP layer issue?).",
69+
exception
70+
)
71+
}
72+
73+
is SocketException -> {
74+
// Catch other socket-level errors (e.g., "Broken pipe", "Socket closed")
75+
Log.e(
76+
TAG,
77+
"--> SocketException occurred (Post-connection socket issue?).",
78+
exception
79+
)
80+
}
81+
82+
is SocketTimeoutException -> {
83+
// Timeout during connection or read/write
84+
Log.e(TAG, "--> Socket Timeout occurred.", exception)
85+
}
86+
87+
is UnknownHostException -> {
88+
// DNS resolution failure
89+
Log.e(TAG, "--> Unknown Host Exception (DNS issue?).", exception)
90+
}
91+
92+
is EOFException -> {
93+
// Often indicates connection closed unexpectedly
94+
Log.w(
95+
TAG,
96+
"--> EOFException occurred (Connection closed unexpectedly?).",
97+
exception
98+
)
99+
}
100+
101+
// --- General I/O Catch-all ---
102+
is IOException -> {
103+
// Catches other IOExceptions (like stream errors, etc.) not handled above
104+
Log.e(TAG, "--> General IOException occurred.", exception)
105+
}
106+
107+
// --- Non-I/O Catch-all ---
108+
else -> {
109+
Log.e(TAG, "--> An unexpected non-IOException occurred.", exception)
110+
}
111+
}
112+
}
113+
}

mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/TrackingPage.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ fun TrackingPage(navController: NavHostController) {
3131
val dialogMessage = remember { mutableStateOf("") }
3232
val context = LocalContext.current
3333
val mixpanel = MixpanelAPI.getInstance(context, MIXPANEL_PROJECT_TOKEN, true)
34+
mixpanel.setEnableLogging(true)
35+
mixpanel.setShouldGzipRequestPayload(true)
36+
mixpanel.setNetworkErrorListener(SimpleLoggingErrorListener())
3437

3538
val trackingActions = listOf(
3639
Triple("Track w/o Properties" ,"Event: \"Track Event!\"", { println("Tracking without properties")

src/main/java/com/mixpanel/android/mpmetrics/AnalyticsMessages.java

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import com.mixpanel.android.util.HttpService;
1414
import com.mixpanel.android.util.LegacyVersionUtils;
1515
import com.mixpanel.android.util.MPLog;
16+
import com.mixpanel.android.util.MixpanelNetworkErrorListener;
1617
import com.mixpanel.android.util.RemoteService;
1718

1819
import org.json.JSONException;
@@ -22,7 +23,6 @@
2223
import java.io.IOException;
2324
import java.io.UnsupportedEncodingException;
2425
import java.net.MalformedURLException;
25-
import java.net.SocketTimeoutException;
2626
import java.util.HashMap;
2727
import java.util.Iterator;
2828
import java.util.Map;
@@ -77,6 +77,10 @@ public static AnalyticsMessages getInstance(final Context messageContext, MPConf
7777
}
7878
}
7979

80+
public void setNetworkErrorListener(MixpanelNetworkErrorListener errorListener) {
81+
mNetworkErrorListener = errorListener;
82+
}
83+
8084
public void eventsMessage(final EventDescription eventDescription) {
8185
final Message m = Message.obtain();
8286
m.what = ENQUEUE_EVENTS;
@@ -171,7 +175,7 @@ protected MPDbAdapter makeDbAdapter(Context context) {
171175
}
172176

173177
protected RemoteService getPoster() {
174-
return new HttpService(mConfig.shouldGzipRequestPayload());
178+
return new HttpService(mConfig.shouldGzipRequestPayload(), mNetworkErrorListener);
175179
}
176180

177181
////////////////////////////////////////////////////
@@ -542,9 +546,6 @@ private void sendData(MPDbAdapter dbAdapter, String token, MPDbAdapter.Table tab
542546
logAboutMessageToMixpanel("Cannot post message to " + url + ".", e);
543547
deleteEvents = false;
544548
mTrackEngageRetryAfter = e.getRetryAfter() * 1000;
545-
} catch (final SocketTimeoutException e) {
546-
logAboutMessageToMixpanel("Cannot post message to " + url + ".", e);
547-
deleteEvents = false;
548549
} catch (final IOException e) {
549550
logAboutMessageToMixpanel("Cannot post message to " + url + ".", e);
550551
deleteEvents = false;
@@ -691,6 +692,7 @@ public long getTrackEngageRetryAfter() {
691692
private final String mInstanceName;
692693
protected final Context mContext;
693694
protected final MPConfig mConfig;
695+
protected MixpanelNetworkErrorListener mNetworkErrorListener;
694696

695697
// Messages for our thread
696698
private static final int ENQUEUE_PEOPLE = 0; // push given JSON message to people DB

src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import androidx.core.content.ContextCompat;
1616

1717
import com.mixpanel.android.util.MPLog;
18+
import com.mixpanel.android.util.MixpanelNetworkErrorListener;
1819
import com.mixpanel.android.util.ProxyServerInteractor;
1920

2021
import org.json.JSONArray;
@@ -572,6 +573,14 @@ public void setServerURL(String serverURL, ProxyServerInteractor callback) {
572573
mConfig.setServerURL(serverURL, callback);
573574
}
574575

576+
/**
577+
* Set the listener for network errors.
578+
* @param listener
579+
*/
580+
public void setNetworkErrorListener(MixpanelNetworkErrorListener listener) {
581+
AnalyticsMessages.getInstance(mContext, mConfig).setNetworkErrorListener(listener);
582+
}
583+
575584
public Boolean getTrackAutomaticEvents() { return mTrackAutomaticEvents; }
576585
/**
577586
* This function creates a distinct_id alias from alias to distinct_id. If distinct_id is null, then it will create an alias

src/main/java/com/mixpanel/android/util/HttpService.java

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
import java.net.HttpURLConnection;
1818
import java.net.InetAddress;
1919
import java.net.URL;
20+
import java.nio.charset.StandardCharsets;
2021
import java.util.Map;
22+
import java.util.Objects;
23+
import java.util.concurrent.TimeUnit;
2124
import java.util.zip.GZIPOutputStream;
2225

2326
import javax.net.ssl.HttpsURLConnection;
@@ -30,28 +33,33 @@ public class HttpService implements RemoteService {
3033

3134

3235
private final boolean shouldGzipRequestPayload;
36+
private final MixpanelNetworkErrorListener networkErrorListener;
3337

3438
private static boolean sIsMixpanelBlocked;
3539
private static final int MIN_UNAVAILABLE_HTTP_RESPONSE_CODE = HttpURLConnection.HTTP_INTERNAL_ERROR;
3640
private static final int MAX_UNAVAILABLE_HTTP_RESPONSE_CODE = 599;
3741

38-
public HttpService(boolean shouldGzipRequestPayload) {
42+
public HttpService(boolean shouldGzipRequestPayload, MixpanelNetworkErrorListener networkErrorListener) {
3943
this.shouldGzipRequestPayload = shouldGzipRequestPayload;
44+
this.networkErrorListener = networkErrorListener;
4045
}
4146

4247
public HttpService() {
43-
this(false);
48+
this(false, null);
4449
}
4550
@Override
4651
public void checkIsMixpanelBlocked() {
4752
Thread t = new Thread(new Runnable() {
4853
public void run() {
4954
try {
50-
InetAddress apiMixpanelInet = InetAddress.getByName("api.mixpanel.com");
55+
long startTimeNanos = System.nanoTime();
56+
String host = "api.mixpanel.com";
57+
InetAddress apiMixpanelInet = InetAddress.getByName(host);
5158
sIsMixpanelBlocked = apiMixpanelInet.isLoopbackAddress() ||
5259
apiMixpanelInet.isAnyLocalAddress();
5360
if (sIsMixpanelBlocked) {
5461
MPLog.v(LOGTAG, "AdBlocker is enabled. Won't be able to use Mixpanel services.");
62+
onNetworkError(null, host, apiMixpanelInet.getHostAddress(), startTimeNanos, -1, -1, new IOException(host + " is blocked"));
5563
}
5664
} catch (Exception e) {
5765
}
@@ -115,11 +123,18 @@ public byte[] performRequest(String endpointUrl, ProxyServerInteractor interacto
115123
while (retries < 3 && !succeeded) {
116124
InputStream in = null;
117125
OutputStream out = null;
118-
OutputStream bout = null;
119126
HttpURLConnection connection = null;
120127

128+
String targetIpAddress = null;
129+
long startTimeNanos = System.nanoTime();
130+
long uncompressedBodySize = -1;
131+
long compressedBodySize = -1;
132+
121133
try {
122134
final URL url = new URL(endpointUrl);
135+
136+
InetAddress inetAddress = InetAddress.getByName(url.getHost());
137+
targetIpAddress = inetAddress.getHostAddress();
123138
connection = (HttpURLConnection) url.openConnection();
124139
if (null != socketFactory && connection instanceof HttpsURLConnection) {
125140
((HttpsURLConnection) connection).setSSLSocketFactory(socketFactory);
@@ -136,25 +151,35 @@ public byte[] performRequest(String endpointUrl, ProxyServerInteractor interacto
136151

137152
connection.setConnectTimeout(2000);
138153
connection.setReadTimeout(30000);
154+
155+
byte[] bodyBytesToSend = null;
139156
if (null != params) {
140157
Uri.Builder builder = new Uri.Builder();
141158
for (Map.Entry<String, Object> param : params.entrySet()) {
142159
builder.appendQueryParameter(param.getKey(), param.getValue().toString());
143160
}
144161
String query = builder.build().getEncodedQuery();
162+
byte[] originalBodyBytes = Objects.requireNonNull(query).getBytes(StandardCharsets.UTF_8);
163+
uncompressedBodySize = originalBodyBytes.length;
164+
145165
if (shouldGzipRequestPayload) {
166+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
167+
try (GZIPOutputStream gzipOut = new GZIPOutputStream(baos)) {
168+
gzipOut.write(originalBodyBytes);
169+
}
170+
bodyBytesToSend = baos.toByteArray();
171+
compressedBodySize = bodyBytesToSend.length;
146172
connection.setRequestProperty(CONTENT_ENCODING_HEADER, GZIP_CONTENT_TYPE_HEADER);
173+
connection.setFixedLengthStreamingMode(compressedBodySize);
147174
} else {
148-
connection.setFixedLengthStreamingMode(query.getBytes().length);
175+
bodyBytesToSend = originalBodyBytes;
176+
connection.setFixedLengthStreamingMode(uncompressedBodySize);
149177
}
150178
connection.setDoOutput(true);
151179
connection.setRequestMethod("POST");
152180
out = connection.getOutputStream();
153-
bout = getBufferedOutputStream(out);
154-
bout.write(query.getBytes("UTF-8"));
155-
bout.flush();
156-
bout.close();
157-
bout = null;
181+
out.write(bodyBytesToSend);
182+
out.flush();
158183
out.close();
159184
out = null;
160185
}
@@ -167,18 +192,21 @@ public byte[] performRequest(String endpointUrl, ProxyServerInteractor interacto
167192
in = null;
168193
succeeded = true;
169194
} catch (final EOFException e) {
195+
onNetworkError(connection, endpointUrl, targetIpAddress, startTimeNanos, uncompressedBodySize, compressedBodySize, e);
170196
MPLog.d(LOGTAG, "Failure to connect, likely caused by a known issue with Android lib. Retrying.");
171197
retries = retries + 1;
172198
} catch (final IOException e) {
199+
onNetworkError(connection, endpointUrl, targetIpAddress, startTimeNanos, uncompressedBodySize, compressedBodySize, e);
173200
if (connection != null && connection.getResponseCode() >= MIN_UNAVAILABLE_HTTP_RESPONSE_CODE && connection.getResponseCode() <= MAX_UNAVAILABLE_HTTP_RESPONSE_CODE) {
174201
throw new ServiceUnavailableException("Service Unavailable", connection.getHeaderField("Retry-After"));
175202
} else {
176203
throw e;
177204
}
205+
} catch (final Exception e) {
206+
onNetworkError(connection, endpointUrl, targetIpAddress, startTimeNanos, uncompressedBodySize, compressedBodySize, e);
207+
throw e;
178208
}
179209
finally {
180-
if (null != bout)
181-
try { bout.close(); } catch (final IOException e) {}
182210
if (null != out)
183211
try { out.close(); } catch (final IOException e) {}
184212
if (null != in)
@@ -193,6 +221,25 @@ public byte[] performRequest(String endpointUrl, ProxyServerInteractor interacto
193221
return response;
194222
}
195223

224+
private void onNetworkError(HttpURLConnection connection, String endpointUrl, String targetIpAddress, long startTimeNanos, long uncompressedBodySize, long compressedBodySize, Exception e) {
225+
if (this.networkErrorListener != null) {
226+
long endTimeNanos = System.nanoTime();
227+
long durationMillis = TimeUnit.NANOSECONDS.toMillis(endTimeNanos - startTimeNanos);
228+
int responseCode = -1;
229+
String responseMessage = "";
230+
if (connection != null) {
231+
try {
232+
responseCode = connection.getResponseCode();
233+
responseMessage = connection.getResponseMessage();
234+
} catch (Exception respExc) {
235+
MPLog.w(LOGTAG, "Could not retrieve response code/message after error", respExc);
236+
}
237+
}
238+
String ip = (targetIpAddress == null) ? "N/A" : targetIpAddress;
239+
this.networkErrorListener.onNetworkError(endpointUrl, ip, durationMillis, uncompressedBodySize, compressedBodySize, responseCode, responseMessage, e);
240+
}
241+
}
242+
196243
private OutputStream getBufferedOutputStream(OutputStream out) throws IOException {
197244
if(shouldGzipRequestPayload) {
198245
return new GZIPOutputStream(new BufferedOutputStream(out), HTTP_OUTPUT_STREAM_BUFFER_SIZE);
@@ -225,3 +272,4 @@ private static byte[] slurp(final InputStream inputStream)
225272
private static final String CONTENT_ENCODING_HEADER = "Content-Encoding";
226273
private static final String GZIP_CONTENT_TYPE_HEADER = "gzip";
227274
}
275+
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.mixpanel.android.util;
2+
3+
public interface MixpanelNetworkErrorListener {
4+
/**
5+
* Called when a network request within the Mixpanel SDK fails.
6+
* This method may be called on a background thread.
7+
*
8+
* @param endpointUrl The URL that failed.
9+
* @param ipAddress The IP address resolved from the endpointUrl's hostname for this attempt (may be "N/A" if DNS lookup failed).
10+
* @param durationMillis The approximate duration in milliseconds from the start of this specific connection attempt until the exception was thrown.
11+
* @param uncompressedBodySize The size in bytes of the request body *before* any compression.
12+
* Will be -1 if no body was sent.
13+
* @param compressedBodySize The size in bytes of the request body *after* Gzip compression.
14+
* Will be -1 if no body was sent or if compression was not enabled
15+
* (in which case uncompressed size applies).
16+
* @param responseCode The HTTP response code returned by the server, if available.
17+
* Defaults to -1 if no response code could be retrieved (e.g., connection error).
18+
* @param responseMessage The HTTP response message returned by the server, if available.
19+
* Defaults to empty string if no response message could be retrieved.
20+
* @param exception The exception that occurred (e.g., IOException, EOFException, etc.).
21+
*/
22+
void onNetworkError(String endpointUrl, String ipAddress, long durationMillis, long uncompressedBodySize, long compressedBodySize, int responseCode, String responseMessage, Exception exception);
23+
}

0 commit comments

Comments
 (0)