
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Debugging distributed Scala applications often feels like navigating a maze without a map, especially when asynchronous boundaries obscure request flows. Implementing observability for Scala with OpenTelemetry solves this by providing unified traces, metrics, and logs that respect functional programming paradigms and JVM concurrency models. Rather than relying on intrusive Java agents or manual thread-local propagation, modern Scala teams use native libraries to capture telemetry data accurately across complex effect systems.
How do you implement observability for Scala with OpenTelemetry natively?
The most reliable approach for Scala avoids the official Java agent entirely. While the Java agent works for simple servlet-based apps, it frequently fails to propagate context correctly through Cats Effect fibers, ZIO runtimes, or Akka/Pekko actors because these frameworks manage their own execution contexts outside standard Java threading models. Instead, use otel4s for Cats Effect or zio-telemetry for ZIO-based applications. These libraries provide type-safe abstractions that treat telemetry as an effect, ensuring context survives every async boundary and fork.
For teams building on the Typelevel stack or using Http4s, otel4s is currently the gold standard in 2026. It provides separate modules for tracing, metrics, and logging that integrate seamlessly with cats-effect. You add the dependency to your build.sbt:
libraryDependencies ++= Seq(
"org.typelevel" %% "otel4s-java-sdk" % "0.12.0",
"org.typelevel" %% "otel4s-contrib-http4s" % "0.12.0"
) Initialization happens at the application entry point where you allocate the SDK resource. This pattern ensures proper flushing of telemetry data on shutdown, which is critical for avoiding data loss in containerized environments:
import org.typelevel.otel4s.sdk.Otel4sSdkAutoConfigure
import cats.effect.{IO, IOApp, Resource}
object Main extends IOApp.Simple {
def run: IO[Unit] = Otel4sSdkAutoConfigure
.configure[IO]
.use { otel4s =>
otel4s.tracerProvider.get("my-scala-service").flatMap { tracer =>
// Your application logic receives the tracer
MyApp.run(tracer)
}
}
} This explicit dependency injection pattern aligns with how we approach application instrumentation across polyglot environments. The tracer instance is passed through your service layers, making dependencies visible and testable rather than hidden in global state.
How does context propagation work across Scala effect systems?
Context propagation is where most Scala observability implementations fail. In traditional Java, OpenTelemetry uses ThreadLocal storage to pass trace context. Scala effect systems deliberately abstract away threads, meaning ThreadLocals are unreliable or completely broken. Native Scala OTel libraries solve this by embedding context directly into the effect monad itself.
In otel4s, the tracer operates within a Tracer[F] constraint. When you call tracer.span("operation-name"), it returns a Resource[F, Span[F]] that manages the span lifecycle. Crucially, when you fork a new fiber with .parTupled or .background, the current trace context is automatically captured and restored in the new fiber. This happens through the effect system's local/context mechanism, not JVM thread locals.
For ZIO users, zio-telemetry provides similar guarantees through ZIO's environment layer. The OpenTelemetry service carries the active span in the ZIO environment, and operators like ZIO.fork automatically inherit this context. A common mistake is mixing raw Future with effect types; Futures execute eagerly on whatever ExecutionContext they're created with, often losing trace context before the effect system can capture it. Always stay within your effect type's ecosystem for async operations.
Handling actor systems and message queues
If you're running Apache Pekko (the open-source Akka successor), context propagation requires additional care. Messages sent between actors don't automatically carry trace context. You must explicitly inject and extract W3C trace context headers into your message protocol. Both otel4s and zio-telemetry provide utilities for serializing context to/from maps that can be embedded in protobuf or JSON messages. For Kafka or RabbitMQ integrations, use the corresponding OTel instrumentation libraries that handle header injection/extraction at the producer/consumer level.
What are the differences between otel4s, zio-telemetry, and the Java agent for Scala?
Choosing the right instrumentation strategy depends heavily on your runtime and team expertise. Each approach has distinct trade-offs in 2026.
| Criteria | otel4s | zio-telemetry | Java Agent |
|---|---|---|---|
| Effect System Support | Cats Effect, Http4s, FS2 | ZIO, ZIO HTTP, ZIO Streams | Servlet, Spring, limited CE/ZIO |
| Context Propagation | Native via IOLocal | Native via ZEnvironment | ThreadLocal (unreliable for fibers) |
| Type Safety | Full (tagless final) | Full (ZIO service pattern) | None (runtime bytecode magic) |
| Auto-instrumentation | HTTP, JDBC, Redis libraries | HTTP, JDBC, Redis libraries | Broad (100+ libraries) |
| Overhead | Low (~2-5%) | Low (~2-5%) | Moderate (~5-15%) |
| Testing | Pure test doubles | Test layers | Nearly impossible to mock |
| Best For | Typelevel ecosystem | ZIO shops | Legacy Java/Scala hybrids |
The Java agent remains useful only when you have significant legacy Java code mixed with Scala, or when you need auto-instrumentation for frameworks that lack native Scala support. For pure Scala services built after 2023, native libraries are almost always superior. They produce cleaner traces, respect your abstraction boundaries, and don't introduce mysterious runtime behavior that complicates debugging—the very problem you're trying to solve.
How do you configure OTLP exporters and sampling for production Scala services?
Configuration should follow the twelve-factor app methodology: environment variables over config files. Both otel4s and zio-telemetry respect the standard OpenTelemetry environment variables, making them compatible with Kubernetes operators and Helm charts.
Essential environment variables for any Scala service:
OTEL_SERVICE_NAME: Always set this explicitly. Default values like "unknown_service:java" make dashboards useless.OTEL_EXPORTER_OTLP_ENDPOINT: Point to your OTel Collector (e.g.,http://otel-collector:4318). Prefer HTTP/protobuf over gRPC in containerized environments to avoid connection pooling issues.OTEL_TRACES_SAMPLER: Useparentbased_tracealwaysfor development,traceidratiowith 0.1-0.5 for high-throughput production services.OTEL_RESOURCE_ATTRIBUTES: Includedeployment.environment,service.version, and cloud provider metadata for filtering.
Sampling deserves special attention. As discussed in monitoring fundamentals, capturing 100% of traces is rarely necessary and often prohibitively expensive. For Scala microservices handling thousands of RPS, configure probabilistic sampling at the SDK level. However, always use parent-based sampling so that if an upstream service decided to sample a request, your Scala service continues that trace. Breaking trace continuity defeats the purpose of distributed tracing.
# Production environment configuration
export OTEL_SERVICE_NAME="payment-processor-scala"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://otel-collector.monitoring:4318"
export OTEL_TRACES_SAMPLER="parentbased_traceidratio"
export OTEL_TRACES_SAMPLER_ARG="0.2"
export OTEL_METRICS_EXPORTER="otlp"
export OTEL_LOGS_EXPORTER="otlp"
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production,service.version=2.4.1,cloud.provider=aws" How do you correlate logs, metrics, and traces in Scala applications?
Telemetry signals are only useful when correlated. A spike in error rate means nothing if you can't jump directly to the offending trace and corresponding log entries. For Scala, this correlation must be handled at the library level since manual MDC (Mapped Diagnostic Context) doesn't survive async boundaries.
otel4s provides otel4s-contrib-logback or otel4s-contrib-log4cats modules that automatically inject trace_id and span_id into structured log output. Configure your logging backend to include these fields in JSON format. When using structured logging, ensure your log aggregation backend (Loki, Elasticsearch) parses these fields and enables trace-log linking in Grafana or Kibana.
For metrics, prefer the OTLP metrics exporter over Prometheus scraping for Scala services. The push-based model works better with autoscaling containers that may not live long enough to be scraped reliably. Record business metrics (payments processed, cache hit rates) alongside infrastructure metrics using the same Meter[F] abstraction. Tag metrics with the same resource attributes used for traces to enable unified dashboards.
Testing observability without external dependencies
A major advantage of native Scala OTel libraries is testability. With otel4s, you can provide a no-op or in-memory tracer during tests without any network calls. Write assertions against captured spans to verify that your business logic creates the expected telemetry structure. This treats observability as a first-class contract rather than an afterthought, catching instrumentation regressions in CI before they reach production.
Deploying Observable Scala Services Reliably
Observability for Scala with OpenTelemetry delivers value only when deployed correctly across your entire service mesh. Start by instrumenting your highest-value paths—payment processing, user authentication, order fulfillment—before expanding to auxiliary services. Use the OTel Collector as a mandatory intermediary; never have Scala services export directly to backends in production. The collector handles batching, retry logic, and tail-based sampling that individual services shouldn't manage.
Monitor your observability pipeline itself. Track exporter queue sizes, dropped spans, and export latency as key health indicators. A silent failure in telemetry export means flying blind during the exact incident when you need visibility most. Set up alerts on collector metrics using the same patterns described in Prometheus Alertmanager configurations.
If you're evaluating your current Scala observability setup or planning a migration from legacy instrumentation, reach out to discuss your specific architecture. Getting the foundation right prevents costly rework and ensures your team can actually debug issues at 3 AM instead of guessing through opaque distributed systems.