What is an Observation.Context, and how do ObservationConvention and a custom Context work together in reusable instrumentation?
answer
- Context = mutable bag through the lifecycle
- subclass Context to carry domain input
- Convention = name + low/high KeyValues from Context
- custom + default convention resolution
- global convention bean overrides call-site
basics
~20 sObservation.Context is a mutable data bag that travels through an Observation's lifecycle, carrying its name, KeyValues, error, and any custom fields. An ObservationConvention reads that Context to decide the Observation's name and KeyValues in one reusable place.
solid answer
~40 s`Observation.Context` is a **mutable carrier object** passed to every handler on each lifecycle callback. It holds the Observation's name, contextual name, low/high-cardinality KeyValues, the thrown error, and — when you subclass it — your own domain fields (e.g., the HttpRequest, the order). Handlers read and mutate it; the tracing handler stashes the span there, the meter handler its Timer.Sample. An **`ObservationConvention<C extends Context>`** externalizes *naming and tagging*: it implements `getName()`, optional `getContextualName()`, `getLowCardinalityKeyValues(C)`, `getHighCardinalityKeyValues(C)`, and `supportsContext(...)`. By passing a custom Context plus a convention to `Observation.createNotStarted(convention, defaultConvention, () -> context, registry)`, you keep instrumentation call-sites clean and let users **override tagging without touching the instrumented code** by supplying their own convention bean. Spring uses this pattern throughout (e.g., `ServerRequestObservationContext`).
code
java · 42 linesimport io.micrometer.common.KeyValues;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationConvention;
import io.micrometer.observation.ObservationRegistry;
class OrderContext extends Observation.Context {
final Order order;
OrderContext(Order order) { this.order = order; }
}
interface OrderObservationConvention extends ObservationConvention<OrderContext> {}
class DefaultOrderObservationConvention implements OrderObservationConvention {
@Override public String getName() { return "order.process"; }
@Override public String getContextualName(OrderContext ctx) {
return "process " + ctx.order.type();
}
@Override public KeyValues getLowCardinalityKeyValues(OrderContext ctx) {
return KeyValues.of("order.type", ctx.order.type());
}
@Override public KeyValues getHighCardinalityKeyValues(OrderContext ctx) {
return KeyValues.of("order.id", ctx.order.id());
}
@Override public boolean supportsContext(Observation.Context c) {
return c instanceof OrderContext;
}
}
class OrderService {
private final ObservationRegistry registry;
private final OrderObservationConvention custom; // may be overridden by a bean
OrderService(ObservationRegistry registry, OrderObservationConvention custom) {
this.registry = registry; this.custom = custom;
}
Receipt process(Order order) {
return Observation.createNotStarted(
custom, new DefaultOrderObservationConvention(),
() -> new OrderContext(order), registry)
.observe(() -> doProcess(order));
}
private Receipt doProcess(Order o) { return new Receipt(); }
}go deeper
Can say Context carries name/tags/error through the Observation.
Knows a custom Context subclass carries domain fields for tagging.
Explains convention resolution and computing KeyValues at stop time.
Designs library instrumentation with Context+Convention+Documentation so apps override tagging without code changes.
## Observation.Context — the mutable data bag `Observation.Context` is the **state object** for one Observation instance. A fresh Context is created per Observation and passed **by reference** to every `ObservationHandler` on every lifecycle callback (`onStart`, `onScopeOpened`, `onError`, `onStop`, …). It is deliberately **mutable** so handlers can read inputs and stash outputs. Built-in fields include: - **name** and **contextualName** (a more specific, possibly higher-cardinality display name — e.g. `"http get /orders/{id}"`), - **low-cardinality KeyValues** and **high-cardinality KeyValues**, - the **error** (`Throwable`) recorded via `observation.error(t)`, - a generic key/value **map** (`put`/`get`) handlers use to share objects (the tracing handler stores the current span here; the meter handler its `Timer.Sample`). ### Custom Context You subclass it to carry **domain input** the convention needs to compute tags: ```java class OrderContext extends Observation.Context { private final Order order; OrderContext(Order order) { this.order = order; } String getType() { return order.type(); } String getOrderId() { return order.id(); } } ``` Spring's own examples: `ServerRequestObservationContext` (wraps the `HttpServletRequest`/response), `ClientRequestObservationContext`, messaging contexts, etc. ## ObservationConvention — externalized naming & tagging An **`ObservationConvention<C extends Observation.Context>`** answers *"what is this Observation called and how is it tagged?"* separately from *where it's instrumented*. Key methods: - `String getName()` — the low-cardinality Observation/metric name. - `String getContextualName(C)` — optional richer span name. - `KeyValues getLowCardinalityKeyValues(C)` — metric + span tags, read off the Context. - `KeyValues getHighCardinalityKeyValues(C)` — span-only tags. - `boolean supportsContext(Observation.Context)` — type guard. ### The default-vs-custom pattern Instrumentation is created with **two** conventions: ```java Observation.createNotStarted( userSuppliedConventionOrNull, // may be null new DefaultOrderConvention(), // fallback, always present () -> new OrderContext(order), // context supplier registry) .observe(() -> process(order)); ``` Micrometer resolves a matching convention: it uses a **globally registered** `ObservationConvention` bean if one supports the Context (via `registry.observationConfig().observationConvention(...)`), otherwise the supplied custom one, otherwise the **default** convention. This lets a **library author** ship instrumentation while an **application** overrides names/tags by registering its own convention — **without editing the library's call-site**. That's the whole point of the indirection. ### ObservationDocumentation Spring/Micrometer pairs conventions with an **`ObservationDocumentation`** enum that declares the observation's name, the expected low/high KeyValue keys, and events — used to generate docs and enforce consistency. ## Lifecycle tie-in 1. `createNotStarted(...)` builds the Observation + Context and resolves the convention. 2. On `start()`, the convention's low/high KeyValues + name are applied to the Context, then handlers run `onStart(context)`. 3. During the scope, handlers can mutate the Context (span stored, MDC set). 4. On error, `context.setError(t)`; handlers mark span/metric. 5. On `stop()`, KeyValues may be recomputed from the now-complete Context (e.g., outcome known), and handlers finalize signals. ## Gotchas - The Context is **not thread-safe** and is meant to be used within the Observation's own scope; don't share it across observations. - A **globally registered convention** silently **overrides** the one passed at the call-site if it `supportsContext` — surprising if you didn't expect it. - Computing KeyValues in the convention lets you defer to **stop time** (e.g., HTTP status), which you can't always know at start. - `supportsContext` must be precise or your convention/handler will grab unrelated Observations. ## When to use Reach for a custom Context + Convention when you're building **reusable/library instrumentation** or want the tagging policy to be overridable and documented, rather than inlining `lowCardinalityKeyValue(...)` at every call-site.
- Why compute KeyValues inside the convention instead of setting them at the call-site with lowCardinalityKeyValue(...)?The convention reads the Context, so tagging is defined once, reusable, overridable by a registered bean, and can be computed at stop time (e.g., HTTP status/outcome that isn't known at start). Call-site tags are inline, duplicated, and not overridable.
- You registered a global ObservationConvention bean and your call-site convention stopped taking effect. Why?A globally registered convention whose supportsContext matches takes precedence over the one passed at the call-site; that's by design so applications can override library instrumentation. Narrow supportsContext or don't register it globally if that's unwanted.
saying these in an interview costs you the question
- Thinking Observation.Context is immutable or emits signals itself.
- Believing the call-site convention always wins over a globally registered one.
- Not realizing a custom Context is how conventions get domain data.
- Sharing one Context across multiple Observations/threads.