Better Stack Traces with Coroutines

Better Stack Traces with Coroutines

Have you ever encountered a stack trace involving coroutines that made you scratch your head in confusion? If so, you're not alone. Coroutines can make stack traces more complicated, but with the right approach, you can make them much easier to understand. In this post, we'll explore how to use a custom coroutine exception handler to improve stack traces involving coroutines.

First, let's start with a quick overview of what coroutines are. Coroutines are a type of concurrency primitive that allow you to write asynchronous code in a synchronous style. In other words, you can write code that looks like it's running synchronously, but it's actually running asynchronously under the hood.

One of the benefits of coroutines is that they can simplify asynchronous code by eliminating the need for callbacks or promises. However, coroutines can also make stack traces more complicated because they involve multiple points of suspension and resumption. This can make it difficult to trace the path of execution through your code.

To make stack traces involving coroutines easier to understand, you can use a custom coroutine exception handler. A coroutine exception handler is a mechanism that allows you to intercept and handle exceptions that occur within coroutines. By default, when an exception is thrown in a coroutine, it will propagate up the call stack until it reaches the top-level coroutine, where it will be re-thrown as an unhandled exception. This can make it difficult to pinpoint the exact location where the exception occurred.

With a custom coroutine exception handler, you can intercept the exception as soon as it occurs and take action to handle it. This allows you to add additional context to the exception, such as the coroutine stack trace, which can make it much easier to understand what went wrong.

Here's an example of how you can use a custom coroutine exception handler with a custom exception postpended to the original exception to improve stack traces involving coroutines:

    internal fun createBreadcrumbsExceptionHandler(): CoroutineExceptionHandler {
        val breadcrumbsException = createBreadcrumbsException()

        return CoroutineExceptionHandler { _, throwable ->
            val currentThread = Thread.currentThread()
            run {
                currentThread.uncaughtExceptionHandler ?: Thread.getDefaultUncaughtExceptionHandler()
            }.uncaughtException(
                currentThread,
                throwable.addBreadcrumbs(breadcrumbsException),
            )
        }
    }

    private fun Throwable.addBreadcrumbs(
        breadcrumbsException: BreadcrumbException,
    ): Throwable {
    val capturedThrowable = this

    // create a new instance of the breadcrumb exception with the original breadcrumb message
    // but with the cause of the throwable we are adding the breadcrumb to
    val newBreadcrumbWithOriginalThrowable =
      breadcrumbsException::class
        .java
        .getDeclaredConstructor(String::class.java, Throwable::class.java)
        .newInstance(breadcrumbsException.message, capturedThrowable.cause)
        .apply { stackTrace = breadcrumbsException.stackTrace }

    // create a new instance of the original throwable but with the breadcrumb exception as the cause
    return this::class
      .java
      .getDeclaredConstructor(String::class.java, Throwable::class.java)
      .newInstance(message, newBreadcrumbWithOriginalThrowable)
      .apply { stackTrace = capturedThrowable.stackTrace }
    }

In the code above, we've declared a method for creating a custom coroutine exception handler where we add a custom BreadcrumbException to the original uncaught exception. This custom BreadcrumbException will be responsible for adding additional information to the stack trace that will better point us to the problem code in our stack trace.

The BreadcrumbException is defined as follows:

    private fun createBreadcrumbsException() =
        BreadcrumbException()
            .apply {
                val queue: Queue<StackTraceElement> = LinkedList(stackTrace.asList())

                val iterator = queue.iterator()
                while (iterator.hasNext()) {
                    val next = iterator.next()

                    // we need to remove all of the 'helper' classes from the stack trace
                    // as that really just adds noise and isn't overly helpful
                    // `CoroutinesBreadcrumbExceptionHandler` is the object containing all of the functions listed so far. `FlowExtensions` and `CoroutineScopeExtensions` will make their appearance later on. 
                    if (next.className == CoroutinesBreadcrumbExceptionHandler::class.qualifiedName ||
                        next.className == FlowExtensions::class.qualifiedName ||
                        next.className == CoroutineScopeExtensions::class.qualifiedName
                    ) {
                        iterator.remove()
                    } else {
                        break
                    }
                }
                stackTrace = queue.toTypedArray()
            }

class BreadcrumbException : RuntimeException("More details about the original stack trace:")

After defining all of the code to add our custom BreadcrumbException via a custom CoroutineExceptionHandler, how do we actually use them? The first way we can use them is by creating an extension function for launching a coroutine.

