Observability for Scala with OpenTelemetry

Khimananda Oli 9 min read Programming and Languages
Observability for Scala with OpenTelemetry

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.

Scala AppZIO / Cats Effectotel4s / zio-telemetryOTLP ExporterOTel CollectorBatch & ProcessReceiversProcessorsTempo / JaegerTracesPrometheusMetricsLoki / ELKLogs
High-level architecture for observability for Scala with OpenTelemetry showing data flow from effect-based app through collector to specialized backends

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.

HTTP RequestSpan: receivefork fiberBusiness LogicSpan: processasync DB callDatabaseSpan: queryExternal APISpan: http-outTrace Context (W3C) propagated via Effect Reader, NOT ThreadLocal
Context propagation sequence in observability for Scala with OpenTelemetry showing spans maintained across fiber forks and async database calls

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.

Criteriaotel4szio-telemetryJava Agent
Effect System SupportCats Effect, Http4s, FS2ZIO, ZIO HTTP, ZIO StreamsServlet, Spring, limited CE/ZIO
Context PropagationNative via IOLocalNative via ZEnvironmentThreadLocal (unreliable for fibers)
Type SafetyFull (tagless final)Full (ZIO service pattern)None (runtime bytecode magic)
Auto-instrumentationHTTP, JDBC, Redis librariesHTTP, JDBC, Redis librariesBroad (100+ libraries)
OverheadLow (~2-5%)Low (~2-5%)Moderate (~5-15%)
TestingPure test doublesTest layersNearly impossible to mock
Best ForTypelevel ecosystemZIO shopsLegacy 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: Use parentbased_tracealways for development, traceidratio with 0.1-0.5 for high-throughput production services.
  • OTEL_RESOURCE_ATTRIBUTES: Include deployment.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"
Start: Scala ServiceWhich effect system?Cats EffectZIONone/LegacyUse otel4sUse zio-telemetryJava Agent + ManualType-safe spansFiber-safe contextPure testingZLayer integrationEnvironment propagationZIO Test supportBroad auto-instrumentationThreadLocal risksHigher overheadAll paths → Configure OTLP Export → Set Sampling → Deploy
Decision flowchart for selecting the right observability for Scala with OpenTelemetry implementation path based on runtime and team constraints

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.

Frequently Asked Questions

Use otel4s, the native Scala implementation of OpenTelemetry. It provides type-safe tracing and metrics APIs compatible with cats-effect and ZIO, avoiding Java agent overhead while supporting current 2026 stable releases for production Scala microservices.

Yes, otel4s offers native Scala support without requiring Java instrumentation agents. This functional approach integrates directly with effect systems, providing safer concurrency handling and compile-time guarantees that generic Java agents cannot offer for idiomatic Scala codebases.

Configure environment variables OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_SERVICE_NAME in your container runtime. The otel4s SDK automatically reads these standard variables to initialize exporters, eliminating hardcoded configuration and ensuring compatibility across staging and production environments.

Absolutely. otel4s propagates trace context across fiber boundaries automatically. Spans created within forked fibers remain linked to parent traces, preserving causal relationships essential for debugging concurrent Scala applications running on modern runtimes.

Yes, but it lacks awareness of Scala-specific constructs like futures and fibers. Native libraries like otel4s are preferred in 2026 because they correctly propagate context through effect system boundaries where bytecode instrumentation typically fails or produces incomplete traces.

It uses ZIO’s service pattern to pass span context implicitly through the effect chain. Developers access the Tracer service from the environment, ensuring spans nest correctly without manual thread-local storage management common in imperative Java frameworks.

Jaeger, Tempo, or Datadog all accept OTLP from Scala services. Choose based on existing infrastructure; Tempo integrates well with Grafana stacks commonly used by Scala teams, while managed options reduce operational burden for smaller engineering organizations.

Minimal when using sampling and async batching. otel4s exports telemetry off the critical path using non-blocking I/O. Enable head-based sampling in high-throughput services to keep latency impact under one percent during peak load in 2026 deployments.

Use the SpanOps API to attach key-value pairs within a traced block. Attributes like user-id or order-total enrich traces for filtering in backends, enabling precise root-cause analysis without modifying core business logic significantly.

Yes, inject trace-id and span-id into MDC using otel4s logging integrations. Backends like Loki then link log lines to specific spans, allowing developers to jump directly from error logs to corresponding distributed trace timelines.

Version 3.x requires Scala 3.3 or later. Legacy Scala 2.13 projects should use otel4s 0.x branches, though migration to Scala 3 is recommended in 2026 for better type inference and improved effect system integration.

Run an OTLP collector via Docker Compose forwarding to Jaeger UI. Configure your Scala app to export to localhost:4317, then verify span structure and attributes visually before deploying to production clusters.

Not automatically like Java agents. You must explicitly wrap sttp, http4s, or pekko-http calls with otel4s middleware. This deliberate approach ensures only intended requests generate spans, preventing noisy telemetry from internal health checks or library calls.

Always enable TLS for OTLP/gRPC endpoints in production. Sensitive attributes should be redacted at the application level before export, as backends store raw data. Follow least-privilege IAM policies for collector access in cloud environments.

Context likely failed to propagate across an async boundary. Ensure you are using otel4s-aware combinators instead of raw Future.map or unsafeRunSync. Verify that the tracer instance is properly threaded through your effect composition layer.