From 5645889ea559adac11a5e02f6bb3f3445c9c6e8f Mon Sep 17 00:00:00 2001 From: lcian Date: Fri, 27 Jun 2025 12:02:41 +0200 Subject: [PATCH 01/16] Add Ktor client integration --- buildSrc/src/main/java/Config.kt | 1 + gradle/libs.versions.toml | 3 + sentry-ktor/api/sentry-ktor.api | 33 +++ sentry-ktor/build.gradle.kts | 90 +++++++ .../io/sentry/ktor/SentryKtorClientPlugin.kt | 121 +++++++++ .../io/sentry/ktor/SentryKtorClientUtils.kt | 112 ++++++++ .../sentry/ktor/SentryKtorClientPluginTest.kt | 253 ++++++++++++++++++ .../api/sentry-samples-ktor.api | 6 + .../sentry-samples-ktor/build.gradle.kts | 26 ++ .../main/java/io/sentry/samples/ktor/Main.kt | 46 ++++ sentry/api/sentry.api | 2 + .../main/java/io/sentry/TypeCheckHint.java | 6 + settings.gradle.kts | 2 + 13 files changed, 701 insertions(+) create mode 100644 sentry-ktor/api/sentry-ktor.api create mode 100644 sentry-ktor/build.gradle.kts create mode 100644 sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt create mode 100644 sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientUtils.kt create mode 100644 sentry-ktor/src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt create mode 100644 sentry-samples/sentry-samples-ktor/api/sentry-samples-ktor.api create mode 100644 sentry-samples/sentry-samples-ktor/build.gradle.kts create mode 100644 sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 69e8d02c89..7945df774f 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -78,6 +78,7 @@ object Config { val SENTRY_OKHTTP_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.okhttp" val SENTRY_REACTOR_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.reactor" val SENTRY_KOTLIN_EXTENSIONS_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.kotlin-extensions" + val SENTRY_KTOR_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.ktor" val group = "io.sentry" val description = "SDK for sentry.io" val versionNameProp = "versionName" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1e72c3db71..516941eeee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ jackson = "2.18.3" jetbrainsCompose = "1.6.11" kotlin = "1.9.24" kotlin-compatible-version = "1.6" +ktorClient = "3.0.0" logback = "1.2.9" log4j2 = "2.20.0" nopen = "1.0.1" @@ -94,6 +95,8 @@ jetbrains-annotations = { module = "org.jetbrains:annotations", version = "23.0. kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClient" } +ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktorClient" } log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j2" } log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j2" } leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version = "2.14" } diff --git a/sentry-ktor/api/sentry-ktor.api b/sentry-ktor/api/sentry-ktor.api new file mode 100644 index 0000000000..4d12358a7a --- /dev/null +++ b/sentry-ktor/api/sentry-ktor.api @@ -0,0 +1,33 @@ +public final class io/sentry/ktor/BuildConfig { + public static final field SENTRY_KTOR_SDK_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; +} + +public final class io/sentry/ktor/SentryKtorClientPluginConfig { + public fun ()V + public final fun getBeforeSpan ()Lio/sentry/ktor/SentryKtorClientPluginConfig$BeforeSpanCallback; + public final fun getCaptureFailedRequests ()Z + public final fun getFailedRequestStatusCodes ()Ljava/util/List; + public final fun getFailedRequestTargets ()Ljava/util/List; + public final fun getScopes ()Lio/sentry/IScopes; + public final fun setBeforeSpan (Lio/sentry/ktor/SentryKtorClientPluginConfig$BeforeSpanCallback;)V + public final fun setCaptureFailedRequests (Z)V + public final fun setFailedRequestStatusCodes (Ljava/util/List;)V + public final fun setFailedRequestTargets (Ljava/util/List;)V + public final fun setScopes (Lio/sentry/IScopes;)V +} + +public abstract interface class io/sentry/ktor/SentryKtorClientPluginConfig$BeforeSpanCallback { + public abstract fun execute (Lio/sentry/ISpan;Lio/ktor/client/request/HttpRequestBuilder;)Lio/sentry/ISpan; +} + +public class io/sentry/ktor/SentryKtorClientPluginContextHook : io/ktor/client/plugins/api/ClientHook { + public fun ()V + public synthetic fun install (Lio/ktor/client/HttpClient;Ljava/lang/Object;)V + public fun install (Lio/ktor/client/HttpClient;Lkotlin/jvm/functions/Function2;)V +} + +public final class io/sentry/ktor/SentryKtorClientPluginKt { + public static final fun getSentryKtorClientPlugin ()Lio/ktor/client/plugins/api/ClientPlugin; +} + diff --git a/sentry-ktor/build.gradle.kts b/sentry-ktor/build.gradle.kts new file mode 100644 index 0000000000..d4e2c3c82a --- /dev/null +++ b/sentry-ktor/build.gradle.kts @@ -0,0 +1,90 @@ +import net.ltgt.gradle.errorprone.errorprone +import org.jetbrains.kotlin.config.KotlinCompilerVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + `java-library` + kotlin("jvm") + jacoco + id("io.sentry.javadoc") + alias(libs.plugins.errorprone) + alias(libs.plugins.gradle.versions) + alias(libs.plugins.buildconfig) +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() +} + +kotlin { explicitApi() } + +dependencies { + api(projects.sentry) + + implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + implementation(projects.sentryKotlinExtensions) + + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.nopen.annotations) + compileOnly(libs.ktor.client.core) + errorprone(libs.errorprone.core) + errorprone(libs.nopen.checker) + errorprone(libs.nullaway) + + testImplementation(projects.sentryTestSupport) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.mockito.inline) + testImplementation(libs.ktor.client.core) + testImplementation(libs.ktor.client.java) + testImplementation(libs.okhttp.mockwebserver) +} + +configure { test { java.srcDir("src/test/java") } } + +jacoco { toolVersion = libs.versions.jacoco.get() } + +tasks.jacocoTestReport { + reports { + xml.required.set(true) + html.required.set(false) + } +} + +tasks { + jacocoTestCoverageVerification { + violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } } + } + check { + dependsOn(jacocoTestCoverageVerification) + dependsOn(jacocoTestReport) + } +} + +buildConfig { + useJavaOutput() + packageName("io.sentry.ktor") + buildConfigField("String", "SENTRY_KTOR_SDK_NAME", "\"${Config.Sentry.SENTRY_KTOR_SDK_NAME}\"") + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") +} + +tasks.withType().configureEach { + dependsOn(tasks.generateBuildConfig) + options.errorprone { + check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "io.sentry") + } +} + +tasks.jar { + manifest { + attributes( + "Sentry-Version-Name" to project.version, + "Sentry-SDK-Name" to Config.Sentry.SENTRY_KTOR_SDK_NAME, + "Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-ktor", + "Implementation-Vendor" to "Sentry", + "Implementation-Title" to project.name, + "Implementation-Version" to project.version, + ) + } +} diff --git a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt new file mode 100644 index 0000000000..f6075535e5 --- /dev/null +++ b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt @@ -0,0 +1,121 @@ +package io.sentry.ktor + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.api.* +import io.ktor.client.plugins.api.ClientPlugin +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.util.* +import io.ktor.util.pipeline.* +import io.sentry.HttpStatusCodeRange +import io.sentry.IScopes +import io.sentry.ISpan +import io.sentry.ScopesAdapter +import io.sentry.Sentry +import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryOptions +import io.sentry.kotlin.SentryContext +import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion +import io.sentry.util.PropagationTargetsUtils +import kotlinx.coroutines.withContext + +/** Configuration for the Sentry Ktor client plugin. */ +public class SentryKtorClientPluginConfig { + /** The [IScopes] instance to use. Defaults to [ScopesAdapter.getInstance]. */ + public var scopes: IScopes = ScopesAdapter.getInstance() + + /** Callback to customize or drop spans before they are created. Return null to drop the span. */ + public var beforeSpan: BeforeSpanCallback? = null + + /** Whether to capture HTTP client errors as Sentry events. Defaults to true. */ + public var captureFailedRequests: Boolean = true + + /** + * The HTTP status code ranges that should be considered as failed requests. Defaults to 500-599 + * (server errors). + */ + public var failedRequestStatusCodes: List = + listOf(HttpStatusCodeRange(HttpStatusCodeRange.DEFAULT_MIN, HttpStatusCodeRange.DEFAULT_MAX)) + + /** + * The list of targets (URLs) for which failed requests should be captured. Supports regex + * patterns. Defaults to capture all requests. + */ + public var failedRequestTargets: List = listOf(SentryOptions.DEFAULT_PROPAGATION_TARGETS) + + /** Callback interface for customizing spans before they are created. */ + public fun interface BeforeSpanCallback { + /** + * Customize or drop a span before it's created. + * + * @param span The span to customize + * @param request The HTTP request being executed + * @return The customized span, or null to drop the span + */ + public fun execute(span: ISpan, request: HttpRequestBuilder): ISpan? + } +} + +internal const val SENTRY_KTOR_CLIENT_PLUGIN_KEY = "SentryKtorClientPlugin" + +/** + * Sentry plugin for Ktor HTTP client that provides automatic instrumentation for HTTP requests, + * including distributed tracing, breadcrumbs, and error capturing. + */ +public val SentryKtorClientPlugin: ClientPlugin = + createClientPlugin(SENTRY_KTOR_CLIENT_PLUGIN_KEY, ::SentryKtorClientPluginConfig) { + // Init + SentryIntegrationPackageStorage.getInstance() + .addPackage("maven:io.sentry:sentry-ktor", BuildConfig.VERSION_NAME) + addIntegrationToSdkVersion("Ktor") + + // Options + val scopes = pluginConfig.scopes + val captureFailedRequests = pluginConfig.captureFailedRequests + val failedRequestStatusCodes = pluginConfig.failedRequestStatusCodes + val failedRequestTargets = pluginConfig.failedRequestTargets + + // Attributes + // Request start time for breadcrumbs + val requestStartTimestampKey = AttributeKey("SentryRequestStartTimestamp") + + onRequest { request, _ -> + request.attributes.put(requestStartTimestampKey, System.currentTimeMillis()) + // TODO: start span + // TODO: inject tracing headers + } + + onResponse { response -> + val request = response.request + val startTimestamp = response.call.attributes.getOrNull(requestStartTimestampKey) + val endTimestamp = System.currentTimeMillis() + + if ( + captureFailedRequests && + failedRequestStatusCodes.any { it.isInRange(response.status.value) } && + PropagationTargetsUtils.contain(failedRequestTargets, request.url.toString()) + ) { + SentryKtorClientUtils.captureClientError(scopes, request, response) + } + + SentryKtorClientUtils.addBreadcrumb(scopes, request, response, startTimestamp, endTimestamp) + + // TODO: end span + } + + on(SentryKtorClientPluginContextHook()) { block -> block() } + } + +public open class SentryKtorClientPluginContextHook : + ClientHook Unit) -> Unit> { + private val phase = PipelinePhase("SentryKtorClientPluginContext") + + override fun install(client: HttpClient, handler: suspend (suspend () -> Unit) -> Unit) { + client.requestPipeline.insertPhaseBefore(HttpRequestPipeline.Before, phase) + client.requestPipeline.intercept(phase) { + withContext(SentryContext(Sentry.forkedCurrentScope(SENTRY_KTOR_CLIENT_PLUGIN_KEY))) { + proceed() + } + } + } +} diff --git a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientUtils.kt b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientUtils.kt new file mode 100644 index 0000000000..b5d828f2a8 --- /dev/null +++ b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientUtils.kt @@ -0,0 +1,112 @@ +package io.sentry.ktor + +import io.ktor.client.request.HttpRequest +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsBytes +import io.ktor.http.Headers +import io.ktor.http.contentLength +import io.ktor.util.toMap +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.IScopes +import io.sentry.SentryEvent +import io.sentry.SpanDataConvention +import io.sentry.TypeCheckHint +import io.sentry.exception.ExceptionMechanismException +import io.sentry.exception.SentryHttpClientException +import io.sentry.protocol.Mechanism +import io.sentry.util.HttpUtils +import io.sentry.util.UrlUtils + +internal object SentryKtorClientUtils { + internal suspend fun captureClientError( + scopes: IScopes, + request: HttpRequest, + response: HttpResponse, + ) { + val urlDetails = UrlUtils.parse(request.url.toString()) + + val mechanism = Mechanism().apply { type = "SentryKtorClientPlugin" } + val exception = + SentryHttpClientException("HTTP Client Error with status code: ${response.status.value}") + val mechanismException = + ExceptionMechanismException(mechanism, exception, Thread.currentThread(), true) + val event = SentryEvent(mechanismException) + + val hint = Hint() + hint.set(TypeCheckHint.KTOR_REQUEST, request) + hint.set(TypeCheckHint.KTOR_RESPONSE, response) + + val sentryRequest = + io.sentry.protocol.Request().apply { + // Cookie is only sent if isSendDefaultPii is enabled + urlDetails.applyToRequest(this) + cookies = if (scopes.options.isSendDefaultPii) request.headers["Cookie"] else null + method = request.method.value + headers = getHeaders(scopes, request.headers) + bodySize = request.content.contentLength + } + + val sentryResponse = + io.sentry.protocol.Response().apply { + // Set-Cookie is only sent if isSendDefaultPii is enabled due to PII + cookies = if (scopes.options.isSendDefaultPii) response.headers["Set-Cookie"] else null + headers = getHeaders(scopes, response.headers) + statusCode = response.status.value + bodySize = response.bodyAsBytes().size.toLong() + } + + event.request = sentryRequest + event.contexts.setResponse(sentryResponse) + + scopes.captureEvent(event, hint) + } + + private fun getHeaders(scopes: IScopes, headers: Headers): MutableMap? { + // Headers are only sent if isSendDefaultPii is enabled due to PII + if (!scopes.options.isSendDefaultPii) { + return null + } + + val res = mutableMapOf() + headers.toMap().forEach { (key, values) -> + if (!HttpUtils.containsSensitiveHeader(key)) { + if (values.size == 1) { + res[key] = values[0] + } else { + for ((i, value) in values.withIndex()) { + res["$key[$i]"] = value + } + } + } + } + return res + } + + internal fun addBreadcrumb( + scopes: IScopes, + request: HttpRequest, + response: HttpResponse, + startTimestamp: Long?, + endTimestamp: Long?, + ) { + val breadcrumb = + Breadcrumb.http(request.url.toString(), request.method.value, response.status.value) + breadcrumb.setData( + SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY, + response.contentLength(), + ) + if (startTimestamp != null) { + breadcrumb.setData(SpanDataConvention.HTTP_START_TIMESTAMP, startTimestamp) + } + if (endTimestamp != null) { + breadcrumb.setData(SpanDataConvention.HTTP_END_TIMESTAMP, endTimestamp) + } + + val hint = Hint().also { it.set(TypeCheckHint.KTOR_REQUEST, request) } + hint[TypeCheckHint.KTOR_REQUEST] = request + hint[TypeCheckHint.KTOR_RESPONSE] = response + + scopes.addBreadcrumb(breadcrumb, hint) + } +} diff --git a/sentry-ktor/src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt b/sentry-ktor/src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt new file mode 100644 index 0000000000..73c78f61c7 --- /dev/null +++ b/sentry-ktor/src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt @@ -0,0 +1,253 @@ +package io.sentry.ktor + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.HttpStatusCodeRange +import io.sentry.IScope +import io.sentry.IScopes +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryEvent +import io.sentry.SentryOptions +import io.sentry.SpanDataConvention +import io.sentry.exception.SentryHttpClientException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.check +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SentryKtorClientPluginTest { + class Fixture { + val scopes = mock() + val server = MockWebServer() + var options: SentryOptions? = null + var scope: IScope? = null + + @SuppressWarnings("LongParameterList") + fun getSut( + httpStatusCode: Int = 201, + responseBody: String = "success", + socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, + captureFailedRequests: Boolean = false, + failedRequestTargets: List = listOf(".*"), + failedRequestStatusCodes: List = + listOf( + HttpStatusCodeRange(HttpStatusCodeRange.DEFAULT_MIN, HttpStatusCodeRange.DEFAULT_MAX) + ), + sendDefaultPii: Boolean = false, + optionsConfiguration: ((SentryOptions) -> Unit)? = null, + ): HttpClient { + options = + SentryOptions().also { + it.dsn = "https://key@sentry.io/proj" + it.isSendDefaultPii = sendDefaultPii + optionsConfiguration?.invoke(it) + } + scope = Scope(options!!) + whenever(scopes.options).thenReturn(options) + doAnswer { (it.arguments[0] as ScopeCallback).run(scope!!) } + .whenever(scopes) + .configureScope(any()) + + server.enqueue( + MockResponse() + .setBody(responseBody) + .addHeader("myResponseHeader", "myValue") + .setSocketPolicy(socketPolicy) + .setResponseCode(httpStatusCode) + ) + + return HttpClient { + install(SentryKtorClientPlugin) { + this.scopes = this@Fixture.scopes + this.captureFailedRequests = captureFailedRequests + this.failedRequestTargets = failedRequestTargets + this.failedRequestStatusCodes = failedRequestStatusCodes + } + } + } + } + + private val fixture = Fixture() + + @Test + fun `adds breadcrumb when http call succeeds`(): Unit = runBlocking { + val sut = fixture.getSut(responseBody = "response body") + val response = sut.get(fixture.server.url("/hello").toString()) + + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals("GET", it.getData("method")) + assertEquals(201, it.getData("status_code")) + assertEquals(fixture.server.url("/hello").toString(), it.getData("url")) + assertEquals(13L, it.getData(SpanDataConvention.HTTP_RESPONSE_CONTENT_LENGTH_KEY)) + assertNotNull(it.getData(SpanDataConvention.HTTP_START_TIMESTAMP)) + }, + anyOrNull(), + ) + } + + @Test + fun `adds breadcrumb when http call fails`(): Unit = runBlocking { + val sut = fixture.getSut(httpStatusCode = 500, responseBody = "error") + val response = sut.get(fixture.server.url("/hello").toString()) + + verify(fixture.scopes) + .addBreadcrumb( + check { + assertEquals("http", it.type) + assertEquals("GET", it.getData("method")) + assertEquals(500, it.getData("status_code")) + assertEquals(fixture.server.url("/hello").toString(), it.getData("url")) + }, + anyOrNull(), + ) + } + + @Test + fun `captures an event if captureFailedRequests is enabled and status code is within the range`(): + Unit = runBlocking { + val sut = fixture.getSut(captureFailedRequests = true, httpStatusCode = 500) + sut.get(fixture.server.url("/hello").toString()) + + verify(fixture.scopes).captureEvent(any(), any()) + } + + @Test + fun `does not capture an event if captureFailedRequests is disabled`(): Unit = runBlocking { + val sut = fixture.getSut(httpStatusCode = 500) + sut.get(fixture.server.url("/hello").toString()) + + verify(fixture.scopes, never()).captureEvent(any(), any()) + } + + @Test + fun `does not capture an event if captureFailedRequests is enabled but status code is not within the range`(): + Unit = runBlocking { + val sut = fixture.getSut(captureFailedRequests = true) + sut.get(fixture.server.url("/hello").toString()) + + verify(fixture.scopes, never()).captureEvent(any(), any()) + } + + @Test + fun `does not capture an event if captureFailedRequests is enabled and domain is not within the targets`(): + Unit = runBlocking { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, + failedRequestTargets = listOf("myapi.com"), + ) + sut.get(fixture.server.url("/hello").toString()) + + verify(fixture.scopes, never()).captureEvent(any(), any()) + } + + @Test + fun `captures failed requests with custom status code ranges`(): Unit = runBlocking { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 404, + failedRequestStatusCodes = listOf(HttpStatusCodeRange(400, 499)), // only client errors + ) + sut.get(fixture.server.url("/hello").toString()) + + verify(fixture.scopes).captureEvent(any(), any()) + } + + @Test + fun `does not capture failed requests outside custom status code ranges`(): Unit = runBlocking { + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = 500, // server error + failedRequestStatusCodes = listOf(HttpStatusCodeRange(400, 499)), // only client errors + ) + sut.get(fixture.server.url("/hello").toString()) + + verify(fixture.scopes, never()).captureEvent(any(), any()) + } + + @Test + fun `captures an error event with request and response data`(): Unit = runBlocking { + val statusCode = 500 + val responseBody = "failure" + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = statusCode, + responseBody = responseBody, + sendDefaultPii = true, + ) + + val requestBody = "test" + val response = + sut.post(fixture.server.url("/hello?myQuery=myValue#myFragment").toString()) { + contentType(ContentType.Text.Plain) + setBody(requestBody) + } + + verify(fixture.scopes) + .captureEvent( + check { + val sentryRequest = it.request!! + assertEquals("http://localhost:${fixture.server.port}/hello", sentryRequest.url) + assertEquals("myQuery=myValue", sentryRequest.queryString) + assertEquals("myFragment", sentryRequest.fragment) + assertEquals("POST", sentryRequest.method) + assertEquals(requestBody.length.toLong(), sentryRequest.bodySize) + assertNotNull(sentryRequest.headers) + + val sentryResponse = it.contexts.response!! + assertEquals(statusCode, sentryResponse.statusCode) + assertEquals(responseBody.length.toLong(), sentryResponse.bodySize) + assertNotNull(sentryResponse.headers) + + assertTrue(it.throwable is SentryHttpClientException) + }, + any(), + ) + } + + @Test + fun `does not capture headers when sendDefaultPii is disabled`(): Unit = runBlocking { + val sut = + fixture.getSut(captureFailedRequests = true, httpStatusCode = 500, sendDefaultPii = false) + + sut.get(fixture.server.url("/hello").toString()) { headers["myHeader"] = "myValue" } + + verify(fixture.scopes) + .captureEvent( + check { + val sentryRequest = it.request!! + assertEquals(null, sentryRequest.headers) + + val sentryResponse = it.contexts.response!! + assertEquals(null, sentryResponse.headers) + }, + any(), + ) + } +} diff --git a/sentry-samples/sentry-samples-ktor/api/sentry-samples-ktor.api b/sentry-samples/sentry-samples-ktor/api/sentry-samples-ktor.api new file mode 100644 index 0000000000..cf3a710f94 --- /dev/null +++ b/sentry-samples/sentry-samples-ktor/api/sentry-samples-ktor.api @@ -0,0 +1,6 @@ +public final class io/sentry/samples/ktor/MainKt { + public static final fun main ()V + public static synthetic fun main ([Ljava/lang/String;)V + public static final fun makeRequests (Lio/ktor/client/HttpClient;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + diff --git a/sentry-samples/sentry-samples-ktor/build.gradle.kts b/sentry-samples/sentry-samples-ktor/build.gradle.kts new file mode 100644 index 0000000000..7b940761df --- /dev/null +++ b/sentry-samples/sentry-samples-ktor/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + kotlin("jvm") + application + alias(libs.plugins.gradle.versions) +} + +application { mainClass.set("io.sentry.samples.ktor.Main") } + +configure { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString() +} + +dependencies { + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.java) + implementation(libs.kotlinx.coroutines) + implementation(projects.sentry) + implementation(projects.sentryKtor) +} + +tasks.test { useJUnitPlatform() } diff --git a/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt b/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt new file mode 100644 index 0000000000..47660af533 --- /dev/null +++ b/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt @@ -0,0 +1,46 @@ +package io.sentry.samples.ktor + +import io.ktor.client.* +import io.ktor.client.engine.java.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.sentry.Sentry +import io.sentry.ktor.SentryKtorClientPlugin +import kotlinx.coroutines.runBlocking + +fun main() { + Sentry.init { options -> + options.dsn = + "https://b9ca97be3ff8f1cef41dffdcb1e5100b@o447951.ingest.us.sentry.io/4508683222843393" + options.isDebug = true + options.isSendDefaultPii = true + options.tracesSampleRate = 1.0 + options.addInAppInclude("io.sentry.samples") + } + + val client = + HttpClient(Java) { install(SentryKtorClientPlugin) { failedRequestTargets = listOf(".*") } } + + runBlocking { makeRequests(client) } + + Sentry.captureMessage("Ktor client sample done") +} + +suspend fun makeRequests(client: HttpClient) { + // Should create breadcrumbs + client.get("https://httpbin.org/get") + client.get("https://httpbin.org/status/404") + + // Should create errors + client.get("https://httpbin.org/status/500") + client.get("https://httpbin.org/status/500?lol=test") + + // Should create breadcrumb + client.post("https://httpbin.org/post") { + setBody("{ \"message\": \"Hello from Sentry Ktor Client!\" }") + headers { + append("Content-Type", "application/json") + append("X-Custom-Header", "Sentry-Ktor-Sample") + } + } +} diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 1f1a3d9a74..1f2bccff73 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4236,6 +4236,8 @@ public final class io/sentry/TypeCheckHint { public static final field GRAPHQL_DATA_FETCHING_ENVIRONMENT Ljava/lang/String; public static final field GRAPHQL_HANDLER_PARAMETERS Ljava/lang/String; public static final field JUL_LOG_RECORD Ljava/lang/String; + public static final field KTOR_REQUEST Ljava/lang/String; + public static final field KTOR_RESPONSE Ljava/lang/String; public static final field LOG4J_LOG_EVENT Ljava/lang/String; public static final field LOGBACK_LOGGING_EVENT Ljava/lang/String; public static final field OKHTTP_REQUEST Ljava/lang/String; diff --git a/sentry/src/main/java/io/sentry/TypeCheckHint.java b/sentry/src/main/java/io/sentry/TypeCheckHint.java index 280dd60543..a07d0f5558 100644 --- a/sentry/src/main/java/io/sentry/TypeCheckHint.java +++ b/sentry/src/main/java/io/sentry/TypeCheckHint.java @@ -131,4 +131,10 @@ public final class TypeCheckHint { /** Used for Spring exchange filter breadcrumbs. */ public static final String SPRING_EXCHANGE_FILTER_REQUEST = "springExchangeFilter:request"; + + /** Used for Ktor response breadcrumbs. */ + public static final String KTOR_RESPONSE = "ktor:response"; + + /** Used for Ktor Request breadcrumbs. */ + public static final String KTOR_REQUEST = "ktor:request"; } diff --git a/settings.gradle.kts b/settings.gradle.kts index 7081c58fbe..9b8a1a0a66 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -62,10 +62,12 @@ include( "sentry-quartz", "sentry-okhttp", "sentry-reactor", + "sentry-ktor", "sentry-samples:sentry-samples-android", "sentry-samples:sentry-samples-console", "sentry-samples:sentry-samples-console-opentelemetry-noagent", "sentry-samples:sentry-samples-jul", + "sentry-samples:sentry-samples-ktor", "sentry-samples:sentry-samples-log4j2", "sentry-samples:sentry-samples-logback", "sentry-samples:sentry-samples-servlet", From 93b4a83a05cfd9b6f4592848aecb7b75e8e05665 Mon Sep 17 00:00:00 2001 From: lcian Date: Fri, 27 Jun 2025 13:58:16 +0200 Subject: [PATCH 02/16] tracing --- .../io/sentry/ktor/SentryKtorClientPlugin.kt | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt index f6075535e5..134a137e5c 100644 --- a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt +++ b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt @@ -7,16 +7,23 @@ import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.util.* import io.ktor.util.pipeline.* +import io.sentry.BaggageHeader import io.sentry.HttpStatusCodeRange import io.sentry.IScopes import io.sentry.ISpan import io.sentry.ScopesAdapter import io.sentry.Sentry import io.sentry.SentryIntegrationPackageStorage +import io.sentry.SentryLongDate import io.sentry.SentryOptions +import io.sentry.SpanStatus import io.sentry.kotlin.SentryContext +import io.sentry.transport.CurrentDateProvider import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion +import io.sentry.util.Platform import io.sentry.util.PropagationTargetsUtils +import io.sentry.util.SpanUtils +import io.sentry.util.TracingUtils import kotlinx.coroutines.withContext /** Configuration for the Sentry Ktor client plugin. */ @@ -57,6 +64,7 @@ public class SentryKtorClientPluginConfig { } internal const val SENTRY_KTOR_CLIENT_PLUGIN_KEY = "SentryKtorClientPlugin" +internal const val TRACE_ORIGIN = "auto.http.ktor" /** * Sentry plugin for Ktor HTTP client that provides automatic instrumentation for HTTP requests, @@ -78,17 +86,45 @@ public val SentryKtorClientPlugin: ClientPlugin = // Attributes // Request start time for breadcrumbs val requestStartTimestampKey = AttributeKey("SentryRequestStartTimestamp") + // Span associated with the request + val requestSpanKey = AttributeKey("SentryRequestSpan") onRequest { request, _ -> - request.attributes.put(requestStartTimestampKey, System.currentTimeMillis()) - // TODO: start span - // TODO: inject tracing headers + request.attributes.put( + requestStartTimestampKey, + CurrentDateProvider.getInstance().currentTimeMillis, + ) + + val parentSpan = if (Platform.isAndroid()) scopes.transaction else scopes.span + val spanOp = "http.client" + val spanDescription = "${request.method.value.toString()} ${request.url.buildString()}" + val span = + if (parentSpan != null) parentSpan.startChild(spanOp, spanDescription) + else Sentry.startTransaction(spanDescription, spanOp) + request.attributes.put(requestSpanKey, span) + + if (SpanUtils.isIgnored(scopes.getOptions().getIgnoredSpanOrigins(), TRACE_ORIGIN)) { + TracingUtils.traceIfAllowed( + scopes, + request.url.buildString(), + request.headers.getAll(BaggageHeader.BAGGAGE_HEADER), + span, + ) + ?.let { tracingHeaders -> + request.headers[tracingHeaders.sentryTraceHeader.name] = + tracingHeaders.sentryTraceHeader.value + tracingHeaders.baggageHeader?.let { + request.headers.remove(BaggageHeader.BAGGAGE_HEADER) + request.headers[it.name] = it.value + } + } + } } onResponse { response -> val request = response.request val startTimestamp = response.call.attributes.getOrNull(requestStartTimestampKey) - val endTimestamp = System.currentTimeMillis() + val endTimestamp = CurrentDateProvider.getInstance().currentTimeMillis if ( captureFailedRequests && @@ -100,7 +136,10 @@ public val SentryKtorClientPlugin: ClientPlugin = SentryKtorClientUtils.addBreadcrumb(scopes, request, response, startTimestamp, endTimestamp) - // TODO: end span + response.call.attributes.getOrNull(requestSpanKey)?.let { span -> + val spanStatus = SpanStatus.fromHttpStatusCode(response.status.value) + span.finish(spanStatus, SentryLongDate(endTimestamp * 1000)) + } } on(SentryKtorClientPluginContextHook()) { block -> block() } From f9538d5df6a1c0ca652135901f33ca66aa38f3dc Mon Sep 17 00:00:00 2001 From: lcian Date: Fri, 27 Jun 2025 14:10:33 +0200 Subject: [PATCH 03/16] improve --- .../src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt | 2 +- .../src/main/java/io/sentry/samples/ktor/Main.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt index 134a137e5c..82b4ad7873 100644 --- a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt +++ b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt @@ -68,7 +68,7 @@ internal const val TRACE_ORIGIN = "auto.http.ktor" /** * Sentry plugin for Ktor HTTP client that provides automatic instrumentation for HTTP requests, - * including distributed tracing, breadcrumbs, and error capturing. + * including error capturing, request/response breadcrumbs, and distributed tracing. */ public val SentryKtorClientPlugin: ClientPlugin = createClientPlugin(SENTRY_KTOR_CLIENT_PLUGIN_KEY, ::SentryKtorClientPluginConfig) { diff --git a/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt b/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt index 47660af533..6455af6e5a 100644 --- a/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt +++ b/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt @@ -21,9 +21,11 @@ fun main() { val client = HttpClient(Java) { install(SentryKtorClientPlugin) { failedRequestTargets = listOf(".*") } } + val tx = Sentry.startTransaction("My Transaction", "test") runBlocking { makeRequests(client) } Sentry.captureMessage("Ktor client sample done") + tx.finish() } suspend fun makeRequests(client: HttpClient) { From 26372fe4e2392c35ab7d1552252a86a95ce2fd61 Mon Sep 17 00:00:00 2001 From: lcian Date: Mon, 30 Jun 2025 16:34:26 +0200 Subject: [PATCH 04/16] fix scopes and stuff --- .../io/sentry/ktor/SentryKtorClientPlugin.kt | 32 ++++++++++--------- .../main/java/io/sentry/samples/ktor/Main.kt | 5 ++- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt index 82b4ad7873..77a6f888df 100644 --- a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt +++ b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt @@ -8,6 +8,7 @@ import io.ktor.client.statement.* import io.ktor.util.* import io.ktor.util.pipeline.* import io.sentry.BaggageHeader +import io.sentry.DateUtils import io.sentry.HttpStatusCodeRange import io.sentry.IScopes import io.sentry.ISpan @@ -20,7 +21,6 @@ import io.sentry.SpanStatus import io.sentry.kotlin.SentryContext import io.sentry.transport.CurrentDateProvider import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion -import io.sentry.util.Platform import io.sentry.util.PropagationTargetsUtils import io.sentry.util.SpanUtils import io.sentry.util.TracingUtils @@ -95,7 +95,7 @@ public val SentryKtorClientPlugin: ClientPlugin = CurrentDateProvider.getInstance().currentTimeMillis, ) - val parentSpan = if (Platform.isAndroid()) scopes.transaction else scopes.span + val parentSpan = Sentry.getCurrentScopes().span val spanOp = "http.client" val spanDescription = "${request.method.value.toString()} ${request.url.buildString()}" val span = @@ -103,13 +103,13 @@ public val SentryKtorClientPlugin: ClientPlugin = else Sentry.startTransaction(spanDescription, spanOp) request.attributes.put(requestSpanKey, span) - if (SpanUtils.isIgnored(scopes.getOptions().getIgnoredSpanOrigins(), TRACE_ORIGIN)) { + if (SpanUtils.isIgnored(Sentry.getCurrentScopes().options.getIgnoredSpanOrigins(), TRACE_ORIGIN)) { TracingUtils.traceIfAllowed( - scopes, - request.url.buildString(), - request.headers.getAll(BaggageHeader.BAGGAGE_HEADER), - span, - ) + Sentry.getCurrentScopes(), + request.url.buildString(), + request.headers.getAll(BaggageHeader.BAGGAGE_HEADER), + span, + ) ?.let { tracingHeaders -> request.headers[tracingHeaders.sentryTraceHeader.name] = tracingHeaders.sentryTraceHeader.value @@ -128,8 +128,8 @@ public val SentryKtorClientPlugin: ClientPlugin = if ( captureFailedRequests && - failedRequestStatusCodes.any { it.isInRange(response.status.value) } && - PropagationTargetsUtils.contain(failedRequestTargets, request.url.toString()) + failedRequestStatusCodes.any { it.isInRange(response.status.value) } && + PropagationTargetsUtils.contain(failedRequestTargets, request.url.toString()) ) { SentryKtorClientUtils.captureClientError(scopes, request, response) } @@ -138,21 +138,23 @@ public val SentryKtorClientPlugin: ClientPlugin = response.call.attributes.getOrNull(requestSpanKey)?.let { span -> val spanStatus = SpanStatus.fromHttpStatusCode(response.status.value) - span.finish(spanStatus, SentryLongDate(endTimestamp * 1000)) + span.finish(spanStatus, SentryLongDate(DateUtils.millisToNanos(endTimestamp))) } } - on(SentryKtorClientPluginContextHook()) { block -> block() } + on(SentryKtorClientPluginContextHook(scopes)) { block -> block() } } -public open class SentryKtorClientPluginContextHook : - ClientHook Unit) -> Unit> { +public open class SentryKtorClientPluginContextHook( + protected val scopes: IScopes +) : ClientHook Unit) -> Unit> { private val phase = PipelinePhase("SentryKtorClientPluginContext") override fun install(client: HttpClient, handler: suspend (suspend () -> Unit) -> Unit) { client.requestPipeline.insertPhaseBefore(HttpRequestPipeline.Before, phase) client.requestPipeline.intercept(phase) { - withContext(SentryContext(Sentry.forkedCurrentScope(SENTRY_KTOR_CLIENT_PLUGIN_KEY))) { + val scopes = this@SentryKtorClientPluginContextHook.scopes.forkedCurrentScope(SENTRY_KTOR_CLIENT_PLUGIN_KEY) + withContext(SentryContext(scopes)) { proceed() } } diff --git a/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt b/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt index 6455af6e5a..d045cc2d1f 100644 --- a/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt +++ b/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt @@ -5,6 +5,7 @@ import io.ktor.client.engine.java.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.sentry.Sentry +import io.sentry.TransactionOptions import io.sentry.ktor.SentryKtorClientPlugin import kotlinx.coroutines.runBlocking @@ -21,7 +22,9 @@ fun main() { val client = HttpClient(Java) { install(SentryKtorClientPlugin) { failedRequestTargets = listOf(".*") } } - val tx = Sentry.startTransaction("My Transaction", "test") + val opts = TransactionOptions().apply { isBindToScope = true } + val tx = Sentry.startTransaction("My Transaction", "test", opts) + runBlocking { makeRequests(client) } Sentry.captureMessage("Ktor client sample done") From c01a7561f08d1d175958183adb1da1873d1ca108 Mon Sep 17 00:00:00 2001 From: Sentry Github Bot Date: Mon, 30 Jun 2025 14:37:20 +0000 Subject: [PATCH 05/16] Format code --- .../io/sentry/ktor/SentryKtorClientPlugin.kt | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt index 77a6f888df..e67e26b48c 100644 --- a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt +++ b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt @@ -103,13 +103,15 @@ public val SentryKtorClientPlugin: ClientPlugin = else Sentry.startTransaction(spanDescription, spanOp) request.attributes.put(requestSpanKey, span) - if (SpanUtils.isIgnored(Sentry.getCurrentScopes().options.getIgnoredSpanOrigins(), TRACE_ORIGIN)) { + if ( + SpanUtils.isIgnored(Sentry.getCurrentScopes().options.getIgnoredSpanOrigins(), TRACE_ORIGIN) + ) { TracingUtils.traceIfAllowed( - Sentry.getCurrentScopes(), - request.url.buildString(), - request.headers.getAll(BaggageHeader.BAGGAGE_HEADER), - span, - ) + Sentry.getCurrentScopes(), + request.url.buildString(), + request.headers.getAll(BaggageHeader.BAGGAGE_HEADER), + span, + ) ?.let { tracingHeaders -> request.headers[tracingHeaders.sentryTraceHeader.name] = tracingHeaders.sentryTraceHeader.value @@ -128,8 +130,8 @@ public val SentryKtorClientPlugin: ClientPlugin = if ( captureFailedRequests && - failedRequestStatusCodes.any { it.isInRange(response.status.value) } && - PropagationTargetsUtils.contain(failedRequestTargets, request.url.toString()) + failedRequestStatusCodes.any { it.isInRange(response.status.value) } && + PropagationTargetsUtils.contain(failedRequestTargets, request.url.toString()) ) { SentryKtorClientUtils.captureClientError(scopes, request, response) } @@ -145,18 +147,18 @@ public val SentryKtorClientPlugin: ClientPlugin = on(SentryKtorClientPluginContextHook(scopes)) { block -> block() } } -public open class SentryKtorClientPluginContextHook( - protected val scopes: IScopes -) : ClientHook Unit) -> Unit> { +public open class SentryKtorClientPluginContextHook(protected val scopes: IScopes) : + ClientHook Unit) -> Unit> { private val phase = PipelinePhase("SentryKtorClientPluginContext") override fun install(client: HttpClient, handler: suspend (suspend () -> Unit) -> Unit) { client.requestPipeline.insertPhaseBefore(HttpRequestPipeline.Before, phase) client.requestPipeline.intercept(phase) { - val scopes = this@SentryKtorClientPluginContextHook.scopes.forkedCurrentScope(SENTRY_KTOR_CLIENT_PLUGIN_KEY) - withContext(SentryContext(scopes)) { - proceed() - } + val scopes = + this@SentryKtorClientPluginContextHook.scopes.forkedCurrentScope( + SENTRY_KTOR_CLIENT_PLUGIN_KEY + ) + withContext(SentryContext(scopes)) { proceed() } } } } From ecaf243f1af98bf64d343fafc4614958cdcfb300 Mon Sep 17 00:00:00 2001 From: lcian Date: Mon, 30 Jun 2025 16:43:49 +0200 Subject: [PATCH 06/16] improve --- sentry-ktor/api/sentry-ktor.api | 3 ++- .../src/main/java/io/sentry/samples/ktor/Main.kt | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry-ktor/api/sentry-ktor.api b/sentry-ktor/api/sentry-ktor.api index 4d12358a7a..546f7113cd 100644 --- a/sentry-ktor/api/sentry-ktor.api +++ b/sentry-ktor/api/sentry-ktor.api @@ -22,7 +22,8 @@ public abstract interface class io/sentry/ktor/SentryKtorClientPluginConfig$Befo } public class io/sentry/ktor/SentryKtorClientPluginContextHook : io/ktor/client/plugins/api/ClientHook { - public fun ()V + public fun (Lio/sentry/IScopes;)V + protected final fun getScopes ()Lio/sentry/IScopes; public synthetic fun install (Lio/ktor/client/HttpClient;Ljava/lang/Object;)V public fun install (Lio/ktor/client/HttpClient;Lkotlin/jvm/functions/Function2;)V } diff --git a/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt b/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt index d045cc2d1f..08565f07f9 100644 --- a/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt +++ b/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt @@ -3,7 +3,6 @@ package io.sentry.samples.ktor import io.ktor.client.* import io.ktor.client.engine.java.* import io.ktor.client.request.* -import io.ktor.client.statement.* import io.sentry.Sentry import io.sentry.TransactionOptions import io.sentry.ktor.SentryKtorClientPlugin From 61e714e5f24c493a4f14700a22b117f24170b06b Mon Sep 17 00:00:00 2001 From: lcian Date: Tue, 1 Jul 2025 15:23:46 +0200 Subject: [PATCH 07/16] work --- .../io/sentry/ktor/SentryKtorClientPlugin.kt | 18 +- .../sentry/ktor/SentryKtorClientPluginTest.kt | 290 +++++++++++++++++- 2 files changed, 299 insertions(+), 9 deletions(-) diff --git a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt index e67e26b48c..a88bfb0c52 100644 --- a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt +++ b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt @@ -19,6 +19,7 @@ import io.sentry.SentryLongDate import io.sentry.SentryOptions import io.sentry.SpanStatus import io.sentry.kotlin.SentryContext +import io.sentry.ktor.SentryKtorClientPluginConfig.BeforeSpanCallback import io.sentry.transport.CurrentDateProvider import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.PropagationTargetsUtils @@ -59,7 +60,7 @@ public class SentryKtorClientPluginConfig { * @param request The HTTP request being executed * @return The customized span, or null to drop the span */ - public fun execute(span: ISpan, request: HttpRequestBuilder): ISpan? + public fun execute(span: ISpan, request: HttpRequest): ISpan? } } @@ -79,6 +80,7 @@ public val SentryKtorClientPlugin: ClientPlugin = // Options val scopes = pluginConfig.scopes + val beforeSpan = pluginConfig.beforeSpan val captureFailedRequests = pluginConfig.captureFailedRequests val failedRequestStatusCodes = pluginConfig.failedRequestStatusCodes val failedRequestTargets = pluginConfig.failedRequestTargets @@ -99,8 +101,7 @@ public val SentryKtorClientPlugin: ClientPlugin = val spanOp = "http.client" val spanDescription = "${request.method.value.toString()} ${request.url.buildString()}" val span = - if (parentSpan != null) parentSpan.startChild(spanOp, spanDescription) - else Sentry.startTransaction(spanDescription, spanOp) + parentSpan?.startChild(spanOp, spanDescription) ?: Sentry.startTransaction(spanDescription, spanOp) request.attributes.put(requestSpanKey, span) if ( @@ -139,6 +140,17 @@ public val SentryKtorClientPlugin: ClientPlugin = SentryKtorClientUtils.addBreadcrumb(scopes, request, response, startTimestamp, endTimestamp) response.call.attributes.getOrNull(requestSpanKey)?.let { span -> + var result: ISpan? = span + + if (beforeSpan != null) { + result = beforeSpan.execute(span, request) + } + + if (result == null) { + // span is dropped + span.spanContext.sampled = false + } + val spanStatus = SpanStatus.fromHttpStatusCode(response.status.value) span.finish(spanStatus, SentryLongDate(DateUtils.millisToNanos(endTimestamp))) } diff --git a/sentry-ktor/src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt b/sentry-ktor/src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt index 73c78f61c7..449f42c219 100644 --- a/sentry-ktor/src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt +++ b/sentry-ktor/src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt @@ -6,6 +6,7 @@ import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.contentType +import io.sentry.BaggageHeader import io.sentry.Breadcrumb import io.sentry.Hint import io.sentry.HttpStatusCodeRange @@ -15,11 +16,19 @@ import io.sentry.Scope import io.sentry.ScopeCallback import io.sentry.SentryEvent import io.sentry.SentryOptions +import io.sentry.Sentry +import io.sentry.SentryTraceHeader +import io.sentry.SentryTracer import io.sentry.SpanDataConvention +import io.sentry.SpanStatus +import io.sentry.TransactionContext import io.sentry.exception.SentryHttpClientException +import io.sentry.mockServerRequestTimeoutMillis +import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.runBlocking import okhttp3.mockwebserver.MockResponse @@ -38,14 +47,19 @@ class SentryKtorClientPluginTest { class Fixture { val scopes = mock() val server = MockWebServer() - var options: SentryOptions? = null - var scope: IScope? = null + lateinit var sentryTracer: SentryTracer + lateinit var options: SentryOptions + lateinit var scope: IScope @SuppressWarnings("LongParameterList") fun getSut( + isSpanActive: Boolean = true, httpStatusCode: Int = 201, responseBody: String = "success", socketPolicy: SocketPolicy = SocketPolicy.KEEP_OPEN, + beforeSpan: SentryKtorClientPluginConfig.BeforeSpanCallback? = null, + includeMockServerInTracePropagationTargets: Boolean = true, + keepDefaultTracePropagationTargets: Boolean = false, captureFailedRequests: Boolean = false, failedRequestTargets: List = listOf(".*"), failedRequestStatusCodes: List = @@ -53,20 +67,34 @@ class SentryKtorClientPluginTest { HttpStatusCodeRange(HttpStatusCodeRange.DEFAULT_MIN, HttpStatusCodeRange.DEFAULT_MAX) ), sendDefaultPii: Boolean = false, - optionsConfiguration: ((SentryOptions) -> Unit)? = null, + optionsConfiguration: Sentry.OptionsConfiguration? = null, ): HttpClient { options = SentryOptions().also { + optionsConfiguration?.configure(it) it.dsn = "https://key@sentry.io/proj" + if (includeMockServerInTracePropagationTargets) { + it.setTracePropagationTargets(listOf(server.hostName)) + } else if (!keepDefaultTracePropagationTargets) { + it.setTracePropagationTargets(listOf("other-api")) + } it.isSendDefaultPii = sendDefaultPii - optionsConfiguration?.invoke(it) } - scope = Scope(options!!) + scope = Scope(options) whenever(scopes.options).thenReturn(options) - doAnswer { (it.arguments[0] as ScopeCallback).run(scope!!) } + doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } .whenever(scopes) .configureScope(any()) + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) + + if (isSpanActive) { + whenever(scopes.span).thenReturn(sentryTracer) + } + + // Mock forkedCurrentScope to return the same scopes instance + whenever(scopes.forkedCurrentScope(SENTRY_KTOR_CLIENT_PLUGIN_KEY)).thenReturn(scopes) + server.enqueue( MockResponse() .setBody(responseBody) @@ -250,4 +278,254 @@ class SentryKtorClientPluginTest { any(), ) } + + @Test + fun `creates a span around the request when there is an active span`(): Unit = runBlocking { + val sut = fixture.getSut() + val url = fixture.server.url("/hello").toString() + sut.get(url) + + assertEquals(1, fixture.sentryTracer.children.size) + val httpClientSpan = fixture.sentryTracer.children.first() + assertEquals("http.client", httpClientSpan.operation) + assertEquals("GET $url", httpClientSpan.description) + assertEquals(201, httpClientSpan.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + assertEquals("auto.http.ktor", httpClientSpan.spanContext.origin) + assertEquals(SpanStatus.OK, httpClientSpan.status) + assertTrue(httpClientSpan.isFinished) + } + + @Test + fun `creates a transaction when there is no active span`(): Unit = runBlocking { + val sut = fixture.getSut(isSpanActive = false) + val url = fixture.server.url("/hello").toString() + sut.get(url) + + // When there's no active span, a transaction is created instead of a child span + // The transaction itself won't be a child of sentryTracer, but would be started independently + // We can't easily test the transaction creation in this mock setup, + // but we can verify no child spans were created on the existing tracer + assertEquals(0, fixture.sentryTracer.children.size) + } + + @Test + fun `span status is set based on http status code`(): Unit = runBlocking { + val sut = fixture.getSut(httpStatusCode = 404) + sut.get(fixture.server.url("/hello").toString()) + + val httpClientSpan = fixture.sentryTracer.children.first() + assertEquals(404, httpClientSpan.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + assertEquals(SpanStatus.NOT_FOUND, httpClientSpan.status) + assertTrue(httpClientSpan.isFinished) + } + + @Test + fun `span status is set to OK for successful requests`(): Unit = runBlocking { + val sut = fixture.getSut(httpStatusCode = 200) + sut.get(fixture.server.url("/hello").toString()) + + val httpClientSpan = fixture.sentryTracer.children.first() + assertEquals(200, httpClientSpan.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + assertEquals(SpanStatus.OK, httpClientSpan.status) + assertTrue(httpClientSpan.isFinished) + } + + @Test + fun `span status is set for server errors`(): Unit = runBlocking { + val sut = fixture.getSut(httpStatusCode = 500) + sut.get(fixture.server.url("/hello").toString()) + + val httpClientSpan = fixture.sentryTracer.children.first() + assertEquals(500, httpClientSpan.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + assertEquals(SpanStatus.INTERNAL_ERROR, httpClientSpan.status) + assertTrue(httpClientSpan.isFinished) + } + + @Test + fun `span status is set for client errors`(): Unit = runBlocking { + val sut = fixture.getSut(httpStatusCode = 400) + sut.get(fixture.server.url("/hello").toString()) + + val httpClientSpan = fixture.sentryTracer.children.first() + assertEquals(400, httpClientSpan.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + assertEquals(SpanStatus.INVALID_ARGUMENT, httpClientSpan.status) + assertTrue(httpClientSpan.isFinished) + } + + @Test + fun `span status is null for unmapped status codes`(): Unit = runBlocking { + val sut = fixture.getSut(httpStatusCode = 418) // I'm a teapot - unmapped status + sut.get(fixture.server.url("/hello").toString()) + + val httpClientSpan = fixture.sentryTracer.children.first() + assertEquals(418, httpClientSpan.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) + assertNull(httpClientSpan.status) + assertTrue(httpClientSpan.isFinished) + } + + @Test + fun `span description includes HTTP method and URL for GET request`(): Unit = runBlocking { + val sut = fixture.getSut() + val url = fixture.server.url("/api/users").toString() + sut.get(url) + + val httpClientSpan = fixture.sentryTracer.children.first() + assertEquals("GET $url", httpClientSpan.description) + } + + @Test + fun `span description includes HTTP method and URL for POST request`(): Unit = runBlocking { + val sut = fixture.getSut() + val url = fixture.server.url("/api/users").toString() + sut.post(url) { + setBody("request body") + } + + val httpClientSpan = fixture.sentryTracer.children.first() + assertEquals("POST $url", httpClientSpan.description) + } + + @Test + fun `span is finished even when request fails`(): Unit = runBlocking { + val sut = fixture.getSut(socketPolicy = SocketPolicy.DISCONNECT_AT_START) + + try { + sut.get(fixture.server.url("/hello").toString()) + } catch (e: Exception) { + // Expected to fail + } + + val httpClientSpan = fixture.sentryTracer.children.firstOrNull() + if (httpClientSpan != null) { + assertTrue(httpClientSpan.isFinished) + } + } + + @Test + fun `multiple requests create multiple spans`(): Unit = runBlocking { + val sut = fixture.getSut() + + // Make multiple requests + sut.get(fixture.server.url("/hello1").toString()) + + // Enqueue another response for the second request + fixture.server.enqueue( + MockResponse() + .setBody("success2") + .setResponseCode(200) + ) + sut.get(fixture.server.url("/hello2").toString()) + + assertEquals(2, fixture.sentryTracer.children.size) + assertTrue(fixture.sentryTracer.children.all { it.isFinished }) + + // Verify both spans have correct properties + val spans = fixture.sentryTracer.children + spans.forEach { span -> + assertEquals("http.client", span.operation) + assertEquals("auto.http.ktor", span.spanContext.origin) + assertTrue(span.isFinished) + } + } + + @Test + fun `when there is an active span and server is listed in tracing origins, adds sentry trace headers to the request`(): Unit = runBlocking { + val sut = fixture.getSut() + sut.get(fixture.server.url("/hello").toString()) + + val recordedRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `when there is an active span and tracing origins contains default regex, adds sentry trace headers to the request`(): Unit = runBlocking { + val sut = fixture.getSut(keepDefaultTracePropagationTargets = true) + sut.get(fixture.server.url("/hello").toString()) + + val recordedRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `when there is an active span and server is not listed in tracing origins, does not add sentry trace headers to the request`(): Unit = runBlocking { + val sut = fixture.getSut(includeMockServerInTracePropagationTargets = false) + sut.get(fixture.server.url("/hello").toString()) + + val recordedRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `when there is an active span and server tracing origins is empty, does not add sentry trace headers to the request`(): Unit = runBlocking { + val sut = fixture.getSut() + fixture.options.setTracePropagationTargets(emptyList()) + sut.get(fixture.server.url("/hello").toString()) + + val recordedRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `when there is no active span, adds sentry trace header to the request from scope`(): Unit = runBlocking { + val sut = fixture.getSut(isSpanActive = false) + sut.get(fixture.server.url("/hello").toString()) + + val recordedRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `does not add sentry-trace header when span origin is ignored`(): Unit = runBlocking { + val sut = fixture.getSut(isSpanActive = false) { options -> + options.setIgnoredSpanOrigins(listOf("auto.http.ktor")) + } + sut.get(fixture.server.url("/hello").toString()) + + val recordedRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `when there is no active span and host is not allowed, does not add sentry trace header to the request`(): Unit = runBlocking { + val sut = fixture.getSut(isSpanActive = false) + fixture.options.setTracePropagationTargets(listOf("some-host-that-does-not-exist")) + sut.get(fixture.server.url("/hello").toString()) + + val recordedRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } + + @Test + fun `when there is an active span, existing baggage headers are merged with sentry baggage into single header`(): Unit = runBlocking { + val sut = fixture.getSut() + sut.get(fixture.server.url("/hello").toString()) { + headers["baggage"] = "thirdPartyBaggage=someValue" + headers.append("baggage", "secondThirdPartyBaggage=secondValue; property;propertyKey=propertyValue,anotherThirdPartyBaggage=anotherValue") + } + + val recordedRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + + val baggageHeaderValues = recordedRequest.headers.values(BaggageHeader.BAGGAGE_HEADER) + assertEquals(1, baggageHeaderValues.size) + assertTrue( + baggageHeaderValues[0].startsWith( + "thirdPartyBaggage=someValue," + + "secondThirdPartyBaggage=secondValue; " + + "property;propertyKey=propertyValue," + + "anotherThirdPartyBaggage=anotherValue" + ) + ) + assertTrue(baggageHeaderValues[0].contains("sentry-public_key=key")) + assertTrue(baggageHeaderValues[0].contains("sentry-transaction=name")) + assertTrue(baggageHeaderValues[0].contains("sentry-trace_id")) + } } From 82c54aa3181bf67476d101260e3dd7ee302651d3 Mon Sep 17 00:00:00 2001 From: lcian Date: Wed, 2 Jul 2025 14:31:01 +0200 Subject: [PATCH 08/16] improve --- sentry-ktor/api/sentry-ktor.api | 2 +- .../io/sentry/ktor/SentryKtorClientPlugin.kt | 44 ++- .../sentry/ktor/SentryKtorClientPluginTest.kt | 316 ++++++------------ 3 files changed, 142 insertions(+), 220 deletions(-) diff --git a/sentry-ktor/api/sentry-ktor.api b/sentry-ktor/api/sentry-ktor.api index 546f7113cd..47ece56229 100644 --- a/sentry-ktor/api/sentry-ktor.api +++ b/sentry-ktor/api/sentry-ktor.api @@ -18,7 +18,7 @@ public final class io/sentry/ktor/SentryKtorClientPluginConfig { } public abstract interface class io/sentry/ktor/SentryKtorClientPluginConfig$BeforeSpanCallback { - public abstract fun execute (Lio/sentry/ISpan;Lio/ktor/client/request/HttpRequestBuilder;)Lio/sentry/ISpan; + public abstract fun execute (Lio/sentry/ISpan;Lio/ktor/client/request/HttpRequest;)Lio/sentry/ISpan; } public class io/sentry/ktor/SentryKtorClientPluginContextHook : io/ktor/client/plugins/api/ClientHook { diff --git a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt index a88bfb0c52..e1f63de684 100644 --- a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt +++ b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt @@ -5,6 +5,7 @@ import io.ktor.client.plugins.api.* import io.ktor.client.plugins.api.ClientPlugin import io.ktor.client.request.* import io.ktor.client.statement.* +import io.ktor.http.* import io.ktor.util.* import io.ktor.util.pipeline.* import io.sentry.BaggageHeader @@ -19,7 +20,6 @@ import io.sentry.SentryLongDate import io.sentry.SentryOptions import io.sentry.SpanStatus import io.sentry.kotlin.SentryContext -import io.sentry.ktor.SentryKtorClientPluginConfig.BeforeSpanCallback import io.sentry.transport.CurrentDateProvider import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion import io.sentry.util.PropagationTargetsUtils @@ -62,6 +62,9 @@ public class SentryKtorClientPluginConfig { */ public fun execute(span: ISpan, request: HttpRequest): ISpan? } + + /** Forcefully use the passed in scope. Used for testing. */ + internal var forceScopes: Boolean = false } internal const val SENTRY_KTOR_CLIENT_PLUGIN_KEY = "SentryKtorClientPlugin" @@ -84,6 +87,7 @@ public val SentryKtorClientPlugin: ClientPlugin = val captureFailedRequests = pluginConfig.captureFailedRequests val failedRequestStatusCodes = pluginConfig.failedRequestStatusCodes val failedRequestTargets = pluginConfig.failedRequestTargets + val forceScopes = pluginConfig.forceScopes // Attributes // Request start time for breadcrumbs @@ -97,18 +101,27 @@ public val SentryKtorClientPlugin: ClientPlugin = CurrentDateProvider.getInstance().currentTimeMillis, ) - val parentSpan = Sentry.getCurrentScopes().span + val parentSpan: ISpan? = if (forceScopes) scopes.getSpan() else Sentry.getSpan() + val spanOp = "http.client" val spanDescription = "${request.method.value.toString()} ${request.url.buildString()}" - val span = - parentSpan?.startChild(spanOp, spanDescription) ?: Sentry.startTransaction(spanDescription, spanOp) + val span: ISpan = + if (parentSpan != null) { + parentSpan.startChild(spanOp, spanDescription) + } else { + Sentry.startTransaction(spanDescription, spanOp) + } + span.spanContext.origin = TRACE_ORIGIN request.attributes.put(requestSpanKey, span) if ( - SpanUtils.isIgnored(Sentry.getCurrentScopes().options.getIgnoredSpanOrigins(), TRACE_ORIGIN) + !SpanUtils.isIgnored( + (if (forceScopes) scopes else Sentry.getCurrentScopes()).options.getIgnoredSpanOrigins(), + TRACE_ORIGIN, + ) ) { TracingUtils.traceIfAllowed( - Sentry.getCurrentScopes(), + if (forceScopes) scopes else Sentry.getCurrentScopes(), request.url.buildString(), request.headers.getAll(BaggageHeader.BAGGAGE_HEADER), span, @@ -124,6 +137,19 @@ public val SentryKtorClientPlugin: ClientPlugin = } } + client.requestPipeline.intercept(HttpRequestPipeline.Before) { + try { + proceed() + } catch (t: Throwable) { + context.attributes.getOrNull(requestSpanKey)?.apply { + throwable = t + status = SpanStatus.INTERNAL_ERROR + finish() + } + throw t + } + } + onResponse { response -> val request = response.request val startTimestamp = response.call.attributes.getOrNull(requestStartTimestampKey) @@ -170,7 +196,11 @@ public open class SentryKtorClientPluginContextHook(protected val scopes: IScope this@SentryKtorClientPluginContextHook.scopes.forkedCurrentScope( SENTRY_KTOR_CLIENT_PLUGIN_KEY ) - withContext(SentryContext(scopes)) { proceed() } + withContext(SentryContext(scopes)) { + val s = Sentry.getCurrentScopes() + println(s) + proceed() + } } } } diff --git a/sentry-ktor/src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt b/sentry-ktor/src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt index 449f42c219..221a31657d 100644 --- a/sentry-ktor/src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt +++ b/sentry-ktor/src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt @@ -14,9 +14,9 @@ import io.sentry.IScope import io.sentry.IScopes import io.sentry.Scope import io.sentry.ScopeCallback +import io.sentry.Sentry import io.sentry.SentryEvent import io.sentry.SentryOptions -import io.sentry.Sentry import io.sentry.SentryTraceHeader import io.sentry.SentryTracer import io.sentry.SpanDataConvention @@ -89,11 +89,11 @@ class SentryKtorClientPluginTest { sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) if (isSpanActive) { + println("span active") whenever(scopes.span).thenReturn(sentryTracer) } - // Mock forkedCurrentScope to return the same scopes instance - whenever(scopes.forkedCurrentScope(SENTRY_KTOR_CLIENT_PLUGIN_KEY)).thenReturn(scopes) + whenever(scopes.forkedCurrentScope(any())).thenReturn(scopes) server.enqueue( MockResponse() @@ -109,6 +109,7 @@ class SentryKtorClientPluginTest { this.captureFailedRequests = captureFailedRequests this.failedRequestTargets = failedRequestTargets this.failedRequestStatusCodes = failedRequestStatusCodes + this.forceScopes = true } } } @@ -153,12 +154,44 @@ class SentryKtorClientPluginTest { } @Test - fun `captures an event if captureFailedRequests is enabled and status code is within the range`(): + fun `captures an event with request and response contexts if captureFailedRequests is enabled and status code is within the range`(): Unit = runBlocking { - val sut = fixture.getSut(captureFailedRequests = true, httpStatusCode = 500) - sut.get(fixture.server.url("/hello").toString()) + val statusCode = 500 + val responseBody = "failure" + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = statusCode, + responseBody = responseBody, + sendDefaultPii = true, + ) - verify(fixture.scopes).captureEvent(any(), any()) + val requestBody = "test" + sut.post(fixture.server.url("/hello?myQuery=myValue#myFragment").toString()) { + contentType(ContentType.Text.Plain) + setBody(requestBody) + } + + verify(fixture.scopes) + .captureEvent( + check { + val sentryRequest = it.request!! + assertEquals("http://localhost:${fixture.server.port}/hello", sentryRequest.url) + assertEquals("myQuery=myValue", sentryRequest.queryString) + assertEquals("myFragment", sentryRequest.fragment) + assertEquals("POST", sentryRequest.method) + assertEquals(requestBody.length.toLong(), sentryRequest.bodySize) + assertNotNull(sentryRequest.headers) + + val sentryResponse = it.contexts.response!! + assertEquals(statusCode, sentryResponse.statusCode) + assertEquals(responseBody.length.toLong(), sentryResponse.bodySize) + assertNotNull(sentryResponse.headers) + + assertTrue(it.throwable is SentryHttpClientException) + }, + any(), + ) } @Test @@ -198,7 +231,7 @@ class SentryKtorClientPluginTest { fixture.getSut( captureFailedRequests = true, httpStatusCode = 404, - failedRequestStatusCodes = listOf(HttpStatusCodeRange(400, 499)), // only client errors + failedRequestStatusCodes = listOf(HttpStatusCodeRange(400, 499)), ) sut.get(fixture.server.url("/hello").toString()) @@ -210,55 +243,14 @@ class SentryKtorClientPluginTest { val sut = fixture.getSut( captureFailedRequests = true, - httpStatusCode = 500, // server error - failedRequestStatusCodes = listOf(HttpStatusCodeRange(400, 499)), // only client errors + httpStatusCode = 500, + failedRequestStatusCodes = listOf(HttpStatusCodeRange(400, 499)), ) sut.get(fixture.server.url("/hello").toString()) verify(fixture.scopes, never()).captureEvent(any(), any()) } - @Test - fun `captures an error event with request and response data`(): Unit = runBlocking { - val statusCode = 500 - val responseBody = "failure" - val sut = - fixture.getSut( - captureFailedRequests = true, - httpStatusCode = statusCode, - responseBody = responseBody, - sendDefaultPii = true, - ) - - val requestBody = "test" - val response = - sut.post(fixture.server.url("/hello?myQuery=myValue#myFragment").toString()) { - contentType(ContentType.Text.Plain) - setBody(requestBody) - } - - verify(fixture.scopes) - .captureEvent( - check { - val sentryRequest = it.request!! - assertEquals("http://localhost:${fixture.server.port}/hello", sentryRequest.url) - assertEquals("myQuery=myValue", sentryRequest.queryString) - assertEquals("myFragment", sentryRequest.fragment) - assertEquals("POST", sentryRequest.method) - assertEquals(requestBody.length.toLong(), sentryRequest.bodySize) - assertNotNull(sentryRequest.headers) - - val sentryResponse = it.contexts.response!! - assertEquals(statusCode, sentryResponse.statusCode) - assertEquals(responseBody.length.toLong(), sentryResponse.bodySize) - assertNotNull(sentryResponse.headers) - - assertTrue(it.throwable is SentryHttpClientException) - }, - any(), - ) - } - @Test fun `does not capture headers when sendDefaultPii is disabled`(): Unit = runBlocking { val sut = @@ -280,7 +272,7 @@ class SentryKtorClientPluginTest { } @Test - fun `creates a span around the request when there is an active span`(): Unit = runBlocking { + fun `creates a span around the request`(): Unit = runBlocking { val sut = fixture.getSut() val url = fixture.server.url("/hello").toString() sut.get(url) @@ -289,228 +281,128 @@ class SentryKtorClientPluginTest { val httpClientSpan = fixture.sentryTracer.children.first() assertEquals("http.client", httpClientSpan.operation) assertEquals("GET $url", httpClientSpan.description) - assertEquals(201, httpClientSpan.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) assertEquals("auto.http.ktor", httpClientSpan.spanContext.origin) assertEquals(SpanStatus.OK, httpClientSpan.status) assertTrue(httpClientSpan.isFinished) } @Test - fun `creates a transaction when there is no active span`(): Unit = runBlocking { - val sut = fixture.getSut(isSpanActive = false) - val url = fixture.server.url("/hello").toString() - sut.get(url) - - // When there's no active span, a transaction is created instead of a child span - // The transaction itself won't be a child of sentryTracer, but would be started independently - // We can't easily test the transaction creation in this mock setup, - // but we can verify no child spans were created on the existing tracer - assertEquals(0, fixture.sentryTracer.children.size) - } - - @Test - fun `span status is set based on http status code`(): Unit = runBlocking { - val sut = fixture.getSut(httpStatusCode = 404) - sut.get(fixture.server.url("/hello").toString()) - - val httpClientSpan = fixture.sentryTracer.children.first() - assertEquals(404, httpClientSpan.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) - assertEquals(SpanStatus.NOT_FOUND, httpClientSpan.status) - assertTrue(httpClientSpan.isFinished) - } - - @Test - fun `span status is set to OK for successful requests`(): Unit = runBlocking { - val sut = fixture.getSut(httpStatusCode = 200) - sut.get(fixture.server.url("/hello").toString()) - - val httpClientSpan = fixture.sentryTracer.children.first() - assertEquals(200, httpClientSpan.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) - assertEquals(SpanStatus.OK, httpClientSpan.status) - assertTrue(httpClientSpan.isFinished) - } - - @Test - fun `span status is set for server errors`(): Unit = runBlocking { - val sut = fixture.getSut(httpStatusCode = 500) - sut.get(fixture.server.url("/hello").toString()) - - val httpClientSpan = fixture.sentryTracer.children.first() - assertEquals(500, httpClientSpan.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) - assertEquals(SpanStatus.INTERNAL_ERROR, httpClientSpan.status) - assertTrue(httpClientSpan.isFinished) - } - - @Test - fun `span status is set for client errors`(): Unit = runBlocking { - val sut = fixture.getSut(httpStatusCode = 400) - sut.get(fixture.server.url("/hello").toString()) - - val httpClientSpan = fixture.sentryTracer.children.first() - assertEquals(400, httpClientSpan.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) - assertEquals(SpanStatus.INVALID_ARGUMENT, httpClientSpan.status) - assertTrue(httpClientSpan.isFinished) - } - - @Test - fun `span status is null for unmapped status codes`(): Unit = runBlocking { - val sut = fixture.getSut(httpStatusCode = 418) // I'm a teapot - unmapped status - sut.get(fixture.server.url("/hello").toString()) - - val httpClientSpan = fixture.sentryTracer.children.first() - assertEquals(418, httpClientSpan.data[SpanDataConvention.HTTP_STATUS_CODE_KEY]) - assertNull(httpClientSpan.status) - assertTrue(httpClientSpan.isFinished) - } - - @Test - fun `span description includes HTTP method and URL for GET request`(): Unit = runBlocking { - val sut = fixture.getSut() - val url = fixture.server.url("/api/users").toString() - sut.get(url) - - val httpClientSpan = fixture.sentryTracer.children.first() - assertEquals("GET $url", httpClientSpan.description) - } - - @Test - fun `span description includes HTTP method and URL for POST request`(): Unit = runBlocking { - val sut = fixture.getSut() - val url = fixture.server.url("/api/users").toString() - sut.post(url) { - setBody("request body") - } - - val httpClientSpan = fixture.sentryTracer.children.first() - assertEquals("POST $url", httpClientSpan.description) - } - - @Test - fun `span is finished even when request fails`(): Unit = runBlocking { - val sut = fixture.getSut(socketPolicy = SocketPolicy.DISCONNECT_AT_START) + fun `finishes span setting throwable and status when request throws`(): Unit = runBlocking { + val sut = fixture.getSut(socketPolicy = SocketPolicy.DISCONNECT_DURING_REQUEST_BODY) + var exception: Exception? = null try { - sut.get(fixture.server.url("/hello").toString()) + sut.post(fixture.server.url("/hello?myQuery=myValue#myFragment").toString()) { + contentType(ContentType.Text.Plain) + setBody("hello hello") + } } catch (e: Exception) { - // Expected to fail - } - - val httpClientSpan = fixture.sentryTracer.children.firstOrNull() - if (httpClientSpan != null) { - assertTrue(httpClientSpan.isFinished) + exception = e } - } - - @Test - fun `multiple requests create multiple spans`(): Unit = runBlocking { - val sut = fixture.getSut() - - // Make multiple requests - sut.get(fixture.server.url("/hello1").toString()) - // Enqueue another response for the second request - fixture.server.enqueue( - MockResponse() - .setBody("success2") - .setResponseCode(200) - ) - sut.get(fixture.server.url("/hello2").toString()) - - assertEquals(2, fixture.sentryTracer.children.size) - assertTrue(fixture.sentryTracer.children.all { it.isFinished }) - - // Verify both spans have correct properties - val spans = fixture.sentryTracer.children - spans.forEach { span -> - assertEquals("http.client", span.operation) - assertEquals("auto.http.ktor", span.spanContext.origin) - assertTrue(span.isFinished) - } + val httpClientSpan = fixture.sentryTracer.children.first() + assertTrue(httpClientSpan.isFinished) + assertEquals(SpanStatus.INTERNAL_ERROR, httpClientSpan.status) + assertEquals( + httpClientSpan.throwable.toString(), + exception.toString(), + ) // stack trace will differ } @Test - fun `when there is an active span and server is listed in tracing origins, adds sentry trace headers to the request`(): Unit = runBlocking { + fun `when there is an active span and server is listed in tracing origins, adds sentry trace headers to the request`(): + Unit = runBlocking { val sut = fixture.getSut() sut.get(fixture.server.url("/hello").toString()) - val recordedRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + val recordedRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! assertNotNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) assertNotNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) } @Test - fun `when there is an active span and tracing origins contains default regex, adds sentry trace headers to the request`(): Unit = runBlocking { + fun `when there is an active span and tracing origins contains default regex, adds sentry trace headers to the request`(): + Unit = runBlocking { val sut = fixture.getSut(keepDefaultTracePropagationTargets = true) sut.get(fixture.server.url("/hello").toString()) - val recordedRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + val recordedRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! assertNotNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) assertNotNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) } @Test - fun `when there is an active span and server is not listed in tracing origins, does not add sentry trace headers to the request`(): Unit = runBlocking { - val sut = fixture.getSut(includeMockServerInTracePropagationTargets = false) - sut.get(fixture.server.url("/hello").toString()) - - val recordedRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! - assertNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) - assertNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) - } - - @Test - fun `when there is an active span and server tracing origins is empty, does not add sentry trace headers to the request`(): Unit = runBlocking { - val sut = fixture.getSut() - fixture.options.setTracePropagationTargets(emptyList()) + fun `when there is an active span and server is not listed in propagation targets, does not add sentry trace headers to the request`(): + Unit = runBlocking { + val sut = + fixture.getSut( + optionsConfiguration = { options -> options.setTracePropagationTargets(emptyList()) }, + includeMockServerInTracePropagationTargets = false, + keepDefaultTracePropagationTargets = false, + ) sut.get(fixture.server.url("/hello").toString()) - val recordedRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + val recordedRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! assertNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) assertNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) } @Test - fun `when there is no active span, adds sentry trace header to the request from scope`(): Unit = runBlocking { - val sut = fixture.getSut(isSpanActive = false) - sut.get(fixture.server.url("/hello").toString()) + fun `when there is no active span, adds sentry trace header to the request from scope`(): Unit = + runBlocking { + val sut = fixture.getSut(isSpanActive = false) + sut.get(fixture.server.url("/hello").toString()) - val recordedRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! - assertNotNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) - assertNotNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) - } + val recordedRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + assertNotNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) + assertNotNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) + } @Test fun `does not add sentry-trace header when span origin is ignored`(): Unit = runBlocking { - val sut = fixture.getSut(isSpanActive = false) { options -> - options.setIgnoredSpanOrigins(listOf("auto.http.ktor")) - } + val sut = + fixture.getSut(isSpanActive = false) { options -> + options.setIgnoredSpanOrigins(listOf("auto.http.ktor")) + } sut.get(fixture.server.url("/hello").toString()) - val recordedRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + val recordedRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! assertNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) assertNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) } @Test - fun `when there is no active span and host is not allowed, does not add sentry trace header to the request`(): Unit = runBlocking { + fun `when there is no active span and host is not allowed, does not add sentry trace header to the request`(): + Unit = runBlocking { val sut = fixture.getSut(isSpanActive = false) fixture.options.setTracePropagationTargets(listOf("some-host-that-does-not-exist")) sut.get(fixture.server.url("/hello").toString()) - val recordedRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + val recordedRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! assertNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) assertNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) } @Test - fun `when there is an active span, existing baggage headers are merged with sentry baggage into single header`(): Unit = runBlocking { + fun `when there is an active span, existing baggage headers are merged with sentry baggage into single header`(): + Unit = runBlocking { val sut = fixture.getSut() sut.get(fixture.server.url("/hello").toString()) { headers["baggage"] = "thirdPartyBaggage=someValue" - headers.append("baggage", "secondThirdPartyBaggage=secondValue; property;propertyKey=propertyValue,anotherThirdPartyBaggage=anotherValue") + headers.append( + "baggage", + "secondThirdPartyBaggage=secondValue; property;propertyKey=propertyValue,anotherThirdPartyBaggage=anotherValue", + ) } - val recordedRequest = fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! + val recordedRequest = + fixture.server.takeRequest(mockServerRequestTimeoutMillis, TimeUnit.MILLISECONDS)!! assertNotNull(recordedRequest.headers[SentryTraceHeader.SENTRY_TRACE_HEADER]) assertNotNull(recordedRequest.headers[BaggageHeader.BAGGAGE_HEADER]) From ad4abc9e6111751fbd301f3ec429cc230971eb58 Mon Sep 17 00:00:00 2001 From: lcian Date: Wed, 2 Jul 2025 14:43:29 +0200 Subject: [PATCH 09/16] improve --- .../src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-ktor/src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt b/sentry-ktor/src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt index 221a31657d..eb1e5dc386 100644 --- a/sentry-ktor/src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt +++ b/sentry-ktor/src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt @@ -338,10 +338,10 @@ class SentryKtorClientPluginTest { Unit = runBlocking { val sut = fixture.getSut( - optionsConfiguration = { options -> options.setTracePropagationTargets(emptyList()) }, includeMockServerInTracePropagationTargets = false, keepDefaultTracePropagationTargets = false, ) + fixture.options.setTracePropagationTargets(emptyList()) sut.get(fixture.server.url("/hello").toString()) val recordedRequest = From eb3db764e3b750cf21e782e9717a2459a90404d4 Mon Sep 17 00:00:00 2001 From: lcian Date: Wed, 2 Jul 2025 16:22:40 +0200 Subject: [PATCH 10/16] improve --- .../java/io/sentry/ktor/SentryKtorClientPlugin.kt | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt index e1f63de684..c5feb0bdba 100644 --- a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt +++ b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt @@ -106,11 +106,8 @@ public val SentryKtorClientPlugin: ClientPlugin = val spanOp = "http.client" val spanDescription = "${request.method.value.toString()} ${request.url.buildString()}" val span: ISpan = - if (parentSpan != null) { - parentSpan.startChild(spanOp, spanDescription) - } else { - Sentry.startTransaction(spanDescription, spanOp) - } + parentSpan?.startChild(spanOp, spanDescription) + ?: Sentry.startTransaction(spanDescription, spanOp) span.spanContext.origin = TRACE_ORIGIN request.attributes.put(requestSpanKey, span) @@ -196,11 +193,7 @@ public open class SentryKtorClientPluginContextHook(protected val scopes: IScope this@SentryKtorClientPluginContextHook.scopes.forkedCurrentScope( SENTRY_KTOR_CLIENT_PLUGIN_KEY ) - withContext(SentryContext(scopes)) { - val s = Sentry.getCurrentScopes() - println(s) - proceed() - } + withContext(SentryContext(scopes)) { proceed() } } } } From 7d8dcd1a95f2ec527fafb1a54e8d425b712c8d22 Mon Sep 17 00:00:00 2001 From: lcian Date: Wed, 2 Jul 2025 17:14:51 +0200 Subject: [PATCH 11/16] improve --- .../src/main/java/io/sentry/samples/ktor/Main.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt b/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt index 08565f07f9..f6e4c3077a 100644 --- a/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt +++ b/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt @@ -16,6 +16,7 @@ fun main() { options.isSendDefaultPii = true options.tracesSampleRate = 1.0 options.addInAppInclude("io.sentry.samples") + options.isGlobalHubMode = true } val client = @@ -35,9 +36,12 @@ suspend fun makeRequests(client: HttpClient) { client.get("https://httpbin.org/get") client.get("https://httpbin.org/status/404") - // Should create errors - client.get("https://httpbin.org/status/500") - client.get("https://httpbin.org/status/500?lol=test") + // Should create breadcrumbs and errors + client.get("https://httpbin.org/status/500?lol=test") // no tags + Sentry.setTag("lol", "test") + client.get("https://httpbin.org/status/502") // lol: test + Sentry.removeTag("lol") + client.get("https://httpbin.org/status/503?lol=test") // no tags // Should create breadcrumb client.post("https://httpbin.org/post") { From 80fb52fe4e851c0e375955b9e5a14468c5f33c3d Mon Sep 17 00:00:00 2001 From: lcian Date: Thu, 3 Jul 2025 10:00:42 +0200 Subject: [PATCH 12/16] changelog --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b4c24237e..0336aed4a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +### Features + +- Add Ktor client integration ([#4527](https://github.com/getsentry/sentry-java/pull/4527)) + - To use the integration, add a dependency on `io.sentry:sentry-ktor`, then install the `SentryKtorClientPlugin` on your `HttpClient`, + e.g.: + ```kotlin + val client = + HttpClient(Java) { + install(io.sentry.ktor.SentryKtorClientPlugin) { + captureFailedRequests = true + failedRequestTargets = listOf(".*") + failedRequestStatusCodes = listOf(HttpStatusCodeRange(500, 599)) + } + } + ``` + ### Fixes - Optimize scope when maxBreadcrumb is 0 ([#4504](https://github.com/getsentry/sentry-java/pull/4504)) From 665b6ec6d3f6909e2fc24f35b610ef5d6e7b9420 Mon Sep 17 00:00:00 2001 From: lcian Date: Thu, 3 Jul 2025 10:00:54 +0200 Subject: [PATCH 13/16] improve --- .../src/main/java/io/sentry/samples/ktor/Main.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt b/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt index f6e4c3077a..32bcad9e3f 100644 --- a/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt +++ b/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt @@ -3,6 +3,8 @@ package io.sentry.samples.ktor import io.ktor.client.* import io.ktor.client.engine.java.* import io.ktor.client.request.* +import io.ktor.http.HttpStatusCode +import io.sentry.HttpStatusCodeRange import io.sentry.Sentry import io.sentry.TransactionOptions import io.sentry.ktor.SentryKtorClientPlugin @@ -20,7 +22,13 @@ fun main() { } val client = - HttpClient(Java) { install(SentryKtorClientPlugin) { failedRequestTargets = listOf(".*") } } + HttpClient(Java) { + install(SentryKtorClientPlugin) { + captureFailedRequests = true + failedRequestTargets = listOf(".*") + failedRequestStatusCodes = listOf(HttpStatusCodeRange(500, 599)) + } + } val opts = TransactionOptions().apply { isBindToScope = true } val tx = Sentry.startTransaction("My Transaction", "test", opts) From d0a408b7444cefc8f395cf534dd2315033a39a7f Mon Sep 17 00:00:00 2001 From: lcian Date: Thu, 3 Jul 2025 11:47:36 +0200 Subject: [PATCH 14/16] improve --- .../src/main/java/io/sentry/samples/ktor/Main.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt b/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt index 32bcad9e3f..8104e698fe 100644 --- a/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt +++ b/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt @@ -3,7 +3,6 @@ package io.sentry.samples.ktor import io.ktor.client.* import io.ktor.client.engine.java.* import io.ktor.client.request.* -import io.ktor.http.HttpStatusCode import io.sentry.HttpStatusCodeRange import io.sentry.Sentry import io.sentry.TransactionOptions From 07652271bf15c4b41368d7c54783223d5ce6c410 Mon Sep 17 00:00:00 2001 From: lcian Date: Thu, 3 Jul 2025 11:51:11 +0200 Subject: [PATCH 15/16] improve --- .../src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt index c5feb0bdba..4914dbb84f 100644 --- a/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt +++ b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt @@ -63,7 +63,10 @@ public class SentryKtorClientPluginConfig { public fun execute(span: ISpan, request: HttpRequest): ISpan? } - /** Forcefully use the passed in scope. Used for testing. */ + /** + * Forcefully use the passed in scope instead of relying on the one injected by [SentryContext]. + * Used for testing. + */ internal var forceScopes: Boolean = false } From b6aeec566f2843965ef07b832c71abfa0e4aaf88 Mon Sep 17 00:00:00 2001 From: lcian Date: Mon, 7 Jul 2025 11:14:18 +0200 Subject: [PATCH 16/16] changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3b60e1346..861c5b4df7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 8.16.1-alpha.2 +## Unreleased ### Features @@ -18,6 +18,8 @@ } ``` +## 8.16.1-alpha.2 + ### Fixes - Optimize scope when maxBreadcrumb is 0 ([#4504](https://github.com/getsentry/sentry-java/pull/4504))