Observability & Tracing · OpenTelemetry course
Lesson 03
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.
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:
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.
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.
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]
Tracer facadeThe 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.
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:
Which artifact is the crossing point into OpenTelemetry?
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]
Nothing clever is happening. Each class holds a reference to the OTel object and forwards to it. Straight from OtelTracer.java:
That is the entire trick. Micrometer's interface on the outside, OpenTelemetry's object on the inside, one delegating call between them. [OtelTracer.java]
Concretely, when Spring MVC handles a request:
Micrometer Tracing ships a small family of handlers, and which one runs decides the span's kind and whether context propagation happens:[handler package]
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.
Two classes in that package pay off things you have already met:
Slf4JEventListener — this is your log correlation. Its javadoc: “Reacts to events with updating of Slf4j's MDC.” On span scope open it calls MDC.put("traceId", …) and MDC.put("spanId", …), and removes them when the scope closes. That is the entire mechanism by which a trace ID appears in a log line — no magic, just an MDC write keyed traceId and spanId by default.[Slf4JEventListener]BaggageTaggingSpanProcessor — copies configured baggage entries onto spans as tags. This is the machinery behind management.tracing.baggage.tag-fields, and therefore the fix for the Lesson 2 trap where baggage never showed up in Jaeger.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.
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.
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.
Both warnings cost people days:
0.1. Out of the box you export 10% of traces, so the one test request you are watching for usually vanishes. Set 1.0 in dev.endpoint has had no default since Boot 3.2. The OTLP span exporter bean is only auto-configured when that property has a value; before 3.2 it defaulted to http://localhost:4318/v1/traces. On 3.2+ omitting it means spans are produced and then go nowhere. Everything looks wired. Nothing arrives.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?
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.
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.
Everything the questions ask about is in this recap, so you do not have to scroll back for it.
Cover the recap with your hand, then answer
Say each answer out loud before you look. Then click to reveal and check.
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.
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.
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.
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.
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.
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.
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.
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.
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.
management.* property defaultsOtelTracer, OtelSpan, OtelPropagator …)ObservationHandler family