Observability & Tracing · OpenTelemetry course

Lesson 03

The Spring Boot 3.x tracing stack, layer by layer

This is the lesson that dissects “a lot of setup” from your meeting — core tracing, then Spring, then Micrometer. There are five layers and three dependencies, and each owns exactly one job. Once you can name the owner of every line, the config stops being magic.

The one win

By the end you can read a Boot 3.x build.gradle and application.yml and say, for every tracing line, which layer owns it — Observation, Micrometer Tracing, the bridge, the OTel SDK, or Actuator.

Start with the boundary, not the layers

Predict first

A span is created in your service and ends up in Jaeger. Does it pass through Actuator on the way? Yes or no — commit before reading on.

No. And that single answer is worth more than the rest of the lesson, because almost everyone assumes otherwise. Actuator is not a stage in the pipeline. It is the thing that builds the pipeline at startup and then steps out of the data path. Your telemetry never travels through it.

There are two entirely different axes here, and confusing them is the root of most Spring tracing confusion:

DATA PATH (runtime — what a request actually travels through) your code / Spring MVC / WebClient / @Scheduled │ creates ▼ Observation ──────► ObservationRegistry │ notifies ▼ tracing ObservationHandler │ calls ▼ Tracer (vendor-neutral facade) │ implemented by ▼ ═══ micrometer-tracing-bridge-otel ═══ ◄── the crossing │ ▼ OpenTelemetry SDK sample · batch · service.name │ ▼ opentelemetry-exporter-otlp ──OTLP──► Collector / Jaeger WIRING (startup — happens once, then it is over) spring-boot-starter-actuator └─ auto-configuration reads management.* └─ constructs every box above, as beans

Actuator is the vertical arrow in the second block. It is never a box in the first.

What is Actuator's role in getting a span to Jaeger?

Words this lesson assumes

Four terms get used loosely in every tracing tutorial you will read. Pinning them down now saves re-reading later.

Facade — a neutral interface in front of several possible implementations, so your code depends on the interface and never the implementation (SLF4J is the one you already know). Bridge — the adapter that implements a facade on top of one specific library. Handler — a callback object registered on a registry, invoked on lifecycle events (start / stop / error). MDC — SLF4J's per-thread map of key/values that your log pattern can print; how a trace ID gets into a log line.

Layer 1 — the Observation API: instrument once

Your code does not create spans. It creates Observations. The Micrometer Observation API exists for one stated reason: “Instrument code once, and get multiple benefits out of it.”[micrometer] One Observation can become a timer, a span, a log — or all three — depending on what is listening.

Spring Boot's own words: “For metrics and traces, Spring Boot uses Micrometer Observation.”[boot] And you mostly do not write them by hand — Spring MVC controllers and Spring Data repositories are already instrumented for you.

┌──► TimerObservationHandler ──► a metric Observation │ (one) ├──► TracingObservationHandler ──► a SPAN ◄── our path │ └──► your own handler ──────────► anything ONE instrumentation point. THREE outcomes. That is the whole idea.

Layer 2 — the registry and its handlers

An Observation is reported to the ObservationRegistry. Registered ObservationHandlers are notified as it starts, stops or errors. Boot wires this for you: “Beans of type ObservationPredicate, GlobalObservationConvention, ObservationFilter and ObservationHandler will be automatically registered on the ObservationRegistry.”[boot]

Micrometer Tracing contributes the tracing handler. That handler is the moment an Observation becomes a span — “Whenever an Observation is used, a corresponding span is created, started, stopped and reported.”[tracing]

Layer 3 — the Tracer facade

The handler does not call OpenTelemetry. It calls Tracer, Micrometer Tracing's vendor-neutral facade — “a simple facade for the most popular tracer libraries.”[tracing] Exactly the SLF4J pattern, applied to tracing instead of logging.

This layer is why your application code contains no OpenTelemetry imports at all.

Layer 4 — the bridge: the actual crossing

