Skip to content
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

Merged
merged 1 commit into from
Jan 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions instrumentation/ktor-1.0/library/README.md
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)
}
}
```
37 changes: 37 additions & 0 deletions instrumentation/ktor-1.0/library/build.gradle.kts
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(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
Copy link
Contributor

Choose a reason for hiding this comment

The 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 SpanStatusExtractor?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this set http.route attribute as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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?

Copy link
Contributor

Choose a reason for hiding this comment

The 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 :(

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

http.route is going to be set by ServerSpanNaming (well, once I find enough time to implement that...)

}
}

return feature
}
}
}
Loading