Ktor ServerにSentryを繋げたかった話

おはようこんにちはこんばんは。

Spring Bootを使っているとSentryを組み込むのは標準で提供されているライブラリを入れて適切に設定を入れるだけで済んだりしますが、Ktorを使っているとドキュメントもなかったりライブラリもなかったりで苦労したので、最終的に組み込めるようにした方法を紹介したいと思います。

## とりあえずSentry入れたいの！！！！
というわけで、入れるだけで動くコードを用意しました。

事前にJava(JVM)用のSentry SDKを入れておきましょう。

https://docs.sentry.io/platforms/java/

追加で、Kotlin Corotine用の拡張も必要になるので入れておきます。

https://docs.sentry.io/platforms/java/enriching-events/scopes/#kotlin-coroutines

（ドキュメントすら読むのが面倒な人はこれをbuild.gradle.ktsに加えておいてください！本当は読んで欲しいですが！）

```kotlin
implementation("io.sentry:sentry:6.6.0")
implementation("io.sentry:sentry-kotlin-extensions:6.6.0")
```

```kotlin
import io.ktor.server.application.*
import io.ktor.server.plugins.*
import io.ktor.server.request.*
import io.ktor.server.sessions.*
import io.sentry.Sentry
import io.sentry.SpanStatus
import io.sentry.kotlin.SentryContext
import kotlinx.coroutines.withContext

fun Application.configureSentry() {
  Sentry.init { options ->
    options.dsn = "<YOUR_SENTRY_DSN_HERE>"
    options.environment = "<YOUR_SENTRY_ENV_NAME_HERE>"
  }

  intercept(ApplicationCallPipeline.Call) {
    withContext(SentryContext()) {
      val transaction = Sentry.startTransaction("Ktor Request", call.request.path())
      Sentry.setTag("path", call.request.path())
      Sentry.setTag("method", call.request.httpMethod.value)
      Sentry.setTag("host", call.request.host())
      Sentry.setTag("protocol", call.request.origin.scheme)

      // ユーザIDなどの追加情報はcallから取れるsessionなどを使えば設定出来るので、必要に応じて付けておく
      Sentry.setTag("user_id", call.sessions.get<UserAuthSession>()?.userId.toString())
      Sentry.setTag("admin_user_id", call.sessions.get<AdminUserAuthSession>()?.adminUserId.toString())

      Sentry.setExtra("query", call.request.queryString())
      try {
        proceed()
        transaction.status = SpanStatus.OK
      } catch (e: NotFoundException) {
        transaction.apply {
          this.throwable = e
          this.status = SpanStatus.NOT_FOUND
        }
        throw e
      } catch (e: BadRequestException) {
        transaction.apply {
          this.throwable = e
          this.status = SpanStatus.INVALID_ARGUMENT
        }
        throw e
      } catch (e: Exception) {
        transaction.apply {
          this.throwable = e
          this.status = SpanStatus.INTERNAL_ERROR
        }
        Sentry.captureException(e)
        throw e
      } finally {
        transaction.finish()
      }
    }
  }
}
```

あくまで、一例にはなりますが起動時にmoduleとして組み込んであげてください。

```kotlin
fun main() {
  embeddedServer(
    Netty,
    port = 8080,
    host = "0.0.0.0",
    module = createKtorModule()
  ).start(wait = true)
}

fun createKtorModule(koin: Koin): Application.() -> Unit {
  val ktorModule: Application.() -> Unit = {
    configureRouting()
    configureSerialization()
    configureHTTP()
    configureSecurity()
    configureAuthentication()

    // これを追加
    configureSentry()
  }
  return ktorModule
}
```

## 詳しい解説
基本的な動作の流れは以下になります。

1. Ktorにリクエストが来る時の処理を乗っ取る
1. CoroutineのcontextをSentryContext()に切り替える
1. Sentryのトランザクションを開始する
1. try-catchの中でリクエストを処理させて、リクエストからもらった情報をトランザクションに付ける
1. エラーがあったらcatchの中でExceptionをもらってそれをそのままSentryに渡してトランザクションの状態を異常終了にする
1. 正常に終了した場合はトランザクションを正常終了する
1. いずれのケースの場合もリクエストの最後でトランザクションを閉じる

ちなみに、Coroutine周りは少し注意が必要です。Sentryのドキュメントによると...

> Sentry's SDK for Java stores the scope and the context in a thread-local variable. To make sure that a coroutine has access to the correct Sentry context, an instance of SentryContext must be provided when launching a coroutine.

と書いてあります。ライブラリの性質上、スレッドローカルに値を保持しているので並行してエラーが起きたりするとめちゃくちゃになってしまうので、Contextを毎回リクエストごとに生成してあげる必要があるわけですね。

（なので今回は、`withContext(SentryContext())`でcontextを切り替えています）