How Micrometer Tracing and OpenTelemetry fit together
The stack has three layers, and each one has a single job. Your code, or Spring's own instrumentation, records an Observation. That is a timed operation with a name and key-value tags, such as an inbound HTTP request or a repository call. Registered handlers turn each Observation into whatever signals you need. A metrics handler records a timer. A tracing handler opens and closes a span. You instrument once and get both outputs, which means the metric name and span name for the same operation cannot drift apart.
Micrometer Tracing is a facade, not a tracer. It defines vendor-neutral types for spans and context propagation. A bridge then maps those calls onto a real tracer implementation, either OpenTelemetry or Brave. With the OpenTelemetry bridge, spans are handed to an exporter. The OTLP exporter sends them to an OpenTelemetry Collector, and the Collector can batch, filter and route them to any backend.
| Layer | Dependency | Responsibility |
|---|---|---|
| Instrumentation API | micrometer-observation (pulled in by spring-boot-starter-actuator) |
Records Observations from Spring and your code |
| Tracing facade + bridge | micrometer-tracing-bridge-otel (or -brave) |
Converts Observations into spans and propagates context |
| Export | opentelemetry-exporter-otlp |
Ships spans over OTLP to a Collector |
This setup pays off for teams running several Boot services that need traces, logs and metrics joined by the same trace ID.
The bridge approach fits Boot better than wiring the OpenTelemetry SDK by hand. Boot auto-configures the tracer, propagators, sampler and exporter from properties. Its framework instrumentation already emits Observations. With a hand-rolled SDK setup, you would own all of that bean wiring yourself and duplicate instrumentation that Spring already provides.
+------------------------------------------------------------------------+
| Spring Boot service (auto-configured from properties) |
| Spring instrumentation Your code |
| (HTTP, data access) (Observation API) |
| | | |
| +--------------+---------------+ |
| v |
| +---------------------------+ |
| | Observation (name, tags) | micrometer-observation |
| +---------------------------+ |
| | | |
| v v |
| +-------------------+ +-----------------------+ |
| | Metrics handler | | Tracing handler | micrometer- |
| | -> Timer metric | | Micrometer Tracing | tracing-bridge |
| +-------------------+ | facade + OTel bridge | -otel |
| +-----------------------+ |
| | |
| v |
| +-----------------------+ |
| | OTLP exporter | opentelemetry- |
| +-----------------------+ exporter-otlp |
+----------------------------------------|-------------------------------+
v OTLP
+-----------------------+
| OpenTelemetry |
| Collector |--> backends
| batch, filter, route |
+-----------------------+
Spring and application code record a single Observation that handlers turn into both a timer metric and a span; the span passes through the Micrometer Tracing OpenTelemetry bridge and the OTLP exporter to an OpenTelemetry Collector, which routes it to backends.
Minimal dependencies and OTLP exporter configuration
Three dependencies are enough. Add spring-boot-starter-actuator, which supplies the Observation infrastructure and tracing auto-configuration. Add micrometer-tracing-bridge-otel, which implements the Micrometer Tracing API on top of the OpenTelemetry SDK. Add opentelemetry-exporter-otlp, which sends spans over the wire. Declare all three without versions and let Spring Boot's dependency management choose them. The bridge, the SDK and the exporter must agree on internal APIs. Hand-pinned versions drift apart during upgrades, and the mismatch tends to show up as runtime linkage errors rather than compile failures.
Endpoint and transport
Point the exporter at a collector with management.otlp.tracing.endpoint. OTLP runs over HTTP/protobuf, conventionally on port 4318 at the /v1/traces path, or over gRPC, conventionally on port 4317. HTTP is the safer default: it passes through ordinary proxies and load balancers and needs no HTTP/2 support along the path. Recent Boot releases expose a transport property for choosing gRPC. Older ones only supported HTTP through auto-configuration.
Resource attributes
Boot uses spring.application.name to derive the service.name resource attribute. Set it explicitly, because backends group traces by it. Add attributes such as deployment.environment through management.opentelemetry.resource-attributes.
Export to a collector
Export to an OpenTelemetry Collector rather than directly to a vendor backend. The collector handles batching, retries, tail sampling and credential management. It also lets you change backends without redeploying services.
These property names have been renamed and regrouped between Boot releases. Check the reference documentation for your exact version before copying any of them.
# application.yaml (property names as in Spring Boot 3.x; later releases
# rename and regroup them, so check the reference docs for your version)
spring:
application:
# Becomes the service.name resource attribute; backends group traces by it
name: checkout-service
management:
tracing:
sampling:
# Default is 0.1. Sample everything here and let the collector
# decide what to keep (e.g. with tail sampling)
probability: 1.0
otlp:
tracing:
# Send to a local OpenTelemetry Collector, not straight to a vendor backend.
# OTLP over HTTP/protobuf: port 4318, path /v1/traces
endpoint:
# Recent releases accept 'grpc' here (port 4317). Older releases
# only support HTTP through auto-configuration
transport: http
opentelemetry:
# Extra resource attributes attached to every exported span
resource-attributes:
deployment.environment: staging
service.namespace: shop
Minimal application.yaml that names the service, samples all spans, and exports them over OTLP/HTTP to an OpenTelemetry Collector with extra resource attributes, using Spring Boot 3.x property names.
Sampling: what to keep and where to decide
In development, set management.tracing.sampling.probability to 1.0 so every request produces a trace you can inspect. In production, set an explicit probability rather than relying on the default, which is low and may not match your traffic. Each sampled span costs CPU and memory in the service, bandwidth to the Collector, and storage in the backend.
The decision is head-based. The first service in a request makes it, and the result travels downstream in the sampled bit of the W3C traceparent trace flags. Downstream services respect that flag instead of deciding again. This keeps traces whole: either every hop records spans or none does. Your probability setting therefore matters most at the edge services that start traces.
Illustrative estimate. Assume an edge service handles 200 requests/s and each request produces 8 spans across services. At 100% sampling that is 1,600 spans/s, roughly 138 million spans per day. At 10% it drops to about 13.8 million. Now assume 0.1% of requests fail. That is about 17,000 failed requests per day, and 10% head sampling keeps only around 1,700 of them. It discards the other 90% before anyone knows they failed.
Head sampling cannot keep errors because it decides before the outcome exists. Tail sampling waits until a trace is complete, then keeps it based on status, latency or attributes. That requires buffering all spans of a trace in one place, which is why it belongs in the OpenTelemetry Collector and not in your services. A common pattern is a generous head probability, with the Collector keeping every error and slow trace and only a fraction of the rest.
# OpenTelemetry Collector config (contrib distribution, which includes tail_sampling).
# Services send with a generous head probability, e.g. in Spring Boot:
# management.tracing.sampling.probability: 0.5
# The Collector then decides what to keep once each trace is complete.
receivers:
otlp:
protocols:
grpc:
http:
processors:
tail_sampling:
decision_wait: 10s # buffer spans this long before deciding on a trace
num_traces: 100000 # max traces held in memory while waiting
policies: # a trace is kept if ANY policy samples it
- name: keep-errors
type: status_code
status_code:
status_codes: [ERROR]
- name: keep-slow
type: latency
latency:
threshold_ms: 1000
- name: sample-the-rest
type: probabilistic
probabilistic:
sampling_percentage: 10
batch:
exporters:
otlp:
endpoint: tracing-backend.example.internal:4317
service:
pipelines:
traces:
receivers: [otlp]
processors: [tail_sampling, batch] # sample first, then batch what is kept
exporters: [otlp]
# All spans of a trace must reach the same Collector instance for this to work.
A Collector tail sampling pipeline that keeps every error trace and every trace slower than one second, plus 10% of the remaining traces, after the services have applied a generous head probability.
Propagating context across HTTP, async and reactive code
Spring Boot propagates context using the W3C Trace Context format by default. An outgoing request carries a traceparent header that holds the trace ID, parent span ID and sampling flag, and the receiving service continues the same trace. This only happens if the client is instrumented. Boot adds observation support to the auto-configured RestTemplate.Builder, RestClient.Builder and WebClient.Builder beans. A client you create with new RestTemplate() or WebClient.create() skips that customization. It still makes calls successfully, but it sends no header, and the downstream service starts a new, unrelated trace. Inject the builder and build your client from it.
Within a single process, the problem is threads. The current span lives in a ThreadLocal, and the context-propagation library captures and restores those values across thread boundaries. For thread pools and @Async, register a ContextPropagatingTaskDecorator on the executor. Boot applies a TaskDecorator bean to the executors it auto-configures. An executor you create yourself, such as a raw Executors.newFixedThreadPool, needs explicit wrapping. Scheduled tasks have no incoming request to inherit from, so each run should start a new root trace. Reactor moves work between threads freely, so ThreadLocal values are unreliable there. Enable automatic context propagation (spring.reactor.context-propagation=auto) so the library restores trace context from the Reactor Context inside operators.
None of these gaps causes an error. An unmanaged executor or an uninstrumented message broker gives you fragmented traces: orphan roots and spans that stop early. You will only notice if you look at a full request path in your tracing backend.
import io.micrometer.context.ContextExecutorService;
import io.micrometer.context.ContextSnapshotFactory;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskDecorator;
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
import org.springframework.web.client.RestClient;
@Configuration
class TracingPropagationConfig {
// Build from the Boot-provided builder so the client is observed and sends `traceparent`.
// RestClient.create() would still work, but downstream calls would start unrelated traces.
@Bean
RestClient inventoryClient(RestClient.Builder builder) {
return builder.baseUrl("").build();
}
// Boot applies this decorator to the executors it auto-configures (used by @Async),
// restoring the caller's trace context on the worker thread.
@Bean
TaskDecorator contextPropagatingTaskDecorator() {
return new ContextPropagatingTaskDecorator();
}
// An executor you create yourself is not touched by Boot: wrap it explicitly,
// otherwise tasks run without a parent span and show up as orphan roots.
@Bean(destroyMethod = "shutdown")
ExecutorService reportExecutor() {
ContextSnapshotFactory snapshots = ContextSnapshotFactory.builder().build();
return ContextExecutorService.wrap(
Executors.newFixedThreadPool(4),
snapshots::captureAll);
}
}
// application.properties (Reactor): restore trace context inside operators
// spring.reactor.context-propagation=auto
An instrumented RestClient built from the injected builder, a ContextPropagatingTaskDecorator for Boot-managed and @Async executors, an explicitly wrapped self-created thread pool, and the Reactor property that enables automatic context propagation.
Log correlation and custom observations
Micrometer Tracing writes the current trace and span IDs into the logging MDC under the keys traceId and spanId, so any log statement made inside a traced request can carry them. Recent Spring Boot versions add these to the default console pattern automatically. If you use a custom pattern, add %X{traceId:-} %X{spanId:-} yourself. The :- default keeps lines emitted outside a span readable instead of printing null. To jump from a log line to its trace, search for that traceId in your tracing backend. Most log UIs can also turn the field into a clickable link if you define a URL template for it.
Custom observations
For business operations that no instrumentation covers, use the Observation API. Observation.createNotStarted(name, registry) wrapped around observe(...) produces both a timer metric and a span from a single call. The @Observed annotation does the same declaratively. It only works with the Spring Boot AOP starter on the classpath and an ObservedAspect bean constructed with the ObservationRegistry. Without both, the annotation is silently ignored.
Cardinality
lowCardinalityKeyValue values become metric tags and span attributes. highCardinalityKeyValue values go only on spans. Every distinct combination of tag values creates a separate time series that the meter registry holds in memory and your metrics backend stores and indexes. If you put a user ID or a full URL with path parameters into a low-cardinality key, the series count grows with your traffic. Heap usage climbs, scrapes slow down, and backend costs or limits are hit. Keep low-cardinality values to small, bounded sets such as outcome, HTTP method, or URI template. Put unbounded values in high-cardinality keys.
Verifying the setup locally and migrating from Spring Cloud Sleuth
Run the loop locally
Start an OpenTelemetry Collector and an OTLP-capable trace viewer in containers. Point both services' OTLP exporters at the Collector, and set sampling to 1.0 for the test. Send one request to service A that calls service B. Open the trace in the viewer and confirm three things: one trace ID, B's server span parented to A's client span, and both service names present. If you see two separate traces, propagation broke at the boundary.
Fragmented-trace checklist:
- The outbound client was built with the auto-configured builder (
RestTemplateBuilder,RestClient.Builder,WebClient.Builder), notnew. - Both services use the same propagation format (W3C vs. B3).
- Async executors and reactive chains carry context, as described in section 4.
- No proxy or gateway strips
traceparentorb3headers.
Migrating from Spring Cloud Sleuth
| Sleuth | Micrometer Tracing |
|---|---|
spring.sleuth.sampler.probability |
management.tracing.sampling.probability |
spring.sleuth.propagation.type |
management.tracing.propagation.type |
spring.sleuth.baggage.remote-fields |
management.tracing.baggage.remote-fields |
| Default propagation: B3 | Default propagation: W3C |
Spans, trace IDs, baggage, and MDC correlation map directly. Sleuth's own Tracer interfaces, its span customizers, and Brave-specific handlers live in different packages and use different extension points, so code that uses them needs rework. Also watch the propagation default. If you migrate one service while its peers still emit B3, traces split silently.
That silent split is the case for the local loop. A two-container check exposes a wrong builder or a header mismatch within minutes. In production, the same fault only shows up as missing traces during an incident.
Alternatives, trade-offs and when to avoid this approach
The bridge is not the only way to get traces out of a Boot service. The main alternative is the OpenTelemetry Java agent, attached at JVM startup.
| Aspect | Micrometer Tracing + OTel bridge | OpenTelemetry Java agent |
|---|---|---|
| Instrumentation | Whatever Boot and your observations cover | Broad zero-code coverage of common libraries |
| Configuration | Spring properties and beans | System properties or environment variables |
| Startup cost | Low | Higher, because classes are transformed at load |
| Debuggability | Plain application code | Bytecode manipulation you cannot step through |
Running both produces duplicate spans for the same HTTP call, often in parallel traces. Pick one per service. Brave is the other bridge, and it only makes sense if you already run a Zipkin-shaped pipeline.
Exporting straight to a vendor removes a hop, but it couples every service to that vendor's endpoint and credentials. A Collector adds a component you must operate. In return it centralizes batching, retries and tail sampling, and tail sampling is impossible inside a single service because no service sees the whole trace.
Budget for failure modes:
- Span loss: an abrupt shutdown or a Collector outage loses whatever spans are still buffered.
- Backpressure: a full export queue drops spans to protect the app, so you should alert on dropped-span counts.
- Hot-path overhead: measure span creation on your own hot paths under load rather than assuming it is negligible.
Skip this setup for a single service that only needs metrics, where Micrometer alone is enough. Also skip it if your organization has already standardized on the agent.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to spring boot micrometer tracing, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.