What is an ObservationHandler and what do its onStart, onStop, and supportsContext callbacks do?
answer
- Handler = does the actual recording (metric/span)
- supportsContext = per-observation filter + type narrowing
- onStart -> start time/open span; onStop -> duration/close span
- same mutable Context passed to every callback
- register via observationConfig().observationHandler()
basics
~20 sAn ObservationHandler is a listener that reacts to an Observation's lifecycle. onStart runs when the observation starts, onStop when it stops (e.g. to record timing), and supportsContext(context) decides whether this handler should handle a given observation at all.
solid answer
~40 sObservationHandler<T extends Observation.Context> is the SPI that turns an Observation into an actual signal. Micrometer's built-in handlers implement it: DefaultMeterObservationHandler records timers/metrics, tracing handlers create and finish spans. Key callbacks: supportsContext(Context) is a filter — the handler only runs if it returns true, and it also narrows the generic type; onStart(context) fires on observation.start() (open the span, note the start time); onStop(context) fires on observation.stop() (record duration, close the span, read tags via context.getLowCardinalityKeyValues()). There are also onError, onEvent, and onScopeOpened/onScopeClosed/onScopeReset for thread-bound context propagation. You register handlers via observationRegistry.observationConfig().observationHandler(handler). The Context object is the shared, mutable bag of data (name, tags, error) passed to every callback for that observation.
code
java · 31 linesimport io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationHandler;
class AuditObservationHandler implements ObservationHandler<Observation.Context> {
@Override
public boolean supportsContext(Observation.Context context) {
// Only handle observations whose name starts with "user."
return context.getName().startsWith("user.");
}
@Override
public void onStart(Observation.Context context) {
context.put("startNanos", System.nanoTime()); // stash state on the context
}
@Override
public void onStop(Observation.Context context) {
long start = context.getOrDefault("startNanos", System.nanoTime());
long durationMs = (System.nanoTime() - start) / 1_000_000;
// low-cardinality tags travel on the context
audit(context.getName(), context.getLowCardinalityKeyValues(), durationMs);
}
@Override
public void onError(Observation.Context context) {
audit(context.getName() + ".error", context.getLowCardinalityKeyValues(), -1);
}
private void audit(String name, Object tags, long ms) { /* ... */ }
}go deeper
Know a handler reacts to start/stop and that built-in handlers create metrics and spans.
Explain supportsContext, the shared Context, and stashing state between onStart and onStop.
Discuss scope callbacks for MDC/context propagation and composite (first-matching vs all-matching) handlers.
Design custom handlers/sinks with performance and thread-safety in mind; know where handlers fit vs predicates/conventions/filters.
## The role of ObservationHandler When you record an `Observation`, Micrometer does not itself produce metrics or spans — it delegates to registered **`ObservationHandler`** instances. This is the extension point (SPI) that decides *what happens* when an observation starts, stops, errors, or changes scope. The interface is generic: ```java interface ObservationHandler<T extends Observation.Context> { boolean supportsContext(Observation.Context context); default void onStart(T context) {} default void onStop(T context) {} default void onError(T context) {} default void onEvent(Observation.Event event, T context) {} default void onScopeOpened(T context) {} default void onScopeClosed(T context) {} default void onScopeReset(T context) {} } ``` ## Observation.Context — the shared bag Every callback receives an `Observation.Context` (or a subtype). It is a **mutable** object created once per observation that carries: the name, the contextual name, low- and high-cardinality `KeyValues` (tags), the current error (if any), and arbitrary key/value data handlers can stash. Because the *same* context instance is passed to `onStart` and later `onStop`, a handler can store state on start (e.g. `Timer.Sample`) and read it back on stop. ## supportsContext — the type/filter gate `supportsContext(context)` decides whether this handler participates in a given observation. Returning `false` means the handler is skipped entirely for that observation. It commonly does an `instanceof` check so a handler only reacts to *its* context type: ```java public boolean supportsContext(Observation.Context context) { return context instanceof ServerRequestObservationContext; } ``` This is how, for example, HTTP-specific handlers ignore JDBC observations. ## Lifecycle order of callbacks For a typical observed operation: 1. `observation.start()` -> **onStart** (each supporting handler). Note the start time / open the span. 2. `observation.openScope()` -> **onScopeOpened**. This binds context to the current thread (e.g. put trace ids in MDC) so nested/child work can see it. Closing the scope -> **onScopeClosed** (and **onScopeReset** on reset). 3. If it fails, `observation.error(throwable)` sets the error on the context -> **onError**. 4. `observation.stop()` -> **onStop**. Record duration, finish the span, publish the timer. `observe(...)`/`observeChecked(...)` wrap all of this for you (start, open scope, run, catch error, close scope, stop). ## Registering handlers ```java observationRegistry.observationConfig() .observationHandler(new MyHandler()); ``` Spring Boot auto-registers the standard ones (metrics via `DefaultMeterObservationHandler`, tracing handlers when a tracer is present). You add custom handlers for bespoke sinks (audit log, custom exporter). Composite handlers exist — `FirstMatchingCompositeObservationHandler` (delegates to the first supporting child, common for tracing so only one tracer handler runs) and `AllMatchingCompositeObservationHandler` (delegates to all). ## Gotchas - **Don't do heavy/blocking work in callbacks** — they run on the observed thread and add latency to every operation. - **Handler exceptions**: throwing from a callback can disrupt recording; keep them defensive. - **State must live on the Context**, not on handler fields, because one handler instance serves many concurrent observations. - `supportsContext` returning `false` is the correct way to opt out per-observation; it is *not* the same as `ObservationPredicate`, which suppresses the whole observation for everyone. ## When to write one Write a custom `ObservationHandler` when you need to fan an observation out to a sink Micrometer doesn't cover, or to enrich MDC/logging on scope open. For merely *dropping* observations or *renaming tags*, use `ObservationPredicate`/`ObservationConvention` instead.
- Why store per-call state (like a start timestamp) on the Context instead of a field on the handler?A single handler instance serves many concurrent observations across threads. Fields would be shared/overwritten; the Context is created per observation and passed to every callback, so it's the correct place for per-observation state.
- What's the difference between supportsContext returning false and an ObservationPredicate returning false?supportsContext only skips that one handler — the observation still exists and other handlers run. An ObservationPredicate returning false makes the whole observation a no-op for everyone (nothing is recorded at all).
saying these in an interview costs you the question
- Saying onStop is where you decide whether to record at all (that's ObservationPredicate)
- Keeping per-observation state in handler instance fields (thread-safety bug)
- Doing blocking I/O inside callbacks and adding latency to every request
- Confusing supportsContext (per-handler filter) with predicates (global suppression)