Skip to content

Commit 49517e1

Browse files
authored
Add CoroutineExceptionHandler (#4259)
* Add CoroutineExceptionHandler * tests * changelog * improve * improve * Update CHANGELOG.md
1 parent df386ef commit 49517e1

File tree

12 files changed

+161
-1
lines changed

12 files changed

+161
-1
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
## Unreleased
44

5+
### Features
6+
7+
- Add `CoroutineExceptionHandler` for reporting uncaught exceptions in coroutines to Sentry ([#4259](https://github.com/getsentry/sentry-java/pull/4259))
8+
- This is now part of `sentry-kotlin-extensions` and can be used together with `SentryContext` when launching a coroutine
9+
- Any exceptions thrown in a coroutine when using the handler will be captured (not rethrown!) and reported to Sentry
10+
- It's also possible to extend `CoroutineExceptionHandler` to implement custom behavior in addition to the one we provide by default
11+
512
### Fixes
613

714
- Use thread context classloader when available ([#4320](https://github.com/getsentry/sentry-java/pull/4320))

buildSrc/src/main/java/Config.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ object Config {
121121

122122
val coroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1"
123123

124+
val coroutinesAndroid = "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1"
125+
124126
val fragment = "androidx.fragment:fragment-ktx:1.3.5"
125127

126128
val reactorCore = "io.projectreactor:reactor-core:3.5.3"
@@ -214,6 +216,7 @@ object Config {
214216
val leakCanaryInstrumentation = "com.squareup.leakcanary:leakcanary-android-instrumentation:2.14"
215217
val composeUiTestJunit4 = "androidx.compose.ui:ui-test-junit4:1.6.8"
216218
val okio = "com.squareup.okio:okio:1.13.0"
219+
val coroutinesTest = "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1"
217220
}
218221

219222
object QualityPlugins {

sentry-apollo-4/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ dependencies {
4141
testImplementation(Config.TestLibs.mockitoInline)
4242
testImplementation(Config.TestLibs.mockWebserver)
4343
testImplementation(Config.Libs.apolloKotlin4)
44-
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
44+
testImplementation(Config.TestLibs.coroutinesTest)
4545
testImplementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0")
4646
}
4747

sentry-kotlin-extensions/api/sentry-kotlin-extensions.api

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,10 @@ public final class io/sentry/kotlin/SentryContext : kotlin/coroutines/AbstractCo
1010
public synthetic fun updateThreadContext (Lkotlin/coroutines/CoroutineContext;)Ljava/lang/Object;
1111
}
1212

13+
public class io/sentry/kotlin/SentryCoroutineExceptionHandler : kotlin/coroutines/AbstractCoroutineContextElement, kotlinx/coroutines/CoroutineExceptionHandler {
14+
public fun <init> ()V
15+
public fun <init> (Lio/sentry/IScopes;)V
16+
public synthetic fun <init> (Lio/sentry/IScopes;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
17+
public fun handleException (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Throwable;)V
18+
}
19+

sentry-kotlin-extensions/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dependencies {
3030
testImplementation(Config.TestLibs.kotlinTestJunit)
3131
testImplementation(Config.TestLibs.mockitoKotlin)
3232
testImplementation(Config.Libs.coroutinesCore)
33+
testImplementation(Config.TestLibs.coroutinesTest)
3334
}
3435

3536
configure<SourceSetContainer> {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package io.sentry.kotlin
2+
3+
import io.sentry.IScopes
4+
import io.sentry.ScopesAdapter
5+
import io.sentry.SentryEvent
6+
import io.sentry.SentryLevel
7+
import io.sentry.exception.ExceptionMechanismException
8+
import io.sentry.protocol.Mechanism
9+
import kotlinx.coroutines.CoroutineExceptionHandler
10+
import org.jetbrains.annotations.ApiStatus
11+
import kotlin.coroutines.AbstractCoroutineContextElement
12+
import kotlin.coroutines.CoroutineContext
13+
14+
/**
15+
* Captures exceptions thrown in coroutines (without rethrowing them) and reports them to Sentry as errors.
16+
*/
17+
@ApiStatus.Experimental
18+
public open class SentryCoroutineExceptionHandler(private val scopes: IScopes = ScopesAdapter.getInstance()) :
19+
AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler {
20+
21+
override fun handleException(context: CoroutineContext, exception: Throwable) {
22+
val mechanism = Mechanism().apply {
23+
type = "CoroutineExceptionHandler"
24+
}
25+
// the current thread is not necessarily the one that threw the exception
26+
val error = ExceptionMechanismException(mechanism, exception, Thread.currentThread())
27+
val event = SentryEvent(error)
28+
event.level = SentryLevel.ERROR
29+
scopes.captureEvent(event)
30+
}
31+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package io.sentry.kotlin
2+
3+
import io.sentry.IScopes
4+
import kotlinx.coroutines.GlobalScope
5+
import kotlinx.coroutines.async
6+
import kotlinx.coroutines.launch
7+
import kotlinx.coroutines.test.runTest
8+
import org.mockito.kotlin.check
9+
import org.mockito.kotlin.mock
10+
import org.mockito.kotlin.verify
11+
import kotlin.test.Test
12+
import kotlin.test.assertSame
13+
import kotlin.test.assertTrue
14+
15+
class SentryCoroutineExceptionHandlerTest {
16+
17+
class Fixture {
18+
val scopes = mock<IScopes>()
19+
20+
fun getSut(): SentryCoroutineExceptionHandler {
21+
return SentryCoroutineExceptionHandler(scopes)
22+
}
23+
}
24+
25+
@Test
26+
fun `captures unhandled exception in launch coroutine`() = runTest {
27+
val fixture = Fixture()
28+
val handler = fixture.getSut()
29+
val exception = RuntimeException("test")
30+
31+
GlobalScope.launch(handler) {
32+
throw exception
33+
}.join()
34+
35+
verify(fixture.scopes).captureEvent(
36+
check {
37+
assertSame(exception, it.throwable)
38+
}
39+
)
40+
}
41+
42+
@Test
43+
fun `captures unhandled exception in launch coroutine with child`() = runTest {
44+
val fixture = Fixture()
45+
val handler = fixture.getSut()
46+
val exception = RuntimeException("test")
47+
48+
GlobalScope.launch(handler) {
49+
launch {
50+
throw exception
51+
}.join()
52+
}.join()
53+
54+
verify(fixture.scopes).captureEvent(
55+
check {
56+
assertSame(exception, it.throwable)
57+
}
58+
)
59+
}
60+
61+
@Test
62+
fun `captures unhandled exception in async coroutine`() = runTest {
63+
val fixture = Fixture()
64+
val handler = fixture.getSut()
65+
val exception = RuntimeException("test")
66+
67+
val deferred = GlobalScope.async() {
68+
throw exception
69+
}
70+
GlobalScope.launch(handler) {
71+
deferred.await()
72+
}.join()
73+
74+
verify(fixture.scopes).captureEvent(
75+
check {
76+
assertTrue { exception.toString().equals(it.throwable.toString()) } // stack trace will differ
77+
}
78+
)
79+
}
80+
}

sentry-samples/sentry-samples-android/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,5 +150,8 @@ dependencies {
150150
implementation(Config.Libs.composeCoil)
151151
implementation(Config.Libs.sentryNativeNdk)
152152

153+
implementation(projects.sentryKotlinExtensions)
154+
implementation(Config.Libs.coroutinesAndroid)
155+
153156
debugImplementation(Config.Libs.leakCanary)
154157
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package io.sentry.samples.android
2+
3+
import io.sentry.kotlin.SentryContext
4+
import io.sentry.kotlin.SentryCoroutineExceptionHandler
5+
import kotlinx.coroutines.GlobalScope
6+
import kotlinx.coroutines.launch
7+
import java.lang.RuntimeException
8+
9+
object CoroutinesUtil {
10+
11+
fun throwInCoroutine() {
12+
GlobalScope.launch(SentryContext() + SentryCoroutineExceptionHandler()) {
13+
throw RuntimeException("Exception in coroutine")
14+
}
15+
}
16+
}

sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,11 @@ public void run() {
270270
binding.openFrameDataForSpans.setOnClickListener(
271271
view -> startActivity(new Intent(this, FrameDataForSpansActivity.class)));
272272

273+
binding.throwInCoroutine.setOnClickListener(
274+
view -> {
275+
CoroutinesUtil.INSTANCE.throwInCoroutine();
276+
});
277+
273278
setContentView(binding.getRoot());
274279
}
275280

sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,12 @@
148148
android:layout_height="wrap_content"
149149
android:text="@string/open_frame_data_for_spans"/>
150150

151+
<Button
152+
android:id="@+id/throw_in_coroutine"
153+
android:layout_width="wrap_content"
154+
android:layout_height="wrap_content"
155+
android:text="@string/throw_in_coroutine"/>
156+
151157
</LinearLayout>
152158

153159
</ScrollView>

sentry-samples/sentry-samples-android/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
<string name="open_frame_data_for_spans">Open Frame Data for Spans Activity</string>
2727
<string name="open_metrics">Delightful Developer Metrics</string>
2828
<string name="test_timber_integration">Test Timber</string>
29+
<string name="throw_in_coroutine">Throw exception in coroutine</string>
2930
<string name="back_main">Back to Main Activity</string>
3031
<string name="tap_me">text</string>
3132
<string name="lipsum">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin nibh lorem, venenatis sed nulla vel, venenatis sodales augue. Mauris varius elit eu ligula volutpat, sed tincidunt orci porttitor. Donec et dignissim lacus, sed luctus ipsum. Praesent ornare luctus tortor sit amet ultricies. Cras iaculis et diam et vulputate. Cras ut iaculis mauris, non pellentesque diam. Nunc in laoreet diam, vitae accumsan eros. Morbi non nunc ac eros molestie placerat vitae id dolor. Quisque ornare aliquam ipsum, a dapibus tortor. In eu sodales tellus.

0 commit comments

Comments
 (0)