-
Notifications
You must be signed in to change notification settings - Fork 848
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add library instrumentation for ktor server #4983
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
# Ktor Instrumentation | ||
|
||
This package contains libraries to help instrument Ktor. Currently, only server instrumentation is supported. | ||
|
||
## Initializing server instrumentation | ||
|
||
Initialize instrumentation by installing the `KtorServerTracing` feature. You must set the `OpenTelemetry` to use with | ||
the feature. | ||
|
||
```kotlin | ||
OpenTelemetry openTelemetry = initializeOpenTelemetryForMe() | ||
|
||
embeddedServer(Netty, 8080) { | ||
install(KtorServerTracing) { | ||
setOpenTelemetry(openTelemetry) | ||
} | ||
} | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile | ||
|
||
plugins { | ||
id("otel.library-instrumentation") | ||
|
||
id("org.jetbrains.kotlin.jvm") | ||
} | ||
|
||
dependencies { | ||
library("io.ktor:ktor-server-core:1.0.0") | ||
|
||
implementation("io.opentelemetry:opentelemetry-extension-kotlin") | ||
|
||
compileOnly("org.jetbrains.kotlin:kotlin-stdlib-jdk8") | ||
|
||
testImplementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") | ||
|
||
// Note, we do not have a :testing library yet because there doesn't seem to be a way to have the Kotlin classes | ||
// available for use from Spock. We will first need to migrate HttpServerTest to be usable outside of Spock. | ||
testLibrary("io.ktor:ktor-server-netty:1.0.0") | ||
} | ||
|
||
tasks { | ||
withType(KotlinCompile::class).configureEach { | ||
kotlinOptions { | ||
jvmTarget = "1.8" | ||
} | ||
} | ||
|
||
val compileTestKotlin by existing(AbstractCompile::class) | ||
|
||
named<GroovyCompile>("compileTestGroovy") { | ||
// Note: look like it should be `classpath += files(sourceSets.test.kotlin.classesDirectory)` | ||
// instead, but kotlin plugin doesn't support it (yet?) | ||
classpath = classpath.plus(files(compileTestKotlin.get().destinationDir)) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
/* | ||
* Copyright The OpenTelemetry Authors | ||
* SPDX-License-Identifier: Apache-2.0 | ||
*/ | ||
|
||
package io.opentelemetry.instrumentation.ktor.v1_0 | ||
|
||
import io.ktor.application.* | ||
import io.ktor.request.* | ||
import io.opentelemetry.context.propagation.TextMapGetter | ||
|
||
internal object ApplicationRequestGetter : TextMapGetter<ApplicationRequest> { | ||
override fun keys(carrier: ApplicationRequest): Iterable<String> { | ||
return carrier.headers.names() | ||
} | ||
|
||
override fun get(carrier: ApplicationRequest?, name: String): String? { | ||
return carrier?.headers?.get(name) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
/* | ||
* Copyright The OpenTelemetry Authors | ||
* SPDX-License-Identifier: Apache-2.0 | ||
*/ | ||
|
||
package io.opentelemetry.instrumentation.ktor.v1_0 | ||
|
||
import io.ktor.application.* | ||
import io.ktor.features.* | ||
import io.ktor.request.* | ||
import io.ktor.response.* | ||
import io.ktor.routing.* | ||
import io.opentelemetry.instrumentation.api.instrumenter.http.CapturedHttpHeaders | ||
import io.opentelemetry.instrumentation.api.instrumenter.http.HttpServerAttributesExtractor | ||
import io.opentelemetry.semconv.trace.attributes.SemanticAttributes | ||
|
||
internal class KtorHttpServerAttributesExtractor(capturedHttpHeaders: CapturedHttpHeaders) : | ||
HttpServerAttributesExtractor<ApplicationRequest, ApplicationResponse>(capturedHttpHeaders) { | ||
|
||
override fun method(request: ApplicationRequest): String { | ||
return request.httpMethod.value | ||
} | ||
|
||
override fun requestHeader(request: ApplicationRequest, name: String): List<String> { | ||
return request.headers.getAll(name) ?: emptyList() | ||
} | ||
|
||
override fun requestContentLength(request: ApplicationRequest, response: ApplicationResponse?): Long? { | ||
return null | ||
} | ||
|
||
override fun requestContentLengthUncompressed(request: ApplicationRequest, response: ApplicationResponse?): Long? { | ||
return null | ||
} | ||
|
||
override fun statusCode(request: ApplicationRequest, response: ApplicationResponse): Int? { | ||
return response.status()?.value | ||
} | ||
|
||
override fun responseContentLength(request: ApplicationRequest, response: ApplicationResponse): Long? { | ||
return null | ||
} | ||
|
||
override fun responseContentLengthUncompressed(request: ApplicationRequest, response: ApplicationResponse): Long? { | ||
return null | ||
} | ||
|
||
override fun responseHeader(request: ApplicationRequest, response: ApplicationResponse, name: String): List<String> { | ||
return response.headers.allValues().getAll(name) ?: emptyList() | ||
} | ||
|
||
override fun flavor(request: ApplicationRequest): String? { | ||
return when (request.httpVersion) { | ||
"HTTP/1.1" -> SemanticAttributes.HttpFlavorValues.HTTP_1_1 | ||
"HTTP/2.0" -> SemanticAttributes.HttpFlavorValues.HTTP_2_0 | ||
else -> null | ||
} | ||
} | ||
|
||
override fun target(request: ApplicationRequest): String { | ||
return request.uri | ||
} | ||
|
||
override fun route(request: ApplicationRequest): String? { | ||
return null | ||
} | ||
|
||
override fun scheme(request: ApplicationRequest): String { | ||
return request.origin.scheme | ||
} | ||
|
||
override fun serverName(request: ApplicationRequest, response: ApplicationResponse?): String? { | ||
return null | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
/* | ||
* Copyright The OpenTelemetry Authors | ||
* SPDX-License-Identifier: Apache-2.0 | ||
*/ | ||
|
||
package io.opentelemetry.instrumentation.ktor.v1_0 | ||
|
||
import io.ktor.features.* | ||
import io.ktor.request.* | ||
import io.ktor.response.* | ||
import io.opentelemetry.instrumentation.api.instrumenter.net.NetServerAttributesExtractor | ||
import io.opentelemetry.semconv.trace.attributes.SemanticAttributes | ||
|
||
internal class KtorNetServerAttributesExtractor : NetServerAttributesExtractor<ApplicationRequest, ApplicationResponse>() { | ||
override fun transport(request: ApplicationRequest): String { | ||
return SemanticAttributes.NetTransportValues.IP_TCP | ||
} | ||
|
||
override fun peerName(request: ApplicationRequest): String { | ||
return request.origin.host | ||
} | ||
|
||
override fun peerPort(request: ApplicationRequest): Int { | ||
return request.origin.port | ||
} | ||
|
||
override fun peerIp(request: ApplicationRequest): String { | ||
return request.origin.remoteHost | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,161 @@ | ||
/* | ||
* Copyright The OpenTelemetry Authors | ||
* SPDX-License-Identifier: Apache-2.0 | ||
*/ | ||
|
||
package io.opentelemetry.instrumentation.ktor.v1_0 | ||
|
||
import io.ktor.application.* | ||
import io.ktor.request.* | ||
import io.ktor.response.* | ||
import io.ktor.routing.* | ||
import io.ktor.util.* | ||
import io.ktor.util.pipeline.* | ||
import io.opentelemetry.api.OpenTelemetry | ||
import io.opentelemetry.context.Context | ||
import io.opentelemetry.extension.kotlin.asContextElement | ||
import io.opentelemetry.instrumentation.api.config.Config | ||
import io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor | ||
import io.opentelemetry.instrumentation.api.instrumenter.Instrumenter | ||
import io.opentelemetry.instrumentation.api.instrumenter.SpanStatusExtractor | ||
import io.opentelemetry.instrumentation.api.instrumenter.http.CapturedHttpHeaders | ||
import io.opentelemetry.instrumentation.api.instrumenter.http.HttpServerMetrics | ||
import io.opentelemetry.instrumentation.api.instrumenter.http.HttpSpanNameExtractor | ||
import io.opentelemetry.instrumentation.api.instrumenter.http.HttpSpanStatusExtractor | ||
import io.opentelemetry.instrumentation.api.servlet.ServerSpanNaming | ||
import kotlinx.coroutines.withContext | ||
|
||
class KtorServerTracing private constructor( | ||
private val instrumenter: Instrumenter<ApplicationRequest, ApplicationResponse> | ||
) { | ||
|
||
class Configuration { | ||
internal lateinit var openTelemetry: OpenTelemetry | ||
|
||
internal var capturedHttpHeaders = CapturedHttpHeaders.server(Config.get()) | ||
|
||
internal val additionalExtractors = mutableListOf<AttributesExtractor<in ApplicationRequest, in ApplicationResponse>>() | ||
|
||
internal var statusExtractor: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't understand why is this a function and not just an instance of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the same pattern as many of the tracing builders. It's because it's very common to want to customize one specific case and then delegate to default for others. |
||
(SpanStatusExtractor<ApplicationRequest, ApplicationResponse>) -> SpanStatusExtractor<in ApplicationRequest, in ApplicationResponse> = { a -> a } | ||
|
||
fun setOpenTelemetry(openTelemetry: OpenTelemetry) { | ||
this.openTelemetry = openTelemetry | ||
} | ||
|
||
fun setStatusExtractor(extractor: (SpanStatusExtractor<ApplicationRequest, ApplicationResponse>) -> SpanStatusExtractor<in ApplicationRequest, in ApplicationResponse>) { | ||
this.statusExtractor = extractor | ||
} | ||
|
||
fun addAttributeExtractor(extractor: AttributesExtractor<in ApplicationRequest, in ApplicationResponse>) { | ||
additionalExtractors.add(extractor) | ||
} | ||
|
||
fun captureHttpHeaders(capturedHttpHeaders: CapturedHttpHeaders) { | ||
this.capturedHttpHeaders = capturedHttpHeaders | ||
} | ||
|
||
internal fun isOpenTelemetryInitialized(): Boolean = this::openTelemetry.isInitialized | ||
} | ||
|
||
private fun start(call: ApplicationCall): Context? { | ||
val parentContext = Context.current() | ||
if (!instrumenter.shouldStart(parentContext, call.request)) { | ||
return null | ||
} | ||
|
||
return instrumenter.start(parentContext, call.request) | ||
} | ||
|
||
private fun end(context: Context, call: ApplicationCall, error: Throwable?) { | ||
instrumenter.end(context, call.request, call.response, error) | ||
} | ||
|
||
companion object Feature : ApplicationFeature<Application, Configuration, KtorServerTracing> { | ||
private val INSTRUMENTATION_NAME = "io.opentelemetry.ktor-1.0" | ||
|
||
private val contextKey = AttributeKey<Context>("OpenTelemetry") | ||
private val errorKey = AttributeKey<Throwable>("OpenTelemetryException") | ||
|
||
override val key: AttributeKey<KtorServerTracing> = AttributeKey("OpenTelemetry") | ||
|
||
override fun install(pipeline: Application, configure: Configuration.() -> Unit): KtorServerTracing { | ||
val configuration = Configuration().apply(configure) | ||
|
||
if (!configuration.isOpenTelemetryInitialized()) { | ||
throw IllegalArgumentException("OpenTelemetry must be set") | ||
} | ||
|
||
val httpAttributesExtractor = KtorHttpServerAttributesExtractor(configuration.capturedHttpHeaders) | ||
|
||
val instrumenterBuilder = Instrumenter.builder<ApplicationRequest, ApplicationResponse>( | ||
configuration.openTelemetry, | ||
INSTRUMENTATION_NAME, | ||
HttpSpanNameExtractor.create(httpAttributesExtractor) | ||
) | ||
|
||
configuration.additionalExtractors.forEach { instrumenterBuilder.addAttributesExtractor(it) } | ||
|
||
with(instrumenterBuilder) { | ||
setSpanStatusExtractor(configuration.statusExtractor(HttpSpanStatusExtractor.create(httpAttributesExtractor))) | ||
addAttributesExtractor(KtorNetServerAttributesExtractor()) | ||
addAttributesExtractor(httpAttributesExtractor) | ||
addRequestMetrics(HttpServerMetrics.get()) | ||
addContextCustomizer(ServerSpanNaming.get()) | ||
} | ||
|
||
val instrumenter = instrumenterBuilder.newServerInstrumenter(ApplicationRequestGetter) | ||
|
||
val feature = KtorServerTracing(instrumenter) | ||
|
||
val startPhase = PipelinePhase("OpenTelemetry") | ||
pipeline.insertPhaseBefore(ApplicationCallPipeline.Monitoring, startPhase) | ||
pipeline.intercept(startPhase) { | ||
val context = feature.start(call) | ||
|
||
if (context != null) { | ||
call.attributes.put(contextKey, context) | ||
withContext(context.asContextElement()) { | ||
try { | ||
proceed() | ||
} catch (err: Throwable) { | ||
// Stash error for reporting later since need ktor to finish setting up the response | ||
call.attributes.put(errorKey, err) | ||
throw err | ||
} | ||
} | ||
} else { | ||
proceed() | ||
} | ||
} | ||
|
||
val postSendPhase = PipelinePhase("OpenTelemetryPostSend") | ||
pipeline.sendPipeline.insertPhaseAfter(ApplicationSendPipeline.After, postSendPhase) | ||
pipeline.sendPipeline.intercept(postSendPhase) { | ||
val context = call.attributes.getOrNull(contextKey) | ||
if (context != null) { | ||
var error: Throwable? = call.attributes.getOrNull(errorKey) | ||
try { | ||
proceed() | ||
} catch (t: Throwable) { | ||
error = t | ||
throw t | ||
} finally { | ||
feature.end(context, call, error) | ||
} | ||
} else { | ||
proceed() | ||
} | ||
} | ||
|
||
pipeline.environment.monitor.subscribe(Routing.RoutingCallStarted) { call -> | ||
val context = call.attributes.getOrNull(contextKey) | ||
if (context != null) { | ||
ServerSpanNaming.updateServerSpanName(context, ServerSpanNaming.Source.SERVLET, { _, arg -> arg.route.parent.toString() }, call) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this set There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah I think so - do you think we should do it here or should ServerSpanName, or a similar helper, set them both? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe we discussed this already somewhere... I don't remember our decision :( There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
} | ||
} | ||
|
||
return feature | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reference while writing this
https://github.com/ktorio/ktor/blob/1.6.7/ktor-features/ktor-metrics-micrometer/jvm/src/io/ktor/metrics/micrometer/MicrometerMetrics.kt