skip to content

How does the Micrometer Observation API create spans, and how do you create a custom span in a Spring Boot 3 application?

level: middleimportance: should knowfreq 55%

answer

  1. ObservationRegistry -> observe()
  2. one Observation = metric + span
  3. ObservationHandler maps start/scope/stop -> span
  4. @Observed needs ObservedAspect bean
  5. low-card = metric tags, high-card = span tags only

basics

~20 s

You wrap code in an Observation using the ObservationRegistry. Each observation becomes a span (and a metric). Start it, run your code inside observe(), and Micrometer Tracing opens a span with the current trace context as parent.

solid answer

~40 s

In Spring Boot 3 the entry point is the **Observation API**. You inject an `ObservationRegistry` and wrap a unit of work: `Observation.createNotStarted("name", registry).observe(() -> ... )`. A registered `ObservationHandler` from Micrometer Tracing turns each observation into a **span** — it opens the span when the observation starts, sets it as the current span (so downstream calls become children), and closes it on stop, recording duration. The same observation also feeds a timer metric, which is the point of the unified API: instrument once, get both metrics and traces. For declarative use you can annotate a method with `@Observed` (requires an `ObservedAspect` bean). You add low-cardinality **key-values** for metric tags and high-cardinality ones for span tags. Manual `Tracer.nextSpan()` is available for lower-level control but Observation is the idiomatic path.

code

java · 27 lines
java
@Service
class OrderService {
    private final ObservationRegistry registry;
    OrderService(ObservationRegistry registry) { this.registry = registry; }

    String process(String orderId) {
        return Observation.createNotStarted("orders.process", registry)
            .lowCardinalityKeyValue("type", "standard")   // -> metric tag + span tag
            .highCardinalityKeyValue("orderId", orderId)   // -> span tag only
            .observe(() -> {
                // runs inside a span that is a child of the current trace
                return "processed-" + orderId;
            });
    }
}

// Declarative alternative (requires an ObservedAspect @Bean):
@Observed(name = "orders.process")
public String processAnnotated(String orderId) { return "processed-" + orderId; }

@Configuration
class ObservedAspectConfig {
    @Bean
    ObservedAspect observedAspect(ObservationRegistry registry) {
        return new ObservedAspect(registry);
    }
}

go deeper

for a junior

Know that you wrap code in an Observation to get a span.

for a middle

Explain the start/scope/stop lifecycle and low- vs high-cardinality key-values.

for a senior

Discuss ObservationConvention/Context for reusable instrumentation and when to drop to Tracer.

for a principal

Design a house instrumentation standard: naming conventions, cardinality governance, and handler composition.

## Why the Observation API exists Before Boot 3, metrics (Micrometer) and tracing (Sleuth) were instrumented separately, so you tagged the same operation twice. Micrometer's **Observation API** (`io.micrometer.observation`) unifies them: you describe an operation **once** as an `Observation`, and pluggable **`ObservationHandler`s** fan that single event out to metrics, tracing, logging, or anything else. Micrometer Tracing ships handlers that convert observations into spans. ## The lifecycle An `Observation` has a lifecycle: `start` -> (`scope opened/closed` possibly many times) -> `error` (optional) -> `stop`. The tracing handler maps these: - **start** -> create a new span (child of the current span if one exists). - **open scope** -> make that span the *current* span on the thread (so nested instrumented calls attach as children, and MDC gets the IDs). - **stop** -> end the span, recording its duration; export it downstream. ## Ways to create an observation 1. **Functional** — the common form: `Observation.createNotStarted("orders.process", registry).observe(() -> doWork())`. `observe()` handles start, scope, error, and stop for you. 2. **Manual** — when you need to control scope explicitly: `Observation obs = Observation.start(name, registry); try (Observation.Scope scope = obs.openScope()) { ... } catch (Exception e) { obs.error(e); throw e; } finally { obs.stop(); }`. 3. **Declarative** — annotate a method `@Observed(name = "orders.process")`. This requires an **`ObservedAspect`** bean (AspectJ/Spring AOP) to be registered; without it the annotation does nothing. ## Key-values (tags): low vs high cardinality - **Low-cardinality key-values** (`lowCardinalityKeyValue`) become **metric tags** and also span tags. They must have a bounded set of values (e.g. `status=200`, `method=GET`) — high-cardinality values here would explode your metrics. - **High-cardinality key-values** (`highCardinalityKeyValue`) become **span tags only** (e.g. `orderId=48213`). They are fine on spans because tracing stores individual spans, but must never be metric tags. ## ObservationConvention and context For reusable, testable naming/tagging you implement an `ObservationConvention<YourContext>` and a custom `Observation.Context` subclass, then pass the convention to `observe`. This keeps naming logic out of business code — the idiomatic pattern Spring's own instrumentation uses. ## Relationship to Tracer The `Tracer` (`io.micrometer.tracing.Tracer`) is the lower-level abstraction the bridge implements (Brave or OTel). You *can* call `tracer.nextSpan().name("x").start()` directly, but you then miss the metrics half and must manage scope yourself. Prefer Observation; reach for `Tracer` only for edge cases (e.g. manually restoring context on a hand-rolled thread). ## Gotchas - `createNotStarted` does nothing until you call `observe`/`start` — forgetting to start is a common silent bug. - `@Observed` silently no-ops without the `ObservedAspect` bean. - If you open a scope but never close it (not using try-with-resources or `observe`), the current-span leaks onto the thread, corrupting later requests — especially dangerous with pooled threads. - Putting high-cardinality values in low-cardinality key-values causes a metrics tag explosion.

  • Why does one Observation give you both a metric and a span?
    Because multiple ObservationHandlers are registered against the ObservationRegistry — a metrics handler and a tracing handler — and each observation lifecycle event is dispatched to all of them, so a single instrumentation produces a timer and a span.
  • What breaks if you use @Observed but forget the ObservedAspect bean?
    Nothing is instrumented — the annotation silently no-ops because there is no aspect to intercept the method and create the observation.

saying these in an interview costs you the question

  • Claiming @Observed works out of the box without an ObservedAspect bean.
  • Putting high-cardinality IDs (orderId, userId) into low-cardinality key-values, causing a metrics tag explosion.
  • Opening a scope manually and never closing it, leaking the current span onto a pooled thread.
  • Thinking Observation only produces traces, not metrics.

context