How the Observation API, Micrometer Tracing and tracer bridges fit together

This section is for backend and platform engineers running multi-service Spring Boot systems who need traces, metrics and logs that point at the same request. The problem it solves is correlation: a slow endpoint shows up on a latency dashboard, and you want to jump straight to the trace and the log lines for that exact call.

One observation, many handlers

The Observation API is the instrumentation primitive. You create an Observation with a name and key-value tags, start it, and stop it. The observation itself records nothing. Handlers registered on the ObservationRegistry react to its lifecycle events:

  • A metrics handler turns the observation into a timer.
  • A tracing handler turns the same observation into a span.

You instrument once and get both signals, with consistent names and tags. Low-cardinality tags go to metrics and spans. High-cardinality tags go only to spans, which keeps your metrics backend from being flooded with unique series.

Micrometer Tracing as a facade

Micrometer Tracing defines Tracer, Span and propagation interfaces. A bridge implements them on top of a real tracer, either OpenTelemetry or Brave. Your code depends only on Micrometer types, so the bridge is a dependency choice, not a rewrite.

The request path

  1. An inbound HTTP request is observed by Spring's server instrumentation. The bridge extracts any incoming trace context.
  2. Your custom observations become child spans and timers.
  3. Trace and span IDs are placed in the logging context, so log lines carry them.
  4. Outbound clients propagate the context to the next service.
  5. Finished spans go to an exporter, usually OTLP, which ships them to a collector for processing and routing.
 Inbound HTTP request (incoming trace context)
                 |
                 v
+--------------------------------------------------------------+
| Spring Boot service                                          |
|  [Server HTTP instrumentation] --> [Custom Observation]      |
|                |                            |                |
|                v                            v                |
|  +--------------------------------------------------------+  |
|  |                  ObservationRegistry                   |  |
|  +--------------------------------------------------------+  |
|          |                                  |                |
|          v                                  v                |
|  +-----------------+               +--------------------+    |
|  | Metrics handler |               | Tracing handler    |    |
|  | -> Timer        |               | -> Span            |    |
|  +-----------------+               +--------------------+    |
|          |                                   |               |
|          v                                   v               |
|  [MeterRegistry]                [Micrometer Tracing facade]  |
|                                      |               |       |
|                                      v               v       |
|                          [OTel or Brave bridge]  [Log MDC]   |
|                                     |                        |
+-------------------------------------|------------------------+
           [Outbound HTTP clients] <--+--> [OTLP exporter]
                      |                           |
                      v                           v
               [Next service]                [Collector]

One observation feeds a metrics handler and a tracing handler; spans flow through the Micrometer Tracing facade to an OpenTelemetry or Brave bridge, which propagates context outbound, puts IDs in logs, and exports via OTLP.

Choosing a bridge and what Spring Boot instruments for you

Micrometer Tracing needs exactly one bridge on the classpath. The bridge decides which tracer records spans and which exporters fit naturally.

OpenTelemetry bridge Brave bridge
Underlying tracer OpenTelemetry SDK Brave (from the Zipkin project)
Natural exporter OTLP Zipkin
Strength Aligns with the wider OpenTelemetry ecosystem and collectors Mature, long-proven in Zipkin-based setups
Typical choice New platforms standardising on OTLP Existing Zipkin estates

Switching bridges is mostly a dependency swap plus configuration, because your code talks to the Observation API, not the tracer. The part that breaks is propagation. If one service sends W3C traceparent headers and the next expects B3, the receiver starts a new trace, and you see two disconnected traces where you expected one. Set the propagation type explicitly, for example with management.tracing.propagation.type, and keep it the same across every service.

There are two alternatives to Micrometer Tracing. You can run the OpenTelemetry Java agent on its own, or call the native OpenTelemetry API directly. Don't combine the agent with Micrometer Tracing in the same service. Both instrument the same HTTP calls, which produces duplicate spans or parallel, disconnected traces. Each service needs a single instrumentation owner.

