Skip to content

Commit e2d324e

Browse files
authored
Merge 3c650dd into 70671a5
2 parents 70671a5 + 3c650dd commit e2d324e

File tree

16 files changed

+1007
-0
lines changed

16 files changed

+1007
-0
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@
77
- Add `SentryUserFeedbackButton` Composable ([#4559](https://github.com/getsentry/sentry-java/pull/4559))
88
- Also added `Sentry.showUserFeedbackDialog` static method
99
- Add deadlineTimeout option ([#4555](https://github.com/getsentry/sentry-java/pull/4555))
10+
- Add Ktor client integration ([#4527](https://github.com/getsentry/sentry-java/pull/4527))
11+
- To use the integration, add a dependency on `io.sentry:sentry-ktor-client`, then install the `SentryKtorClientPlugin` on your `HttpClient`,
12+
e.g.:
13+
```kotlin
14+
val client =
15+
HttpClient(Java) {
16+
install(io.sentry.ktorClient.SentryKtorClientPlugin) {
17+
captureFailedRequests = true
18+
failedRequestTargets = listOf(".*")
19+
failedRequestStatusCodes = listOf(HttpStatusCodeRange(500, 599))
20+
}
21+
}
22+
```
1023

1124
### Fixes
1225

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ apiValidation {
6969
"sentry-samples-spring-boot-jakarta-opentelemetry-noagent",
7070
"sentry-samples-spring-boot-webflux",
7171
"sentry-samples-spring-boot-webflux-jakarta",
72+
"sentry-samples-ktor-client",
7273
"sentry-uitest-android",
7374
"sentry-uitest-android-benchmark",
7475
"sentry-uitest-android-critical",

buildSrc/src/main/java/Config.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ object Config {
7878
val SENTRY_OKHTTP_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.okhttp"
7979
val SENTRY_REACTOR_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.reactor"
8080
val SENTRY_KOTLIN_EXTENSIONS_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.kotlin-extensions"
81+
val SENTRY_KTOR_CLIENT_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.ktor-client"
8182
val group = "io.sentry"
8283
val description = "SDK for sentry.io"
8384
val versionNameProp = "versionName"

gradle/libs.versions.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ jackson = "2.18.3"
1313
jetbrainsCompose = "1.6.11"
1414
kotlin = "1.9.24"
1515
kotlin-compatible-version = "1.6"
16+
ktorClient = "3.0.0"
1617
logback = "1.2.9"
1718
log4j2 = "2.20.0"
1819
nopen = "1.0.1"
@@ -95,6 +96,8 @@ jetbrains-annotations = { module = "org.jetbrains:annotations", version = "23.0.
9596
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
9697
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
9798
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
99+
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClient" }
100+
ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktorClient" }
98101
log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j2" }
99102
log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j2" }
100103
leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version = "2.14" }
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
public final class io/sentry/ktorClient/BuildConfig {
2+
public static final field SENTRY_KTOR_CLIENT_SDK_NAME Ljava/lang/String;
3+
public static final field VERSION_NAME Ljava/lang/String;
4+
}
5+
6+
public final class io/sentry/ktorClient/SentryKtorClientPluginConfig {
7+
public fun <init> ()V
8+
public final fun getBeforeSpan ()Lio/sentry/ktorClient/SentryKtorClientPluginConfig$BeforeSpanCallback;
9+
public final fun getCaptureFailedRequests ()Z
10+
public final fun getFailedRequestStatusCodes ()Ljava/util/List;
11+
public final fun getFailedRequestTargets ()Ljava/util/List;
12+
public final fun getScopes ()Lio/sentry/IScopes;
13+
public final fun setBeforeSpan (Lio/sentry/ktorClient/SentryKtorClientPluginConfig$BeforeSpanCallback;)V
14+
public final fun setCaptureFailedRequests (Z)V
15+
public final fun setFailedRequestStatusCodes (Ljava/util/List;)V
16+
public final fun setFailedRequestTargets (Ljava/util/List;)V
17+
public final fun setScopes (Lio/sentry/IScopes;)V
18+
}
19+
20+
public abstract interface class io/sentry/ktorClient/SentryKtorClientPluginConfig$BeforeSpanCallback {
21+
public abstract fun execute (Lio/sentry/ISpan;Lio/ktor/client/request/HttpRequest;)Lio/sentry/ISpan;
22+
}
23+
24+
public class io/sentry/ktorClient/SentryKtorClientPluginContextHook : io/ktor/client/plugins/api/ClientHook {
25+
public fun <init> (Lio/sentry/IScopes;)V
26+
protected final fun getScopes ()Lio/sentry/IScopes;
27+
public synthetic fun install (Lio/ktor/client/HttpClient;Ljava/lang/Object;)V
28+
public fun install (Lio/ktor/client/HttpClient;Lkotlin/jvm/functions/Function2;)V
29+
}
30+
31+
public final class io/sentry/ktorClient/SentryKtorClientPluginKt {
32+
public static final fun getSentryKtorClientPlugin ()Lio/ktor/client/plugins/api/ClientPlugin;
33+
}
34+
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import net.ltgt.gradle.errorprone.errorprone
2+
import org.jetbrains.kotlin.config.KotlinCompilerVersion
3+
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
4+
5+
plugins {
6+
`java-library`
7+
kotlin("jvm")
8+
jacoco
9+
id("io.sentry.javadoc")
10+
alias(libs.plugins.errorprone)
11+
alias(libs.plugins.gradle.versions)
12+
alias(libs.plugins.buildconfig)
13+
}
14+
15+
tasks.withType<KotlinCompile>().configureEach {
16+
kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8.toString()
17+
}
18+
19+
kotlin { explicitApi() }
20+
21+
dependencies {
22+
api(projects.sentry)
23+
24+
implementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION))
25+
api(projects.sentryKotlinExtensions)
26+
27+
compileOnly(libs.jetbrains.annotations)
28+
compileOnly(libs.nopen.annotations)
29+
compileOnly(libs.ktor.client.core)
30+
errorprone(libs.errorprone.core)
31+
errorprone(libs.nopen.checker)
32+
errorprone(libs.nullaway)
33+
34+
testImplementation(projects.sentryTestSupport)
35+
testImplementation(libs.kotlin.test.junit)
36+
testImplementation(libs.mockito.kotlin)
37+
testImplementation(libs.mockito.inline)
38+
testImplementation(libs.ktor.client.core)
39+
testImplementation(libs.ktor.client.java)
40+
testImplementation(libs.okhttp.mockwebserver)
41+
}
42+
43+
configure<SourceSetContainer> { test { java.srcDir("src/test/java") } }
44+
45+
jacoco { toolVersion = libs.versions.jacoco.get() }
46+
47+
tasks.jacocoTestReport {
48+
reports {
49+
xml.required.set(true)
50+
html.required.set(false)
51+
}
52+
}
53+
54+
tasks {
55+
jacocoTestCoverageVerification {
56+
violationRules { rule { limit { minimum = Config.QualityPlugins.Jacoco.minimumCoverage } } }
57+
}
58+
check {
59+
dependsOn(jacocoTestCoverageVerification)
60+
dependsOn(jacocoTestReport)
61+
}
62+
}
63+
64+
buildConfig {
65+
useJavaOutput()
66+
packageName("io.sentry.ktorClient")
67+
buildConfigField(
68+
"String",
69+
"SENTRY_KTOR_CLIENT_SDK_NAME",
70+
"\"${Config.Sentry.SENTRY_KTOR_CLIENT_SDK_NAME}\"",
71+
)
72+
buildConfigField("String", "VERSION_NAME", "\"${project.version}\"")
73+
}
74+
75+
tasks.withType<JavaCompile>().configureEach {
76+
dependsOn(tasks.generateBuildConfig)
77+
options.errorprone {
78+
check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR)
79+
option("NullAway:AnnotatedPackages", "io.sentry")
80+
}
81+
}
82+
83+
tasks.jar {
84+
manifest {
85+
attributes(
86+
"Sentry-Version-Name" to project.version,
87+
"Sentry-SDK-Name" to Config.Sentry.SENTRY_KTOR_CLIENT_SDK_NAME,
88+
"Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-ktor-client",
89+
"Implementation-Vendor" to "Sentry",
90+
"Implementation-Title" to project.name,
91+
"Implementation-Version" to project.version,
92+
)
93+
}
94+
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
package io.sentry.ktorClient
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.plugins.api.*
5+
import io.ktor.client.plugins.api.ClientPlugin
6+
import io.ktor.client.request.*
7+
import io.ktor.client.statement.*
8+
import io.ktor.util.*
9+
import io.ktor.util.pipeline.*
10+
import io.sentry.BaggageHeader
11+
import io.sentry.BuildConfig
12+
import io.sentry.HttpStatusCodeRange
13+
import io.sentry.IScopes
14+
import io.sentry.ISpan
15+
import io.sentry.ScopesAdapter
16+
import io.sentry.Sentry
17+
import io.sentry.SentryDate
18+
import io.sentry.SentryIntegrationPackageStorage
19+
import io.sentry.SentryOptions
20+
import io.sentry.SpanStatus
21+
import io.sentry.kotlin.SentryContext
22+
import io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion
23+
import io.sentry.util.Platform
24+
import io.sentry.util.PropagationTargetsUtils
25+
import io.sentry.util.SpanUtils
26+
import io.sentry.util.TracingUtils
27+
import kotlinx.coroutines.withContext
28+
29+
/** Configuration for the Sentry Ktor client plugin. */
30+
public class SentryKtorClientPluginConfig {
31+
/** The [IScopes] instance to use. Defaults to [ScopesAdapter.getInstance]. */
32+
public var scopes: IScopes = ScopesAdapter.getInstance()
33+
34+
/** Callback to customize or drop spans before they are created. Return null to drop the span. */
35+
public var beforeSpan: BeforeSpanCallback? = null
36+
37+
/** Whether to capture HTTP client errors as Sentry events. Defaults to true. */
38+
public var captureFailedRequests: Boolean = true
39+
40+
/**
41+
* The HTTP status code ranges that should be considered as failed requests. Defaults to 500-599
42+
* (server errors).
43+
*/
44+
public var failedRequestStatusCodes: List<HttpStatusCodeRange> =
45+
listOf(HttpStatusCodeRange(HttpStatusCodeRange.DEFAULT_MIN, HttpStatusCodeRange.DEFAULT_MAX))
46+
47+
/**
48+
* The list of targets (URLs) for which failed requests should be captured. Supports regex
49+
* patterns. Defaults to capture all requests.
50+
*/
51+
public var failedRequestTargets: List<String> = listOf(SentryOptions.DEFAULT_PROPAGATION_TARGETS)
52+
53+
/** Callback interface for customizing spans before they are created. */
54+
public fun interface BeforeSpanCallback {
55+
/**
56+
* Customize or drop a span before it's created.
57+
*
58+
* @param span The span to customize
59+
* @param request The HTTP request being executed
60+
* @return The customized span, or null to drop the span
61+
*/
62+
public fun execute(span: ISpan, request: HttpRequest): ISpan?
63+
}
64+
65+
/**
66+
* Forcefully use the passed in scope instead of relying on the one injected by [SentryContext].
67+
* Used for testing.
68+
*/
69+
internal var forceScopes: Boolean = false
70+
}
71+
72+
internal const val SENTRY_KTOR_CLIENT_PLUGIN_KEY = "SentryKtorClientPlugin"
73+
internal const val TRACE_ORIGIN = "auto.http.ktor-client"
74+
75+
/**
76+
* Sentry plugin for Ktor HTTP client that provides automatic instrumentation for HTTP requests,
77+
* including error capturing, request/response breadcrumbs, and distributed tracing.
78+
*/
79+
public val SentryKtorClientPlugin: ClientPlugin<SentryKtorClientPluginConfig> =
80+
createClientPlugin(SENTRY_KTOR_CLIENT_PLUGIN_KEY, ::SentryKtorClientPluginConfig) {
81+
// Init
82+
SentryIntegrationPackageStorage.getInstance()
83+
.addPackage("maven:io.sentry:sentry-ktor-client", BuildConfig.VERSION_NAME)
84+
addIntegrationToSdkVersion("KtorClient")
85+
86+
// Options
87+
val scopes = pluginConfig.scopes
88+
val beforeSpan = pluginConfig.beforeSpan
89+
val captureFailedRequests = pluginConfig.captureFailedRequests
90+
val failedRequestStatusCodes = pluginConfig.failedRequestStatusCodes
91+
val failedRequestTargets = pluginConfig.failedRequestTargets
92+
val forceScopes = pluginConfig.forceScopes
93+
94+
// Attributes
95+
// Request start time for breadcrumbs
96+
val requestStartTimestampKey = AttributeKey<SentryDate>("SentryRequestStartTimestamp")
97+
// Span associated with the request
98+
val requestSpanKey = AttributeKey<ISpan>("SentryRequestSpan")
99+
100+
onRequest { request, _ ->
101+
request.attributes.put(
102+
requestStartTimestampKey,
103+
(if (forceScopes) scopes else Sentry.getCurrentScopes()).options.dateProvider.now(),
104+
)
105+
106+
val parentSpan: ISpan? =
107+
if (forceScopes) scopes.getSpan()
108+
else {
109+
if (Platform.isAndroid()) scopes.transaction else scopes.span
110+
}
111+
112+
val spanOp = "http.client"
113+
val spanDescription = "${request.method.value.toString()} ${request.url.buildString()}"
114+
val span: ISpan? = parentSpan?.startChild(spanOp, spanDescription)
115+
if (span != null) {
116+
span.spanContext.origin = TRACE_ORIGIN
117+
request.attributes.put(requestSpanKey, span)
118+
}
119+
120+
if (
121+
!SpanUtils.isIgnored(
122+
(if (forceScopes) scopes else Sentry.getCurrentScopes()).options.getIgnoredSpanOrigins(),
123+
TRACE_ORIGIN,
124+
)
125+
) {
126+
TracingUtils.traceIfAllowed(
127+
if (forceScopes) scopes else Sentry.getCurrentScopes(),
128+
request.url.buildString(),
129+
request.headers.getAll(BaggageHeader.BAGGAGE_HEADER),
130+
span,
131+
)
132+
?.let { tracingHeaders ->
133+
request.headers[tracingHeaders.sentryTraceHeader.name] =
134+
tracingHeaders.sentryTraceHeader.value
135+
tracingHeaders.baggageHeader?.let {
136+
request.headers.remove(BaggageHeader.BAGGAGE_HEADER)
137+
request.headers[it.name] = it.value
138+
}
139+
}
140+
}
141+
}
142+
143+
client.requestPipeline.intercept(HttpRequestPipeline.Before) {
144+
try {
145+
proceed()
146+
} catch (t: Throwable) {
147+
context.attributes.getOrNull(requestSpanKey)?.apply {
148+
throwable = t
149+
status = SpanStatus.INTERNAL_ERROR
150+
finish()
151+
}
152+
throw t
153+
}
154+
}
155+
156+
onResponse { response ->
157+
val request = response.request
158+
val startTimestamp = response.call.attributes.getOrNull(requestStartTimestampKey)
159+
val endTimestamp =
160+
(if (forceScopes) scopes else Sentry.getCurrentScopes()).options.dateProvider.now()
161+
162+
if (
163+
captureFailedRequests &&
164+
failedRequestStatusCodes.any { it.isInRange(response.status.value) } &&
165+
PropagationTargetsUtils.contain(failedRequestTargets, request.url.toString())
166+
) {
167+
SentryKtorClientUtils.captureClientError(scopes, request, response)
168+
}
169+
170+
SentryKtorClientUtils.addBreadcrumb(scopes, request, response, startTimestamp, endTimestamp)
171+
172+
response.call.attributes.getOrNull(requestSpanKey)?.let { span ->
173+
var result: ISpan? = span
174+
175+
if (beforeSpan != null) {
176+
result = beforeSpan.execute(span, request)
177+
}
178+
179+
if (result == null) {
180+
// span is dropped
181+
span.spanContext.sampled = false
182+
}
183+
184+
val spanStatus = SpanStatus.fromHttpStatusCode(response.status.value)
185+
span.finish(spanStatus, endTimestamp)
186+
}
187+
}
188+
189+
on(SentryKtorClientPluginContextHook(scopes)) { block -> block() }
190+
}
191+
192+
/**
193+
* Context hook to manage scopes during request handling. Forks the current scope and uses
194+
* [SentryContext] to ensure that the whole pipeline runs within the correct scopes.
195+
*/
196+
public open class SentryKtorClientPluginContextHook(protected val scopes: IScopes) :
197+
ClientHook<suspend (suspend () -> Unit) -> Unit> {
198+
private val phase = PipelinePhase("SentryKtorClientPluginContext")
199+
200+
override fun install(client: HttpClient, handler: suspend (suspend () -> Unit) -> Unit) {
201+
client.requestPipeline.insertPhaseBefore(HttpRequestPipeline.Before, phase)
202+
client.requestPipeline.intercept(phase) {
203+
val scopes =
204+
this@SentryKtorClientPluginContextHook.scopes.forkedCurrentScope(
205+
SENTRY_KTOR_CLIENT_PLUGIN_KEY
206+
)
207+
withContext(SentryContext(scopes)) { proceed() }
208+
}
209+
}
210+
}

0 commit comments

Comments
 (0)