Skip to content

Commit 5293677

Browse files
committed
Tighten up Sentry error reporting significantly
1 parent 65533b4 commit 5293677

5 files changed

Lines changed: 123 additions & 36 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@
112112

113113
<meta-data android:name="search-engine" android:resource="@xml/noindex" />
114114

115+
<meta-data android:name="io.sentry.auto-init" android:value="false" />
115116
<meta-data android:name="io.sentry.enabled" android:value="${sentryEnabled}" />
116117
<meta-data android:name="io.sentry.dsn" android:value="${sentryDsn}" />
117118
</application>

app/src/main/java/tech/httptoolkit/android/HttpToolkitApplication.kt

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import com.android.installreferrer.api.InstallReferrerClient.InstallReferrerResp
1212
import com.android.installreferrer.api.InstallReferrerStateListener
1313
import com.beust.klaxon.Json
1414
import com.beust.klaxon.Klaxon
15-
import io.sentry.Sentry
1615
import kotlinx.coroutines.Dispatchers
1716
import kotlinx.coroutines.withContext
1817
import net.swiftzer.semver.SemVer
@@ -71,19 +70,19 @@ class HttpToolkitApplication : Application() {
7170
super.onCreate()
7271
prefs = getSharedPreferences(HTTP_TOOLKIT_PREFERENCES_NAME, MODE_PRIVATE)
7372

74-
Thread.setDefaultUncaughtExceptionHandler { _, _ ->
73+
initSentry(this)
74+
75+
val previousUncaughtHandler = Thread.getDefaultUncaughtExceptionHandler()
76+
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
7577
prefs.edit { putBoolean(APP_CRASHED_PREF, true) }
78+
previousUncaughtHandler?.uncaughtException(thread, throwable)
7679
}
7780

7881
// Check if we've been recreated unexpectedly, with no crashes in the meantime:
7982
val appCrashed = prefs.getBoolean(APP_CRASHED_PREF, false)
8083
prefs.edit { putBoolean(APP_CRASHED_PREF, false) }
8184

8285
vpnWasKilled = vpnShouldBeRunning && !isVpnActive() && !appCrashed && !isProbablyEmulator
83-
if (vpnWasKilled) {
84-
Sentry.captureMessage("VPN killed in the background")
85-
// The UI will show an alert next time the MainActivity is created.
86-
}
8786

8887
Log.i(TAG, "App created")
8988
}

app/src/main/java/tech/httptoolkit/android/ProxyVpnRunnable.kt

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,9 @@ import tech.httptoolkit.android.vpn.SessionHandler
88
import tech.httptoolkit.android.vpn.SessionManager
99
import tech.httptoolkit.android.vpn.socket.SocketNIODataService
1010
import io.sentry.Sentry
11-
import tech.httptoolkit.android.vpn.transport.PacketHeaderException
1211
import java.io.FileInputStream
1312
import java.io.FileOutputStream
1413
import java.io.InterruptedIOException
15-
import java.net.ConnectException
1614
import java.net.InetSocketAddress
1715
import java.nio.ByteBuffer
1816

@@ -80,23 +78,8 @@ class ProxyVpnRunnable(
8078
packet.limit(length)
8179
handler.handlePacket(packet)
8280
} catch (e: Exception) {
83-
val errorMessage = (e.message ?: e.toString())
84-
Log.e(TAG, errorMessage)
85-
86-
val isIgnorable =
87-
(e is ConnectException && errorMessage == "Permission denied") ||
88-
// Nothing we can do if the internet goes down:
89-
(e is ConnectException && errorMessage == "Network is unreachable") ||
90-
(e is ConnectException && errorMessage.contains("ENETUNREACH")) ||
91-
// Too many open files - can't make more sockets, not much we can do:
92-
(e is ConnectException && errorMessage == "Too many open files") ||
93-
(e is ConnectException && errorMessage.contains("EMFILE")) ||
94-
// IPv6 is not supported here yet:
95-
(e is PacketHeaderException && errorMessage.contains("IP version should be 4 but was 6"))
96-
97-
if (!isIgnorable) {
98-
Sentry.captureException(e)
99-
}
81+
Log.e(TAG, e.message ?: e.toString())
82+
Sentry.captureException(e)
10083
}
10184