Once you add a bridge and an exporter, Spring Boot instruments two things:

  • Inbound requests: Spring MVC and WebFlux server requests get spans.
  • Outbound calls: clients built from the auto-configured RestTemplateBuilder, RestClient.Builder or WebClient.Builder get spans and propagation headers.

The word "built" matters. A client created with new RestTemplate() never passes through Boot's customisers. It still works, but it sends no trace headers, so the downstream service starts a fresh trace and you get no error to tell you. The example below shows the fix: inject the builder and call build().

import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

// BEFORE: works, but is invisible to tracing.
// 'new RestTemplate()' bypasses Boot's customisers, so no client span
// is recorded and no traceparent/B3 header is sent. The downstream
// service starts a fresh trace and the call appears disconnected.
@Service
class InventoryClientBefore {
    private final RestTemplate restTemplate = new RestTemplate();

    String stockFor(String sku) {
        return restTemplate.getForObject(
            "", String.class, sku);
    }
}

// AFTER: build from the auto-configured RestTemplateBuilder.
// Boot has already applied its observation customiser to this builder,
// so every call gets a client span and propagation headers.
@Service
class InventoryClientAfter {
    private final RestTemplate restTemplate;

    InventoryClientAfter(RestTemplateBuilder builder) {
        // Customise further if needed; keep starting from the injected builder.
        this.restTemplate = builder.build();
    }

    String stockFor(String sku) {
        return restTemplate.getForObject(
            "", String.class, sku);
    }
}

A RestTemplate created with 'new' sends requests without trace context. A RestTemplate built from the injected RestTemplateBuilder records a client span and propagates the trace to the downstream service.

Writing custom instrumentation with the Observation API

Most of the time you don't need the full lifecycle. Observation.createNotStarted(name, registry) builds an observation. observe(...) then starts it, opens a scope, runs your code, records any thrown exception and stops it. When the work spans callbacks, you drive it manually:

  • start() begins timing.
  • openScope() makes the observation current, so log correlation and child spans attach to it. Close the scope in a finally.
  • error(throwable) attaches the failure.
  • event(...) marks a point in time, such as a retry.
  • stop() ends it. Forget stop() and the timer and span never complete.

Key values are where most mistakes happen. Low-cardinality key values become metric tags and span attributes. High-cardinality key values become span attributes only. Each distinct tag combination is a separate metric series.

Suppose you put user.id in a low-cardinality key on a timer, with 50,000 active users (illustrative estimate). The timer then produces up to 50,000 series per combination of the other tags, before you count percentile histogram buckets. That inflates registry memory and your metrics backend bill. Put IDs, order numbers and URLs with path variables in high-cardinality keys. Keep low-cardinality keys to bounded sets such as outcome, operation type or region.

@Observed wraps a method in an observation when an ObservedAspect bean is registered. It relies on Spring AOP proxies, so self-invocation is not intercepted. It suits coarse service methods where the method name is a good observation name. Use the programmatic API when you need conditional key values or events.

Instrument boundaries and meaningful operations, such as an outbound call, a queue consume or a pricing calculation, rather than every method. Overhead, export volume and trace noise all grow with span count.

import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;

public class PricingService {
    private final ObservationRegistry registry;

    public PricingService(ObservationRegistry registry) { this.registry = registry; }

    public Quote price(String userId, String orderId, String region) {
        Observation obs = Observation.createNotStarted("pricing.calculate", registry)
            // Bounded set: becomes a metric tag and a span attribute
            .lowCardinalityKeyValue("region", region)
            // Unbounded IDs: span attributes only, so no extra metric series
            .highCardinalityKeyValue("user.id", userId)
            .highCardinalityKeyValue("order.id", orderId);

        obs.start(); // begins timing
        // The scope makes obs current, so log correlation and child spans attach to it
        try (Observation.Scope scope = obs.openScope()) {
            try {
                return calculate(orderId);
            } catch (TransientPricingException e) {
                obs.event(Observation.Event.of("pricing.retry")); // point-in-time marker
                return calculate(orderId);
            }
        } catch (RuntimeException e) {
            obs.error(e); // recorded on the span and as the error tag on the timer
            throw e;
        } finally {
            obs.stop(); // without this the timer and span never complete
        }
    }

