Skip to content

Commit 3b4aeeb

Browse files
authored
Ktor client instrumentation (#7982)
Client implementation for Ktor 2.0. Resolves #4972. - Moved server instrumentation under `server` package - Implemented a plugin for ktor `HttpClient`
1 parent 9a9a42b commit 3b4aeeb

File tree

16 files changed

+424
-11
lines changed

16 files changed

+424
-11
lines changed

instrumentation/ktor/ktor-2.0/library/README.md

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Library Instrumentation for Ktor version 2.0 and higher
22

3-
This package contains libraries to help instrument Ktor. Currently, only server instrumentation is supported.
3+
This package contains libraries to help instrument Ktor. Server and client instrumentations are supported.
44

55
## Quickstart
66

@@ -35,11 +35,26 @@ Initialize instrumentation by installing the `KtorServerTracing` feature. You mu
3535
the feature.
3636

3737
```kotlin
38-
OpenTelemetry openTelemetry = initializeOpenTelemetryForMe()
38+
val openTelemetry: OpenTelemetry = initializeOpenTelemetryForMe()
3939

4040
embeddedServer(Netty, 8080) {
4141
install(KtorServerTracing) {
4242
setOpenTelemetry(openTelemetry)
4343
}
4444
}
4545
```
46+
47+
## Initializing client instrumentation
48+
49+
Initialize instrumentation by installing the `KtorClientTracing` feature. You must set the `OpenTelemetry` to use with
50+
the feature.
51+
52+
```kotlin
53+
val openTelemetry: OpenTelemetry = initializeOpenTelemetryForMe()
54+
55+
val client = HttpClient {
56+
install(KtorClientTracing) {
57+
setOpenTelemetry(openTelemetry)
58+
}
59+
}
60+
```

instrumentation/ktor/ktor-2.0/library/build.gradle.kts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ plugins {
66
id("org.jetbrains.kotlin.jvm")
77
}
88

9+
val ktorVersion = "2.0.0"
10+
911
dependencies {
10-
library("io.ktor:ktor-server-core:2.0.0")
12+
library("io.ktor:ktor-client-core:$ktorVersion")
13+
library("io.ktor:ktor-server-core:$ktorVersion")
1114

1215
implementation(project(":instrumentation:ktor:ktor-common:library"))
1316
implementation("io.opentelemetry:opentelemetry-extension-kotlin")
@@ -16,7 +19,8 @@ dependencies {
1619

1720
testImplementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
1821

19-
testLibrary("io.ktor:ktor-server-netty:2.0.0")
22+
testLibrary("io.ktor:ktor-server-netty:$ktorVersion")
23+
testLibrary("io.ktor:ktor-client-cio:$ktorVersion")
2024
}
2125

2226
tasks {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.instrumentation.ktor.v2_0
7+
8+
/**
9+
* Common properties for both client and server instrumentations
10+
*/
11+
internal object InstrumentationProperties {
12+
13+
internal const val INSTRUMENTATION_NAME = "io.opentelemetry.ktor-2.0"
14+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.instrumentation.ktor.v2_0.client
7+
8+
import io.ktor.client.*
9+
import io.ktor.client.call.*
10+
import io.ktor.client.plugins.*
11+
import io.ktor.client.request.*
12+
import io.ktor.client.statement.*
13+
import io.ktor.util.*
14+
import io.ktor.util.pipeline.*
15+
import io.opentelemetry.context.Context
16+
import io.opentelemetry.context.propagation.ContextPropagators
17+
import io.opentelemetry.extension.kotlin.asContextElement
18+
import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter
19+
import kotlinx.coroutines.withContext
20+
21+
class KtorClientTracing internal constructor(
22+
private val instrumenter: Instrumenter<HttpRequestData, HttpResponse>,
23+
private val propagators: ContextPropagators,
24+
) {
25+
26+
private fun createSpan(requestBuilder: HttpRequestBuilder): Context? {
27+
val parentContext = Context.current()
28+
val requestData = requestBuilder.build()
29+
30+
return if (instrumenter.shouldStart(parentContext, requestData)) {
31+
instrumenter.start(parentContext, requestData)
32+
} else {
33+
null
34+
}
35+
}
36+
37+
private fun populateRequestHeaders(requestBuilder: HttpRequestBuilder, context: Context) {
38+
propagators.textMapPropagator.inject(context, requestBuilder, KtorHttpHeadersSetter)
39+
}
40+
41+
private fun endSpan(context: Context, call: HttpClientCall, error: Throwable?) {
42+
endSpan(context, HttpRequestBuilder().takeFrom(call.request), call.response, error)
43+
}
44+
45+
private fun endSpan(context: Context, requestBuilder: HttpRequestBuilder, response: HttpResponse?, error: Throwable?) {
46+
instrumenter.end(context, requestBuilder.build(), response, error)
47+
}
48+
49+
companion object : HttpClientPlugin<KtorClientTracingBuilder, KtorClientTracing> {
50+
51+
private val openTelemetryContextKey = AttributeKey<Context>("OpenTelemetry")
52+
53+
override val key = AttributeKey<KtorClientTracing>("OpenTelemetry")
54+
55+
override fun prepare(block: KtorClientTracingBuilder.() -> Unit) = KtorClientTracingBuilder().apply(block).build()
56+
57+
override fun install(plugin: KtorClientTracing, scope: HttpClient) {
58+
installSpanCreation(plugin, scope)
59+
installSpanEnd(plugin, scope)
60+
}
61+
62+
private fun installSpanCreation(plugin: KtorClientTracing, scope: HttpClient) {
63+
val createSpanPhase = PipelinePhase("OpenTelemetryCreateSpan")
64+
scope.sendPipeline.insertPhaseAfter(HttpSendPipeline.State, createSpanPhase)
65+
66+
scope.sendPipeline.intercept(createSpanPhase) {
67+
val requestBuilder = context
68+
val openTelemetryContext = plugin.createSpan(requestBuilder)
69+
70+
if (openTelemetryContext != null) {
71+
try {
72+
requestBuilder.attributes.put(openTelemetryContextKey, openTelemetryContext)
73+
plugin.populateRequestHeaders(requestBuilder, openTelemetryContext)
74+
75+
withContext(openTelemetryContext.asContextElement()) { proceed() }
76+
} catch (e: Throwable) {
77+
plugin.endSpan(openTelemetryContext, requestBuilder, null, e)
78+
throw e
79+
}
80+
} else {
81+
proceed()
82+
}
83+
}
84+
}
85+
86+
private fun installSpanEnd(plugin: KtorClientTracing, scope: HttpClient) {
87+
val endSpanPhase = PipelinePhase("OpenTelemetryEndSpan")
88+
scope.receivePipeline.insertPhaseBefore(HttpReceivePipeline.State, endSpanPhase)
89+
90+
scope.receivePipeline.intercept(endSpanPhase) {
91+
val openTelemetryContext = it.call.attributes.getOrNull(openTelemetryContextKey)
92+
93+
if (openTelemetryContext != null) {
94+
try {
95+
withContext(openTelemetryContext.asContextElement()) { proceed() }
96+
plugin.endSpan(openTelemetryContext, it.call, null)
97+
} catch (e: Throwable) {
98+
plugin.endSpan(openTelemetryContext, it.call, e)
99+
throw e
100+
}
101+
} else {
102+
proceed()
103+
}
104+
}
105+
}
106+
}
107+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.instrumentation.ktor.v2_0.client
7+
8+
import io.ktor.client.request.*
9+
import io.ktor.client.statement.HttpResponse
10+
import io.opentelemetry.api.OpenTelemetry
11+
import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor
12+
import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter
13+
import io.opentelemetry.instrumentation.api.instrumenter.SpanKindExtractor.alwaysClient
14+
import io.opentelemetry.instrumentation.api.instrumenter.http.HttpClientAttributesExtractor
15+
import io.opentelemetry.instrumentation.api.instrumenter.http.HttpClientMetrics
16+
import io.opentelemetry.instrumentation.api.instrumenter.http.HttpSpanNameExtractor
17+
import io.opentelemetry.instrumentation.api.instrumenter.http.HttpSpanStatusExtractor
18+
import io.opentelemetry.instrumentation.ktor.v2_0.InstrumentationProperties.INSTRUMENTATION_NAME
19+
20+
class KtorClientTracingBuilder {
21+
22+
private var openTelemetry: OpenTelemetry? = null
23+
private val additionalExtractors = mutableListOf<AttributesExtractor<in HttpRequestData, in HttpResponse>>()
24+
private val httpAttributesExtractorBuilder = HttpClientAttributesExtractor.builder(
25+
KtorHttpClientAttributesGetter,
26+
KtorNetClientAttributesGetter,
27+
)
28+
29+
fun setOpenTelemetry(openTelemetry: OpenTelemetry) {
30+
this.openTelemetry = openTelemetry
31+
}
32+
33+
fun setCapturedRequestHeaders(vararg headers: String) =
34+
setCapturedRequestHeaders(headers.asList())
35+
36+
fun setCapturedRequestHeaders(headers: List<String>) {
37+
httpAttributesExtractorBuilder.setCapturedRequestHeaders(headers)
38+
}
39+
40+
fun setCapturedResponseHeaders(vararg headers: String) =
41+
setCapturedResponseHeaders(headers.asList())
42+
43+
fun setCapturedResponseHeaders(headers: List<String>) {
44+
httpAttributesExtractorBuilder.setCapturedResponseHeaders(headers)
45+
}
46+
47+
fun addAttributesExtractors(vararg extractors: AttributesExtractor<in HttpRequestData, in HttpResponse>) =
48+
addAttributesExtractors(extractors.asList())
49+
50+
fun addAttributesExtractors(extractors: Iterable<AttributesExtractor<in HttpRequestData, in HttpResponse>>) {
51+
additionalExtractors += extractors
52+
}
53+
54+
internal fun build(): KtorClientTracing {
55+
val initializedOpenTelemetry = openTelemetry
56+
?: throw IllegalArgumentException("OpenTelemetry must be set")
57+
58+
val instrumenterBuilder = Instrumenter.builder<HttpRequestData, HttpResponse>(
59+
initializedOpenTelemetry,
60+
INSTRUMENTATION_NAME,
61+
HttpSpanNameExtractor.create(KtorHttpClientAttributesGetter),
62+
)
63+
64+
val instrumenter = instrumenterBuilder
65+
.setSpanStatusExtractor(HttpSpanStatusExtractor.create(KtorHttpClientAttributesGetter))
66+
.addAttributesExtractor(httpAttributesExtractorBuilder.build())
67+
.addAttributesExtractors(additionalExtractors)
68+
.addOperationMetrics(HttpClientMetrics.get())
69+
.buildInstrumenter(alwaysClient())
70+
71+
return KtorClientTracing(
72+
instrumenter = instrumenter,
73+
propagators = initializedOpenTelemetry.propagators,
74+
)
75+
}
76+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.instrumentation.ktor.v2_0.client
7+
8+
import io.ktor.client.request.*
9+
import io.ktor.client.statement.HttpResponse
10+
import io.opentelemetry.instrumentation.api.instrumenter.http.HttpClientAttributesGetter
11+
12+
internal object KtorHttpClientAttributesGetter : HttpClientAttributesGetter<HttpRequestData, HttpResponse> {
13+
14+
override fun getUrl(request: HttpRequestData) =
15+
request.url.toString()
16+
17+
override fun getFlavor(request: HttpRequestData, response: HttpResponse?) =
18+
null
19+
20+
override fun getMethod(request: HttpRequestData) =
21+
request.method.value
22+
23+
override fun getRequestHeader(request: HttpRequestData, name: String) =
24+
request.headers.getAll(name).orEmpty()
25+
26+
override fun getStatusCode(request: HttpRequestData, response: HttpResponse, error: Throwable?) =
27+
response.status.value
28+
29+
override fun getResponseHeader(request: HttpRequestData, response: HttpResponse, name: String) =
30+
response.headers.getAll(name).orEmpty()
31+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.instrumentation.ktor.v2_0.client
7+
8+
import io.ktor.client.request.HttpRequestBuilder
9+
import io.opentelemetry.context.propagation.TextMapSetter
10+
11+
internal object KtorHttpHeadersSetter : TextMapSetter<HttpRequestBuilder> {
12+
13+
override fun set(carrier: HttpRequestBuilder?, key: String, value: String) {
14+
carrier?.headers?.set(key, value)
15+
}
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* Copyright The OpenTelemetry Authors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package io.opentelemetry.instrumentation.ktor.v2_0.client
7+
8+
import io.ktor.client.request.*
9+
import io.ktor.client.statement.*
10+
import io.opentelemetry.instrumentation.api.instrumenter.net.NetClientAttributesGetter
11+
import io.opentelemetry.semconv.trace.attributes.SemanticAttributes.NetTransportValues.IP_TCP
12+
13+
internal object KtorNetClientAttributesGetter : NetClientAttributesGetter<HttpRequestData, HttpResponse> {
14+
15+
override fun getTransport(request: HttpRequestData, response: HttpResponse?) = IP_TCP
16+
17+
override fun getPeerName(request: HttpRequestData) = request.url.host
18+
19+
override fun getPeerPort(request: HttpRequestData) = request.url.port
20+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
package io.opentelemetry.instrumentation.ktor.v2_0
6+
package io.opentelemetry.instrumentation.ktor.v2_0.server
77

88
import io.ktor.server.request.*
99
import io.opentelemetry.context.propagation.TextMapGetter
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
package io.opentelemetry.instrumentation.ktor.v2_0
6+
package io.opentelemetry.instrumentation.ktor.v2_0.server
77

88
import io.ktor.server.plugins.*
99
import io.ktor.server.request.*

0 commit comments

Comments
 (0)