• トップ
  • ブログ一覧
  • Kotlin Coroutines withContextを使ってもStackTraceを諦めたくない
  • Kotlin Coroutines withContextを使ってもStackTraceを諦めたくない

    江幡(エンジニア)江幡(エンジニア)
    2025.09.24

    IT技術

    はじめに

    本記事は、以前の記事
    Kotlin Coroutines withContextを使っているとStackTraceを用いた不具合調査が辛いのをどうにかしたい
    の解決提案編となります。

    どんな問題だったか

    Koltin CouroitnesのwithContextを利用してスレッド変更すると
    StackTraceが途切れてしまい不具合発生時の処理履歴が追えなくなる

    という問題でした。

    詳しくは前回の記事を確認してください。

    解決案1:withContextごとに、例外ラッパークラスにStackTrace情報を詰め込む(問題あり…)

    ↓まずは、例外ラッパークラスを作成。

    1/*
    2 * Exceptionをラップするためのクラス
    3 */
    4class WrappedException(
    5    message: String,
    6    cause: Exception
    7) : Exception(message, cause) {
    8
    9    /**
    10     * 例外の根本的な原因を取得するプロパティ
    11     *
    12     * このプロパティは、WrappedExceptionがラップしている例外の根本的な原因を返す
    13     * もしWrappedExceptionがラップしている例外が別のWrappedExceptionであれば、その根本的な原因を再帰的に取得
    14     */
    15    val rootCause: Throwable?
    16        get() = cause?.let {
    17            if (it is WrappedException) {
    18                it.rootCause
    19            } else {
    20                it
    21            }
    22        }
    23}

    ↓次に、独自のwithContextを作成

    1/*
    2* withContextを使用して、例外をラップするための関数
    3 *
    4 * @param context CoroutineContext
    5 * @param block suspend CoroutineScope.() -> T
    6 * @return T
    7 *
    8 * 呼び出し元のメソッド名と行番号を取得し、例外が発生した場合にその情報を含む[WrappedException]を投げる。
    9 */
    10suspend fun  withContextWrapException(
    11    context: CoroutineContext,
    12    block: suspend CoroutineScope.() -> T,
    13): T {
    14    val throwable = Throwable()
    15    return withContext(context) {
    16        try {
    17            block()
    18        } catch (e: CancellationException) {
    19            // CancellationExceptionはそのまま投げる(suspend中断例外の伝播のため)
    20            throw e
    21        } catch (e: Exception) {
    22            val callerStackTraceElement = throwable.stackTrace[1] // withContextWrapExceptionを呼び出しているメソッド
    23            val callerOfCallerStackTraceElement = throwable.stackTrace[2] // 「withContextWrapExceptionを呼び出しているメソッド」を呼び出しているメソッド
    24            val callerInfo =
    25                "(${callerOfCallerStackTraceElement.fileName}:${callerOfCallerStackTraceElement.lineNumber}→${callerStackTraceElement.fileName}:${callerStackTraceElement.lineNumber})"
    26            throw WrappedException(callerInfo, e)
    27        }
    28    }
    29}

    ↓あとは、withContextの代わりに使うだけ。

    1 // 適当なRpository
    2class HogeRepository(
    3    private val dispatcherIO: CoroutineDispatcher = Dispatchers.IO
    4) {
    5    suspend fun getHoge(): String = withContextWrapException(dispatcherIO) {
    6        throw RuntimeException("適当な例外")
    7    }
    8}

    ↓ViewModelから呼び出すと

    1 // 適当なViewModelから呼び出す
    2class MainViewModel(
    3    private val repository: HogeRepository = HogeRepository()
    4) : ViewModel() {
    5
    6    fun onClickButton() {
    7        viewModelScope.launch {
    8            try {
    9                hogeRepository.getHoge()
    10            } catch (e: CancellationException) {
    11                throw e // Jobキャンセル例外はキャッチしたらダメです
    12            } catch (e: Exception) {
    13                Log.e("hoge", "onClickButton", e)
    14                _errorMessage.update { e.stackTraceToString() }
    15            }
    16        }
    17    }
    18}

    ↓以下のStackTraceが出力されます。※行数ズレてるのは気にしないでください。

    これなら、どれだけスレッド変更しても大丈夫です
    ↓試しにUseCaseを作成

    1 // HogeUseCase
    2class HogeUseCase(
    3    private val hogeRepository: HogeRepository = HogeRepository(),
    4    private val dispatcherDefault: CoroutineDispatcher = Dispatchers.Default,
    5) {
    6    suspend fun fetchHoge(): String = withContextWrapException(dispatcherDefault) {
    7        val hoge = hogeRepository.getHoge()
    8        return@withContextWrapException hoge
    9    }
    10}

    ↓ViewModelから呼び出してみる

    1 // 適当なViewModelから呼び出す
    2class MainViewModel(
    3    private val repository: HogeRepository = HogeRepository()
    4) : ViewModel() {
    5
    6    fun onClickButton() {
    7        viewModelScope.launch {
    8            try {
    9                hogeUseCase.fetchHoge()
    10            } catch (e: CancellationException) {
    11                throw e // Jobキャンセル例外はキャッチしたらダメです
    12            } catch (e: Exception) {
    13                Log.e("hoge", "onClickButton", e)
    14            }
    15        }
    16    }
    17}

    ↓これで出力されたStackTrace。※行数ズレてるのは気にしないでください。
    ViewModel→UseCase→Repositoryの呼び出しがあったことがよく分かる。

    が、しかし。
    この方法には致命的な問題点があります。

    それは・・・
    難読化されていると使えない。

    えー。そうなんです。
    ThrowableからStackTraceを取得して文字列としてWrappedExceptionに渡しているため
    難読化されているとまともなログが出力されません。

    解決案2:CoroutineNameを活用する

    致命的な問題を抱えている案1はちょっと・・・
    というわけで、案2です。

    案1のWrappedExceptionはそのまま利用して
    ↓withContextWrapExceptionをちょっと改良します。

    1 // withContextの位置変わってるので注意
    2suspend fun  withContextWrapException(
    3    context: CoroutineContext,
    4    callerCoroutineName: String? = null,
    5    block: suspend CoroutineScope.() -> T,
    6): T {
    7    return try {
    8        withContext(
    9            if (callerCoroutineName != null) {
    10                context + CoroutineName(callerCoroutineName)
    11            } else {
    12                context
    13            }
    14        ) {
    15            block()
    16        }
    17    } catch (e: CancellationException) {
    18        // CancellationExceptionはそのまま投げる(suspend中断例外の伝播のため)
    19        throw e
    20    } catch (e: Exception) {
    21        val callerInfo = coroutineContext[CoroutineName]?.name.orEmpty()
    22        throw WrappedException(callerInfo, e)
    23
    24    }
    25}

    ↓UseCaseもちょっと修正

    1 // HogeUseCase
    2class HogeUseCase(
    3    private val hogeRepository: HogeRepository = HogeRepository(),
    4    private val dispatcherDefault: CoroutineDispatcher = Dispatchers.Default,
    5) {
    6    suspend fun fetchHoge(): String = withContextWrapException(
    7        context = dispatcherDefault,
    8        callerCoroutineName = "HogeUseCase"
    9    ) {
    10        val hoge = hogeRepository.getHoge()
    11        return@withContextWrapException hoge
    12    }
    13}

    ↓ViewModelからの呼び出しもちょっと修正

    1 // 適当なViewModelから呼び出す
    2class MainViewModel(
    3    private val repository: HogeRepository = HogeRepository()
    4) : ViewModel() {
    5
    6    fun onClickButton() {
    7        viewModelScope.launch {
    8            try {
    9                // withContextじゃなくて、launchにCoroutineNameを渡すのもアリ
    10                withContext(CoroutineName("MainViewModel.onClickButton")) {
    11                    hogeUseCase.fetchHoge()
    12                }
    13            } catch (e: CancellationException) {
    14                throw e // Jobキャンセル例外はキャッチしたらダメです
    15            } catch (e: Exception) {
    16                Log.e("hoge", "onClickButton", e)
    17            }
    18        }
    19    }
    20}

    これで実行すると・・・難読化されてても処理を追うログを出力できる。
    ↓ちょっと分かりづらいがMainViewModel→HogeUseCase→HogeReposiytoryの順で呼ばれていることがわかる。

    最後に

    案2は、せっかく難読化しているのにログに出力してしまっていいのかな・・・?
    という気持ちもあり・・・正直スッキリしていない。
    withContextでスレッドを切り替えるたびにtry-catchするのが一番まじめなんだろうな・・・
    面倒くさがってはだめ・・・ということですかね。

    もっと良い解決方法ないかなぁ・・・

    ライトコードでは、エンジニアを積極採用中!

    ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。

    採用情報へ

    おすすめ記事

    エンジニア大募集中!

    ライトコードでは、エンジニアを積極採用中です。

    特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

    また、フリーランスエンジニア様も大募集中です。

    background