    private Quote calculate(String orderId) { /* pricing logic */ return new Quote(); }

    static class Quote {}
    static class TransientPricingException extends RuntimeException {}
}

A manually driven observation around a pricing calculation that keeps region as a low-cardinality key and user and order IDs as high-cardinality keys, records a retry event and errors, and always closes the scope and stops the observation.

Keeping context intact across threads, async executors and reactive code

A span becomes a child only if the current observation is visible when the new one starts. That "current" value lives in thread-bound state, held in ThreadLocals managed by the tracer and the Observation registry. As long as a request stays on one thread, parent/child relationships form without any effort. Once work moves to another thread, the new thread starts with empty state. The span it creates becomes a new root, and the trace splits in two.

Context gets lost in a few predictable places:

  • Thread pools and CompletableFuture: a task submitted to a plain ExecutorService runs without the caller's context.
  • @Async and @Scheduled methods: scheduled tasks have no parent by design. @Async work is only connected if its executor propagates context.
  • Reactive pipelines: operators can switch threads at any point, so ThreadLocal state is unreliable unless it is restored from the Reactor Context.

The context-propagation library addresses the first two cases. ContextSnapshot captures the current values, and ContextExecutorService.wrap(...) restores them inside every task the wrapped executor runs. For Spring-managed executors, apply a context-propagating task decorator so that @Async work inherits the caller's context. For Reactor, Hooks.enableAutomaticContextPropagation() restores ThreadLocals from the Reactor Context at operator boundaries.

Messaging needs separate checks. Whether trace headers are written and read depends on the specific broker integration and how it is configured, so confirm it per broker with a real end-to-end trace.

Most broken traces come from lost context, not from exporter faults. Auto-instrumentation covers framework entry and exit points. It does not cover the thread hops your own code introduces.

import io.micrometer.context.ContextExecutorService;
import io.micrometer.context.ContextSnapshotFactory;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.core.task.TaskDecorator;
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
import org.springframework.scheduling.annotation.EnableAsync;
import reactor.core.publisher.Hooks;

@SpringBootApplication
@EnableAsync
public class OrdersApplication {

    public static void main(String[] args) {
        // Reactor: restore ThreadLocals (current observation/span) from the
        // Reactor Context whenever an operator hops threads.
        Hooks.enableAutomaticContextPropagation();
        SpringApplication.run(OrdersApplication.class, args);
    }

    // @Async: Boot applies a TaskDecorator bean to its auto-configured executor,
    // so each task restores the caller's context instead of starting a new root span.
    @Bean
    TaskDecorator contextPropagatingTaskDecorator() {
        return new ContextPropagatingTaskDecorator();
    }

    // Plain thread pool used for CompletableFuture.supplyAsync(..., pricingExecutor).
    // Each submitted task gets a snapshot captured on the submitting thread.
    @Bean(destroyMethod = "shutdown")
    ExecutorService pricingExecutor() {
        ContextSnapshotFactory snapshots = ContextSnapshotFactory.builder().build();
        return ContextExecutorService.wrap(
                Executors.newFixedThreadPool(4),
                snapshots::captureAll);
    }
}

Shows the three common fixes for lost trace context: a context-propagating TaskDecorator for @Async, a ContextExecutorService-wrapped thread pool for CompletableFuture work, and Reactor's automatic context propagation hook.

Sampling and trace baggage: deciding what is kept and what crosses boundaries

Set management.tracing.sampling.probability to a value between 0.0 and 1.0. The decision is made once, at the root span, and travels downstream in the propagation headers. Downstream services follow it instead of deciding again, so a trace is either complete or absent. An unsampled observation still runs its handlers, which means timers and counters from the same observation are recorded on every request. Sampling reduces trace volume but does not change your metrics.

