diff --git a/CHANGELOG.md b/CHANGELOG.md index baf158193a..b81f6b7468 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 - Send logcat through Sentry Logs ([#4487](https://github.com/getsentry/sentry-java/pull/4487)) 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..47ece56229 --- /dev/null +++ b/sentry-ktor/api/sentry-ktor.api @@ -0,0 +1,34 @@ +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/HttpRequest;)Lio/sentry/ISpan; +} + +public class io/sentry/ktor/SentryKtorClientPluginContextHook : io/ktor/client/plugins/api/ClientHook { + 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 +} + +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..4914dbb84f --- /dev/null +++ b/sentry-ktor/src/main/java/io/sentry/ktor/SentryKtorClientPlugin.kt @@ -0,0 +1,202 @@ +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.http.* +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 +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.PropagationTargetsUtils +import io.sentry.util.SpanUtils +import io.sentry.util.TracingUtils +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: HttpRequest): ISpan? + } + + /** + * Forcefully use the passed in scope instead of relying on the one injected by [SentryContext]. + * Used for testing. + */ + internal var forceScopes: Boolean = false +} + +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, + * including error capturing, request/response breadcrumbs, and distributed tracing. + */ +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 beforeSpan = pluginConfig.beforeSpan + val captureFailedRequests = pluginConfig.captureFailedRequests + val failedRequestStatusCodes = pluginConfig.failedRequestStatusCodes + val failedRequestTargets = pluginConfig.failedRequestTargets + val forceScopes = pluginConfig.forceScopes + + // 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, + CurrentDateProvider.getInstance().currentTimeMillis, + ) + + 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: ISpan = + parentSpan?.startChild(spanOp, spanDescription) + ?: Sentry.startTransaction(spanDescription, spanOp) + span.spanContext.origin = TRACE_ORIGIN + request.attributes.put(requestSpanKey, span) + + if ( + !SpanUtils.isIgnored( + (if (forceScopes) scopes else Sentry.getCurrentScopes()).options.getIgnoredSpanOrigins(), + TRACE_ORIGIN, + ) + ) { + TracingUtils.traceIfAllowed( + if (forceScopes) scopes else Sentry.getCurrentScopes(), + 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 + } + } + } + } + + 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) + val endTimestamp = CurrentDateProvider.getInstance().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) + + 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))) + } + } + + on(SentryKtorClientPluginContextHook(scopes)) { block -> block() } + } + +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() } + } + } +} 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..eb1e5dc386 --- /dev/null +++ b/sentry-ktor/src/test/java/io/sentry/ktor/SentryKtorClientPluginTest.kt @@ -0,0 +1,423 @@ +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.BaggageHeader +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.Sentry +import io.sentry.SentryEvent +import io.sentry.SentryOptions +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 +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() + 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 = + listOf( + HttpStatusCodeRange(HttpStatusCodeRange.DEFAULT_MIN, HttpStatusCodeRange.DEFAULT_MAX) + ), + sendDefaultPii: Boolean = false, + 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 + } + scope = Scope(options) + whenever(scopes.options).thenReturn(options) + doAnswer { (it.arguments[0] as ScopeCallback).run(scope) } + .whenever(scopes) + .configureScope(any()) + + sentryTracer = SentryTracer(TransactionContext("name", "op"), scopes) + + if (isSpanActive) { + println("span active") + whenever(scopes.span).thenReturn(sentryTracer) + } + + whenever(scopes.forkedCurrentScope(any())).thenReturn(scopes) + + 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 + this.forceScopes = true + } + } + } + } + + 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 with request and response contexts if captureFailedRequests is enabled and status code is within the range`(): + Unit = runBlocking { + val statusCode = 500 + val responseBody = "failure" + val sut = + fixture.getSut( + captureFailedRequests = true, + httpStatusCode = statusCode, + responseBody = responseBody, + sendDefaultPii = true, + ) + + 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 + 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)), + ) + 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, + failedRequestStatusCodes = listOf(HttpStatusCodeRange(400, 499)), + ) + sut.get(fixture.server.url("/hello").toString()) + + verify(fixture.scopes, never()).captureEvent(any(), 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(), + ) + } + + @Test + fun `creates a span around the request`(): 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("auto.http.ktor", httpClientSpan.spanContext.origin) + assertEquals(SpanStatus.OK, httpClientSpan.status) + assertTrue(httpClientSpan.isFinished) + } + + @Test + 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.post(fixture.server.url("/hello?myQuery=myValue#myFragment").toString()) { + contentType(ContentType.Text.Plain) + setBody("hello hello") + } + } catch (e: Exception) { + exception = e + } + + 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 { + 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 propagation targets, does not add sentry trace headers to the request`(): + Unit = runBlocking { + val sut = + fixture.getSut( + includeMockServerInTracePropagationTargets = false, + keepDefaultTracePropagationTargets = false, + ) + 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")) + } +} 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..8104e698fe --- /dev/null +++ b/sentry-samples/sentry-samples-ktor/src/main/java/io/sentry/samples/ktor/Main.kt @@ -0,0 +1,61 @@ +package io.sentry.samples.ktor + +import io.ktor.client.* +import io.ktor.client.engine.java.* +import io.ktor.client.request.* +import io.sentry.HttpStatusCodeRange +import io.sentry.Sentry +import io.sentry.TransactionOptions +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") + options.isGlobalHubMode = true + } + + val client = + 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) + + runBlocking { makeRequests(client) } + + Sentry.captureMessage("Ktor client sample done") + tx.finish() +} + +suspend fun makeRequests(client: HttpClient) { + // Should create breadcrumbs + client.get("https://httpbin.org/get") + client.get("https://httpbin.org/status/404") + + // 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") { + 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 72d0bc1d62..fcfa62ac1f 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -4221,6 +4221,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",