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

0 commit comments

Comments
 (0)