object CoroutineScopeExtensions {
    fun CoroutineScope.launchWithBreadcrumbs(
        context: CoroutineContext = EmptyCoroutineContext,
        start: CoroutineStart = CoroutineStart.DEFAULT,
        block: suspend CoroutineScope.() -> Unit,
    ): Job {
        return this.launch(
            context = context + createBreadcrumbsExceptionHandler(),
            start = start,
            block = block,
        )
    }
}

This has been a lot to setup, but what does it get us? We are going to look at some code that throws an exception and some before/after comparisons of the stack traces.

First, the code:

class MainActivity : AppCompatActivity() {
    private val viewModel : MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        viewModel.testException()
    }
}
class MainViewModel : ViewModel() {

    fun testException() {
        viewModelScope.launch {
            throwException1()
        }
    }

    private suspend fun throwException1() {
        delay(300)
        throwException2()
    }

    private suspend fun throwException2() {
        delay(300)
        throwException3()
    }

    private suspend fun throwException3() {
        delay(300)
        throw IllegalArgumentException("oh no")
    }
}

The code above provides the following stack trace:

E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.joetr.coroutines, PID: 11825
    java.lang.IllegalArgumentException: oh no
        at com.joetr.coroutines.MainViewModel.throwException3(MainViewModel.kt:36)
        at com.joetr.coroutines.MainViewModel.access$throwException3(MainViewModel.kt:16)
        at com.joetr.coroutines.MainViewModel$throwException3$1.invokeSuspend(Unknown Source:14)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.DispatchedTaskKt.resume(DispatchedTask.kt:234)
        at kotlinx.coroutines.DispatchedTaskKt.dispatch(DispatchedTask.kt:166)
        at kotlinx.coroutines.CancellableContinuationImpl.dispatchResume(CancellableContinuationImpl.kt:397)
        at kotlinx.coroutines.CancellableContinuationImpl.resumeImpl(CancellableContinuationImpl.kt:431)
        at kotlinx.coroutines.CancellableContinuationImpl.resumeImpl$default(CancellableContinuationImpl.kt:420)
        at kotlinx.coroutines.CancellableContinuationImpl.resumeUndispatched(CancellableContinuationImpl.kt:518)
        at kotlinx.coroutines.android.HandlerContext$scheduleResumeAfterDelay$$inlined$Runnable$1.run(Runnable.kt:19)
        at android.os.Handler.handleCallback(Handler.java:942)
        at android.os.Handler.dispatchMessage(Handler.java:99)
        at android.os.Looper.loopOnce(Looper.java:201)
        at android.os.Looper.loop(Looper.java:288)
        at android.app.ActivityThread.main(ActivityThread.java:7898)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936)
        Suppressed: kotlinx.coroutines.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@37c4392, Dispatchers.Main.immediate]

But when we modify the code that launches the coroutine to:

    fun testException() {
        // using our custom `launchWithBreadcrumbs` method
        viewModelScope.launchWithBreadcrumbs {
            throwException1()
        }
    }

We get a stack trace with a good bit more information in it. When we examine the following stack trace, we can see references to MainActivity's onCreate and MainViewModel's testException method, which were both originally missing in the first stack trace.

E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.joetr.coroutines, PID: 12126
    java.lang.IllegalArgumentException: oh no
        at com.joetr.coroutines.MainViewModel.throwException3(MainViewModel.kt:36)
        at com.joetr.coroutines.MainViewModel.access$throwException3(MainViewModel.kt:16)
        at com.joetr.coroutines.MainViewModel$throwException3$1.invokeSuspend(Unknown Source:14)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.DispatchedTaskKt.resume(DispatchedTask.kt:234)
        at kotlinx.coroutines.DispatchedTaskKt.dispatch(DispatchedTask.kt:166)
        at kotlinx.coroutines.CancellableContinuationImpl.dispatchResume(CancellableContinuationImpl.kt:397)
        at kotlinx.coroutines.CancellableContinuationImpl.resumeImpl(CancellableContinuationImpl.kt:431)
        at kotlinx.coroutines.CancellableContinuationImpl.resumeImpl$default(CancellableContinuationImpl.kt:420)
        at kotlinx.coroutines.CancellableContinuationImpl.resumeUndispatched(CancellableContinuationImpl.kt:518)
        at kotlinx.coroutines.android.HandlerContext$scheduleResumeAfterDelay$$inlined$Runnable$1.run(Runnable.kt:19)
        at android.os.Handler.handleCallback(Handler.java:942)
        at android.os.Handler.dispatchMessage(Handler.java:99)
        at android.os.Looper.loopOnce(Looper.java:201)
        at android.os.Looper.loop(Looper.java:288)
        at android.app.ActivityThread.main(ActivityThread.java:7898)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936)
     Caused by: com.joetr.coroutines.BreadcrumbException: More details about the original stack trace:
        at com.joetr.coroutines.MainViewModel.testException(MainViewModel.kt:19)
        at com.joetr.coroutines.MainActivity.onCreate(MainActivity.kt:21)
        at android.app.Activity.performCreate(Activity.java:8290)
        at android.app.Activity.performCreate(Activity.java:8269)
        at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1384)
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3657)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3813)
        at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:101)
        at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
        at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2308)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loopOnce(Looper.java:201) 
        at android.os.Looper.loop(Looper.java:288) 
        at android.app.ActivityThread.main(ActivityThread.java:7898) 
        at java.lang.reflect.Method.invoke(Native Method) 
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548) 
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936)

