Micrometer’s Observation API: Unified Observability for the JVM
For too long, the gap between writing code and understanding its production behavior has been frustratingly wide. We’ve deployed applications, crossed our fingers, and relied solely on sparse logs when things inevitably break. Micrometer’s Observation API (available since version 1.10) closes this gap entirely, fundamentally changing the JVM observability landscape by bringing vendor-neutral, unified metrics and tracing with unprecedented sophistication.
The Observability Triforce: Unified
Modern observability rests on three pillars: metrics, traces, and logs. Historically, these lived in separate silos with different tools and APIs. The Observation API unifies them under a single abstraction layer, letting you instrument once and export everywhere—whether that’s Prometheus, Datadog, New Relic, or your own custom backend.
This matters immensely in microservices architectures. Without a vendor-neutral layer, you are locked into specific monitoring solutions or drowning in custom integration code. Micrometer and its Observation API become your insurance policy against vendor lock-in while giving you best-in-class features.
Introducing the Observation API
The Observation API, introduced in Micrometer 1.10, is not just an incremental update; it’s a fundamental rethinking of how to instrument modern Java applications. The biggest change is native support for distributed tracing alongside metrics, powered by the OpenTelemetry standard under the hood.
The new ObservationRegistry API unifies metrics and traces into a single concept: observations. When you create an observation, Micrometer automatically generates both metrics (like counters and timers) and trace spans. This solves the duplicate instrumentation problem where you’d previously instrument the same code twice.
// Micrometer 1.10+ - unified instrumentation
ObservationRegistry registry = ObservationRegistry.create();
Observation.createNotStarted("http.server.requests", registry)
.lowCardinalityKeyValue("method", "GET")
.highCardinalityKeyValue("uri", "/api/users/12345")
.observe(() -> {
// Your business logic
return userService.getUser(userId);
});
This single block of code produces timing metrics, success/failure counts, and a distributed trace span. The distinction between low and high cardinality key-values is crucial—low cardinality tags become metric dimensions, while high cardinality ones only appear in traces, preventing metric explosion.
Context Propagation: Essential for Asynchronous Code
One of the most elegant additions is the Context Propagation library. In reactive or asynchronous code, thread-local storage fails because your context gets lost between threads. Context Propagation solves this by automatically carrying observability context (like trace IDs) across thread boundaries, reactive streams, and even Kotlin coroutines.
Important Note: For automatic propagation to work in frameworks like Project Reactor, a configuration step may be required. Spring Boot 3.4+ provides automatic configuration via a property, but earlier versions may need manual hook setup.
// Context automatically propagates through reactive chains
Mono<OrderSummary> processOrder(String orderId) {
return validateOrder(orderId)
.flatMap(order -> chargePayment(order))
.flatMap(payment -> updateInventory(payment))
.map(result -> createSummary(result))
.tap(Micrometer.observation(registry))
.contextWrite(Context.of(ObservationThreadLocalAccessor.KEY,
registry.getCurrentObservation()));
}
// The .tap() operator instruments your reactive chain, capturing timing and context
The key here is using the .tap(Micrometer.observation(registry)) operator combined with .contextWrite() to propagate the observation context through the reactive chain. Behind the scenes, Context Propagation handles the complexity of maintaining trace IDs and span relationships across asynchronous boundaries—a task that was previously a major source of bugs in reactive applications.”
Exemplars: Bridging Metrics and Traces
Exemplars are a killer feature that directly connects aggregated metrics to specific trace examples. They are sample trace IDs stored alongside metric data points. When you see a spike in latency on your dashboard, exemplars let you jump directly to the traces that contributed to that spike.
// Exemplars automatically captured
Timer timer = Timer.builder("database.query")
.publishPercentiles(0.95, 0.99)
.register(registry);
// When this runs, the active trace ID is captured as an exemplar
timer.record(() -> database.executeQuery(sql));
Prometheus and Grafana support exemplars natively. You click on a data point in your latency graph, and Grafana shows you the actual traces from that time period, giving you X-ray vision into your performance data.
Meter Binders: Auto-Instrumentation Out of the Box
Micrometer ships with extensive auto-instrumentation through meter binders. These are pre-built modules for common libraries and frameworks—JVM metrics, Tomcat, Netty, Hibernate, JDBC connection pools, Kafka clients, and more.
// Rich JVM metrics with a few lines
new JvmMemoryMetrics().bindTo(registry);
new JvmGcMetrics().bindTo(registry);
new JvmThreadMetrics().bindTo(registry);
// Database connection pool metrics
new HikariDataSourceMetrics(dataSource, "hikari", Tags.empty())
.bindTo(registry);
Spring Boot 3+ takes this further with auto-configuration. Simply add Micrometer to your classpath, and you get automatic instrumentation of web endpoints, database queries, and cache operations. The metrics just appear in your monitoring backend without writing any instrumentation code.
Custom Metrics That Actually Matter
While auto-instrumentation covers infrastructure, your business logic needs custom metrics. Micrometer makes this straightforward with four core meter types: Counter, Gauge, Timer, and Distribution Summary.
To see this in practice, let’s look at instrumenting a payment processing system. Notice how we use the core meter types to capture different aspects of the service:
public class PaymentService {
private final Timer paymentDuration;
private final DistributionSummary paymentAmounts;
public PaymentService(MeterRegistry registry) {
// Timer includes built-in counters for both success and failure
this.paymentDuration = Timer.builder("payments.duration")
.description("Payment processing time")
.publishPercentiles(0.5, 0.95, 0.99)
.register(registry);
// Distribution summaries can safely record unique amounts
this.paymentAmounts = DistributionSummary.builder("payments.amount")
.baseUnit("USD")
.register(registry);
}
public PaymentResult processPayment(PaymentRequest request) {
return paymentDuration.recordCallable(() -> {
PaymentResult result = executePayment(request);
paymentAmounts.record(request.getAmount());
return result;
});
}
}
This instrumentation gives you payment throughput, success rates, latency percentiles, and amount distributions. Note that Timer automatically tracks both successful and failed executions, so you don’t need separate counters.
Cardinality: The Performance Killer
The biggest mistake developers make is creating high-cardinality dimensions. Every unique combination of tag values creates a separate time series in your metrics backend. Tags like User IDs, Request IDs, or session IDs can explode your metric count into millions of series, crushing your monitoring system.
Micrometer’s Observation API addresses this with the low/high cardinality distinction.
// Good - high cardinality only in traces
Observation.createNotStarted("api.requests", registry)
.lowCardinalityKeyValue("method", "GET")
.lowCardinalityKeyValue("status", "200")
.highCardinalityKeyValue("user_id", userId) // Appears only in traces/logs
.highCardinalityKeyValue("request_id", requestId) // Appears only in traces/logs
.observe(() -> handleRequest());
Keep your metric dimensions (low cardinality) to things with bounded values: HTTP methods, status codes, grouped endpoints, service names. Everything else goes into traces (high cardinality) where the impact is manageable.
The OpenTelemetry Connection
Micrometer’s Observation API embraces OpenTelemetry for both metrics and distributed tracing while maintaining its own mature APIs. This hybrid approach gives you the best of both worlds: Micrometer’s metrics maturity and OpenTelemetry’s standardized observability.
Under the hood, Micrometer’s tracing (via the Micrometer Tracing project) bridges to OpenTelemetry’s trace SDK. Your observations generate OpenTelemetry spans that work with any OTel-compatible backend. Additionally, Micrometer provides an OTLP registry that allows you to export metrics in the OpenTelemetry Protocol format, giving you full OpenTelemetry compatibility for both metrics and traces.
This future-proofs your instrumentation as the industry converges on OpenTelemetry standards.
Conclusion
With Micrometer, you aren’t just shipping code—you’re shipping a complete, vendor-neutral visibility layer, finally giving you the confidence and clarity required to operate modern Java microservices at scale. The Observation API’s unified approach to metrics and tracing, combined with its OpenTelemetry compatibility, makes it the definitive choice for JVM observability.
Useful Links
Official Documentation:
- Micrometer Documentation – Comprehensive official documentation and guides
- Micrometer Observation API – Deep dive into the unified observation API
- Spring Boot Actuator – Spring Boot’s integration with Micrometer
GitHub and Source:
- Micrometer GitHub – Source code, issues, and examples
- Micrometer Tracing – Distributed tracing implementation details
Best Practices:
- High Cardinality Metrics – Understanding metric cardinality concerns
- Spring Boot Observability – Official Spring guide to observability in Boot 3.x