Something has to implement Tracer using a real tracing library. That is the bridge, and it is the single point where your application crosses into OpenTelemetry:

Tracer (facade — one interface) │ ┌────────────┴────────────┐ ▼ ▼ micrometer-tracing- micrometer-tracing- bridge-otel bridge-brave │ │ ▼ ▼ OpenTelemetry Brave / Zipkin ◄── YOURS ──► Swap the bridge artifact and EVERYTHING ABOVE stays identical. Not one line of your application code changes.

Which artifact is the crossing point into OpenTelemetry?

Inside the bridge — what “bridge” actually means

Predict first

The bridge has to make OpenTelemetry look like Micrometer's Tracer. If you had to write that yourself, how many classes would you need — one clever one, or one per interface? Commit before reading on.

One per interface. The bridge is a textbook adapter: for every type in Micrometer's tracing API there is an Otel-prefixed class that implements it by wrapping the equivalent OpenTelemetry object. Here is the real package, io.micrometer.tracing.otel.bridge:[source]

MICROMETER TRACING API THE BRIDGE WRAPS (OTel) io.micrometer.tracing …tracing.otel.bridge io.opentelemetry ────────────────────── ────────────────── ──────────────── Tracer ◄──────── OtelTracer ──────► api.trace.Tracer Span ◄──────── OtelSpan ──────► api.trace.Span Span.Builder ◄──────── OtelSpanBuilder ──────► SpanBuilder TraceContext ◄──────── OtelTraceContext ──────► SpanContext CurrentTraceContext ◄──────── OtelCurrentTraceContext ─► context.Context ScopedSpan ◄──────── OtelScopedSpan ──────► Span + Scope BaggageManager ◄──────── OtelBaggageManager ──────► api.baggage.Baggage BaggageInScope ◄──────── OtelBaggageInScope ──────► Baggage + Scope SpanCustomizer ◄──────── OtelSpanCustomizer ──────► Span (propagation) ◄──────── OtelPropagator ──────► TextMapPropagator LEFT = what your code and Spring's handlers talk to (stable) RIGHT = OpenTelemetry (swappable) MIDDLE = ~23 classes whose whole job is translation

Nothing clever is happening. Each class holds a reference to the OTel object and forwards to it. Straight from OtelTracer.java:

public class OtelTracer implements Tracer { ◄── Micrometer's interface private final io.opentelemetry.api.trace.Tracer tracer; ◄── the OTel object … io.opentelemetry.api.trace.Span span = this.tracer.spanBuilder(name).startSpan(); ◄── the real OTel call … }

That is the entire trick. Micrometer's interface on the outside, OpenTelemetry's object on the inside, one delegating call between them. [OtelTracer.java]

Follow one span all the way through

Concretely, when Spring MVC handles a request:

Spring MVC instrumentation │ Observation.start() ▼ ObservationRegistry ──notifies──► DefaultTracingObservationHandler │ "make me a span" ▼ Tracer ◄── interface (micrometer) │ ▼ OtelTracer ◄── bridge class │ this.tracer.spanBuilder(…) ▼ .startSpan() io.opentelemetry.api.trace.Tracer │ ▼ OTel SDK ──► exporter ──► OTLP Everything above the bridge line names only MICROMETER types. Everything below names only OPENTELEMETRY types.

Why there are three tracing handlers, not one

Micrometer Tracing ships a small family of handlers, and which one runs decides the span's kind and whether context propagation happens:[handler package]

DefaultTracingObservationHandler plain in-process work ─────────────────► INTERNAL span PropagatingSenderTracingObservationHandler you are CALLING someone ───────────────► CLIENT span + INJECTS traceparent / baggage into the outgoing headers PropagatingReceiverTracingObservationHandler you were CALLED ───────────────────────► SERVER span + EXTRACTS traceparent / baggage from the incoming headers TracingAwareMeterObservationHandler makes the metrics handler tracing-aware (exemplars)

So the inject/extract machinery from Lesson 2 is not somewhere else in the stack — it is these two handlers, calling through the same bridge into OtelPropagator. That is why one dependency swap changes both how spans are made and how headers are written.

The bridge does more than spans

Two classes in that package pay off things you have already met:

How does OtelTracer make OpenTelemetry look like Micrometer?

Now the swap claim is concrete rather than a slogan. Changing to micrometer-tracing-bridge-brave substitutes a parallel package of Brave-prefixed adapters implementing those same interfaces. Your code, Spring's instrumentation and the handlers all still talk to Tracer, Span and TraceContext — so nothing above the bridge notices.

Layer 5 — the OTel SDK and the exporter

Past the bridge you are in the territory from Lesson 1. The SDK applies the sampling decision, batches spans, and stamps them with the Resource — including service.name. The exporter serialises to OTLP and puts it on the wire, over HTTP :4318 or gRPC :4317.

The three dependencies, and what each buys

implementation 'org.springframework.boot:spring-boot-starter-actuator' └─ THE WIRING. Auto-configuration for everything below. "All tracer implementations need the spring-boot-starter-actuator dependency." Without it, nothing auto-configures at all. implementation 'io.micrometer:micrometer-tracing-bridge-otel' └─ THE CROSSING. Implements Tracer on top of OpenTelemetry. Pulls in micrometer-tracing transitively. implementation 'io.opentelemetry:opentelemetry-exporter-otlp' └─ THE WIRE. Serialises spans to OTLP and ships them.

Boot's docs: “Spring Boot Actuator provides dependency management and auto-configuration for Micrometer Tracing.” [src]

Three artifacts, three jobs: wire it, cross into OTel, put it on the wire. Adding the bridge alone does nothing — no auto-configuration runs without Actuator. That is your meeting's “a lot of setup,” and it is genuinely only three lines.

The config, and the two traps

management: tracing: sampling: probability: 1.0 # ⚠ default is 0.1 otlp: tracing: endpoint: http://localhost:4318/v1/traces # ⚠ NO default since 3.2 transport: http # default http (not grpc)

Both warnings cost people days:

Note also that transport defaults to http, so Actuator wires an HTTP exporter aimed at :4318. A config pointing at :4317 while transport is still http is a misconfiguration worth catching in review.

Other defaults you now own: management.tracing.enabled = true; batching at max-queue-size 2048, max-batch-size 512, schedule-delay 5s; and propagation.consume = [W3C, B3, B3_MULTI] — the traceparent header from Lesson 2.[boot props]

Traces worked on Boot 3.1, stopped on 3.3, config unchanged. Why?

The other route — the Java agent

Everything above is the library route: Spring produces the telemetry, in-process, from dependencies you declared. There is a second, completely different way to instrument the same app, and you should be able to recognise it on sight.

LIBRARY ROUTE (this lesson) JAVA AGENT ROUTE ─────────────────────────── ───────────────────────────── add 3 dependencies add a JVM flag: configure management.* -javaagent:opentelemetry- Actuator wires the beans javaagent.jar SPRING produces the spans configure OTEL_* env vars controlled in your code the agent wires ITSELF BYTECODE produces the spans controlled outside the app Both end at the same OTLP export. They are ALTERNATIVES, not layers.

The agent “dynamically injects bytecode to capture telemetry from many popular libraries and frameworks” at class-load time, covering “inbound requests, outbound HTTP calls, database calls, and so on” — with no code or dependency changes at all.[agent] Spring is never consulted; the agent rewrites the classes as they load.

Running both at once is a known source of duplicate spans. So the first question to ask your team is which route this project is on — because if it is the agent, none of the management.* properties above apply.

Prove it — recall drill

Everything the questions ask about is in this recap, so you do not have to scroll back for it.

YOUR CODE / SPRING MVC / WebClient │ ▼ 1 Observation ................ micrometer-observation │ "instrument once, many benefits" ▼ 2 ObservationRegistry ........ tracing ObservationHandler │ observation ──► span happens HERE ▼ 3 Tracer (facade) ............ micrometer-tracing │ vendor-neutral, SLF4J-style ▼ 4 ═══ BRIDGE ═══ ............. micrometer-tracing-bridge-otel │ ◄── the crossing into OTel │ OtelTracer implements Tracer, │ wraps io.opentelemetry…Tracer ▼ (swap for -brave ⇒ Zipkin) 5 OTel SDK ─► Exporter ....... opentelemetry-exporter-otlp │ sample · batch · service.name ▼ OTLP ──► http :4318 (default) or grpc :4317 WIRED BY: spring-boot-starter-actuator, from management.* at startup only — never in the data path DEFAULTS: sampling.probability 0.1 ⚠ 90% dropped otlp.tracing.endpoint (none) ⚠ since Boot 3.2 otlp.tracing.transport http

Cover the recap with your hand, then answer

Say each answer out loud before you look. Then click to reveal and check.

1. Name the five layers between your code and the wire, in order.

Observation → ObservationRegistry + tracing handler → Tracer (facade) → bridge (micrometer-tracing-bridge-otel) → OTel SDK + exporter. Actuator is not among them — it built them all at startup.

2. Which artifact is the crossing into OpenTelemetry, and what changes if you swap it?

micrometer-tracing-bridge-otel. Swap it for micrometer-tracing-bridge-brave and you get Zipkin/Brave instead — with zero changes to your application code, because everything above the bridge talks only to the Tracer facade.

3. Mechanically, how does the bridge make OTel look like Micrometer? Name the pattern.

The adapter pattern, one class per interface. OtelTracer implements Tracer (Micrometer's interface) while holding a private final io.opentelemetry.api.trace.Tracer field and forwarding to it — this.tracer.spanBuilder(name).startSpan(). Same for OtelSpan, OtelTraceContext, OtelBaggageManager, OtelPropagator and about twenty more. Above the bridge only Micrometer types are named; below it, only OpenTelemetry types.

4. Why are there three tracing ObservationHandlers rather than one?

Because the span's kind and the propagation differ by situation. DefaultTracingObservationHandler → INTERNAL. PropagatingSenderTracingObservationHandler → CLIENT, and injects traceparent/baggage into outgoing headers. PropagatingReceiverTracingObservationHandler → SERVER, and extracts them from incoming headers. The Lesson 2 inject/extract machinery is these two handlers.

5. Which layer owns management.tracing.sampling.probability, and what is its default?

Actuator owns the property (it reads it at startup); the OTel SDK owns the behaviour (it applies the sampling decision at runtime). Default 0.1 — 10% of traces exported, which is why your one test request usually disappears.

6. Traces worked on Boot 3.1 and stopped on 3.3 with identical config. Why?

management.otlp.tracing.endpoint lost its default in Boot 3.2. From 3.2 onward the OTLP exporter bean is only auto-configured when the property has a value, so spans are still produced — and exported nowhere.

7. How can you tell whether a project uses the library route or the Java agent?

Look at the start command and the build file. Agent route: a -javaagent:opentelemetry-javaagent.jar flag and OTEL_* environment variables, with no tracing dependencies. Library route: spring-boot-starter-actuator + a bridge + an exporter in the build file, configured under management.*. Both together usually means duplicate spans.

Primary source — read this next

Fifteen minutes on Spring Boot → Actuator → Tracing, then open your own build.gradle beside it and name the owner of every tracing line. That exercise is the win for this lesson. Pin the versioned URL that matches your project's exact minor — the live docs drift.

I'm your teacher — interrogate me. Natural next questions: “Show me a custom Observation in real code”, “What exactly does the SDK do when it batches?”, or “How do I get the trace ID into my log lines?” And when you have your project's build.gradle to hand, paste it — we will name every line together.

Sources