StateFlow

A common pattern in the Android world is to expose the state of your feature from your ViewModel to be collected in your Fragment / Activity. The second way we can use our custom exception handler is via an extension on the stateIn function provided via the Coroutines package.

object FlowExtensions {
    fun <T> Flow<T>.stateInWithBreadcrumbs(
        scope: CoroutineScope,
        started: SharingStarted,
        initialValue: T,
    ): StateFlow<T> {
        return stateIn(
            scope = scope + createBreadcrumbsExceptionHandler(),
            started = started,
            initialValue = initialValue,
        )
    }
}

When we use the standard stateIn method, there is a lot to be desired about the stack trace.

class MainViewModel : ViewModel() {
    val state = flow<String> {
        throw IllegalArgumentException("oh no")
    }.stateIn(
        scope = viewModelScope,
        started = SharingStarted.Eagerly,
        initialValue = "",
    )
}
class MainActivity : AppCompatActivity() {
    private val viewModel : MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        lifecycleScope.launch {
            viewModel.state.collect {
                Log.d("MainActivity", it)
            }
        }
    }
}

The code above provides the following stack trace:

E/AndroidRuntime: FATAL EXCEPTION: main
    Process: com.joetr.coroutines, PID: 12872
    java.lang.IllegalArgumentException: oh no
        at com.joetr.coroutines.MainViewModel$state$1.invokeSuspend(MainViewModel.kt:19)
        at com.joetr.coroutines.MainViewModel$state$1.invoke(Unknown Source:8)
        at com.joetr.coroutines.MainViewModel$state$1.invoke(Unknown Source:4)
        at kotlinx.coroutines.flow.SafeFlow.collectSafely(Builders.kt:61)
        at kotlinx.coroutines.flow.AbstractFlow.collect(Flow.kt:230)
        at kotlinx.coroutines.flow.FlowKt__ShareKt$launchSharing$1.invokeSuspend(Share.kt:214)
        at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
        at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
        at kotlinx.coroutines.EventLoop.processUnconfinedEvent(EventLoop.common.kt:69)
        at kotlinx.coroutines.internal.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:376)
        at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:30)
        at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable$default(Cancellable.kt:25)
        at kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:110)
        at kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:126)
        at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch(Builders.common.kt:56)
        at kotlinx.coroutines.BuildersKt.launch(Unknown Source:1)
        at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch$default(Builders.common.kt:47)
        at kotlinx.coroutines.BuildersKt.launch$default(Unknown Source:1)
        at com.joetr.coroutines.MainActivity.onCreate(MainActivity.kt:22)
        at android.app.Activity.performCreate(Activity.java:8290)
        at android.app.Activity.performCreate(Activity.java:8269)
        at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1384)
        at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3657)
        at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3813)
        at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:101)
        at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
        at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
        at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2308)
        at android.os.Handler.dispatchMessage(Handler.java:106)
        at android.os.Looper.loopOnce(Looper.java:201)
        at android.os.Looper.loop(Looper.java:288)
        at android.app.ActivityThread.main(ActivityThread.java:7898)
        at java.lang.reflect.Method.invoke(Native Method)
        at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
        at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936)
        Suppressed: kotlinx.coroutines.DiagnosticCoroutineContextException: [StandaloneCoroutine{Cancelling}@a43f471, Dispatchers.Main.immediate]

When we use our custom stateInWithBreadcrumb function, we can see that we again get some more information in our stack trace.