Hi,
I’m one of the maintainers of Micrometer. I’m not sure where the version number in the article (2.0) is coming from but as of today (2025-11-08), there is no 2.0 release of Micrometer, the latest version is 1.16.0.
There are other issues with the article too, some of the examples are recommending that it is usually a bad idea (payment instrumentation) , some other examples just simply don’t work (Reactor) and some of the fact that the article claims are just simply not true (overhead).
Hi Jonatan, Thank you so much for taking the time to provide this detailed feedback. As a Micrometer maintainer, your perspective is invaluable, and I sincerely appreciate you pointing out these crucial inaccuracies. I have immediately updated and revised the article based on your comments: 1. **Version Number:** My apologies for referencing Micrometer 2.0. I have corrected this across the article to accurately refer to the **Observation API in Micrometer 1.10+** and Spring Boot 3.x. The title and framing have been adjusted accordingly. 2. **Payment Instrumentation:** I’ve reviewed and kept the unified counter pattern, but I added a **clear warning**… Read more »
Thank you for the updates! 1. **Version Number:** Can I ask you where did you get the version number 2.0? The url still contains 2.0 as a version, could you please fix that too? 2. **Payment Instrumentation:** I don’t understand the warning, there is no high cardinality issue with the DistributionSummary, there is nothing wrong with recording unique amounts. There is still a problem with the other parts: Timer (and DistributionSummary) has a counter so the two counters are redundant, also you are not measuring the failure scenario with a Timer. 3. **Reactive Example:** The one-liner hook config is optional if… Read more »
Thank you so much for taking the time to provide such detailed feedback! I really appreciate your patience and expertise. I’ve made all the corrections you mentioned: Version references: Removed all 2.0 mentions (the article didn’t contain URLs, but I’ve ensured no version references remain) Payment instrumentation: Removed the incorrect cardinality warning, eliminated the redundant counters, and switched to recordCallable() to properly measure both success and failure scenarios Reactive example: Updated to use .tap(Micrometer.observation(registry)) to measure actual execution rather than pipeline setup, and clarified the hook configuration note Overhead claims: Removed the entire performance section with incorrect claims – you’re… Read more »
Hi,
Thanks again for making the updates. I was referring to the url of the article not urls in the article but this is fixed now, thank you very much!
Just one last note, Reactor instrumentation: creating the observation and calling observed is not needed:
Observation.createNotStarted("user.fetch"
, registry).observe(
You can delete that part from the example, the single .tap call does the trick. I’m also thinking that a single webclient call might not be the best example since webclient itself is already instrumented (it’s ok though, might worth to add a note).
Hi again,
I’ve updated the example to:
.tap()directlyprocessOrder) that better demonstrates why Context Propagation matters for your own business logic.contextWrite()call to explicitly show context propagation in actionThe new example now clearly shows how to instrument your own multi-step reactive operations, which is much more useful than showing an already-instrumented WebClient call.
Thanks again for the detailed feedback – the article is much more accurate now!
Thank you very much!
Hi,
It seems my previous comment was removed for some reason, let me retry:
I’m one of the maintainers of Micrometer. I’m not sure where the version number in the article (2.0) is coming from but as of today (2025-11-08), there is no 2.0 release of Micrometer, the latest version is 1.16.0.