skip to content

How does Micrometer Tracing hook into the Observation API so Spring MVC/WebClient calls get spans automatically, and how does context propagate to a downstream service?

level: seniorimportance: should knowfreq 42%

answer

  1. Observation = one front door; tracing = a handler set
  2. Default/Sender/Receiver TracingObservationHandler
  3. sender injects, receiver extracts trace headers
  4. ServerHttpObservationFilter → server span
  5. span in scope → MDC traceId/spanId for logs

basics

~20 s

Spring instruments HTTP and other work as Observations. Tracing registers ObservationHandlers that turn each Observation into a span. For outbound calls a propagating handler injects trace headers; on the server side another handler extracts them, so the downstream span joins the same trace.

solid answer

~40 s

Micrometer Observation is the single instrumentation API; tracing is one handler behind it. When tracing is on the classpath, Boot registers DefaultTracingObservationHandler plus PropagatingSenderTracingObservationHandler and PropagatingReceiverTracingObservationHandler on the ObservationRegistry. Every Observation Spring already emits — server request via ServerHttpObservationFilter, RestClient/WebClient client requests, @Observed methods, @Scheduled tasks — is converted to a span with matching timing, tags (from KeyValues), and errors. The sender handler injects propagation headers (W3C traceparent or B3) into outgoing requests; the receiver handler extracts them from inbound requests and continues the same trace. That's why cross-service traces stitch without manual code. You rarely touch the Tracer directly — the Observation lifecycle drives span start/scope/stop, so correlation ids also land in MDC for logs.

code

java · 19 lines
java
// You instrument ONCE via Observation; tracing handler makes the span.
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;

@Service
class ReportService {
    private final ObservationRegistry registry;
    ReportService(ObservationRegistry registry) { this.registry = registry; }

    Report build(String id) {
        return Observation.createNotStarted("report.build", registry)
            .lowCardinalityKeyValue("type", "pdf")   // becomes a span tag + metric tag
            .observe(() -> compute(id));                // span start/scope/stop handled for you
    }
}

// Outbound call auto-propagates headers when using the observation-enabled builder:
// WebClient client = webClientBuilder.baseUrl("http://svc-b").build();
// The PropagatingSenderTracingObservationHandler injects traceparent automatically.

go deeper

for a junior

Know Spring auto-creates spans for HTTP requests via the Observation integration.

for a middle

Name the three tracing observation handlers and the inject/extract roles.

for a senior

Explain end-to-end propagation across services and MDC log correlation as a scope side effect.

for a principal

Govern propagation-format consistency, sampling propagation, and single-instrumentation strategy across services.

## The layering: Observation is the front door Micrometer defines **Observation** as the *single* instrumentation abstraction: you observe a piece of work once, and multiple concerns react to it. An `ObservationRegistry` holds a list of **`ObservationHandler`s**; each observation's start/stop/error/scope events are dispatched to every handler that supports its context. - **Metrics** are one handler (`DefaultMeterObservationHandler`) → produces timers/counters. - **Tracing** is another set of handlers → produces spans. So tracing is *not* a parallel instrumentation path; it's a **consumer of Observations**. Instrument once, get both metrics and traces. ## The tracing handlers Boot registers With a bridge + Actuator on the classpath, `ObservationAutoConfiguration` / tracing auto-config add: - **`DefaultTracingObservationHandler`** — for a generic Observation: on start it creates and scopes a span; on stop it ends it; it copies the Observation's low-cardinality `KeyValues` onto the span as tags and records errors. This is what makes `@Observed` methods and scheduled tasks traced. - **`PropagatingSenderTracingObservationHandler`** — for **client/sender** observations (outgoing HTTP). It creates a *client* span and **injects** the current trace context into the outbound carrier (request headers) using the configured propagator. - **`PropagatingReceiverTracingObservationHandler`** — for **server/receiver** observations (incoming HTTP). It **extracts** trace context from inbound headers and creates a *server* span that continues the remote trace (child of the caller's span). ## Where the observations come from (you get them for free) Spring Boot auto-instruments common work as Observations: - **Inbound HTTP (MVC)**: `ServerHttpObservationFilter` observes each request → receiver handler extracts headers → server span. - **Inbound HTTP (WebFlux)**: analogous WebFilter-based instrumentation. - **Outbound HTTP**: `RestClient`, `RestTemplate` (with observation config), and `WebClient` emit client observations → sender handler injects headers → client span. - **`@Observed`** methods (needs `ObservedAspect` bean) → generic span. - **`@Scheduled`** tasks, and other integrations (JDBC/R2DBC via extra libs, messaging) as their instrumentation ships observations. ## The propagation flow across two services 1. Service A handles a request → receiver handler creates server span **S1** (trace T). 2. A calls Service B via `WebClient` → sender handler creates client span **S2** (child of S1) and **injects** `traceparent: 00-<traceId T>-<spanId S2>-01` into the outgoing headers. 3. Service B's `ServerHttpObservationFilter` observes the inbound request → receiver handler **extracts** those headers → creates server span **S3** as a child of S2, **same trace T**. 4. Both services export their spans; the backend (Zipkin/Tempo) assembles one trace across A and B. The header format (`traceparent` for W3C vs `X-B3-*` for B3) is decided by the bridge default and `management.tracing.propagation.type`. Both ends must agree. ## Log correlation as a side effect Because the span is put **in scope** during the Observation, the tracing bridge also populates the **MDC** with `traceId`/`spanId` (Boot's default logging pattern prints `[appName,traceId,spanId]`). So logs are automatically correlated to traces without extra code. ## Sampling interaction The **sampling decision** (from `management.tracing.sampling.probability`) is made at trace start and encoded in the propagated `traceparent` sampled flag. Downstream services **honor** the upstream decision (respecting parent-based sampling), so an entire trace is consistently sampled or dropped end-to-end. Unsampled traces still propagate ids (for log correlation) but export nothing. ## Gotchas - `@Observed` needs an `ObservedAspect` bean (Boot registers it when AOP is present) — otherwise the annotation is a no-op. - If a downstream service uses a **different propagation format**, extraction fails and it starts a *new* trace → broken linkage. - Manual `Tracer` spans coexist with Observation spans, but mixing styles carelessly can double-instrument. - WebClient must use the observation-enabled builder (Boot's auto-configured `WebClient.Builder`) for client spans to appear.

  • Which handler injects trace headers into an outbound WebClient call, and which continues the trace on the server?
    PropagatingSenderTracingObservationHandler injects context into the outgoing request (client span). On the receiving service, PropagatingReceiverTracingObservationHandler extracts those headers and creates the server span as a child, continuing the same trace.
  • Why do trace/span ids appear in your logs without you writing MDC code?
    The Observation puts the span in scope, and the tracing bridge syncs trace/span ids into SLF4J's MDC. Boot's default logging pattern includes [app,traceId,spanId], so every log line during the span is correlated.

saying these in an interview costs you the question

  • Thinking metrics and tracing are separate instrumentation you must write twice
  • Believing spans appear for @Observed without an ObservedAspect bean
  • Assuming context propagates even when the two services use different header formats
  • Expecting client spans from a plain new WebClient not built from the observation-enabled builder

context