Skip to content

Add CoroutineExceptionHandler #4259

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Apr 11, 2025
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

### Features

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

### Fixes

- Use thread context classloader when available ([#4320](https://github.com/getsentry/sentry-java/pull/4320))
Expand Down
3 changes: 3 additions & 0 deletions buildSrc/src/main/java/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ object Config {

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

val coroutinesAndroid = "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1"

val fragment = "androidx.fragment:fragment-ktx:1.3.5"

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

object QualityPlugins {
Expand Down
2 changes: 1 addition & 1 deletion sentry-apollo-4/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ dependencies {
testImplementation(Config.TestLibs.mockitoInline)
testImplementation(Config.TestLibs.mockWebserver)
testImplementation(Config.Libs.apolloKotlin4)
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation(Config.TestLibs.coroutinesTest)
testImplementation("org.jetbrains.kotlin:kotlin-reflect:2.0.0")
}

Expand Down
7 changes: 7 additions & 0 deletions sentry-kotlin-extensions/api/sentry-kotlin-extensions.api
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,10 @@ public final class io/sentry/kotlin/SentryContext : kotlin/coroutines/AbstractCo
public synthetic fun updateThreadContext (Lkotlin/coroutines/CoroutineContext;)Ljava/lang/Object;
}

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

1 change: 1 addition & 0 deletions sentry-kotlin-extensions/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies {
testImplementation(Config.TestLibs.kotlinTestJunit)
testImplementation(Config.TestLibs.mockitoKotlin)
testImplementation(Config.Libs.coroutinesCore)
testImplementation(Config.TestLibs.coroutinesTest)
}

configure<SourceSetContainer> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package io.sentry.kotlin

import io.sentry.IScopes
import io.sentry.ScopesAdapter
import io.sentry.SentryEvent
import io.sentry.SentryLevel
import io.sentry.exception.ExceptionMechanismException
import io.sentry.protocol.Mechanism
import kotlinx.coroutines.CoroutineExceptionHandler
import org.jetbrains.annotations.ApiStatus
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext

/**
* Captures exceptions thrown in coroutines (without rethrowing them) and reports them to Sentry as errors.
*/
@ApiStatus.Experimental
public open class SentryCoroutineExceptionHandler(private val scopes: IScopes = ScopesAdapter.getInstance()) :
AbstractCoroutineContextElement(CoroutineExceptionHandler), CoroutineExceptionHandler {

override fun handleException(context: CoroutineContext, exception: Throwable) {
val mechanism = Mechanism().apply {
type = "CoroutineExceptionHandler"
}
// the current thread is not necessarily the one that threw the exception
val error = ExceptionMechanismException(mechanism, exception, Thread.currentThread())
val event = SentryEvent(error)
event.level = SentryLevel.ERROR
scopes.captureEvent(event)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package io.sentry.kotlin

import io.sentry.IScopes
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.mockito.kotlin.check
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import kotlin.test.Test
import kotlin.test.assertSame
import kotlin.test.assertTrue

class SentryCoroutineExceptionHandlerTest {

class Fixture {
val scopes = mock<IScopes>()

fun getSut(): SentryCoroutineExceptionHandler {
return SentryCoroutineExceptionHandler(scopes)
}
}

@Test
fun `captures unhandled exception in launch coroutine`() = runTest {
val fixture = Fixture()
val handler = fixture.getSut()
val exception = RuntimeException("test")

GlobalScope.launch(handler) {
throw exception
}.join()

verify(fixture.scopes).captureEvent(
check {
assertSame(exception, it.throwable)
}
)
}

@Test
fun `captures unhandled exception in launch coroutine with child`() = runTest {
val fixture = Fixture()
val handler = fixture.getSut()
val exception = RuntimeException("test")

GlobalScope.launch(handler) {
launch {
throw exception
}.join()
}.join()

verify(fixture.scopes).captureEvent(
check {
assertSame(exception, it.throwable)
}
)
}

@Test
fun `captures unhandled exception in async coroutine`() = runTest {
val fixture = Fixture()
val handler = fixture.getSut()
val exception = RuntimeException("test")

val deferred = GlobalScope.async() {
throw exception
}
GlobalScope.launch(handler) {
deferred.await()
}.join()

verify(fixture.scopes).captureEvent(
check {
assertTrue { exception.toString().equals(it.throwable.toString()) } // stack trace will differ
}
)
}
}
3 changes: 3 additions & 0 deletions sentry-samples/sentry-samples-android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -150,5 +150,8 @@ dependencies {
implementation(Config.Libs.composeCoil)
implementation(Config.Libs.sentryNativeNdk)

implementation(projects.sentryKotlinExtensions)
implementation(Config.Libs.coroutinesAndroid)

debugImplementation(Config.Libs.leakCanary)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.sentry.samples.android

import io.sentry.kotlin.SentryContext
import io.sentry.kotlin.SentryCoroutineExceptionHandler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.lang.RuntimeException

object CoroutinesUtil {

fun throwInCoroutine() {
GlobalScope.launch(SentryContext() + SentryCoroutineExceptionHandler()) {
throw RuntimeException("Exception in coroutine")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,11 @@ public void run() {
binding.openFrameDataForSpans.setOnClickListener(
view -> startActivity(new Intent(this, FrameDataForSpansActivity.class)));

binding.throwInCoroutine.setOnClickListener(
view -> {
CoroutinesUtil.INSTANCE.throwInCoroutine();
});

setContentView(binding.getRoot());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@
android:layout_height="wrap_content"
android:text="@string/open_frame_data_for_spans"/>

<Button
android:id="@+id/throw_in_coroutine"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/throw_in_coroutine"/>

</LinearLayout>

</ScrollView>
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
<string name="open_frame_data_for_spans">Open Frame Data for Spans Activity</string>
<string name="open_metrics">Delightful Developer Metrics</string>
<string name="test_timber_integration">Test Timber</string>
<string name="throw_in_coroutine">Throw exception in coroutine</string>
<string name="back_main">Back to Main Activity</string>
<string name="tap_me">text</string>
<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.
Expand Down
Loading