diff --git a/CHANGELOG.md b/CHANGELOG.md index 049b2a818da..662aab793a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index d5f2e5029df..b379a389d82 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -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" @@ -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 { diff --git a/sentry-apollo-4/build.gradle.kts b/sentry-apollo-4/build.gradle.kts index 6e8c292966b..e591c7ec0c6 100644 --- a/sentry-apollo-4/build.gradle.kts +++ b/sentry-apollo-4/build.gradle.kts @@ -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") } diff --git a/sentry-kotlin-extensions/api/sentry-kotlin-extensions.api b/sentry-kotlin-extensions/api/sentry-kotlin-extensions.api index 0555383c1b1..e11ec192499 100644 --- a/sentry-kotlin-extensions/api/sentry-kotlin-extensions.api +++ b/sentry-kotlin-extensions/api/sentry-kotlin-extensions.api @@ -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 ()V + public fun (Lio/sentry/IScopes;)V + public synthetic fun (Lio/sentry/IScopes;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun handleException (Lkotlin/coroutines/CoroutineContext;Ljava/lang/Throwable;)V +} + diff --git a/sentry-kotlin-extensions/build.gradle.kts b/sentry-kotlin-extensions/build.gradle.kts index c8aa448e511..6d66ed250a9 100644 --- a/sentry-kotlin-extensions/build.gradle.kts +++ b/sentry-kotlin-extensions/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { testImplementation(Config.TestLibs.kotlinTestJunit) testImplementation(Config.TestLibs.mockitoKotlin) testImplementation(Config.Libs.coroutinesCore) + testImplementation(Config.TestLibs.coroutinesTest) } configure { diff --git a/sentry-kotlin-extensions/src/main/java/io/sentry/kotlin/SentryCoroutineExceptionHandler.kt b/sentry-kotlin-extensions/src/main/java/io/sentry/kotlin/SentryCoroutineExceptionHandler.kt new file mode 100644 index 00000000000..9fc4a23d2e1 --- /dev/null +++ b/sentry-kotlin-extensions/src/main/java/io/sentry/kotlin/SentryCoroutineExceptionHandler.kt @@ -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) + } +} diff --git a/sentry-kotlin-extensions/src/test/java/io/sentry/kotlin/SentryCoroutineExceptionHandlerTest.kt b/sentry-kotlin-extensions/src/test/java/io/sentry/kotlin/SentryCoroutineExceptionHandlerTest.kt new file mode 100644 index 00000000000..1f21105dcf1 --- /dev/null +++ b/sentry-kotlin-extensions/src/test/java/io/sentry/kotlin/SentryCoroutineExceptionHandlerTest.kt @@ -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() + + 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 + } + ) + } +} diff --git a/sentry-samples/sentry-samples-android/build.gradle.kts b/sentry-samples/sentry-samples-android/build.gradle.kts index cba0dfd2d77..4af4e0eac26 100644 --- a/sentry-samples/sentry-samples-android/build.gradle.kts +++ b/sentry-samples/sentry-samples-android/build.gradle.kts @@ -150,5 +150,8 @@ dependencies { implementation(Config.Libs.composeCoil) implementation(Config.Libs.sentryNativeNdk) + implementation(projects.sentryKotlinExtensions) + implementation(Config.Libs.coroutinesAndroid) + debugImplementation(Config.Libs.leakCanary) } diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/CoroutinesUtil.kt b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/CoroutinesUtil.kt new file mode 100644 index 00000000000..6e43574819d --- /dev/null +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/CoroutinesUtil.kt @@ -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") + } + } +} diff --git a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java index a4085bf8225..802e765a9e4 100644 --- a/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java +++ b/sentry-samples/sentry-samples-android/src/main/java/io/sentry/samples/android/MainActivity.java @@ -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()); } diff --git a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml index b8a47c6bd59..71c8059d588 100644 --- a/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml +++ b/sentry-samples/sentry-samples-android/src/main/res/layout/activity_main.xml @@ -148,6 +148,12 @@ android:layout_height="wrap_content" android:text="@string/open_frame_data_for_spans"/> +