FATAL EXCEPTION: main                                                                              Process: com.joetr.dynamicproxies, PID: 13069                                                                               java.lang.IllegalArgumentException: oh no
    at com.joetr.coroutines.MainViewModel$state$1.invokeSuspend(MainViewModel.kt:19)
    at com.joetr.coroutines.MainViewModel$state$1.invoke(Unknown Source:8)
    at com.joetr.coroutines.MainViewModel$state$1.invoke(Unknown Source:4)
    at kotlinx.coroutines.flow.SafeFlow.collectSafely(Builders.kt:61)
    at kotlinx.coroutines.flow.AbstractFlow.collect(Flow.kt:230)
    at kotlinx.coroutines.flow.FlowKt__ShareKt$launchSharing$1.invokeSuspend(Share.kt:214)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:106)
    at kotlinx.coroutines.EventLoop.processUnconfinedEvent(EventLoop.common.kt:69)
    at kotlinx.coroutines.internal.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:376)
    at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:30)
    at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable$default(Cancellable.kt:25)
    at kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:110)
    at kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:126)
    at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch(Builders.common.kt:56)
    at kotlinx.coroutines.BuildersKt.launch(Unknown Source:1)
    at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch$default(Builders.common.kt:47)
    at kotlinx.coroutines.BuildersKt.launch$default(Unknown Source:1)
    at com.joetr.coroutines.MainActivity.onCreate(MainActivity.kt:22)
    at android.app.Activity.performCreate(Activity.java:8290)
    at android.app.Activity.performCreate(Activity.java:8269)
    at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1384)
    at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3657)
    at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:3813)
    at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:101)
    at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)
    at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)
    at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2308)
    at android.os.Handler.dispatchMessage(Handler.java:106)
    at android.os.Looper.loopOnce(Looper.java:201)
    at android.os.Looper.loop(Looper.java:288)
    at android.app.ActivityThread.main(ActivityThread.java:7898)
    at java.lang.reflect.Method.invoke(Native Method)
    at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:548)
    at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:936)
Caused by: com.joetr.coroutines.BreadcrumbException: More details about the original stack trace:
    at com.joetr.coroutines.MainViewModel.<init>(MainViewModel.kt:20)
    at java.lang.reflect.Constructor.newInstance0(Native Method)
    at java.lang.reflect.Constructor.newInstance(Constructor.java:343)
    at androidx.lifecycle.ViewModelProvider$NewInstanceFactory.create(ViewModelProvider.kt:202)
    at androidx.lifecycle.ViewModelProvider$AndroidViewModelFactory.create(ViewModelProvider.kt:324)
    at androidx.lifecycle.ViewModelProvider$AndroidViewModelFactory.create(ViewModelProvider.kt:306)
    at androidx.lifecycle.ViewModelProvider$AndroidViewModelFactory.create(ViewModelProvider.kt:280)
    at androidx.lifecycle.SavedStateViewModelFactory.create(SavedStateViewModelFactory.kt:128)
    at dagger.hilt.android.internal.lifecycle.HiltViewModelFactory.create(HiltViewModelFactory.java:118)
    at androidx.lifecycle.ViewModelProvider.get(ViewModelProvider.kt:187)
    at androidx.lifecycle.ViewModelProvider.get(ViewModelProvider.kt:153)
    at androidx.lifecycle.ViewModelLazy.getValue(ViewModelLazy.kt:53)
    at androidx.lifecycle.ViewModelLazy.getValue(ViewModelLazy.kt:35)
    at com.joetr.coroutines.MainActivity.getViewModel(MainActivity.kt:16)
2023-04-09 19:37:10.303 13069-13069 AndroidRuntime          com.joetr.dynamicproxies             E      at com.joetr.coroutines.MainActivity.access$getViewModel(MainActivity.kt:14)
    at com.joetr.coroutines.MainActivity$onCreate$1.invokeSuspend(MainActivity.kt:23)
    at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)
    at kotlinx.coroutines.internal.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:367)
                                                                                                        ... 25 more

By using a custom coroutine exception handler, we can improve the readability of stack traces involving coroutines. This allows us to quickly and easily identify the location of the exception and the path of execution leading up to it.

In conclusion, coroutines can make stack traces more complicated, but with the right approach, you can make them much easier to understand. By using a custom coroutine exception handler, you can intercept exceptions as soon as they occur and add additional context to the exception, such as the coroutine stack trace. This can make it much easier to identify the location of the exception and the path of execution leading up to it.

Gist with all relevant code can be found here.