Observability for Kotlin with OpenTelemetry

Khimananda Oli 7 min read Programming and Languages
Observability for Kotlin with OpenTelemetry

By Khimananda Oli | Last reviewed: August 2026

Debugging latency issues in distributed Kotlin applications requires more than log aggregation; you need correlated telemetry data to pinpoint bottlenecks across service boundaries. Implementing observability for Kotlin with OpenTelemetry provides the standardized signals necessary to understand system behavior without vendor lock-in. This guide covers the practical configuration of the Java Agent, manual instrumentation patterns specific to Kotlin coroutines, and exporting data via OTLP to backends like Jaeger or Grafana Tempo.

Kotlin ApplicationBusiness Logic / CoroutinesOTEL Java AgentKotlin Context PropagationOTLP / gRPCOpenTelemetry CollectorBatch & ProcessFilter Sensitive DataObservability BackendTraces (Jaeger/Tempo)Metrics (Prometheus)Logs (Loki/Elastic)
High-level architecture for observability for Kotlin with OpenTelemetry showing signal flow from instrumented app to backend storage.

How do you configure auto-instrumentation for observability for Kotlin with OpenTelemetry?

The most reliable starting point for any JVM-based language is the OpenTelemetry Java Agent. Because Kotlin compiles to standard Java bytecode, the agent can intercept HTTP requests, database calls, and messaging operations without code changes. However, treating Kotlin exactly like Java leads to gaps in async execution tracking. You must explicitly enable context propagation modules designed for Kotlin coroutines.

Attaching the agent in Gradle

In 2026, the recommended approach uses the official Gradle plugin rather than raw JVM arguments. This ensures version alignment between the agent and your build tools.

plugins {
    id("io.opentelemetry.instrumentation.gradle") version "2.12.0"
}

dependencies {
    // Core API for manual instrumentation later
    implementation("io.opentelemetry:opentelemetry-api:1.45.0")
    
    // CRITICAL: Enables context propagation across coroutine boundaries
    implementation("io.opentelemetry:opentelemetry-extension-kotlin:1.45.0")
}

When running locally or in CI, specify the endpoint and service name via environment variables. Avoid hardcoding these in application.conf to maintain twelve-factor compliance.

  • OTEL_SERVICE_NAME=payment-service-kotlin
  • OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
  • OTEL_TRACES_EXPORTER=otlp
  • OTEL_METRICS_EXPORTER=otlp

A common mistake in mixed Java/Kotlin projects is assuming the base agent handles suspend functions automatically. Without the opentelemetry-extension-kotlin dependency on the classpath, trace context frequently breaks at the first suspension point, resulting in orphaned spans that make debugging impossible. Always verify this dependency exists before troubleshooting missing traces.

How do you handle coroutine context propagation in Kotlin traces?

Kotlin’s structured concurrency model decouples execution threads from logical workflows. Standard ThreadLocal-based context propagation fails when a coroutine resumes on a different worker thread. To maintain valid parent-child relationships in your traces, you must bridge the OpenTelemetry context into the Kotlin CoroutineContext.

Main ThreadWorker Thread AWorker Thread BStart Spansuspend call + ContextChild Span ActiveSuspendedResume + Context RestoredSpan ContinuesReturn ResultEnd Parent Span
Coroutine context propagation sequence ensuring trace continuity across thread switches during observability for Kotlin with OpenTelemetry.

Bridging contexts manually

While newer agent versions attempt automatic bridging, explicit wrapping remains the safest pattern for complex async pipelines. Use the asContextElement() extension function to bind the current OTel context to the coroutine scope.

import io.opentelemetry.kotlin.extensions.asContextElement
import kotlinx.coroutines.withContext

suspend fun processOrder(orderId: String) {
    val span = tracer.spanBuilder("process-order")
        .setAttribute("order.id", orderId)
        .startSpan()
    
    try {
        // Bridges OTel context into coroutine context
        withContext(span.asContextElement()) {
            validateInventory(orderId)  // Child span linked correctly
            chargePayment(orderId)      // Even if suspended/resumed elsewhere
        }
    } finally {
        span.end()
    }
}

If you use MDC (Mapped Diagnostic Context) for logging, remember that SLF4J MDC is also ThreadLocal-bound. Combine both bridges to ensure log correlation IDs survive suspension points alongside trace context. This alignment between logs and traces is fundamental to effective structured logging best practices.

What are the best practices for manual instrumentation in Kotlin?

Auto-instrumentation captures infrastructure interactions, but business logic visibility requires manual spans. In Kotlin, avoid creating spans inside pure functions or high-frequency loops. Focus on domain boundaries, external integrations, and decision points that affect user experience.

Instrumentation TargetRecommended ApproachCommon Pitfall
HTTP ControllersAuto-instrumentation onlyAdding redundant manual server spans
Database QueriesAuto-instrumentation + SQL commentsManually wrapping every repository call
Domain ServicesManual spans for key operationsOver-tracing trivial helper methods
External API CallsAuto + custom attributes for business IDsForgetting to capture error status codes
Background JobsManual root spans with job metadataRelying on thread-based auto-detection

Enriching spans with semantic attributes

Raw timing data lacks context. Always attach semantic attributes following OpenTelemetry conventions so backends can index and query effectively. For e-commerce systems processing payments in Nepal or globally, include transaction identifiers and regional metadata.

val span = tracer.spanBuilder("payment.process")
    .setAttribute("payment.gateway", "esewa")
    .setAttribute("transaction.amount", amount)
    .setAttribute("transaction.currency", "NPR")
    .setAttribute("user.tier", customerTier)
    .startSpan()

// Record exceptions properly instead of just logging
try {
    gateway.charge(amount)
} catch (e: PaymentDeclinedException) {
    span.setStatus(StatusCode.ERROR, e.message)
    span.recordException(e)
    throw e
} finally {
    span.end()
}