This makes low head-based rates cheap but blind. At a low rate, most rare slow or failed requests are dropped before anyone knows they matter. Tail-based sampling in a collector waits until a trace is complete and keeps it based on its outcome. The cost is extra infrastructure and memory to buffer every span until that decision.

Span attribute Trace baggage
Scope One span Every downstream hop
Sent over the wire Only to the exporter On every outgoing request
Typical use Describing work Correlation keys such as a tenant ID

Baggage is key-value context that propagates with the trace. Declare allowed keys in management.tracing.baggage.remote-fields, because undeclared keys are not propagated. List the keys that should appear in log lines in management.tracing.baggage.correlation.fields to copy them into MDC. Set a value with tracer.createBaggageInScope(key, value) and close the scope when the work ends.

Keep baggage small. Every key adds header overhead to every downstream call. It can also reach third-party APIs you call, so never put sensitive data in it. Do not use baggage as a general-purpose context bag.

Exporting with the OTLP exporter through a collector

Point the exporter at a collector rather than at your tracing backend. With the OpenTelemetry bridge and the OTLP exporter on the classpath, set management.otlp.tracing.endpoint to the collector's OTLP/HTTP traces endpoint, such as port 4318 on localhost for a sidecar, with the /v1/traces path. Property names have moved between Spring Boot releases, so confirm the exact key against the reference documentation for your version.

Why not export straight to the backend? Direct export ties your application to the backend's health. A slow or unreachable backend fills the exporter's queue, uses memory and threads in your process, and drops spans in ways that are hard to see. A local or sidecar collector accepts spans quickly over loopback and handles batching, retries and backoff itself. It can also filter or redact attributes and apply tail-based sampling, which needs complete traces that no single service has. You can change these policies without redeploying applications.

The cost is one more component to deploy, size, monitor and upgrade. If the collector fails, spans are lost, so give it health checks and alerts like any other dependency.

For log correlation, Spring Boot's default logging pattern includes a correlation section that adds the trace and span IDs to each log line when a tracer is active. Customize it with logging.pattern.correlation instead of hand-writing MDC lookups.

To verify end to end:

  1. Send one request to service A, which calls service B.
  2. Copy the trace ID from A's log line.
  3. Find the same ID in B's logs.
  4. Open that trace in the backend and confirm that both services' spans share one tree.

Testing, troubleshooting and the impact on cost and engineering time

spans appear under one trace. When that check fails, the cause is usually in this table:

Symptom Likely cause Check
No trace IDs in logs No tracer bridge on the classpath Confirm exactly one bridge dependency
B starts a new trace Header not propagated, or A and B use different formats (W3C vs B3) Align the propagation type on both sides
Async work loses the parent Executor not wrapped with context propagation Wrap the executor or use a context-aware one
Some requests never appear Sampled out Raise probability temporarily
Duplicate spans per call Two instrumentation owners, such as an agent plus the bridge Remove one

Debugging in a live environment should be the last step. In unit tests, use TestObservationRegistry from Micrometer's observation test module and assert names, low-cardinality keys and completion. This catches renamed observations without a backend.

The costs are dependency and configuration work, running a collector, and per-span CPU, allocation and network overhead. Metric cardinality is the largest risk. High-cardinality values belong in traces, not in metric tags.

Illustrative estimate (assumed numbers, not a benchmark): spans/day = requests/s × sampling probability × spans per trace × 86,400. At 200 req/s, 0.1 probability and 6 spans per trace, that is 120 spans/s, or about 10.4 million spans/day.

The time savings come from three sources:

  • One instrumentation point produces both metrics and traces.
  • Correlated logs shorten cross-service debugging.
  • The tracer can be swapped without rewriting instrumentation.

Adopt this approach when you run several Spring services. Avoid it for a single small service where metrics and logs are enough. Also avoid it when your team already relies fully on the OpenTelemetry 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 .env by 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.