OpenTelemetry in Spring Boot 3: Distributed Tracing for Production Observability
Learn to implement distributed tracing in Spring Boot 3 using OpenTelemetry.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
- ✓Java 17+ (21 recommended for virtual threads compatibility)
- ✓Spring Boot 3.2+ with spring-boot-starter-web and spring-boot-starter-actuator
- ✓Docker for running Jaeger or Zipkin locally
- ✓Basic understanding of microservices architecture and HTTP request lifecycle
• OpenTelemetry is the open standard for distributed tracing, metrics, and logs • Spring Boot 3.2+ auto-configures OpenTelemetry via the micrometer-tracing bridge • Instrument your app with @WithSpan and Tracer for end-to-end request tracking • Export traces to Jaeger, Zipkin, or any OTLP-compatible backend • Key gotcha: Always set spring.application.name to avoid orphaned traces
Think of OpenTelemetry like a package tracking system for your web app. When a user clicks "Buy Now," that request travels through multiple services—like a package moving through sorting facilities. OpenTelemetry attaches a tracking number (trace ID) to every request, logging each hop's duration and errors. Without it, debugging is like finding a lost package with no tracking info.
Here's the hard truth: most teams get observability wrong. They slap on basic logging and wonder why production incidents take hours to debug. In microservices, a single user request can fan out across 10+ services, databases, and queues. When that request fails—say a payment timeout in a SaaS billing pipeline—you need more than stack traces. You need the full story: which service dropped the ball, how long each hop took, and what data was in flight.
This is where OpenTelemetry (OTel) comes in. It's the industry standard for distributed tracing, born from the merger of OpenTracing and OpenCensus. Spring Boot 3.2+ ships with first-class OTel support via Micrometer Tracing, replacing the deprecated Spring Cloud Sleuth. With a few annotations and configuration properties, you get end-to-end trace IDs propagated across HTTP calls, messaging queues, and database queries.
In this guide, we'll instrument a real-time analytics service step-by-step. You'll learn to trace gRPC calls, handle async operations, and export to Jaeger. We'll also dissect a production incident where distributed tracing saved a $50K/hour outage. By the end, you'll have production-grade observability that your on-call team will thank you for.
Why OpenTelemetry Is the Only Observability Standard That Matters
If you're still wiring Micrometer or Jaeger agents directly into your Spring Boot 3 app, you're building technical debt. OpenTelemetry (OTel) is the CNCF-graduated standard that unifies traces, metrics, and logs under a single API. Spring Boot 3.2+ ships with io.micrometer:micrometer-tracing-bridge-otel and io.opentelemetry:opentelemetry-exporter-otlp as first-class citizens. Here's the hard truth: most teams configure this incorrectly by mixing manual Span creation with auto-instrumentation, leading to broken context propagation. Start with the spring-boot-starter-actuator and add management.tracing.enabled=true in your application.yml. Then, for production, always set spring.sleuth.enabled=false (yes, Sleuth is dead—migrate now). Below is the minimal auto-configuration that works with any OTLP-compatible backend like Grafana Tempo or Honeycomb.
SpanBuilder with auto-instrumentation—every HTTP request spawned 5 orphaned spans.What the Official Docs Won't Tell You
The Spring Boot 3.2 reference guide says to add io.micrometer:micrometer-tracing-bridge-otel and io.opentelemetry:opentelemetry-exporter-otlp—but it doesn't tell you that spring.sleuth.enabled=false is mandatory if you ever had Sleuth on the classpath, even if you removed the dependency. The OTel SDK's BatchSpanProcessor has a default export timeout of 30 seconds, which will drop traces under high load in a real-time analytics system. You must set otel.bsp.schedule.delay=500 and otel.bsp.max.export.batch.size=512 in your application.properties to avoid data loss. Also, the @NewSpan annotation from Micrometer is broken with spring.jpa.open-in-view=true because Hibernate sessions close before the span ends. The fix: set spring.jpa.open-in-view=false or use @Transactional(readOnly=true) on your repositories.
otel.bsp.schedule.delay was left at 30 seconds; the processor never flushed before the JVM heap filled with pending spans.Custom Context Propagation for Async and Reactive Pipelines
Distributed tracing breaks the moment you cross thread boundaries—@Async, CompletableFuture, WebFlux, or Kafka listeners. Spring Boot 3's Micrometer Tracing uses ThreadLocal context, which won't propagate to worker threads unless you explicitly wrap your code. For @Async, annotate your configuration with @EnableAsync and add a Executor bean that uses ContextSnapshotFactory from Micrometer. For reactive stacks (Spring WebFlux + Reactor), you must install the reactor-core micrometer hook: Hooks.enableAutomaticContextPropagation(). Without this, every flatMap() call loses the trace context. For Kafka consumers, set spring.kafka.consumer.properties[spring.tracing.enabled]=true—but note that this only works with spring-kafka 3.0+. Here's the reactor setup that saved a real-time analytics pipeline from context loss.
Hooks.enableAutomaticContextPropagation()—each message appeared as a root span.Sampling Strategies That Won't Bankrupt Your Observability Budget
In production, tracing every request is financially and operationally infeasible—a payment-processing system handling 10k req/s generates 5GB of trace data per hour. The default AlwaysOnSampler is a rookie mistake. Use TraceIdRatioBased at 1-5% for general traffic, but implement head-based sampling with a custom Sampler for critical paths. Spring Boot 3 allows you to define a Sampler bean: return Sampler.alwaysSample() for /api/payments and Sampler.neverSample() for /health. For tail-based sampling, you need an OTel collector configuration with tail_sampling processor, which samples based on latency or error status. The collector is mandatory for multi-service traces because head-based sampling can drop the root span. Below is a Spring Boot sampler that prioritizes high-value transactions without drowning your backend.
AlwaysOnSampler across 15 microservices—after switching to 5% ratio-based sampling, the bill dropped to $2.3k with no loss of critical trace data.Sampler bean is mandatory for cost control; tail-based sampling requires an OTel collector.Production Debugging with OpenTelemetry and Spring Boot 3
When your distributed system starts misbehaving in production, OpenTelemetry is your first responder. Here's the hard truth: most teams get this wrong by only instrumenting happy paths. You need to trace failures, timeouts, and retries. In Spring Boot 3.2+, use the @WithSpan annotation on service methods that interact with external systems. For example, annotate your payment gateway call with @WithSpan("processPayment") and add attributes like paymentId and amount. This lets you correlate a failed payment with a spike in latency. Use the OpenTelemetry Java agent (opentelemetry-javaagent.jar v1.32+) for zero-code instrumentation of JDBC, HTTP clients, and messaging. But don't stop there: manually add spans for critical business logic using Span.current(). In your configuration, ensure otel.traces.exporter=otlp and otel.exporter.otlp.endpoint=http://localhost:4318. For high-traffic services, sample at 10% using otel.traces.sampler=parentbased_traceidratio and otel.traces.sampler.arg=0.1. This reduces storage costs while keeping enough traces for anomaly detection. Use Grafana Tempo or Jaeger to visualize traces. A common production issue is missing context propagation across thread pools; always use @Async with a custom TaskDecorator that propagates the OpenTelemetry context. Otherwise, you'll orphan spans and lose the trace chain.
Troubleshooting Trace Gaps and Missing Spans
You've instrumented your Spring Boot 3.2 application, but traces are incomplete or missing entirely. This is the #1 complaint from teams adopting OpenTelemetry. First, verify the agent is attached: check JVM arguments for -javaagent:opentelemetry-javaagent.jar. Second, ensure the OTLP exporter endpoint is reachable. Use curl http://localhost:4318/v1/traces to test. Third, check for library conflicts. Spring Boot 3.2 ships with Micrometer Tracing 1.2.x, which can clash with the OpenTelemetry agent. Remove micrometer-tracing-bridge-otel from your dependencies if using the agent. Fourth, inspect your application.properties for missing configuration. Common mistake: setting otel.traces.exporter=none by accident. Fifth, enable debug logging: logging.level.io.opentelemetry=DEBUG. This will show you if spans are being created but not exported. Sixth, check for sampling decisions. If you set a low sampling rate, most traces will be dropped. Use otel.traces.sampler=always_on temporarily for debugging. Seventh, verify context propagation across HTTP boundaries. For REST APIs, ensure your RestTemplate or WebClient uses the OpenTelemetry instrumentation. Add the opentelemetry-instrumentation-spring-webmvc library. Eighth, test with a simple endpoint: create a controller that calls a traced service and verify the trace appears in your backend. Ninth, if using Kafka, add the opentelemetry-instrumentation-kafka-clients-2.6 library and configure the interceptor. Tenth, check for thread-local issues: never reuse threads without propagating context. Use a ThreadPoolTaskExecutor with a custom TaskDecorator that wraps the OpenTelemetry context.
opentelemetry-instrumentation-spring-webmvc dependency caused all HTTP spans to be dropped. The team had spent hours blaming the network. Always verify instrumentation libraries in your dependency tree.Advanced OpenTelemetry Configuration for High-Volume Production
In high-volume production environments, naive OpenTelemetry configuration will kill your performance and blow your storage budget. Here's how to configure for scale. Use batch span processing: set otel.bsp.schedule.delay=100 (milliseconds) and otel.bsp.max.export.batch.size=512. This reduces export overhead. For sampling, use head-based sampling with a consistent probability. In Spring Boot 3.2, configure otel.traces.sampler=parentbased_traceidratio with otel.traces.sampler.arg=0.1 for 10% sampling. But for critical services (e.g., payment processing), use always_on and rely on tail-based sampling in your backend (e.g., Grafana Tempo). To reduce span attributes, avoid adding high-cardinality attributes like user emails or session IDs. Instead, use span events for detailed data. For example, log request payloads as events: span.addEvent("request.payload", Attributes.of(AttributeKey.stringKey("payload"), requestBody)). This keeps traces searchable without bloating indexes. Use attribute limits: set otel.attribute.count.limit=128 and otel.attribute.value.length.limit=4096. This prevents a single span from consuming too much memory. For multi-region deployments, use different exporter endpoints per region: otel.exporter.otlp.endpoint=http://otel-collector.region1:4318. Finally, integrate with your existing monitoring stack. Export metrics via otel.metrics.exporter=otlp and logs via OpenTelemetry Collector. In Spring Boot 3.2, use Micrometer to expose JVM metrics and correlate them with traces via trace_id in logs.
Integrating OpenTelemetry with Spring Boot Actuator and Micrometer
Spring Boot Actuator and Micrometer are your allies in building a unified observability pipeline. In Spring Boot 3.2+, Micrometer Tracing is integrated with OpenTelemetry via the micrometer-tracing-bridge-otel dependency. However, if you're using the OpenTelemetry Java agent, remove this bridge to avoid double-instrumentation. Here's the correct setup: include micrometer-registry-otlp for metrics export, and use the agent for traces. Configure Actuator to expose metrics: management.endpoints.web.exposure.include=health,metrics,prometheus. Then, use Micrometer's @Timed annotation on controller methods to capture latency histograms. For example: @Timed(value = "payment.processing", description = "Time taken to process payment", histogram = true). This generates metrics like payment.processing_seconds with percentiles. Correlate these metrics with traces by adding the trace_id as a metric tag. In your custom metrics, use MeterRegistry to create counters and timers, and add the current trace ID from Span.current().getSpanContext().getTraceId(). This enables drill-down from a metric spike to the specific trace. For logs, use Structured Logging (e.g., Logback with JSON layout) and include trace_id and span_id in each log entry. In Spring Boot 3.2, configure logging.pattern.console=%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n and add a custom converter to inject trace IDs. Finally, use the OpenTelemetry Collector to receive traces, metrics, and logs, and forward them to your observability backend (e.g., Grafana LGTM stack). This unified approach eliminates silos and provides a single pane of glass for debugging.
The Phantom Payment Timeout: How Distributed Tracing Saved Our $50K/Hour SaaS Billing Pipeline
- Never assume external services are the culprit without trace data.
- Instrument every downstream dependency—including caches and queues—in your tracing.
- Set meaningful span names and attributes; 'redis.call' is useless for debugging.
ps aux | grep opentelemetry-javaagentcurl -v http://localhost:4318/v1/traces-javaagent:opentelemetry-javaagent.jar to JAVA_OPTS| File | Command / Code | Purpose |
|---|---|---|
| OpenTelemetryConfig.java | @Configuration | Why OpenTelemetry Is the Only Observability Standard That Ma |
| application.properties | spring.sleuth.enabled=false | What the Official Docs Won't Tell You |
| ReactiveTracingConfig.java | @Configuration | Custom Context Propagation for Async and Reactive Pipelines |
| CustomSampler.java | public class CustomSampler implements Sampler { | Sampling Strategies That Won't Bankrupt Your Observability B |
| PaymentService.java | @Service | Production Debugging with OpenTelemetry and Spring Boot 3 |
| application.properties | otel.traces.exporter=otlp | Troubleshooting Trace Gaps and Missing Spans |
| application-production.properties | otel.traces.sampler=parentbased_traceidratio | Advanced OpenTelemetry Configuration for High-Volume Product |
| MetricsConfig.java | @Configuration | Integrating OpenTelemetry with Spring Boot Actuator and Micr |
Key takeaways
Interview Questions on This Topic
How does OpenTelemetry handle context propagation across asynchronous boundaries in Spring Boot?
Context which is stored in a ThreadLocal. For async operations, you must manually propagate the context using Context.makeCurrent() or use a TaskDecorator that wraps the OpenTelemetry context. Spring's @Async requires a custom TaskDecorator to prevent orphan spans. The OpenTelemetry Java agent automatically instruments some async frameworks like CompletableFuture and RxJava.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
That's Spring Boot. Mark it forged?
5 min read · try the examples if you haven't