Skip to content

Commit 569c88a

Browse files
authored
Merge 93b4a83 into 596fe1e
2 parents 596fe1e + 93b4a83 commit 569c88a

File tree

13 files changed

+740
-0
lines changed

13 files changed

+740
-0
lines changed

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_SDK_NAME = "$SENTRY_JAVA_SDK_NAME.ktor"
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"
@@ -94,6 +95,8 @@ jetbrains-annotations = { module = "org.jetbrains:annotations", version = "23.0.
9495
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
9596
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
9697
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
98+
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktorClient" }
99+
ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktorClient" }
97100
log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j2" }
98101
log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j2" }
99102
leakcanary = { module = "com.squareup.leakcanary:leakcanary-android", version = "2.14" }

sentry-ktor/api/sentry-ktor.api

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
public final class io/sentry/ktor/BuildConfig {
2+
public static final field SENTRY_KTOR_SDK_NAME Ljava/lang/String;
3+
public static final field VERSION_NAME Ljava/lang/String;
4+
}
5+
6+
public final class io/sentry/ktor/SentryKtorClientPluginConfig {
7+
public fun <init> ()V
8+
public final fun getBeforeSpan ()Lio/sentry/ktor/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/ktor/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/ktor/SentryKtorClientPluginConfig$BeforeSpanCallback {
21+
public abstract fun execute (Lio/sentry/ISpan;Lio/ktor/client/request/HttpRequestBuilder;)Lio/sentry/ISpan;
22+
}
23+
24+
public class io/sentry/ktor/SentryKtorClientPluginContextHook : io/ktor/client/plugins/api/ClientHook {
25+
public fun <init> ()V
26+
public synthetic fun install (Lio/ktor/client/HttpClient;Ljava/lang/Object;)V
27+
public fun install (Lio/ktor/client/HttpClient;Lkotlin/jvm/functions/Function2;)V
28+
}
29+
30+
public final class io/sentry/ktor/SentryKtorClientPluginKt {
31+
public static final fun getSentryKtorClientPlugin ()Lio/ktor/client/plugins/api/ClientPlugin;
32+
}
33+

sentry-ktor/build.gradle.kts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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+
implementation(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.ktor")
67+
buildConfigField("String", "SENTRY_KTOR_SDK_NAME", "\"${Config.Sentry.SENTRY_KTOR_SDK_NAME}\"")
68+
buildConfigField("String", "VERSION_NAME", "\"${project.version}\"")
69+
}
70+
71+
tasks.withType<JavaCompile>().configureEach {
72+
dependsOn(tasks.generateBuildConfig)
73+
options.errorprone {
74+
check("NullAway", net.ltgt.gradle.errorprone.CheckSeverity.ERROR)
75+
option("NullAway:AnnotatedPackages", "io.sentry")
76+
}
77+
}
78+
79+
tasks.jar {
80+
manifest {
81+
attributes(
82+
"Sentry-Version-Name" to project.version,
83+
"Sentry-SDK-Name" to Config.Sentry.SENTRY_KTOR_SDK_NAME,
84+
"Sentry-SDK-Package-Name" to "maven:io.sentry:sentry-ktor",
85+
"Implementation-Vendor" to "Sentry",
86+
"Implementation-Title" to project.name,
87+
"Implementation-Version" to project.version,
88+
)
89+
}
90+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package io.sentry.ktor
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.HttpStatusCodeRange
12+
import io.sentry.IScopes
13+
import io.sentry.ISpan
14+
import io.sentry.ScopesAdapter
15+
import io.sentry.Sentry
16+
import io.sentry.SentryIntegrationPackageStorage
17+
import io.sentry.SentryLongDate
18+
import io.sentry.SentryOptions
19+
import io.sentry.SpanStatus
20+
import io.sentry.kotlin.SentryContext
21+
import io.sentry.transport.CurrentDateProvider
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: HttpRequestBuilder): ISpan?
63+
}
64+
}
65+
66+
internal const val SENTRY_KTOR_CLIENT_PLUGIN_KEY = "SentryKtorClientPlugin"
67+
internal const val TRACE_ORIGIN = "auto.http.ktor"
68+
69+
/**
70+
* Sentry plugin for Ktor HTTP client that provides automatic instrumentation for HTTP requests,
71+
* including distributed tracing, breadcrumbs, and error capturing.
72+
*/
73+
public val SentryKtorClientPlugin: ClientPlugin<SentryKtorClientPluginConfig> =
74+
createClientPlugin(SENTRY_KTOR_CLIENT_PLUGIN_KEY, ::SentryKtorClientPluginConfig) {
75+
// Init
76+
SentryIntegrationPackageStorage.getInstance()
77+
.addPackage("maven:io.sentry:sentry-ktor", BuildConfig.VERSION_NAME)
78+
addIntegrationToSdkVersion("Ktor")
79+
80+
// Options
81+
val scopes = pluginConfig.scopes
82+
val captureFailedRequests = pluginConfig.captureFailedRequests
83+
val failedRequestStatusCodes = pluginConfig.failedRequestStatusCodes
84+
val failedRequestTargets = pluginConfig.failedRequestTargets
85+
86+
// Attributes
87+
// Request start time for breadcrumbs
88+
val requestStartTimestampKey = AttributeKey<Long>("SentryRequestStartTimestamp")
89+
// Span associated with the request
90+
val requestSpanKey = AttributeKey<ISpan>("SentryRequestSpan")
91+
92+
onRequest { request, _ ->
93+
request.attributes.put(
94+
requestStartTimestampKey,
95+
CurrentDateProvider.getInstance().currentTimeMillis,
96+
)
97+
98+
val parentSpan = if (Platform.isAndroid()) scopes.transaction else scopes.span
99+
val spanOp = "http.client"
100+
val spanDescription = "${request.method.value.toString()} ${request.url.buildString()}"
101+
val span =
102+
if (parentSpan != null) parentSpan.startChild(spanOp, spanDescription)
103+
else Sentry.startTransaction(spanDescription, spanOp)
104+
request.attributes.put(requestSpanKey, span)
105+
106+
if (SpanUtils.isIgnored(scopes.getOptions().getIgnoredSpanOrigins(), TRACE_ORIGIN)) {
107+
TracingUtils.traceIfAllowed(
108+
scopes,
109+
request.url.buildString(),
110+
request.headers.getAll(BaggageHeader.BAGGAGE_HEADER),
111+
span,
112+
)
113+
?.let { tracingHeaders ->
114+
request.headers[tracingHeaders.sentryTraceHeader.name] =
115+
tracingHeaders.sentryTraceHeader.value
116+
tracingHeaders.baggageHeader?.let {
117+
request.headers.remove(BaggageHeader.BAGGAGE_HEADER)
118+
request.headers[it.name] = it.value
119+
}
120+
}
121+
}
122+
}
123+
124+
onResponse { response ->
125+
val request = response.request
126+
val startTimestamp = response.call.attributes.getOrNull(requestStartTimestampKey)
127+
val endTimestamp = CurrentDateProvider.getInstance().currentTimeMillis
128+
129+
if (
130+
captureFailedRequests &&
131+
failedRequestStatusCodes.any { it.isInRange(response.status.value) } &&
132+
PropagationTargetsUtils.contain(failedRequestTargets, request.url.toString())
133+
) {
134+
SentryKtorClientUtils.captureClientError(scopes, request, response)
135+
}
136+
137+
SentryKtorClientUtils.addBreadcrumb(scopes, request, response, startTimestamp, endTimestamp)
138+
139+
response.call.attributes.getOrNull(requestSpanKey)?.let { span ->
140+
val spanStatus = SpanStatus.fromHttpStatusCode(response.status.value)
141+
span.finish(spanStatus, SentryLongDate(endTimestamp * 1000))
142+
}
143+
}
144+
145+
on(SentryKtorClientPluginContextHook()) { block -> block() }
146+
}
147+
148+
public open class SentryKtorClientPluginContextHook :
149+
ClientHook<suspend (suspend () -> Unit) -> Unit> {
150+
private val phase = PipelinePhase("SentryKtorClientPluginContext")
151+
152+
override fun install(client: HttpClient, handler: suspend (suspend () -> Unit) -> Unit) {
153+
client.requestPipeline.insertPhaseBefore(HttpRequestPipeline.Before, phase)
154+
client.requestPipeline.intercept(phase) {
155+
withContext(SentryContext(Sentry.forkedCurrentScope(SENTRY_KTOR_CLIENT_PLUGIN_KEY))) {
156+
proceed()
157+
}
158+
}
159+
}
160+
}

0 commit comments

Comments
 (0)