Never put PII (personally identifiable information) directly into span attributes. Use hashed references or opaque IDs that can be joined against secure data stores during investigations. This discipline supports compliance frameworks like ISO 27001 and prevents accidental data leakage through observability backends.

How do you export telemetry data efficiently from Kotlin services?

Export configuration directly impacts application performance and operational costs. The OTLP protocol over gRPC is the default choice for 2026 deployments due to its binary efficiency and streaming support. HTTP/protobuf serves as a fallback when network policies block gRPC ports.

Tuning batch exporters

Default exporter settings prioritize low latency over throughput. For high-volume Kotlin services, increase batch sizes and intervals to reduce network overhead. These values should be tuned based on your specific traffic patterns and collector capacity.

  • OTEL_BSPR_MAX_EXPORT_BATCH_SIZE=512 (default 512, consider 1024 for high-throughput)
  • OTEL_BSPR_SCHEDULE_DELAY=5000 (milliseconds between exports)
  • OTEL_BSPR_MAX_QUEUE_SIZE=2048 (prevent memory pressure during spikes)
  • OTEL_EXPORTER_OTLP_COMPRESSION=gzip (reduces bandwidth ~70%)

Always deploy an OpenTelemetry Collector as a sidecar or daemonset rather than exporting directly to managed backends. The collector buffers transient failures, performs tail-based sampling to reduce costs, and sanitizes sensitive headers before data leaves your infrastructure. Direct exports from application pods create tight coupling and amplify outage impact when backend endpoints experience latency.

Sampling strategies for cost control

Tracing every request becomes prohibitively expensive at scale. Implement head-based sampling for development environments and probabilistic or trace-id ratio sampling for production. For critical paths like payment processing, use parent-based sampling to ensure complete traces are retained even when overall sample rates drop to 1%.

Configure sampling decisions at the collector level whenever possible. This allows dynamic adjustment without redeploying Kotlin services and ensures consistent policies across polyglot architectures where multiple languages share the same trace pipeline.

Implementing Production-Grade Observability for Kotlin with OpenTelemetry

Successful observability for Kotlin with OpenTelemetry depends on respecting the language's concurrency model while adhering to vendor-neutral standards. Start with the Java Agent and Kotlin extensions for baseline coverage, add manual spans only where business context matters, and route all telemetry through a local collector. Validate your setup by triggering errors in staging and confirming end-to-end trace reconstruction before relying on it during incidents. If your team needs assistance designing compliant, audit-ready observability pipelines for JVM workloads, reach out to discuss your architecture.

Frequently Asked Questions

Add the opentelemetry-bom and opentelemetry-exporter-otlp dependencies to your build.gradle.kts file. Configure the SDK using environment variables or a properties file, then register it as a global tracer provider during application startup in 2026.

Yes, use the opentelemetry-kotlin-coroutines extension to propagate context across suspend functions. Without this library, trace context breaks at suspension points, causing fragmented spans and incomplete distributed traces in async Kotlin applications.

Typically under three percent CPU overhead with batch exporting enabled. Synchronous exporters increase latency significantly, so always configure async batch processors and sample rates appropriate for your production traffic volume.

Yes, include spring-boot-starter-actuator and micrometer-tracing-bridge-otel. Auto-configuration handles instrumentation for WebFlux and MVC controllers, database calls, and HTTP clients without manual span creation in most standard Kotlin services.

Configure OTEL_EXPORTER_OTLP_ENDPOINT pointing to your Jaeger OTLP receiver port. Jaeger natively supports OTLP since v1.45, eliminating the need for legacy Thrift or gRPC-specific exporter dependencies in modern deployments.

Missing context usually indicates absent coroutine instrumentation. Add opentelemetry-kotlin-coroutines to dependencies and ensure withContext wraps async blocks properly. Thread-local context does not transfer across dispatcher switches without explicit propagation handlers.

No, serialization frameworks like kotlinx.serialization have community instrumentations. Manual spans are only needed for custom business logic boundaries where automatic instrumentation cannot infer meaningful operation names or attributes.

Implement tail-based sampling or probabilistic head sampling via OTEL_TRACES_SAMPLER_ARG. Filter low-value spans at the SDK level before export to minimize backend ingestion fees while preserving error and high-latency trace visibility.

Attribute values may contain PII if captured carelessly. Use attribute redaction processors or custom samplers to mask fields. Never log request bodies or headers containing tokens without explicit sanitization filters configured in your pipeline.

Yes, inject trace_id and span_id into MDC using opentelemetry-logback-mdc-1.0. Structured loggers automatically include these identifiers, enabling direct navigation between log entries and corresponding distributed traces in observability backends.

Use OpenTelemetry Collector v0.105 or later for full OTLP/HTTP and gRPC compatibility. Older versions may lack support for newer semantic conventions used by recent Kotlin instrumentation libraries released after mid-2025.

Use InMemorySpanExporter from opentelemetry-sdk-testing module. Assert span names, attributes, and hierarchy in JUnit tests without external collectors, ensuring instrumentation correctness before deploying to staging environments.

Early Ktor instrumentations had context propagation bugs fixed in v2.0+. Ensure you use ktor-server-opentelemetry matching your Ktor major version. Verify server and client plugins are both registered for complete request coverage.

Enable OTEL_LOG_LEVEL=debug to inspect SDK initialization and export failures. Check network connectivity to collectors, verify sampler configuration, and confirm instrumentation modules match your framework versions exactly.

Use OpenTelemetry directly for unified signals and vendor neutrality. Micrometer adds abstraction overhead and requires bridging. Native OTel metrics API provides equivalent functionality with better correlation to traces in 2026 stacks.