Micrometer Observation API
The Micrometer Observation API provides a single instrumentation point that produces both traces and metrics.
Instead of separately instrumenting your code with a Tracer for traces and a MeterRegistry for metrics, you create an Observation that automatically generates both signals.
This document is part of the Observability in Quarkus reference guide which features this and other observability related components.
The quarkus-observation extension integrates the Observation API with Quarkus, providing:
-
The
@ObservedCDI interceptor for declarative instrumentation -
Programmatic
ObservationAPI for manual instrumentation -
Observations produce traces and a Timer metric
-
Automatic bridging to OpenTelemetry tracing (when
quarkus-opentelemetryis present) -
Automatic bridging to Micrometer metrics (via any configured
MeterRegistry) -
Context propagation across threads and async boundaries
Using the extension
If you already have your Quarkus project, you can add the quarkus-observation extension
to it by running the following command in your project base directory:
quarkus extension add observation
./mvnw quarkus:add-extension -Dextensions='observation'
./gradlew addExtension --extensions='observation'
This will add the following to your build file:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-observation</artifactId>
</dependency>
implementation("io.quarkus:quarkus-observation")
Choosing backends
The quarkus-observation extension produces traces and metrics through pluggable backends.
Add the backend extensions that match your infrastructure:
| Scenario | Additional dependencies | What you get |
|---|---|---|
Observation API only |
None |
|
OpenTelemetry tracing |
|
Observations produce OTel spans with parent-child relationships and context propagation |
Prometheus metrics |
|
Observation timer metrics scraped at |
OTel tracing + Prometheus metrics |
|
Spans via OTel exporters, metrics via Prometheus scraping |
Full OTel backend (traces + metrics) |
|
Spans and metrics exported via OTel OTLP protocol |
|
No telemetry will be produce without a backend. The |
For example, to get OpenTelemetry tracing with Prometheus metrics from observations the following dependencies are needed:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-observation</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-opentelemetry</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-micrometer-registry-prometheus</artifactId>
</dependency>
Using @Observed
The @Observed annotation instruments CDI bean methods declaratively.
Each invocation creates an Observation that produces a trace span and a timer metric.
import jakarta.enterprise.context.ApplicationScoped;
import io.micrometer.observation.annotation.Observed;
@ApplicationScoped
public class GreetingService {
@Observed (1)
public String greet(String name) {
return "Hello, " + name;
}
@Observed(name = "custom.greeting", (2)
contextualName = "greeting-operation", (3)
lowCardinalityKeyValues = {"service", "greeting"}) (4)
public String customGreet(String name) {
return "Hi, " + name;
}
}
| 1 | Creates an observation named after the method (greet). The span contextual name defaults to GreetingService#greet. |
| 2 | Overrides the observation name (used for the timer metric name). |
| 3 | Overrides the contextual name (used for the span name). |
| 4 | Adds low-cardinality key-values as tags on both the span and the metric. |
Supported return types
The @Observed interceptor handles synchronous and asynchronous return types:
-
Synchronous methods — the observation spans the method execution
-
Uni<T>— the observation stays open until theUnicompletes or fails -
Multi<T>— the observation stays open until theMulticompletes, fails, or is cancelled -
CompletionStage<T>— the observation stays open until the future completes
Programmatic observations
For finer control, inject ObservationRegistry and create observations manually.
Simple observation
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
@Inject
ObservationRegistry registry;
public String doWork() {
Observation observation = Observation.createNotStarted("my.operation", registry);
observation.lowCardinalityKeyValue("operation.type", "compute");
return observation.observe(() -> {
// your business logic
return "result";
});
}
The observe() method handles start, scope, stop, and error recording automatically.
Nested observations with scopes
Observations created within a scope automatically become children of the outer observation:
Observation parent = Observation.start("parent.operation", registry);
try (Observation.Scope parentScope = parent.openScope()) {
// work in parent context
Observation child = Observation.start("child.operation", registry);
try (Observation.Scope childScope = child.openScope()) {
// work in child context — child span is linked to parent
}
child.stop();
}
parent.stop();
Error recording
Errors are recorded on the observation and propagated to the span (as error status) and metric (as an error tag):
Observation observation = Observation.start("risky.operation", registry);
try (Observation.Scope scope = observation.openScope()) {
riskyOperation();
} catch (Exception e) {
observation.error(e); (1)
throw e;
} finally {
observation.stop();
}
| 1 | Records the error on the span and adds error=<exception class> to the metric tags. |
Low and high cardinality attributes
Low and high cardinality attributes will end up in traces but only low cardinality ones will be used as metrics Tags (or attributes, if OTel is used).
Cardinality is related with the nr. of different values an attribute can have.
As an example, the deployment.environment.name attribute typically has a small, fixed set of possible values such as test, development, staging, and prod. This results in a cardinality of 4, which is considered low.
In contrast, a user identifier represented by enduser.id in a system with 2 million users can have up to 2 million distinct values. This leads to a cardinality of 2 million, which is considered high.
All frameworks face challenges with high cardinality, but the Observation API offers a clean interface that encourages you to consider this issue upfront and keep it under control.
|
It’s up to the user to understand if an attribute is low or high cardinality and place it under the right bucket. High-cardinality attributes in metrics can cause a rapid increase in the number of metric dimensions, potentially leading to excessive memory usage and out-of-memory errors. |
Sender and Receiver contexts
For distributed trace propagation across service boundaries, use SenderContext and ReceiverContext.
These contexts tell the tracing handler to inject or extract trace context from a carrier (e.g., HTTP headers, message headers).
Sender (outgoing requests)
import io.micrometer.observation.Observation;
import io.micrometer.observation.transport.Kind;
import io.micrometer.observation.transport.SenderContext;
Map<String, String> headers = new HashMap<>();
SenderContext<Map<String, String>> senderContext = new SenderContext<>(
(carrier, key, value) -> carrier.put(key, value), (1)
Kind.CLIENT); (2)
senderContext.setCarrier(headers);
Observation observation = Observation.createNotStarted("client.call",
() -> senderContext, registry);
observation.start();
try (Observation.Scope scope = observation.openScope()) {
// metadata with the context to be sent
client.call(headers);
}
observation.stop();
| 1 | A setter function that injects trace context keys into the carrier. |
| 2 | Kind.CLIENT creates a span with SpanKind.CLIENT. Use Kind.PRODUCER for messaging. |
Receiver (incoming requests)
import io.micrometer.observation.transport.ReceiverContext;
ReceiverContext<Map<String, String>> receiverContext = new ReceiverContext<>(
(carrier, key) -> carrier.get(key), (1)
Kind.SERVER); (2)
receiverContext.setCarrier(incomingHeaders);
Observation observation = Observation.createNotStarted("server.handle",
() -> receiverContext, registry);
observation.start();
try (Observation.Scope scope = observation.openScope()) {
// span is linked to the incoming trace context
handleRequest();
}
observation.stop();
| 1 | A getter function that extracts trace context keys from the carrier. |
| 2 | Kind.SERVER creates a span with SpanKind.SERVER. Use Kind.CONSUMER for messaging. |
Customization
The Observation API provides several CDI extension points for customizing behavior. Register them as CDI beans and they are automatically discovered.
ObservationFilter
An ObservationFilter modifies observation contexts before they are processed by handlers.
Use it to add common tags to all observations:
import jakarta.enterprise.context.ApplicationScoped;
import io.micrometer.common.KeyValue;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationFilter;
@ApplicationScoped
public class CustomObservationFilter implements ObservationFilter {
@Override
public Observation.Context map(Observation.Context context) {
context.addLowCardinalityKeyValue(KeyValue.of("env", "production"));
return context;
}
}
ObservationPredicate
An ObservationPredicate controls which observations are created.
Return false to suppress an observation entirely (no span, no metric):
import jakarta.enterprise.context.ApplicationScoped;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationPredicate;
@ApplicationScoped
public class CustomObservationPredicate implements ObservationPredicate {
@Override
public boolean test(String name, Observation.Context context) {
return !"noisy.internal.operation".equals(name);
}
}
ObservedInterceptorConvention
An ObservedInterceptorConvention customizes the naming and tagging for @Observed methods.
It overrides the default convention for all intercepted methods:
import jakarta.enterprise.context.ApplicationScoped;
import io.micrometer.common.KeyValues;
import io.quarkus.observation.cdi.ObservedInterceptorContext;
import io.quarkus.observation.cdi.convention.ObservedInterceptorConvention;
@ApplicationScoped
public class CustomObservedConvention implements ObservedInterceptorConvention {
/**
* Timer name
*/
@Override
public String getName() {
return "custom.observed";
}
/**
* Trace name
*/
@Override
public String getContextualName(ObservedInterceptorContext context) {
return "custom-" + context.getInvocationContext().getMethod().getName();
}
@Override
public KeyValues getLowCardinalityKeyValues(ObservedInterceptorContext context) {
return KeyValues.of(
"code.function", context.getInvocationContext().getMethod().getName(),
"code.namespace", context.getInvocationContext().getMethod()
.getDeclaringClass().getName(),
"custom.key", "custom.value");
}
}
Custom MeterObservationHandler
A custom MeterObservationHandler replaces the default metric recording logic.
When provided as a CDI bean, it takes over from the built-in DefaultMeterObservationHandler supported by Micrometer. This is an example using Micrometer’s MeterRegistry:
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.observation.MeterObservationHandler;
import io.micrometer.observation.Observation;
@ApplicationScoped
public class CustomMeterHandler implements MeterObservationHandler<Observation.Context> {
@Inject
MeterRegistry meterRegistry;
@Override
public void onStop(Observation.Context context) {
Counter.builder("observation.count")
.tag("name", context.getName())
.register(meterRegistry)
.increment();
}
@Override
public boolean supportsContext(Observation.Context context) {
return true;
}
}
When using the quarkus-micrometer-opentelemetry bridge, the metrics will be implemented with OpenTelemetry.
|
A custom |
Disabling the extension
To disable the Observation extension at build time:
quarkus.observation.enabled=false
When disabled, @Observed interceptors are not registered and no ObservationRegistry bean is created.
Configuration Reference
Configuration property fixed at build time - All other configuration properties are overridable at runtime
Configuration property |
Type |
Default |
|---|---|---|
Whether the Observation API support is enabled. Environment variable: Show more |
boolean |
|
Whether to register a handler that prints observation lifecycle events to the log. Useful for debugging. The handler is registered last, after all other handlers. Environment variable: Show more |
boolean |
|