Enterprise Java

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:

GitHub and Source:

Best Practices:

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

9 Comments
Oldest
Newest Most Voted
Jonatan Ivanov
7 months ago

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.

Jonatan Ivanov
7 months ago
Reply to  Jonatan Ivanov

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).

Last edited 7 months ago by Jonatan Ivanov
Jonatan Ivanov
7 months ago

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 »

Jonatan Ivanov
7 months ago

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).

Jonatan Ivanov
6 months ago

Thank you very much!

Jonatan Ivanov
7 months ago

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.

Back to top button