10285
packet.clear()
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package tech.httptoolkit.android
2+
3+
import android.content.Context
4+
import io.sentry.SentryEvent
5+
import io.sentry.SentryOptions
6+
import io.sentry.android.core.SentryAndroid
7+
import tech.httptoolkit.android.vpn.transport.PacketHeaderException
8+
import java.io.IOException
9+
import java.net.BindException
10+
import java.net.ConnectException
11+
import java.net.SocketException
12+
import java.net.SocketTimeoutException
13+
import java.security.cert.CertificateException
14+
15+
/**
16+
* Central Sentry configuration. We initialize manually (auto-init is disabled in the
17+
* manifest via io.sentry.auto-init) so that we can attach a beforeSend hook. This is the
18+
* single place where we filter out expected/unactionable noise and collapse high-cardinality
19+
* issues, rather than scattering ignore-checks across individual capture call sites.
20+
*
21+
* The DSN and enabled flag are still read from the manifest meta-data, so the existing
22+
* build-type gating (reporting only in release builds) continues to apply unchanged.
23+
*/
24+
fun initSentry(context: Context) {
25+
SentryAndroid.init(context) { options ->
26+
options.beforeSend = SentryOptions.BeforeSendCallback { event, _ ->
27+
if (shouldDropEvent(event)) {
28+
null
29+
} else {
30+
normalizeEvent(event)
31+
event
32+
}
33+
}
34+
}
35+
}
36+
37+
private fun shouldDropEvent(event: SentryEvent): Boolean {
38+
val throwable = event.throwable
39+
return throwable != null && causedByIgnorableException(throwable)
40+
}
41+
42+
/**
43+
* Walks the full cause chain, so an expected error wrapped in something else is still dropped.
44+
*/
45+
private fun causedByIgnorableException(throwable: Throwable): Boolean {
46+
var current: Throwable? = throwable
47+
val seen = HashSet<Throwable>()
48+
while (current != null && seen.add(current)) {
49+
if (isIgnorableException(current)) return true
50+
current = current.cause
51+
}
52+
return false
53+
}
54+
55+
private fun isIgnorableException(e: Throwable): Boolean {
56+
val message = e.message ?: ""
57+
return when (e) {
58+
// Plain connection failures: the upstream/proxy was unreachable, timed out, or the
59+
// local address couldn't be bound. All expected for a VPN proxy, nothing to fix here.
60+
is SocketTimeoutException -> true
61+
is ConnectException -> true
62+
is BindException -> true
63+
64+
// IPv6 isn't supported by our packet parsing yet - known and unactionable.
65+
is PacketHeaderException -> message.contains("IP version should be 4 but was 6")
66+
67+
// Mid-connection socket failures and file-descriptor exhaustion, all expected operationally.
68+
is SocketException ->
69+
message.contains("Connection reset") ||
70+
message.contains("Broken pipe") ||
71+
message.contains("EPIPE") ||
72+
message.contains("ENETUNREACH") ||
73+
message.contains("Network is unreachable") ||
74+
message.contains("EMFILE") ||
75+
message.contains("Too many open files")
76+
77+
is IOException ->
78+
message.contains("unexpected end of stream") ||
79+
message.contains("Too many open files")
80+
81+
// Android 12+ forbids starting our foreground service from the background. This is a
82+
// platform restriction we can't avoid here, so we don't report it as a crash.
83+
// (BackgroundServiceStartNotAllowedException is itself an IllegalStateException.)
84+
is IllegalStateException -> message.contains("Not allowed to start service")
85+
86+
else -> false
87+
}
88+
}
89+
90+
/**
91+
* Collapse known high-cardinality issues into a single group by giving them a stable
92+
* fingerprint, instead of letting dynamic values in the message split one underlying
93+
* problem into thousands of separate Sentry issues.
94+
*/
95+
private fun normalizeEvent(event: SentryEvent) {
96+
val throwable = event.throwable ?: return
97+
if (
98+
throwable is CertificateException &&
99+
(throwable.message ?: "").contains("Proxy returned mismatched certificate")
100+
) {
101+
// The message embeds the (always different) cert fingerprints, so group by a fixed key.
102+
event.fingerprints = listOf("proxy-cert-mismatch")
103+
}
104+
}

app/src/main/java/tech/httptoolkit/android/main/MainActivity.kt

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,6 @@ import com.google.android.gms.common.GooglePlayServicesUtil
3434
import com.google.android.material.dialog.MaterialAlertDialogBuilder
3535
import io.sentry.Sentry
3636
import kotlinx.coroutines.*
37-
import java.net.ConnectException
38-
import java.net.SocketTimeoutException
3937
import java.security.cert.Certificate
4038
import java.security.cert.X509Certificate
4139
import androidx.core.net.toUri
@@ -442,10 +440,7 @@ class MainActivity : ComponentActivity(), CoroutineScope by MainScope() {
442440

443441
mainState = ConnectionState.FAILED
444442

445-
// We report errors only that aren't simple connection failures
446-
if (e !is SocketTimeoutException && e !is ConnectException) {
447-
Sentry.captureException(e)
448-
}
443+
Sentry.captureException(e)
449444
}
450445
}
451446

@@ -572,8 +567,16 @@ class MainActivity : ComponentActivity(), CoroutineScope by MainScope() {
572567
// If we tried to enable notifications, and it didn't work (the user
573568
// ignored us) then try try again.
574569
requestNotificationPermission(true)
570+
} else if (resultCode == RESULT_CANCELED) {
571+
mainState = ConnectionState.DISCONNECTED
575572
} else {
576-
Sentry.captureMessage("Non-OK result $resultCode for requestCode $requestCode")
573+
val requestName = when (requestCode) {
574+
START_VPN_REQUEST -> "start-vpn"
575+
INSTALL_CERT_REQUEST -> "install-cert"
576+
ENABLE_NOTIFICATIONS_REQUEST -> "enable-notifications"
577+
else -> "other"
578+
}
579+
Sentry.captureMessage("Non-OK result $resultCode for $requestName request")
577580
mainState = ConnectionState.FAILED
578581
}
579582
}
@@ -614,10 +617,7 @@ class MainActivity : ComponentActivity(), CoroutineScope by MainScope() {
614617

615618
mainState = ConnectionState.FAILED
616619

617-
// We report errors only that aren't simple connection failures
618-
if (e !is SocketTimeoutException && e !is ConnectException) {
619-
Sentry.captureException(e)
620-
}
620+
Sentry.captureException(e)
621621
}
622622
}
623623
}

0 commit comments

Comments
 (0)