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..ea7694c5207 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.54.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..6517509c444
--- /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("X-Request-ID", "X-Correlation-ID")
+ 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 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
+
+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:
+
+```text
+[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..f49d7d05fe1
--- /dev/null
+++ b/ktor-client/ktor-client-plugins/ktor-client-opentelemetry/jvm/src/io/ktor/client/plugins/opentelemetry/OpenTelemetry.kt
@@ -0,0 +1,271 @@
+/*
+ * 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?.headers?.remove(key)
+ carrier?.headers?.append(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) {
+ val response = (cause as? ResponseException)?.response
+
+ span.recordException(cause)
+ span.setStatus(StatusCode.ERROR, cause.message ?: "")
+ span.setAttribute(
+ OtelAttributeKey.stringKey("error.type"),
+ cause::class.qualifiedName ?: "unknown"
+ )
+ 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) {
+ 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
+ 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
+ }
+ }
+ }
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..70f98a32bef
--- /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"
}