Walk through the manual span/scope lifecycle with the Micrometer Tracer API. Why must you close the scope AND end the span, and what breaks if you don't?
answer
- scope ≠ span lifecycle
- withSpan in try, end() in finally
- close scope = detach from thread; end = finalize+export
- leak → next pooled request parents under stale span
- prefer Observation.observe() to avoid manual leaks
basics
~20 sCreate a span (tracer.nextSpan().name(...)), start it, open a scope with tracer.withSpan(span) so it becomes 'current', do work, then close the scope (try-with-resources) and call span.end(). Skipping end() loses timing/export; leaving a scope open leaks context onto later work on that thread.
solid answer
~50 sThe Tracer exposes two distinct concerns: the span's data lifecycle and its thread-current scope. You create with tracer.nextSpan().name("op"), start it (records start time), then wrap the work in try (SpanInScope ws = tracer.withSpan(span.start())) so child spans and log correlation see it as current. In finally you span.end(), which stamps the end time and hands the span to the exporter. Closing SpanInScope only detaches it from the thread — it does NOT end the span; ending the span does NOT close the scope. Forget end() and the span never exports and timing is wrong. Forget to close the scope and it leaks: the next task on that (pooled) thread wrongly parents under a finished span, corrupting traces. Prefer the Observation API when possible so Spring manages this for you; drop to the raw Tracer only for ad-hoc custom spans.
code
java · 22 linesimport io.micrometer.tracing.Span;
import io.micrometer.tracing.Tracer;
@Service
class TaxService {
private final Tracer tracer;
TaxService(Tracer tracer) { this.tracer = tracer; }
BigDecimal calculate(Order o) {
Span span = tracer.nextSpan().name("calculateTax");
try (Tracer.SpanInScope scope = tracer.withSpan(span.start())) {
span.tag("order.id", o.id());
span.event("rate-lookup");
return doCalc(o); // child spans nest under this one
} catch (RuntimeException ex) {
span.error(ex); // exception recorded on the span
throw ex;
} finally {
span.end(); // finalize + export; scope already closed
}
}
}go deeper
Know the create→start→scope→end order and that both closing scope and ending span matter.
Explain the difference between scope and span lifecycle and use try-with-resources + finally.
Articulate scope-leak failure modes on pooled threads and cross-thread propagation; prefer Observation.
Set team conventions (Observation-first, executor context propagation) and review for leak-prone raw Tracer use.
## Two orthogonal lifecycles Micrometer's `Tracer` deliberately separates two things people conflate: 1. **Span data lifecycle** — `start()` … `end()`. This is about *the span's own record*: when it began, its tags/events, when it finished. `end()` is what finalizes duration and queues the span for export. 2. **Scope / current-span lifecycle** — `Tracer.SpanInScope`. This is about *thread-local visibility*: which span is 'current' on **this** thread right now, so newly created spans become its children and logging can inject the trace/span id into MDC. These are independent. Closing the scope does not end the span; ending the span does not close the scope. ## The canonical pattern ```java Span span = tracer.nextSpan().name("calculateTax"); try (Tracer.SpanInScope ws = tracer.withSpan(span.start())) { span.tag("region", "EU"); // key/value on the span span.event("lookup-rates"); // timestamped annotation doWork(); } catch (Exception e) { span.error(e); // records exception throw e; } finally { span.end(); // stamps end time + exports } ``` Step by step: - `tracer.nextSpan()` builds a span, choosing a parent from the **current** span if one exists (otherwise a new trace root). - `.name(...)` sets the operation name. - `span.start()` records the start timestamp and returns the span. - `tracer.withSpan(span)` opens a `SpanInScope` and makes `span` the thread-current span. `tracer.currentSpan()` now returns it. - try-with-resources auto-calls `ws.close()` at block exit — detaching the span from the thread and restoring whatever was current before. - `span.end()` in `finally` finalizes and exports. ## Why both are mandatory ### If you never call `end()` - The span has no end time → never finalized → **never exported**. The trace shows a missing/dangling span. - In some SDKs unended spans accumulate → memory pressure. ### If you never close the `SpanInScope` - The span stays **current** on the thread. Because Spring runs on **pooled threads** (Tomcat/Netty workers, `@Async`, schedulers), the *next unrelated request* handled by that same thread inherits the leaked span as its parent. - Result: spans from different requests get stitched into one giant bogus trace, or new spans parent under an already-**ended** span → corrupt, misleading traces and wrong latencies. - This is the classic **scope leak**, and it's silent — no exception, just bad data. ### Reversed/mismatched ordering Ending the span *before* closing the scope is legal but you must still close the scope. `end()` without `withSpan` is fine if you never needed it current. The safe habit: `withSpan` in try-with-resources, `end()` in `finally`. ## Cross-thread propagation `SpanInScope` is **thread-bound**. If you hand work to another thread (executor, reactive operator), the new thread has no current span. Use **context propagation**: capture with `io.micrometer.context.ContextSnapshot` (via `context-propagation` + `micrometer-tracing`'s integration) and re-open the scope on the target thread, or wrap the executor with `ContextSnapshotFactory`/`ContextExecutorService`. Simply passing the `Span` object and calling `tracer.withSpan(span)` on the new thread also works, as long as you close that scope too. ## Prefer Observation over raw Tracer Most of the time you should **not** hand-roll spans. Use the **Micrometer Observation API**: ```java Observation.createNotStarted("calculateTax", registry) .observe(() -> doWork()); ``` The `DefaultTracingObservationHandler` opens the span, puts it in scope, records error/stop, and closes everything — eliminating leak bugs. Drop to the raw `Tracer` only for custom spans that don't fit an Observation. ## Gotchas checklist - Always try-with-resources the `SpanInScope`. - Always `end()` in `finally`. - Record errors with `span.error(e)` for exception tagging. - Don't share one `Span` across threads without re-scoping (and re-closing) on each. - Unsampled spans still start/end and propagate ids; they just aren't exported.
- What exactly does tracer.currentSpan() return, and when is it null?It returns the span currently in scope on the calling thread (set by an open SpanInScope or an active Observation). It's null when no scope is open on that thread — e.g., on a fresh pool thread you dispatched work to without propagating context.
- How do you avoid writing this lifecycle by hand?Use the Observation API: Observation.createNotStarted(name, registry).observe(runnable) or @Observed. The DefaultTracingObservationHandler starts the span, scopes it, records errors, and ends/closes everything, so scope leaks and missing end() calls can't happen.
- You call span.start() and span.end() but never withSpan(). Is the span exported?Yes, if sampled it exports with correct timing. You just never made it the thread-current span, so nested spans wouldn't auto-parent to it and logs wouldn't get its ids — but its own record is complete.
saying these in an interview costs you the question
- Believing closing SpanInScope also ends/exports the span
- Believing span.end() closes the scope
- Ignoring scope leaks on pooled threads
- Assuming a Span is automatically current on any thread that holds a reference to it
- Hand-rolling spans when an Observation would manage the lifecycle safely