# OpenTelemetry for Kotlin: Traces, Metrics & Logs with OpenObserve

> Instrument Kotlin/JVM apps with the OpenTelemetry Java SDK and coroutine-safe context propagation - traces, metrics, and logs via OTLP to OpenObserve.

Source: https://openobserve.ai/opentelemetry/kotlin/
Published: 2026-07-08
Language: Kotlin

---

Kotlin on the JVM does not have its own OpenTelemetry SDK - and that is good news. Kotlin services use the **OpenTelemetry Java SDK and the Java agent directly**, which means you inherit one of the most mature implementations in the entire project: traces, metrics, *and* logs are all stable, and the zero-code javaagent instruments more than a hundred libraries out of the box. What Kotlin adds is one sharp edge - coroutines break the thread-local context propagation the Java SDK relies on - and one small artifact, `opentelemetry-extension-kotlin`, that fixes it. This guide covers the full path: SDK setup with Gradle Kotlin DSL, coroutine-safe tracing, metrics, logs, and shipping everything to [OpenObserve](https://openobserve.ai/) over OTLP.

If you are new to OpenTelemetry itself - the signals, the collector, OTLP - start with [What is OpenTelemetry? A Complete Guide](/blog/what-is-opentelemetry/) and come back here for the Kotlin specifics.

One scoping note up front: this guide is about server-side and CLI Kotlin on the JVM. Android has a separate [opentelemetry-android](https://github.com/open-telemetry/opentelemetry-android) project built on top of the Java SDK, with mobile-specific concerns (app lifecycle, disk buffering) that are out of scope here.

## Why OpenTelemetry for Kotlin

- **Vendor-neutral instrumentation.** Instrument once, export anywhere OTLP is accepted - no proprietary SDK in your codebase.
- **The full maturity of OpenTelemetry Java.** The Java implementation is one of the most mature, with all three signals stable. Kotlin gets all of it for free, including the javaagent's zero-code instrumentation of OkHttp, JDBC, gRPC, Kafka clients, and the engines under frameworks like Ktor and Spring.
- **First-party coroutine support.** `opentelemetry-extension-kotlin` ships from the main `opentelemetry-java` repository as a stable artifact, so coroutine context propagation is a supported feature, not a community hack.

### Signal stability for Kotlin (via the Java SDK)

As of mid-2026, the status Kotlin/JVM inherits from OpenTelemetry Java is:

| Signal  | Status | Notes |
|---------|--------|-------|
| Traces  | Stable | API and SDK stable; safe for production |
| Metrics | Stable | API and SDK stable; OTLP metric exporters are GA |
| Logs    | Stable | Logs bridge API and SDK are stable; appender bridges (Logback, Log4j) live in the instrumentation repo and carry `-alpha` artifact versions even though they are widely used in production |

The one Kotlin-specific caveat is not a signal at all: **context propagation across coroutines is your responsibility** unless you run the javaagent. We cover both paths below.

## Prerequisites

- **JDK 8 or later** (JDK 17+ recommended for new services) and **Kotlin 1.8+** with `kotlinx-coroutines` if you use coroutines.
- **An OpenObserve instance.** Either an [OpenObserve Cloud](https://cloud.openobserve.ai) account or a self-hosted instance. For local testing, a single container is enough:

```bash
docker run -d --name openobserve \
  -p 5080:5080 -p 5081:5081 \
  -e ZO_ROOT_USER_EMAIL="root@example.com" \
  -e ZO_ROOT_USER_PASSWORD="Complexpass#123" \
  public.ecr.aws/zinclabs/openobserve:latest
```

- **Your OpenObserve credentials.** OTLP ingestion uses HTTP Basic auth. Generate the token once:

```bash
echo -n 'root@example.com:Complexpass#123' | base64
```

This prints `cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=` - the value you will pass in the `Authorization: Basic` header.

In OpenObserve Cloud, the ingestion page under **Data Sources** shows your organization-specific endpoint and a ready-made Basic auth token, so you rarely need to construct it by hand.

## Zero-code instrumentation: the javaagent

Kotlin compiles to JVM bytecode, so the **OpenTelemetry javaagent** - the same one Java services use - works unmodified. It rewrites bytecode at class-load time to instrument HTTP clients and servers, JDBC, gRPC, Kafka, and dozens of other libraries, and it exports traces, metrics, and logs over OTLP with no code changes.

Download the agent and attach it at startup:

```bash
curl -L -O https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar

java -javaagent:./opentelemetry-javaagent.jar -jar build/libs/checkout-service.jar
```

For local development with Gradle Kotlin DSL, wire it into your run task:

```kotlin
// build.gradle.kts
tasks.withType<JavaExec> {
    jvmArgs("-javaagent:${rootDir}/opentelemetry-javaagent.jar")
}
```

Two things make the agent especially attractive for Kotlin:

1. **Automatic coroutine context propagation.** Since agent version 1.22.0, the agent instruments `kotlinx.coroutines` so the active trace context follows a coroutine across suspension points and thread switches. The single biggest Kotlin pitfall disappears.
2. **`@WithSpan` works on suspend functions.** Add `io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations` and annotate a `suspend fun` - the agent understands the continuation-passing transform and ends the span when the coroutine actually completes, not when the function first suspends.

The agent is the right default for most Kotlin services. Use manual SDK setup (next section) when you cannot attach an agent, want to minimize startup overhead, or need full control over the pipeline. The two approaches also combine: run the agent for library instrumentation and use the API + `opentelemetry-extension-kotlin` for your own custom spans.

## Manual instrumentation

Add the SDK with Gradle Kotlin DSL. Use the BOM so every OpenTelemetry artifact stays on the same version:

```kotlin
// build.gradle.kts
dependencies {
    implementation(platform("io.opentelemetry:opentelemetry-bom:1.61.0"))
    implementation("io.opentelemetry:opentelemetry-api")
    implementation("io.opentelemetry:opentelemetry-sdk")
    implementation("io.opentelemetry:opentelemetry-exporter-otlp")
    implementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure")
    implementation("io.opentelemetry:opentelemetry-extension-kotlin")
}
```

One deviation from other language SDKs that trips people up immediately: **the plain Java SDK builders do not read `OTEL_*` environment variables.** Environment-based configuration lives in the `opentelemetry-sdk-extension-autoconfigure` module (included above), which you invoke explicitly. This is different from Go or Python, where the exporters read the environment on their own.

Initialize once at startup:

```kotlin
import io.opentelemetry.sdk.OpenTelemetrySdk
import io.opentelemetry.sdk.autoconfigure.AutoConfiguredOpenTelemetrySdk

// Reads OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS,
// OTEL_SERVICE_NAME, etc. — see the OpenObserve section below.
val openTelemetry: OpenTelemetrySdk = AutoConfiguredOpenTelemetrySdk.builder()
    .setResultAsGlobal() // also registers GlobalOpenTelemetry
    .build()
    .openTelemetrySdk

// Flush pending telemetry on exit — skipping this drops the last batch.
Runtime.getRuntime().addShutdownHook(Thread { openTelemetry.close() })
```

### Traces

Get a `Tracer` once (top-level `val` or injected singleton), then create spans around units of work. The Java API is pleasant from Kotlin, and the coroutine extension is where it becomes idiomatic:

```kotlin
import io.opentelemetry.api.trace.StatusCode
import io.opentelemetry.extension.kotlin.asContextElement
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext

val tracer = openTelemetry.getTracer("checkout-service")

suspend fun processPayment(orderId: String) {
    val span = tracer.spanBuilder("process-payment")
        .setAttribute("order.id", orderId)
        .setAttribute("payment.provider", "stripe")
        .startSpan()
    try {
        // asContextElement() carries the OTel context in the CoroutineContext,
        // so it survives suspension and thread switches. Everything inside —
        // including child coroutines — sees this span as current.
        withContext(span.asContextElement()) {
            chargeCard()   // spans started here become children of process-payment
            delay(40)      // resumes on any thread; context still intact
        }
    } catch (t: Throwable) {
        span.recordException(t)
        span.setStatus(StatusCode.ERROR, t.message ?: "payment failed")
        throw t
    } finally {
        span.end()
    }
}

suspend fun chargeCard() {
    val span = tracer.spanBuilder("charge-card").startSpan()
    try {
        withContext(span.asContextElement()) {
            delay(25) // simulate the provider call
        }
    } finally {
        span.end()
    }
}
```

The critical rule: **never rely on `Span.makeCurrent()` across a suspension point.** The Java SDK stores the current context in a `ThreadLocal`; coroutines hop threads at every suspension, so the context stays behind on the old thread. This code is subtly broken:

```kotlin
// WRONG — thread-local context does not survive suspension
suspend fun broken() {
    val span = tracer.spanBuilder("outer").startSpan()
    span.makeCurrent().use {
        delay(100) // may resume on a different thread
        // "inner" can parent to the wrong span — or to no span at all
        tracer.spanBuilder("inner").startSpan().end()
    }
    span.end()
}
```

The fix is always the same: convert the context to a coroutine context element with `asContextElement()` (available on both `Context` and `Span`) and install it via `withContext` or pass it to `launch`/`async`. Child coroutines inherit their parent's `CoroutineContext`, so once the element is installed, structured concurrency propagates the trace for you:

```kotlin
import io.opentelemetry.context.Context
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope

suspend fun fanOut() = coroutineScope {
    // Both children inherit the current OTel context automatically
    // because it already rides in this scope's CoroutineContext.
    val stock = async { checkInventory() }
    val fraud = async { runFraudCheck() }
    stock.await() to fraud.await()
}

// Bridging FROM a non-coroutine call site INTO a coroutine:
fun handleRequest(scope: kotlinx.coroutines.CoroutineScope) {
    val otelContext = Context.current() // capture on the calling thread
    scope.launch(otelContext.asContextElement()) {
        processPayment("ord_8412")
    }
}
```

Note the snapshot semantics: `asContextElement()` captures the context *at the moment you call it*. Capture it on the thread where the span is current, not inside the coroutine you are launching.

### Metrics

Metrics have no coroutine complications when you pass attributes explicitly - instruments are thread-safe and cheap to call. Create them once, not per request:

```kotlin
import io.opentelemetry.api.common.AttributeKey
import io.opentelemetry.api.common.Attributes

val meter = openTelemetry.getMeter("checkout-service")

val ordersProcessed = meter.counterBuilder("orders.processed")
    .setDescription("Number of orders processed")
    .setUnit("{order}")
    .build()

val paymentDuration = meter.histogramBuilder("payment.duration")
    .setDescription("Payment processing duration")
    .setUnit("ms")
    .build()

val providerKey: AttributeKey<String> = AttributeKey.stringKey("payment.provider")

fun recordOrder(elapsedMs: Double) {
    ordersProcessed.add(1, Attributes.of(providerKey, "stripe"))
    paymentDuration.record(elapsedMs)
}
```

For gauges over state you do not control (queue depth, pool size), use an async instrument with a callback:

```kotlin
meter.gaugeBuilder("queue.depth")
    .setUnit("{message}")
    .buildWithCallback { measurement ->
        measurement.record(paymentQueue.size.toDouble())
    }
```

The autoconfigured SDK exports metrics on a periodic reader (60 s default; set `OTEL_METRIC_EXPORT_INTERVAL=15000` for 15 s).

### Logs

The logs signal is stable on the JVM, and the intended pattern is a **bridge**: keep logging through your normal facade (almost always SLF4J + Logback in Kotlin services) and let an OpenTelemetry appender forward records into the OTLP pipeline. Logs written inside an active span automatically carry `trace_id` and `span_id`, so you can pivot from a slow trace straight to its log lines in OpenObserve.

Add the Logback appender (bridge artifacts live in the instrumentation repo and carry an `-alpha` version suffix - they are stable in practice but pin the version):

```kotlin
// build.gradle.kts
dependencies {
    implementation("io.opentelemetry.instrumentation:opentelemetry-logback-appender-1.0:2.28.0-alpha")
}
```

Register it in `logback.xml` alongside your console appender:

```xml
<configuration>
  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder><pattern>%d %-5level %logger - %msg%n</pattern></encoder>
  </appender>
  <appender name="OTEL"
      class="io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender">
    <captureMdcAttributes>*</captureMdcAttributes>
  </appender>
  <root level="INFO">
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="OTEL"/>
  </root>
</configuration>
```

Then hand the appender your SDK instance after initialization, and log as usual:

```kotlin
import io.opentelemetry.instrumentation.logback.appender.v1_0.OpenTelemetryAppender
import org.slf4j.LoggerFactory

fun main() {
    // ... AutoConfiguredOpenTelemetrySdk setup from above ...
    OpenTelemetryAppender.install(openTelemetry)

    val log = LoggerFactory.getLogger("checkout-service")
    log.info("order processed order_id=ord_8412 amount_cents=4599")
    log.error("payment declined reason=insufficient_funds")
}
```

If you run the javaagent instead, skip all of this - the agent injects the appender automatically and captures Logback/Log4j output with no configuration.

## Sending data to OpenObserve

OpenObserve ingests OTLP natively - no collector required in between, although running one is a fine architecture too. Two transports are supported:

- **OTLP/HTTP** at `<host>/api/<org>/v1/traces`, `/v1/metrics`, and `/v1/logs` (port `5080` self-hosted). Recommended.
- **OTLP/gRPC** on port `5081` (self-hosted), which additionally requires an `organization` header alongside `Authorization`.

Two Java-specific deviations from other SDKs to know before you set variables:

1. **Default protocol.** The javaagent (2.x) defaults to `http/protobuf`, but the SDK autoconfigure module defaults to **gRPC on port 4317**. When you set up the SDK yourself, always set `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf` explicitly.
2. **Env vars need autoconfigure.** As noted above, `OTEL_*` variables only take effect through the javaagent or `AutoConfiguredOpenTelemetrySdk` - plain `SdkTracerProvider.builder()` code ignores them.

**OpenObserve Cloud:**

```bash
export OTEL_SERVICE_NAME="checkout-service"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.openobserve.ai/api/<your-org-name>"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20<your-base64-token>"
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment=production"

java -javaagent:./opentelemetry-javaagent.jar -jar build/libs/checkout-service.jar
```

**Self-hosted OpenObserve:**

```bash
export OTEL_SERVICE_NAME="checkout-service"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:5080/api/default"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM="
export OTEL_RESOURCE_ATTRIBUTES="service.version=1.4.2,deployment.environment=dev"

./gradlew run
```

The same variables drive both the javaagent and the autoconfigured SDK, so one configuration works for either instrumentation path.

Three rules that prevent 90% of setup failures:

1. **No trailing slash on the endpoint.** The exporter appends `/v1/traces` (or `/v1/metrics`, `/v1/logs`) itself. `.../api/default/` produces `.../api/default//v1/traces` and a 404.
2. **Percent-encode the space in the header value.** `OTEL_EXPORTER_OTLP_HEADERS` uses a `key=value,key=value` format in which the value must be URL-encoded - hence `Basic%20<token>`, not `Basic <token>`.
3. **The organization is part of the URL path.** `/api/default` targets the `default` organization; replace it with your org name in OpenObserve Cloud (visible on the ingestion/Data Sources page, which also shows the exact endpoint and token to copy).

If you would rather configure in code than via the environment - common when credentials come from a secret manager - every exporter builder accepts the values directly:

```kotlin
import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter

val exporter = OtlpHttpSpanExporter.builder()
    .setEndpoint("http://localhost:5080/api/default/v1/traces")
    .addHeader("Authorization", "Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=")
    .build()
```

Note that `setEndpoint` on the HTTP exporters takes the *full* signal path, whereas the environment variable takes the base URL - and in code the header uses a literal space, not `%20`. Environment variables win in fewer surprises; prefer them. For a broader tour of exporter configuration, see [A Beginner's Guide to OTLP Exporters](/blog/otel-exporters-introduction/).

## Verifying data arrival in OpenObserve

Run the service, exercise a few requests, then open the OpenObserve UI (`http://localhost:5080` self-hosted, or your cloud URL):

1. **Streams** - you should see a `default` traces stream plus streams for your metrics and logs. If a stream exists, ingestion is working end to end.
2. **Traces** - pick your service (`checkout-service`) on the Traces page and open a trace. You should see `process-payment` with `charge-card` nested under it - nested correctly even though both lived inside coroutines, which is your proof that context propagation is working.
3. **Logs** - query the log stream and confirm records include `trace_id` and `span_id` fields; that confirms the Logback bridge is correlating correctly.
4. **Metrics** - your `orders.processed` and `payment.duration` instruments appear as metric streams you can chart or attach to dashboards and alerts.

If nothing appears within ~30 seconds, jump to Troubleshooting below - and check the process's stderr first, because the Java SDK logs export failures through `java.util.logging`.

## Production tips

- **Keep the batch processors (never simple/synchronous export in production).** The autoconfigured SDK and the javaagent already use `BatchSpanProcessor` and a batching log processor by default. Tune with `OTEL_BSP_*` variables only if you see queue-full drops.
- **Sample at the head, keep parents authoritative.** For high-traffic services, ratio sampling with parent-based override is the standard starting point:

  ```bash
  export OTEL_TRACES_SAMPLER="parentbased_traceidratio"
  export OTEL_TRACES_SAMPLER_ARG="0.1"   # keep 10% of root traces
  ```

  `parentbased_` ensures a downstream Kotlin service honors the sampling decision made upstream, so you get complete traces instead of fragments.
- **Invest in resource attributes.** `service.name`, `service.version`, and `deployment.environment` are the dimensions you will filter by in every OpenObserve query. Set them via `OTEL_SERVICE_NAME` / `OTEL_RESOURCE_ATTRIBUTES` so operations can override them per environment without a rebuild.
- **Never launch trace-relevant work from `GlobalScope` or a bare new scope.** A coroutine launched outside the structured-concurrency tree does not inherit the caller's `CoroutineContext`, so the trace context is silently dropped. If detached work must stay trace-linked, capture `Context.current().asContextElement()` at the call site and pass it to `launch` explicitly.
- **Watch `runBlocking` boundaries.** Entering `runBlocking` from a thread with an active span does not carry the context into the coroutine world by itself - pass `runBlocking(Context.current().asContextElement()) { ... }` when bridging blocking code into coroutines.
- **Close the SDK on shutdown.** `openTelemetry.close()` (or `shutdown()` on each provider) flushes the final batches. Wire it into a JVM shutdown hook - short-lived jobs and CLIs lose their last spans without it.
- **Record errors on spans, not just in logs.** `span.recordException(t)` plus `span.setStatus(StatusCode.ERROR)` is what makes failed requests filterable in the Traces UI. In Kotlin, be careful not to swallow `CancellationException` in the process - rethrow it after recording.
- **Pin the BOM and upgrade in lockstep.** Import `opentelemetry-bom` for SDK artifacts, and keep javaagent/instrumentation versions (2.x) matched with the SDK line they target. Mixing releases is the most common source of confusing `NoSuchMethodError`s after an upgrade.
- **Mind javaagent startup cost.** The agent adds a few hundred milliseconds to several seconds of startup and some memory overhead. For latency-critical cold starts (serverless), prefer manual SDK setup with only the instrumentation you need.

## Troubleshooting

**Symptom: `Failed to export spans ... Connection refused: localhost/127.0.0.1:4317`.**
Fix: the SDK fell back to its default - gRPC to localhost:4317 - because `OTEL_EXPORTER_OTLP_ENDPOINT` or `OTEL_EXPORTER_OTLP_PROTOCOL` is not set (or not visible to the process). Set the protocol to `http/protobuf` and the endpoint to your OpenObserve base URL. Remember the autoconfigure default is gRPC even though the javaagent's is HTTP.

**Symptom: exporter logs `404 Not Found` and no data arrives.**
Fix: almost always a URL problem. Remove any trailing slash from `OTEL_EXPORTER_OTLP_ENDPOINT`, and confirm the path includes your organization (`/api/default`, not just the host). The final URL the exporter calls should look like `http://localhost:5080/api/default/v1/traces`.

**Symptom: `401 Unauthorized` on every export.**
Fix: regenerate the token with `echo -n 'email:password' | base64` (the `-n` matters - a trailing newline corrupts the token), and make sure the header value in `OTEL_EXPORTER_OTLP_HEADERS` is `Authorization=Basic%20<token>` with the space percent-encoded. If you set headers in code via `addHeader`, use a literal space there instead.

**Symptom: env vars are set, but the SDK behaves as if they do not exist.**
Fix: you are constructing providers by hand with `SdkTracerProvider.builder()`, which ignores the environment. Either initialize through `AutoConfiguredOpenTelemetrySdk` (add `opentelemetry-sdk-extension-autoconfigure`) or run under the javaagent - those are the two components that read `OTEL_*` variables.

**Symptom: spans inside suspend functions appear as separate root traces, or parent to a random unrelated request.**
Fix: thread-local context leaked across coroutine boundaries. Replace `makeCurrent()` around suspension points with `withContext(span.asContextElement())`, pass `Context.current().asContextElement()` into `launch`/`async` when crossing from non-coroutine code, and check that any custom `CoroutineDispatcher` or thread pool is not being handed work without the context element. Under the javaagent (1.22.0+), this propagation is automatic - if you see it there, verify the agent is actually attached.

**Symptom: the service exits cleanly but the last few spans and logs never arrive.**
Fix: the batch processors were never flushed. Call `close()` on the `OpenTelemetrySdk` (or `shutdown()`/`forceFlush()` on each provider) in a shutdown hook, with a timeout so a dead backend cannot hang your exit. This bites short-lived jobs hardest - for those, consider `forceFlush()` after each unit of work.

With the Java SDK wired up once at startup, `opentelemetry-extension-kotlin` keeping the context glued to your coroutines, and OTLP pointed at OpenObserve, a Kotlin service gets correlated traces, metrics, and logs in a single backend - searchable side by side, with no vendor-specific code anywhere in your application.
