From 05177994249d305d85e92d19a3a7e1810a52ea47 Mon Sep 17 00:00:00 2001 From: Subhanshu Bansal Date: Sun, 29 Mar 2026 21:34:26 +0530 Subject: [PATCH 01/10] Added Open Telemetry Support for KTOR --- .idea/vcs.xml | 2 +- gradle/libs.versions.toml | 4 + .../ktor-client-opentelemetry/README.md | 135 +++++++ .../api/ktor-client-opentelemetry.api | 23 ++ .../api/ktor-client-opentelemetry.klib.api | 0 .../build.gradle.kts | 20 + .../plugins/opentelemetry/OpenTelemetry.kt | 238 ++++++++++++ .../opentelemetry/OpenTelemetryTest.kt | 213 +++++++++++ .../ktor-server-opentelemetry/README.md | 125 ++++++ .../api/ktor-server-opentelemetry.api | 25 ++ .../api/ktor-server-opentelemetry.klib.api | 0 .../build.gradle.kts | 19 + .../plugins/opentelemetry/OpenTelemetry.kt | 358 ++++++++++++++++++ .../opentelemetry/OpenTelemetryTest.kt | 248 ++++++++++++ settings.gradle.kts | 2 + 15 files changed, 1411 insertions(+), 1 deletion(-) create mode 100644 ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md create mode 100644 ktor-client/ktor-client-plugins/ktor-client-opentelemetry/api/ktor-client-opentelemetry.api create mode 100644 ktor-client/ktor-client-plugins/ktor-client-opentelemetry/api/ktor-client-opentelemetry.klib.api create mode 100644 ktor-client/ktor-client-plugins/ktor-client-opentelemetry/build.gradle.kts create mode 100644 ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt create mode 100644 ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/test/io/ktor/client/plugins/opentelemetry/OpenTelemetryTest.kt create mode 100644 ktor-server/ktor-server-plugins/ktor-server-opentelemetry/README.md create mode 100644 ktor-server/ktor-server-plugins/ktor-server-opentelemetry/api/ktor-server-opentelemetry.api create mode 100644 ktor-server/ktor-server-plugins/ktor-server-opentelemetry/api/ktor-server-opentelemetry.klib.api create mode 100644 ktor-server/ktor-server-plugins/ktor-server-opentelemetry/build.gradle.kts create mode 100644 ktor-server/ktor-server-plugins/ktor-server-opentelemetry/jvm/src/io/ktor/server/plugins/opentelemetry/OpenTelemetry.kt create mode 100644 ktor-server/ktor-server-plugins/ktor-server-opentelemetry/jvm/test/io/ktor/server/plugins/opentelemetry/OpenTelemetryTest.kt diff --git a/.idea/vcs.xml b/.idea/vcs.xml index d4be8047281..05b80bdc59e 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -24,6 +24,6 @@ - + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ead9a6bbbc1..f315882f7a9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,6 +46,7 @@ logback = "1.5.24" dropwizard = "4.2.37" micrometer = "1.16.2" +opentelemetry = "1.46.0" jansi = "2.4.2" typesafe = "1.4.5" @@ -210,6 +211,9 @@ okio = { module = "com.squareup.okio:okio", version.ref = "okio" } dropwizard-core = { module = "io.dropwizard.metrics:metrics-core", version.ref = "dropwizard" } dropwizard-jvm = { module = "io.dropwizard.metrics:metrics-jvm", version.ref = "dropwizard" } micrometer = { module = "io.micrometer:micrometer-core", version.ref = "micrometer" } +opentelemetry-api = { module = "io.opentelemetry:opentelemetry-api", version.ref = "opentelemetry" } +opentelemetry-sdk = { module = "io.opentelemetry:opentelemetry-sdk", version.ref = "opentelemetry" } +opentelemetry-sdk-testing = { module = "io.opentelemetry:opentelemetry-sdk-testing", version.ref = "opentelemetry" } mustache = { module = "com.github.spullara.mustache.java:compiler", version.ref = "mustache" } freemarker = { module = "org.freemarker:freemarker", version.ref = "freemarker" } diff --git a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md new file mode 100644 index 00000000000..263e41517ae --- /dev/null +++ b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md @@ -0,0 +1,135 @@ +# ktor-client-opentelemetry + +Native OpenTelemetry integration for Ktor HTTP client. Provides distributed tracing, metrics, and trace context injection for outgoing requests following [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/http/http-spans/). + +## Installation + +Add the dependency to your project: + +```kotlin +dependencies { + implementation("io.ktor:ktor-client-opentelemetry:$ktor_version") +} +``` + +## Usage + +### Basic setup + +```kotlin +import io.ktor.client.plugins.opentelemetry.* +import io.opentelemetry.sdk.OpenTelemetrySdk + +val openTelemetry = OpenTelemetrySdk.builder() + .setTracerProvider(sdkTracerProvider) + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .build() + +val client = HttpClient(CIO) { + install(OpenTelemetry) { + openTelemetry = openTelemetry + } +} +``` + +### Individual component configuration + +```kotlin +val client = HttpClient(CIO) { + install(OpenTelemetry) { + tracerProvider = sdkTracerProvider + meterProvider = sdkMeterProvider + propagators = W3CTraceContextPropagator.getInstance() + } +} +``` + +### Header capture + +```kotlin +val client = HttpClient(CIO) { + install(OpenTelemetry) { + openTelemetry = otel + captureRequestHeaders("Authorization", "X-Api-Key") + captureResponseHeaders("X-RateLimit-Remaining") + } +} +``` + +### Filtering + +```kotlin +val client = HttpClient(CIO) { + install(OpenTelemetry) { + openTelemetry = otel + filter { request -> !request.url.encodedPath.startsWith("/internal") } + } +} +``` + +## Features + +### Client spans + +Creates a `CLIENT` span for each outgoing HTTP request with attributes: + +| Attribute | Description | +|---|---| +| `http.request.method` | HTTP method (GET, POST, etc.) | +| `server.address` | Target server hostname | +| `server.port` | Target server port | +| `url.full` | Full request URL | +| `http.response.status_code` | Response status code | +| `error.type` | Exception class name or HTTP status code (on error) | + +### Trace context injection + +Automatically injects trace context headers into outgoing requests using the configured `TextMapPropagator`. With W3C Trace Context (default), the `traceparent` and `tracestate` headers are added to every outgoing request. + +### Server-client context propagation + +When used together with [ktor-server-opentelemetry](../../ktor-server/ktor-server-plugins/ktor-server-opentelemetry), trace context flows automatically from incoming server requests to outgoing client requests. The server plugin propagates the OTEL context through Kotlin coroutines, so client spans created inside a request handler automatically become children of the server span: + +```kotlin +fun Application.module() { + install(io.ktor.server.plugins.opentelemetry.OpenTelemetry) { + openTelemetry = otel + } + + val client = HttpClient(CIO) { + install(io.ktor.client.plugins.opentelemetry.OpenTelemetry) { + openTelemetry = otel + } + } + + routing { + get("/api/users/{id}") { + // The client span for this call is automatically a child of the server span + val user = client.get("http://user-service/users/${call.parameters["id"]}") + call.respond(user.body()) + } + } +} +``` + +This produces a distributed trace: + +``` +[Server] GET /api/users/{id} + └── [Client] HTTP GET → user-service +``` + +### Metrics + +| Metric | Type | Description | +|---|---|---| +| `http.client.request.duration` | Histogram (seconds) | Duration of HTTP client requests | + +Metric attributes include `http.request.method`, `http.response.status_code`, and `server.address`. + +### Error handling + +- Network errors and exceptions are recorded on the span via `Span.recordException()` +- HTTP 4xx/5xx responses set the span status to `ERROR` +- The `error.type` attribute is set to the exception class name or HTTP status code +- Spans are always ended, even when exceptions occur, preventing span leaks diff --git a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/api/ktor-client-opentelemetry.api b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/api/ktor-client-opentelemetry.api new file mode 100644 index 00000000000..da09f92b278 --- /dev/null +++ b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/api/ktor-client-opentelemetry.api @@ -0,0 +1,23 @@ +public final class io/ktor/client/plugins/opentelemetry/OpenTelemetryClientConfig { + public fun ()V + public final fun captureRequestHeaders ([Ljava/lang/String;)V + public final fun captureResponseHeaders ([Ljava/lang/String;)V + public final fun filter (Lkotlin/jvm/functions/Function1;)V + public final fun getInstrumentationName ()Ljava/lang/String; + public final fun getInstrumentationVersion ()Ljava/lang/String; + public final fun getMeterProvider ()Lio/opentelemetry/api/metrics/MeterProvider; + public final fun getOpenTelemetry ()Lio/opentelemetry/api/OpenTelemetry; + public final fun getPropagators ()Lio/opentelemetry/context/propagation/TextMapPropagator; + public final fun getTracerProvider ()Lio/opentelemetry/api/trace/TracerProvider; + public final fun setInstrumentationName (Ljava/lang/String;)V + public final fun setInstrumentationVersion (Ljava/lang/String;)V + public final fun setMeterProvider (Lio/opentelemetry/api/metrics/MeterProvider;)V + public final fun setOpenTelemetry (Lio/opentelemetry/api/OpenTelemetry;)V + public final fun setPropagators (Lio/opentelemetry/context/propagation/TextMapPropagator;)V + public final fun setTracerProvider (Lio/opentelemetry/api/trace/TracerProvider;)V +} + +public final class io/ktor/client/plugins/opentelemetry/OpenTelemetryKt { + public static final fun getOpenTelemetry ()Lio/ktor/client/plugins/api/ClientPlugin; +} + diff --git a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/api/ktor-client-opentelemetry.klib.api b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/api/ktor-client-opentelemetry.klib.api new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/build.gradle.kts b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/build.gradle.kts new file mode 100644 index 00000000000..644d0ac5ea8 --- /dev/null +++ b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/build.gradle.kts @@ -0,0 +1,20 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +plugins { + id("ktorbuild.project.client-plugin") +} + +kotlin { + sourceSets { + jvmMain.dependencies { + api(libs.opentelemetry.api) + } + jvmTest.dependencies { + implementation(libs.opentelemetry.sdk) + implementation(libs.opentelemetry.sdk.testing) + implementation(projects.ktorServerTestHost) + } + } +} diff --git a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt new file mode 100644 index 00000000000..9ee4f598f7f --- /dev/null +++ b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt @@ -0,0 +1,238 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.client.plugins.opentelemetry + +import io.ktor.client.plugins.* +import io.ktor.client.plugins.api.* +import io.ktor.client.request.* +import io.ktor.util.* +import io.ktor.utils.io.* +import io.opentelemetry.api.metrics.* +import io.opentelemetry.api.trace.* +import io.opentelemetry.context.Context +import io.opentelemetry.context.propagation.* +import io.opentelemetry.api.OpenTelemetry as OpenTelemetryApi +import io.opentelemetry.api.common.AttributeKey as OtelAttributeKey +import io.opentelemetry.api.common.Attributes as OtelAttributes + +/** + * Configuration for the client-side [OpenTelemetry] plugin. + * + * Supports two configuration styles: + * - Set [openTelemetry] to provide an [OpenTelemetryApi] instance with all components configured. + * - Set individual [tracerProvider], [meterProvider], and [propagators] properties to override + * specific components. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.plugins.opentelemetry.OpenTelemetryClientConfig) + */ +@KtorDsl +public class OpenTelemetryClientConfig { + /** + * The [OpenTelemetryApi] instance providing tracer, meter, and propagators. + * Individual properties ([tracerProvider], [meterProvider], [propagators]) override + * the corresponding components from this instance when set. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.plugins.opentelemetry.OpenTelemetryClientConfig.openTelemetry) + */ + public var openTelemetry: OpenTelemetryApi = OpenTelemetryApi.noop() + + /** + * Overrides the [TracerProvider] from [openTelemetry]. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.plugins.opentelemetry.OpenTelemetryClientConfig.tracerProvider) + */ + public var tracerProvider: TracerProvider? = null + + /** + * Overrides the [MeterProvider] from [openTelemetry]. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.plugins.opentelemetry.OpenTelemetryClientConfig.meterProvider) + */ + public var meterProvider: MeterProvider? = null + + /** + * Overrides the [TextMapPropagator] from [openTelemetry]. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.plugins.opentelemetry.OpenTelemetryClientConfig.propagators) + */ + public var propagators: TextMapPropagator? = null + + /** + * The instrumentation scope name used to obtain tracer and meter instances. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.plugins.opentelemetry.OpenTelemetryClientConfig.instrumentationName) + */ + public var instrumentationName: String = "io.ktor.client" + + /** + * The instrumentation scope version. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.plugins.opentelemetry.OpenTelemetryClientConfig.instrumentationVersion) + */ + public var instrumentationVersion: String? = null + + internal val capturedRequestHeaders: MutableList = mutableListOf() + internal val capturedResponseHeaders: MutableList = mutableListOf() + + /** + * Captures the specified request header values as span attributes. + * Attribute keys follow the pattern `http.request.header.`. + * + * @param headers header names to capture (case-insensitive) + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.plugins.opentelemetry.OpenTelemetryClientConfig.captureRequestHeaders) + */ + public fun captureRequestHeaders(vararg headers: String) { + capturedRequestHeaders.addAll(headers.map { it.lowercase() }) + } + + /** + * Captures the specified response header values as span attributes. + * Attribute keys follow the pattern `http.response.header.`. + * + * @param headers header names to capture (case-insensitive) + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.plugins.opentelemetry.OpenTelemetryClientConfig.captureResponseHeaders) + */ + public fun captureResponseHeaders(vararg headers: String) { + capturedResponseHeaders.addAll(headers.map { it.lowercase() }) + } + + internal var filter: (HttpRequestBuilder) -> Boolean = { true } + + /** + * Filters which requests should be instrumented. + * Return `true` to trace the request, `false` to skip. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.plugins.opentelemetry.OpenTelemetryClientConfig.filter) + */ + public fun filter(predicate: (HttpRequestBuilder) -> Boolean) { + filter = predicate + } +} + +private object RequestBuilderHeadersSetter : TextMapSetter { + override fun set(carrier: HttpRequestBuilder?, key: String, value: String) { + carrier?.header(key, value) + } +} + +/** + * A plugin that enables OpenTelemetry distributed tracing and metrics for Ktor HTTP client requests. + * Automatically creates client spans for outgoing requests, injects trace context into request headers, + * and records request duration metrics following OpenTelemetry semantic conventions. + * + * When used together with the server-side OpenTelemetry plugin, trace context is automatically + * propagated from server handlers to outgoing client requests via [Context.current]. + * + * ```kotlin + * val client = HttpClient(CIO) { + * install(OpenTelemetry) { + * openTelemetry = sdkOpenTelemetry + * } + * } + * ``` + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.client.plugins.opentelemetry.OpenTelemetry) + * + * @see OpenTelemetryClientConfig + */ +public val OpenTelemetry: ClientPlugin = + createClientPlugin("OpenTelemetry", ::OpenTelemetryClientConfig) { + val otel = pluginConfig.openTelemetry + val tracerProvider = pluginConfig.tracerProvider ?: otel.tracerProvider + val meterProvider = pluginConfig.meterProvider ?: otel.meterProvider + val propagator = pluginConfig.propagators ?: otel.propagators.textMapPropagator + + val tracer = tracerProvider.tracerBuilder(pluginConfig.instrumentationName).apply { + pluginConfig.instrumentationVersion?.let { setInstrumentationVersion(it) } + }.build() + + val meter = meterProvider.meterBuilder(pluginConfig.instrumentationName).apply { + pluginConfig.instrumentationVersion?.let { setInstrumentationVersion(it) } + }.build() + + val requestDuration = meter.histogramBuilder("http.client.request.duration") + .setDescription("Duration of HTTP client requests") + .setUnit("s") + .build() + + val requestHeaders = pluginConfig.capturedRequestHeaders.toList() + val responseHeaders = pluginConfig.capturedResponseHeaders.toList() + val filter = pluginConfig.filter + + client.plugin(HttpSend).intercept { request -> + if (!filter(request)) return@intercept execute(request) + + val parentContext = Context.current() + val startTime = System.nanoTime() + + val span = tracer.spanBuilder("HTTP ${request.method.value}") + .setParent(parentContext) + .setSpanKind(SpanKind.CLIENT) + .setAttribute(OtelAttributeKey.stringKey("http.request.method"), request.method.value) + .setAttribute(OtelAttributeKey.stringKey("server.address"), request.url.host) + .setAttribute(OtelAttributeKey.longKey("server.port"), request.url.port.toLong()) + .setAttribute(OtelAttributeKey.stringKey("url.full"), request.url.buildString()) + .startSpan() + + for (headerName in requestHeaders) { + val values = request.headers.getAll(headerName) + if (!values.isNullOrEmpty()) { + span.setAttribute( + OtelAttributeKey.stringArrayKey("http.request.header.$headerName"), + values + ) + } + } + + propagator.inject(parentContext.with(span), request, RequestBuilderHeadersSetter) + + try { + val call = execute(request) + val statusCode = call.response.status.value + + span.setAttribute(OtelAttributeKey.longKey("http.response.status_code"), statusCode.toLong()) + + for (headerName in responseHeaders) { + val values = call.response.headers.getAll(headerName) + if (!values.isNullOrEmpty()) { + span.setAttribute( + OtelAttributeKey.stringArrayKey("http.response.header.$headerName"), + values + ) + } + } + + if (statusCode >= 400) { + span.setStatus(StatusCode.ERROR) + span.setAttribute(OtelAttributeKey.stringKey("error.type"), statusCode.toString()) + } + + span.end() + + val durationSeconds = (System.nanoTime() - startTime) / 1_000_000_000.0 + requestDuration.record( + durationSeconds, + OtelAttributes.builder() + .put("http.request.method", request.method.value) + .put("http.response.status_code", statusCode.toLong()) + .put("server.address", request.url.host) + .build() + ) + + call + } catch (cause: Throwable) { + span.recordException(cause) + span.setStatus(StatusCode.ERROR, cause.message ?: "") + span.setAttribute( + OtelAttributeKey.stringKey("error.type"), + cause::class.qualifiedName ?: "unknown" + ) + span.end() + throw cause + } + } + } diff --git a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/test/io/ktor/client/plugins/opentelemetry/OpenTelemetryTest.kt b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/test/io/ktor/client/plugins/opentelemetry/OpenTelemetryTest.kt new file mode 100644 index 00000000000..a955600a29d --- /dev/null +++ b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/test/io/ktor/client/plugins/opentelemetry/OpenTelemetryTest.kt @@ -0,0 +1,213 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.client.plugins.opentelemetry + +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import io.opentelemetry.api.trace.* +import io.opentelemetry.api.trace.propagation.* +import io.opentelemetry.context.propagation.* +import io.opentelemetry.sdk.* +import io.opentelemetry.sdk.testing.exporter.* +import io.opentelemetry.sdk.trace.* +import io.opentelemetry.sdk.trace.export.* +import kotlin.test.* + +class OpenTelemetryClientTest { + + private fun createTestOtel(spanExporter: InMemorySpanExporter): io.opentelemetry.api.OpenTelemetry { + val tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(spanExporter)) + .build() + return OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .build() + } + + @Test + fun `spans are created for outgoing requests`() = testApplication { + routing { + get("/test") { + call.respondText("hello") + } + } + + val spanExporter = InMemorySpanExporter.create() + val otel = createTestOtel(spanExporter) + + val testClient = createClient { + install(OpenTelemetry) { + openTelemetry = otel + } + } + + testClient.get("/test") + + val spans = spanExporter.finishedSpanItems + assertEquals(1, spans.size) + + val span = spans.first() + assertEquals(SpanKind.CLIENT, span.kind) + assertEquals("HTTP GET", span.name) + + val statusCode = span.attributes.get( + io.opentelemetry.api.common.AttributeKey.longKey("http.response.status_code") + ) + assertEquals(200L, statusCode) + } + + @Test + fun `trace context headers are injected`() = testApplication { + var receivedTraceParent: String? = null + + routing { + get("/test") { + receivedTraceParent = call.request.headers["traceparent"] + call.respondText("hello") + } + } + + val spanExporter = InMemorySpanExporter.create() + val otel = createTestOtel(spanExporter) + + val testClient = createClient { + install(OpenTelemetry) { + openTelemetry = otel + } + } + + testClient.get("/test") + + assertNotNull(receivedTraceParent, "traceparent header should be injected") + assertTrue(receivedTraceParent!!.startsWith("00-"), "traceparent should follow W3C format") + } + + @Test + fun `error status codes are recorded`() = testApplication { + routing { + get("/not-found") { + call.respond(HttpStatusCode.NotFound, "not found") + } + } + + val spanExporter = InMemorySpanExporter.create() + val otel = createTestOtel(spanExporter) + + val testClient = createClient { + install(OpenTelemetry) { + openTelemetry = otel + } + } + + testClient.get("/not-found") + + val spans = spanExporter.finishedSpanItems + assertEquals(1, spans.size) + + val span = spans.first() + assertEquals(StatusCode.ERROR, span.status.statusCode) + + val statusCode = span.attributes.get( + io.opentelemetry.api.common.AttributeKey.longKey("http.response.status_code") + ) + assertEquals(404L, statusCode) + } + + @Test + fun `filtered requests are not traced`() = testApplication { + routing { + get("/health") { + call.respondText("ok") + } + get("/api") { + call.respondText("data") + } + } + + val spanExporter = InMemorySpanExporter.create() + val otel = createTestOtel(spanExporter) + + val testClient = createClient { + install(OpenTelemetry) { + openTelemetry = otel + filter { request -> !request.url.encodedPath.startsWith("/health") } + } + } + + testClient.get("/health") + testClient.get("/api") + + val spans = spanExporter.finishedSpanItems + assertEquals(1, spans.size) + assertEquals("HTTP GET", spans.first().name) + } + + @Test + fun `request headers are captured as span attributes`() = testApplication { + routing { + get("/test") { + call.respondText("ok") + } + } + + val spanExporter = InMemorySpanExporter.create() + val otel = createTestOtel(spanExporter) + + val testClient = createClient { + install(OpenTelemetry) { + openTelemetry = otel + captureRequestHeaders("X-Custom-Header") + } + } + + testClient.get("/test") { + header("X-Custom-Header", "custom-value") + } + + val spans = spanExporter.finishedSpanItems + assertEquals(1, spans.size) + + val headerValues = spans.first().attributes.get( + io.opentelemetry.api.common.AttributeKey.stringArrayKey("http.request.header.x-custom-header") + ) + assertNotNull(headerValues) + assertEquals(listOf("custom-value"), headerValues) + } + + @Test + fun `server address attributes are set`() = testApplication { + routing { + get("/test") { + call.respondText("ok") + } + } + + val spanExporter = InMemorySpanExporter.create() + val otel = createTestOtel(spanExporter) + + val testClient = createClient { + install(OpenTelemetry) { + openTelemetry = otel + } + } + + testClient.get("/test") + + val spans = spanExporter.finishedSpanItems + assertEquals(1, spans.size) + + val span = spans.first() + val serverAddress = span.attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("server.address")) + assertNotNull(serverAddress) + + val method = span.attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("http.request.method")) + assertEquals("GET", method) + } +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/README.md b/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/README.md new file mode 100644 index 00000000000..49e5d46dd2f --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/README.md @@ -0,0 +1,125 @@ +# ktor-server-opentelemetry + +Native OpenTelemetry integration for Ktor server applications. Provides distributed tracing, metrics, and trace context propagation following [OpenTelemetry semantic conventions](https://opentelemetry.io/docs/specs/semconv/http/http-spans/). + +## Installation + +Add the dependency to your project: + +```kotlin +dependencies { + implementation("io.ktor:ktor-server-opentelemetry:$ktor_version") +} +``` + +## Usage + +### Basic setup + +```kotlin +import io.ktor.server.plugins.opentelemetry.* +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.trace.SdkTracerProvider + +val openTelemetry = OpenTelemetrySdk.builder() + .setTracerProvider(sdkTracerProvider) + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .build() + +fun Application.module() { + install(OpenTelemetry) { + openTelemetry = openTelemetry + } +} +``` + +### Individual component configuration + +Instead of passing a full `OpenTelemetry` instance, you can set components individually: + +```kotlin +install(OpenTelemetry) { + tracerProvider = sdkTracerProvider + meterProvider = sdkMeterProvider + propagators = W3CTraceContextPropagator.getInstance() +} +``` + +### Header capture + +Capture request and response headers as span attributes: + +```kotlin +install(OpenTelemetry) { + openTelemetry = otel + captureRequestHeaders("X-Request-ID", "X-Correlation-ID") + captureResponseHeaders("X-Response-Time") +} +``` + +### Filtering + +Exclude specific requests from instrumentation: + +```kotlin +install(OpenTelemetry) { + openTelemetry = otel + filter { call -> !call.request.path().startsWith("/health") } +} +``` + +### Route transformation + +Customize how route templates appear in spans: + +```kotlin +install(OpenTelemetry) { + openTelemetry = otel + transformRoute { node -> "/api/v1${node.path}" } +} +``` + +## Features + +### Server spans + +Creates a `SERVER` span for each incoming HTTP request with attributes: + +| Attribute | Description | +|---|---| +| `http.request.method` | HTTP method (GET, POST, etc.) | +| `url.path` | Request path | +| `url.scheme` | URL scheme (http/https) | +| `server.address` | Server hostname | +| `server.port` | Server port | +| `network.protocol.version` | HTTP version (1.1, 2, etc.) | +| `user_agent.original` | User-Agent header value | +| `http.route` | Resolved route template (e.g., `/users/{id}`) | +| `http.response.status_code` | Response status code | +| `error.type` | Exception class name (on error) | + +Span names are automatically updated from the initial `GET /users/42` to `GET /users/{id}` when routing resolves the matched route template. + +### Trace context propagation + +Extracts incoming trace context from request headers using the configured `TextMapPropagator` (W3C Trace Context by default). The extracted context is set as the parent of the server span, enabling distributed traces across services. + +The OTEL context is propagated through Kotlin coroutines via a `ThreadContextElement`, so `Context.current()` returns the correct context inside request handlers. This enables automatic parent span detection when using the [ktor-client-opentelemetry](../../ktor-client/ktor-client-plugins/ktor-client-opentelemetry) plugin for outgoing requests. + +### Metrics + +Records the following metrics: + +| Metric | Type | Description | +|---|---|---| +| `http.server.request.duration` | Histogram (seconds) | Duration of HTTP server requests | +| `http.server.active_requests` | UpDownCounter | Number of active HTTP server requests | + +Metric attributes include `http.request.method`, `http.response.status_code`, `http.route`, and `url.scheme`. + +### Error recording + +Exceptions thrown during request processing are recorded on the span: +- Span status is set to `ERROR` for 5xx responses or unhandled exceptions +- The exception is recorded via `Span.recordException()` +- The `error.type` attribute is set to the exception class name diff --git a/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/api/ktor-server-opentelemetry.api b/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/api/ktor-server-opentelemetry.api new file mode 100644 index 00000000000..338f7e0f1be --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/api/ktor-server-opentelemetry.api @@ -0,0 +1,25 @@ +public final class io/ktor/server/plugins/opentelemetry/OpenTelemetryConfig { + public fun ()V + public final fun captureRequestHeaders ([Ljava/lang/String;)V + public final fun captureResponseHeaders ([Ljava/lang/String;)V + public final fun filter (Lkotlin/jvm/functions/Function1;)V + public final fun getInstrumentationName ()Ljava/lang/String; + public final fun getInstrumentationVersion ()Ljava/lang/String; + public final fun getMeterProvider ()Lio/opentelemetry/api/metrics/MeterProvider; + public final fun getOpenTelemetry ()Lio/opentelemetry/api/OpenTelemetry; + public final fun getPropagators ()Lio/opentelemetry/context/propagation/TextMapPropagator; + public final fun getTracerProvider ()Lio/opentelemetry/api/trace/TracerProvider; + public final fun setInstrumentationName (Ljava/lang/String;)V + public final fun setInstrumentationVersion (Ljava/lang/String;)V + public final fun setMeterProvider (Lio/opentelemetry/api/metrics/MeterProvider;)V + public final fun setOpenTelemetry (Lio/opentelemetry/api/OpenTelemetry;)V + public final fun setPropagators (Lio/opentelemetry/context/propagation/TextMapPropagator;)V + public final fun setTracerProvider (Lio/opentelemetry/api/trace/TracerProvider;)V + public final fun spanName (Lkotlin/jvm/functions/Function1;)V + public final fun transformRoute (Lkotlin/jvm/functions/Function1;)V +} + +public final class io/ktor/server/plugins/opentelemetry/OpenTelemetryKt { + public static final fun getOpenTelemetry ()Lio/ktor/server/application/ApplicationPlugin; +} + diff --git a/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/api/ktor-server-opentelemetry.klib.api b/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/api/ktor-server-opentelemetry.klib.api new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/build.gradle.kts b/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/build.gradle.kts new file mode 100644 index 00000000000..5798202c55a --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +plugins { + id("ktorbuild.project.server-plugin") +} + +kotlin { + sourceSets { + jvmMain.dependencies { + api(libs.opentelemetry.api) + } + jvmTest.dependencies { + implementation(libs.opentelemetry.sdk) + implementation(libs.opentelemetry.sdk.testing) + } + } +} diff --git a/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/jvm/src/io/ktor/server/plugins/opentelemetry/OpenTelemetry.kt b/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/jvm/src/io/ktor/server/plugins/opentelemetry/OpenTelemetry.kt new file mode 100644 index 00000000000..fc8d4adc2ba --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/jvm/src/io/ktor/server/plugins/opentelemetry/OpenTelemetry.kt @@ -0,0 +1,358 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.plugins.opentelemetry + +import io.ktor.http.* +import io.ktor.http.HttpMethod.Companion.DefaultMethods +import io.ktor.server.application.* +import io.ktor.server.application.hooks.* +import io.ktor.server.request.* +import io.ktor.server.routing.* +import io.ktor.util.* +import io.ktor.util.pipeline.* +import io.ktor.utils.io.* +import io.opentelemetry.api.metrics.* +import io.opentelemetry.api.trace.* +import io.opentelemetry.context.Context +import io.opentelemetry.context.Scope +import io.opentelemetry.context.propagation.* +import kotlinx.coroutines.ThreadContextElement +import kotlinx.coroutines.withContext +import kotlin.coroutines.CoroutineContext +import io.opentelemetry.api.OpenTelemetry as OpenTelemetryApi +import io.opentelemetry.api.common.AttributeKey as OtelAttributeKey +import io.opentelemetry.api.common.Attributes as OtelAttributes + +/** + * Configuration for the [OpenTelemetry] server plugin. + * + * Supports two configuration styles: + * - Set [openTelemetry] to provide an [OpenTelemetryApi] instance with all components configured. + * - Set individual [tracerProvider], [meterProvider], and [propagators] properties to override + * specific components. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.plugins.opentelemetry.OpenTelemetryConfig) + */ +@KtorDsl +public class OpenTelemetryConfig { + /** + * The [OpenTelemetryApi] instance providing tracer, meter, and propagators. + * Individual properties ([tracerProvider], [meterProvider], [propagators]) override + * the corresponding components from this instance when set. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.plugins.opentelemetry.OpenTelemetryConfig.openTelemetry) + */ + public var openTelemetry: OpenTelemetryApi = OpenTelemetryApi.noop() + + /** + * Overrides the [TracerProvider] from [openTelemetry]. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.plugins.opentelemetry.OpenTelemetryConfig.tracerProvider) + */ + public var tracerProvider: TracerProvider? = null + + /** + * Overrides the [MeterProvider] from [openTelemetry]. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.plugins.opentelemetry.OpenTelemetryConfig.meterProvider) + */ + public var meterProvider: MeterProvider? = null + + /** + * Overrides the [TextMapPropagator] from [openTelemetry]. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.plugins.opentelemetry.OpenTelemetryConfig.propagators) + */ + public var propagators: TextMapPropagator? = null + + /** + * The instrumentation scope name used to obtain tracer and meter instances. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.plugins.opentelemetry.OpenTelemetryConfig.instrumentationName) + */ + public var instrumentationName: String = "io.ktor.server" + + /** + * The instrumentation scope version. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.plugins.opentelemetry.OpenTelemetryConfig.instrumentationVersion) + */ + public var instrumentationVersion: String? = null + + internal val capturedRequestHeaders: MutableList = mutableListOf() + internal val capturedResponseHeaders: MutableList = mutableListOf() + + /** + * Captures the specified request header values as span attributes. + * Attribute keys follow the pattern `http.request.header.`. + * + * @param headers header names to capture (case-insensitive) + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.plugins.opentelemetry.OpenTelemetryConfig.captureRequestHeaders) + */ + public fun captureRequestHeaders(vararg headers: String) { + capturedRequestHeaders.addAll(headers.map { it.lowercase() }) + } + + /** + * Captures the specified response header values as span attributes. + * Attribute keys follow the pattern `http.response.header.`. + * + * @param headers header names to capture (case-insensitive) + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.plugins.opentelemetry.OpenTelemetryConfig.captureResponseHeaders) + */ + public fun captureResponseHeaders(vararg headers: String) { + capturedResponseHeaders.addAll(headers.map { it.lowercase() }) + } + + internal var spanNameExtractor: (ApplicationCall) -> String = { call -> + "${call.request.httpMethod.value} ${call.request.path()}" + } + + /** + * Customizes the initial span name for each request. + * The span name is automatically updated to use the route template when a route is resolved. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.plugins.opentelemetry.OpenTelemetryConfig.spanName) + */ + public fun spanName(block: (ApplicationCall) -> String) { + spanNameExtractor = block + } + + internal var filter: (ApplicationCall) -> Boolean = { true } + + /** + * Filters which requests should be instrumented. + * Return `true` to trace the request, `false` to skip. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.plugins.opentelemetry.OpenTelemetryConfig.filter) + */ + public fun filter(predicate: (ApplicationCall) -> Boolean) { + filter = predicate + } + + internal var transformRoute: (RoutingNode) -> String = { it.path } + + /** + * Configures route label extraction from the resolved [RoutingNode]. + * Defaults to [RoutingNode.path]. + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.plugins.opentelemetry.OpenTelemetryConfig.transformRoute) + */ + public fun transformRoute(block: (RoutingNode) -> String) { + transformRoute = block + } +} + +private object HeadersTextMapGetter : TextMapGetter { + override fun keys(carrier: Headers): Iterable = carrier.names() + override fun get(carrier: Headers?, key: String): String? = carrier?.get(key) +} + +/** + * Coroutine context element that propagates the OpenTelemetry [Context] across coroutine dispatches. + * Ensures that [Context.current] returns the correct OTEL context inside request handlers, + * enabling automatic parent span detection in nested client calls. + */ +private class OtelCoroutineContextElement( + private val otelContext: Context +) : ThreadContextElement { + companion object Key : CoroutineContext.Key + + override val key: CoroutineContext.Key get() = Key + + override fun updateThreadContext(context: CoroutineContext): Scope = + otelContext.makeCurrent() + + override fun restoreThreadContext(context: CoroutineContext, oldState: Scope) { + oldState.close() + } +} + +private val OtelContextHook = object : Hook Unit) -> Unit> { + override fun install( + pipeline: ApplicationCallPipeline, + handler: suspend (ApplicationCall, suspend () -> Unit) -> Unit + ) { + val phase = PipelinePhase("OpenTelemetryContext") + pipeline.insertPhaseAfter(ApplicationCallPipeline.Monitoring, phase) + pipeline.intercept(phase) { + handler(call, ::proceed) + } + } +} + +private data class CallTrace( + val span: Span, + val context: Context, + val startTimeNanos: Long, + var route: String? = null, + var throwable: Throwable? = null +) + +/** + * A plugin that enables OpenTelemetry distributed tracing and metrics in your Ktor server application. + * Automatically creates server spans for incoming HTTP requests, propagates trace context, + * and records request duration metrics following OpenTelemetry semantic conventions. + * + * The plugin supports W3C Trace Context propagation, custom header capture, route-aware span naming, + * and coroutine-safe context propagation for integration with the client-side [OpenTelemetry] plugin. + * + * ```kotlin + * install(OpenTelemetry) { + * openTelemetry = sdkOpenTelemetry + * captureRequestHeaders("X-Request-ID") + * } + * ``` + * + * [Report a problem](https://ktor.io/feedback/?fqname=io.ktor.server.plugins.opentelemetry.OpenTelemetry) + * + * @see OpenTelemetryConfig + */ +public val OpenTelemetry: ApplicationPlugin = + createApplicationPlugin("OpenTelemetry", ::OpenTelemetryConfig) { + val otel = pluginConfig.openTelemetry + val tracerProvider = pluginConfig.tracerProvider ?: otel.tracerProvider + val meterProvider = pluginConfig.meterProvider ?: otel.meterProvider + val propagator = pluginConfig.propagators ?: otel.propagators.textMapPropagator + + val tracer = tracerProvider.tracerBuilder(pluginConfig.instrumentationName).apply { + pluginConfig.instrumentationVersion?.let { setInstrumentationVersion(it) } + }.build() + + val meter = meterProvider.meterBuilder(pluginConfig.instrumentationName).apply { + pluginConfig.instrumentationVersion?.let { setInstrumentationVersion(it) } + }.build() + + val requestDuration = meter.histogramBuilder("http.server.request.duration") + .setDescription("Duration of HTTP server requests") + .setUnit("s") + .build() + + val activeRequests = meter.upDownCounterBuilder("http.server.active_requests") + .setDescription("Number of active HTTP server requests") + .build() + + val traceKey = AttributeKey("openTelemetryTrace") + val requestHeaders = pluginConfig.capturedRequestHeaders.toList() + val responseHeaders = pluginConfig.capturedResponseHeaders.toList() + val filter = pluginConfig.filter + val spanNameExtractor = pluginConfig.spanNameExtractor + val transformRoute = pluginConfig.transformRoute + + @OptIn(InternalAPI::class) + on(Metrics) { call -> + if (call.request.httpMethod !in DefaultMethods) return@on + if (!filter(call)) return@on + + val parentContext = propagator.extract( + Context.current(), + call.request.headers, + HeadersTextMapGetter + ) + + val spanBuilder = tracer.spanBuilder(spanNameExtractor(call)) + .setParent(parentContext) + .setSpanKind(SpanKind.SERVER) + .setAttribute(OtelAttributeKey.stringKey("http.request.method"), call.request.httpMethod.value) + .setAttribute(OtelAttributeKey.stringKey("url.path"), call.request.path()) + .setAttribute(OtelAttributeKey.stringKey("url.scheme"), call.request.local.scheme) + .setAttribute(OtelAttributeKey.stringKey("server.address"), call.request.local.serverHost) + .setAttribute(OtelAttributeKey.longKey("server.port"), call.request.local.serverPort.toLong()) + .setAttribute( + OtelAttributeKey.stringKey("network.protocol.version"), + call.request.local.version.removePrefix("HTTP/") + ) + + call.request.userAgent()?.let { + spanBuilder.setAttribute(OtelAttributeKey.stringKey("user_agent.original"), it) + } + + for (headerName in requestHeaders) { + val values = call.request.headers.getAll(headerName) + if (!values.isNullOrEmpty()) { + spanBuilder.setAttribute( + OtelAttributeKey.stringArrayKey("http.request.header.$headerName"), + values + ) + } + } + + val span = spanBuilder.startSpan() + call.attributes.put(traceKey, CallTrace(span, parentContext.with(span), System.nanoTime())) + activeRequests.add(1) + } + + on(OtelContextHook) { call, proceed -> + val trace = call.attributes.getOrNull(traceKey) + if (trace != null) { + withContext(OtelCoroutineContextElement(trace.context)) { + proceed() + } + } else { + proceed() + } + } + + on(ResponseSent) { call -> + val trace = call.attributes.getOrNull(traceKey) ?: return@on + val statusCode = call.response.status()?.value ?: 0 + + trace.span.setAttribute(OtelAttributeKey.longKey("http.response.status_code"), statusCode.toLong()) + + trace.route?.let { route -> + trace.span.setAttribute(OtelAttributeKey.stringKey("http.route"), route) + trace.span.updateName("${call.request.httpMethod.value} $route") + } + + for (headerName in responseHeaders) { + val values = call.response.headers.values(headerName) + if (values.isNotEmpty()) { + trace.span.setAttribute( + OtelAttributeKey.stringArrayKey("http.response.header.$headerName"), + values + ) + } + } + + if (statusCode >= 500 || trace.throwable != null) { + trace.span.setStatus(StatusCode.ERROR) + } + + trace.throwable?.let { throwable -> + trace.span.recordException(throwable) + trace.span.setAttribute( + OtelAttributeKey.stringKey("error.type"), + throwable::class.qualifiedName ?: "unknown" + ) + } + + trace.span.end() + activeRequests.add(-1) + + val durationSeconds = (System.nanoTime() - trace.startTimeNanos) / 1_000_000_000.0 + requestDuration.record( + durationSeconds, + OtelAttributes.builder() + .put("http.request.method", call.request.httpMethod.value) + .put("http.response.status_code", statusCode.toLong()) + .put("http.route", trace.route ?: call.request.path()) + .put("url.scheme", call.request.local.scheme) + .build() + ) + } + + on(CallFailed) { call, cause -> + call.attributes.getOrNull(traceKey)?.throwable = cause + throw cause + } + + application.monitor.subscribe(RoutingRoot.RoutingCallStarted) { call -> + call.attributes.getOrNull(traceKey)?.let { trace -> + trace.route = transformRoute(call.route) + } + } + } diff --git a/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/jvm/test/io/ktor/server/plugins/opentelemetry/OpenTelemetryTest.kt b/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/jvm/test/io/ktor/server/plugins/opentelemetry/OpenTelemetryTest.kt new file mode 100644 index 00000000000..8cba2bedb19 --- /dev/null +++ b/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/jvm/test/io/ktor/server/plugins/opentelemetry/OpenTelemetryTest.kt @@ -0,0 +1,248 @@ +/* + * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ + +package io.ktor.server.plugins.opentelemetry + +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.testing.* +import io.opentelemetry.api.trace.* +import io.opentelemetry.api.trace.propagation.* +import io.opentelemetry.context.propagation.* +import io.opentelemetry.sdk.* +import io.opentelemetry.sdk.testing.exporter.* +import io.opentelemetry.sdk.trace.* +import io.opentelemetry.sdk.trace.export.* +import kotlin.test.* + +class OpenTelemetryTest { + + private fun createTestOtel(spanExporter: InMemorySpanExporter): io.opentelemetry.api.OpenTelemetry { + val tracerProvider = SdkTracerProvider.builder() + .addSpanProcessor(SimpleSpanProcessor.create(spanExporter)) + .build() + return OpenTelemetrySdk.builder() + .setTracerProvider(tracerProvider) + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .build() + } + + @Test + fun `spans are created for requests`() = testApplication { + val spanExporter = InMemorySpanExporter.create() + + install(OpenTelemetry) { + openTelemetry = createTestOtel(spanExporter) + } + + routing { + get("/test") { + call.respondText("hello") + } + } + + client.get("/test") + + val spans = spanExporter.finishedSpanItems + assertEquals(1, spans.size) + + val span = spans.first() + assertEquals(SpanKind.SERVER, span.kind) + assertEquals("GET /test", span.name) + + val method = span.attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("http.request.method")) + assertEquals("GET", method) + + val statusCode = span.attributes.get( + io.opentelemetry.api.common.AttributeKey.longKey("http.response.status_code") + ) + assertEquals(200L, statusCode) + } + + @Test + fun `span name is updated with route template`() = testApplication { + val spanExporter = InMemorySpanExporter.create() + + install(OpenTelemetry) { + openTelemetry = createTestOtel(spanExporter) + } + + routing { + get("/users/{id}") { + call.respondText("user") + } + } + + client.get("/users/42") + + val spans = spanExporter.finishedSpanItems + assertEquals(1, spans.size) + assertEquals("GET /users/{id}", spans.first().name) + + val route = spans.first().attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("http.route")) + assertEquals("/users/{id}", route) + } + + @Test + fun `error status is recorded on spans`() = testApplication { + val spanExporter = InMemorySpanExporter.create() + + install(OpenTelemetry) { + openTelemetry = createTestOtel(spanExporter) + } + + routing { + get("/error") { + call.respond(HttpStatusCode.InternalServerError, "error") + } + } + + client.get("/error") + + val spans = spanExporter.finishedSpanItems + assertEquals(1, spans.size) + + val span = spans.first() + assertEquals(StatusCode.ERROR, span.status.statusCode) + + val statusCode = span.attributes.get( + io.opentelemetry.api.common.AttributeKey.longKey("http.response.status_code") + ) + assertEquals(500L, statusCode) + } + + @Test + fun `request headers are captured as span attributes`() = testApplication { + val spanExporter = InMemorySpanExporter.create() + + install(OpenTelemetry) { + openTelemetry = createTestOtel(spanExporter) + captureRequestHeaders("X-Request-ID") + } + + routing { + get("/test") { + call.respondText("ok") + } + } + + client.get("/test") { + header("X-Request-ID", "req-123") + } + + val spans = spanExporter.finishedSpanItems + assertEquals(1, spans.size) + + val headerValues = spans.first().attributes.get( + io.opentelemetry.api.common.AttributeKey.stringArrayKey("http.request.header.x-request-id") + ) + assertNotNull(headerValues) + assertEquals(listOf("req-123"), headerValues) + } + + @Test + fun `incoming trace context is propagated`() = testApplication { + val spanExporter = InMemorySpanExporter.create() + + install(OpenTelemetry) { + openTelemetry = createTestOtel(spanExporter) + } + + routing { + get("/test") { + call.respondText("ok") + } + } + + val traceId = "0af7651916cd43dd8448eb211c80319c" + val parentSpanId = "b7ad6b7169203331" + + client.get("/test") { + header("traceparent", "00-$traceId-$parentSpanId-01") + } + + val spans = spanExporter.finishedSpanItems + assertEquals(1, spans.size) + + val span = spans.first() + assertEquals(traceId, span.spanContext.traceId) + assertEquals(parentSpanId, span.parentSpanId) + } + + @Test + fun `filtered requests are not traced`() = testApplication { + val spanExporter = InMemorySpanExporter.create() + + install(OpenTelemetry) { + openTelemetry = createTestOtel(spanExporter) + filter { it.request.path().startsWith("/api") } + } + + routing { + get("/health") { + call.respondText("ok") + } + get("/api") { + call.respondText("data") + } + } + + client.get("/health") + client.get("/api") + + val spans = spanExporter.finishedSpanItems + assertEquals(1, spans.size) + assertEquals("GET /api", spans.first().name) + } + + @Test + fun `custom route transformation is applied`() = testApplication { + val spanExporter = InMemorySpanExporter.create() + + install(OpenTelemetry) { + openTelemetry = createTestOtel(spanExporter) + transformRoute { "/api${it.path}" } + } + + routing { + get("/users") { + call.respondText("users") + } + } + + client.get("/users") + + val spans = spanExporter.finishedSpanItems + assertEquals(1, spans.size) + + val route = spans.first().attributes.get(io.opentelemetry.api.common.AttributeKey.stringKey("http.route")) + assertEquals("/api/users", route) + } + + @Test + fun `custom http method requests are not traced`() = testApplication { + val spanExporter = InMemorySpanExporter.create() + + install(OpenTelemetry) { + openTelemetry = createTestOtel(spanExporter) + } + + routing { + get("/test") { + call.respondText("hello") + } + } + + client.get("/test") + assertEquals(1, spanExporter.finishedSpanItems.size) + + client.request("/test") { + method = HttpMethod("CUSTOM") + } + assertEquals(1, spanExporter.finishedSpanItems.size, "Custom methods should not be traced") + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index fe016796b01..a1b9dea0039 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -90,6 +90,7 @@ projects { +"ktor-server-method-override" +"ktor-server-metrics" +"ktor-server-metrics-micrometer" + +"ktor-server-opentelemetry" +"ktor-server-mustache" +"ktor-server-openapi" +"ktor-server-partial-content" @@ -155,6 +156,7 @@ projects { +"ktor-client-serialization" } +"ktor-client-logging" + +"ktor-client-opentelemetry" +"ktor-client-resources" +"ktor-client-websockets" } From 10535a1665aa27025fb701b74595bc6d18161a02 Mon Sep 17 00:00:00 2001 From: Subhanshu Bansal Date: Sun, 29 Mar 2026 21:55:53 +0530 Subject: [PATCH 02/10] Uplifted Open Telemetry Version to v1.54.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f315882f7a9..ea7694c5207 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,7 +46,7 @@ logback = "1.5.24" dropwizard = "4.2.37" micrometer = "1.16.2" -opentelemetry = "1.46.0" +opentelemetry = "1.54.0" jansi = "2.4.2" typesafe = "1.4.5" From bd1c8d8911f67eb9462755cb28d1dc40e862988b Mon Sep 17 00:00:00 2001 From: Subhanshu Bansal <77500658+Subhanshu20135@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:58:52 +0530 Subject: [PATCH 03/10] Update ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../client/plugins/opentelemetry/OpenTelemetry.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt index 9ee4f598f7f..142fedbe625 100644 --- a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt +++ b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt @@ -232,6 +232,17 @@ public val OpenTelemetry: ClientPlugin = cause::class.qualifiedName ?: "unknown" ) span.end() + + val durationSeconds = (System.nanoTime() - startTime) / 1_000_000_000.0 + requestDuration.record( + durationSeconds, + OtelAttributes.builder() + .put("http.request.method", request.method.value) + .put("server.address", request.url.host) + .put("error.type", cause::class.qualifiedName ?: "unknown") + .build() + ) + throw cause } } From bdc637a20df94e9804d264816b88d31a8c721397 Mon Sep 17 00:00:00 2001 From: Subhanshu Bansal <77500658+Subhanshu20135@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:59:14 +0530 Subject: [PATCH 04/10] Update ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../ktor-client-plugins/ktor-client-opentelemetry/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md index 263e41517ae..ee66812e201 100644 --- a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md +++ b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md @@ -50,7 +50,7 @@ val client = HttpClient(CIO) { val client = HttpClient(CIO) { install(OpenTelemetry) { openTelemetry = otel - captureRequestHeaders("Authorization", "X-Api-Key") + captureRequestHeaders("X-Request-ID", "X-Correlation-ID") captureResponseHeaders("X-RateLimit-Remaining") } } From 2ccc5080e1b0616c54589484da2c3e4b454eff2a Mon Sep 17 00:00:00 2001 From: Subhanshu Bansal <77500658+Subhanshu20135@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:59:43 +0530 Subject: [PATCH 05/10] Update ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../ktor-client-plugins/ktor-client-opentelemetry/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md index ee66812e201..a995559499f 100644 --- a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md +++ b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md @@ -88,7 +88,7 @@ Automatically injects trace context headers into outgoing requests using the con ### Server-client context propagation -When used together with [ktor-server-opentelemetry](../../ktor-server/ktor-server-plugins/ktor-server-opentelemetry), trace context flows automatically from incoming server requests to outgoing client requests. The server plugin propagates the OTEL context through Kotlin coroutines, so client spans created inside a request handler automatically become children of the server span: +When used together with [ktor-server-opentelemetry](../../../ktor-server/ktor-server-plugins/ktor-server-opentelemetry), trace context flows automatically from incoming server requests to outgoing client requests. The server plugin propagates the OTEL context through Kotlin coroutines, so client spans created inside a request handler automatically become children of the server span: ```kotlin fun Application.module() { From a4807989981c0350c154ce9781df8c2f7a0edfd9 Mon Sep 17 00:00:00 2001 From: Subhanshu Bansal <77500658+Subhanshu20135@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:59:58 +0530 Subject: [PATCH 06/10] Update ktor-server/ktor-server-plugins/ktor-server-opentelemetry/README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../ktor-server-plugins/ktor-server-opentelemetry/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/README.md b/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/README.md index 49e5d46dd2f..70f98a32bef 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/README.md +++ b/ktor-server/ktor-server-plugins/ktor-server-opentelemetry/README.md @@ -104,7 +104,7 @@ Span names are automatically updated from the initial `GET /users/42` to `GET /u Extracts incoming trace context from request headers using the configured `TextMapPropagator` (W3C Trace Context by default). The extracted context is set as the parent of the server span, enabling distributed traces across services. -The OTEL context is propagated through Kotlin coroutines via a `ThreadContextElement`, so `Context.current()` returns the correct context inside request handlers. This enables automatic parent span detection when using the [ktor-client-opentelemetry](../../ktor-client/ktor-client-plugins/ktor-client-opentelemetry) plugin for outgoing requests. +The OTEL context is propagated through Kotlin coroutines via a `ThreadContextElement`, so `Context.current()` returns the correct context inside request handlers. This enables automatic parent span detection when using the [ktor-client-opentelemetry](../../../ktor-client/ktor-client-plugins/ktor-client-opentelemetry) plugin for outgoing requests. ### Metrics From e7c8d6ccb84e18b8844ccaa67f4c497016f087ee Mon Sep 17 00:00:00 2001 From: Subhanshu Bansal <77500658+Subhanshu20135@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:48:32 +0530 Subject: [PATCH 07/10] Update ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../ktor-client-plugins/ktor-client-opentelemetry/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md index a995559499f..53d5e0a3f56 100644 --- a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md +++ b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md @@ -84,7 +84,7 @@ Creates a `CLIENT` span for each outgoing HTTP request with attributes: ### Trace context injection -Automatically injects trace context headers into outgoing requests using the configured `TextMapPropagator`. With W3C Trace Context (default), the `traceparent` and `tracestate` headers are added to every outgoing request. +Automatically injects trace context headers into outgoing requests that pass the configured `filter`, using the configured `TextMapPropagator`. With W3C Trace Context (default), the `traceparent` and `tracestate` headers are added to each traced request. ### Server-client context propagation From cdf9a8c9cd97b17556f837045ba6761406943f55 Mon Sep 17 00:00:00 2001 From: Subhanshu Bansal Date: Mon, 30 Mar 2026 22:04:48 +0530 Subject: [PATCH 08/10] Fixed Issues --- .../ktor-client-opentelemetry/README.md | 2 +- .../plugins/opentelemetry/OpenTelemetry.kt | 24 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md index 53d5e0a3f56..6517509c444 100644 --- a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md +++ b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/README.md @@ -114,7 +114,7 @@ fun Application.module() { This produces a distributed trace: -``` +```text [Server] GET /api/users/{id} └── [Client] HTTP GET → user-service ``` diff --git a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt index 142fedbe625..411935ee6e0 100644 --- a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt +++ b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt @@ -115,7 +115,8 @@ public class OpenTelemetryClientConfig { private object RequestBuilderHeadersSetter : TextMapSetter { override fun set(carrier: HttpRequestBuilder?, key: String, value: String) { - carrier?.header(key, value) + carrier?.headers?.remove(key) + carrier?.headers?.append(key, value) } } @@ -225,12 +226,33 @@ public val OpenTelemetry: ClientPlugin = call } catch (cause: Throwable) { + val response = (cause as? ResponseException)?.response + val statusCode = response?.status?.value + span.recordException(cause) span.setStatus(StatusCode.ERROR, cause.message ?: "") span.setAttribute( OtelAttributeKey.stringKey("error.type"), cause::class.qualifiedName ?: "unknown" ) + if (statusCode != null) { + span.setAttribute(OtelAttributeKey.longKey("http.response.status_code"), statusCode.toLong()) + span.setAttribute(OtelAttributeKey.stringKey("error.type"), statusCode.toString()) + for (headerName in responseHeaders) { + val values = response.headers.getAll(headerName) + if (!values.isNullOrEmpty()) { + span.setAttribute( + OtelAttributeKey.stringArrayKey("http.response.header.$headerName"), + values + ) + } + } + } else { + span.setAttribute( + OtelAttributeKey.stringKey("error.type"), + cause::class.qualifiedName ?: "unknown" + ) + } span.end() val durationSeconds = (System.nanoTime() - startTime) / 1_000_000_000.0 From 517bfd660a916d950fba6f65676170a03ca588bc Mon Sep 17 00:00:00 2001 From: Subhanshu Bansal Date: Mon, 30 Mar 2026 23:14:17 +0530 Subject: [PATCH 09/10] Fixed Issues: Removed unnecessary --- .../src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt index 411935ee6e0..25268a3e577 100644 --- a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt +++ b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt @@ -228,7 +228,7 @@ public val OpenTelemetry: ClientPlugin = } catch (cause: Throwable) { val response = (cause as? ResponseException)?.response val statusCode = response?.status?.value - + span.recordException(cause) span.setStatus(StatusCode.ERROR, cause.message ?: "") span.setAttribute( From d5aa6267b5a6559b6622b6c218b882942e024e7d Mon Sep 17 00:00:00 2001 From: Subhanshu Bansal Date: Mon, 30 Mar 2026 23:58:03 +0530 Subject: [PATCH 10/10] Refactored exception handling in OpenTelemetry plugin to remove redundant variable and streamline status code extraction. --- .../src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt index 25268a3e577..f49d7d05fe1 100644 --- a/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt +++ b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt @@ -227,7 +227,6 @@ public val OpenTelemetry: ClientPlugin = call } catch (cause: Throwable) { val response = (cause as? ResponseException)?.response - val statusCode = response?.status?.value span.recordException(cause) span.setStatus(StatusCode.ERROR, cause.message ?: "") @@ -235,7 +234,8 @@ public val OpenTelemetry: ClientPlugin = OtelAttributeKey.stringKey("error.type"), cause::class.qualifiedName ?: "unknown" ) - if (statusCode != null) { + if (response != null) { + val statusCode = response.status.value span.setAttribute(OtelAttributeKey.longKey("http.response.status_code"), statusCode.toLong()) span.setAttribute(OtelAttributeKey.stringKey("error.type"), statusCode.toString()) for (headerName in responseHeaders) {