Skip to main content
spring boot the mechanics of magic

Metrics with Micrometer

5 min read Chapter 19 of 24
Summary

This section introduces Micrometer as the standard metrics...

This section introduces Micrometer as the standard metrics library for Spring Boot, focusing on implementing business metrics for the LogisticsCore application. It explains the dimensional metrics model (using tags) versus the legacy hierarchical model, emphasizing tags as key-value pairs for flexible aggregation. The draft provides concrete Java 21+ code examples for creating custom Counter and Timer metrics to track orders processed and processing duration, including the use of the @Timed annotation. It highlights the critical problem of cardinality explosion when using high-variability tags (like userId or orderId) and advocates for bounded, low-cardinality tags (region, status) to maintain system performance. Best practices for metric naming, tagging, and documentation are discussed. The integration covers MeterRegistry, programmatic registration, and the interaction with Spring Boot Actuator's /actuator/metrics endpoint. The tone is pragmatic, focusing on mechanics and trade-offs, though it could be more prescriptive in aligning with the required 'Principal Engineer' voice.

Metrics with Micrometer

In the LogisticsCore application, observability is enforced through rigorously defined metrics using Micrometer, the standard metrics facade for Spring Boot–based services. Micrometer provides a vendor-neutral abstraction over monitoring systems such as Prometheus, Datadog, and New Relic, enabling consistent metric collection and export. This integration is non-negotiable for production readiness: metrics must be dimensional, semantically named, and free of cardinality hazards. The MeterRegistry, injected by Spring Boot’s auto-configuration, is the central artifact responsible for metric registration, lifecycle management, and backend synchronization.

Micrometer Architecture and JVM Integration

Micrometer operates at the JVM level by instrumenting code paths and capturing timing, counts, and state snapshots. It does not rely on JVM internal telemetry (e.g., JMX) but instead uses application-level instrumentation—either programmatic or annotation-driven—to record events. When virtual threads (Project Loom) are in use, Micrometer’s Timer.Sample must be scoped within the same virtual thread context to ensure accurate timing, as thread-local state is not preserved across virtual thread yields. This requires explicit handling when using structured concurrency or Thread.startVirtualThread().

Spring Boot auto-configures a MeterRegistry instance and binds it to the application context. However, developers must understand that this is a Spring Boot convention, not a Spring Framework core capability. The distinction is critical: Spring Framework provides the inversion-of-control container and AOP infrastructure; Spring Boot supplies the opinionated auto-configuration that enables Micrometer integration out of the box.

Custom Metric Implementation in LogisticsCore

Custom metrics in LogisticsCore must be implemented programmatically or via annotations, with full awareness of the underlying proxy mechanism. The following examples use Java 21 features—records, pattern matching, and virtual threads—where applicable.

Counter: Tracking Order Processing Events

Counters are monotonic accumulators. In LogisticsCore, the total number of processed orders is tracked using a dimensional counter. The metric name follows the convention logistics.orders.processed, with tags for bounded dimensions.

// Example 1: Dimensional Counter for Order Processing
package com.logistics.core.metrics;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Component;

@Component
public record OrderMetrics(MeterRegistry registry) {

    private final Counter ordersProcessed = Counter.builder("logistics.orders.processed")
        .description("Total number of orders processed")
        .tag("application", "logistics-core")
        .register(registry);

    public void increment(String region, String status) {
        registry.counter("logistics.orders.processed", "region", region, "status", status)
            .increment();
    }
}

Note the use of registry.counter() with var-arg tags: this ensures tag reuse and avoids unnecessary Counter re-creation. The record syntax enforces immutability and reduces boilerplate.

Timer: Measuring Order Processing Latency

Latency is captured using Timer, which records duration distributions. In LogisticsCore, order processing occurs within virtual threads. The Timer.Sample must be started and stopped in the same virtual thread to maintain correctness.

// Example 2: Timer with Virtual Thread Context
package com.logistics.core.metrics;

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.StructuredTaskScope;

@Component
public record OrderProcessingTimer(MeterRegistry registry) {

    private final Timer processingTimer = Timer.builder("logistics.order.processing.duration")
        .description("Time taken to process a single order")
        .publishPercentiles(0.5, 0.95, 0.99)
        .register(registry);

    public void executeInVirtualThread(Runnable task) {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            var thread = Thread.ofVirtual().start(() -> {
                var sample = Timer.start(registry);
                try {
                    task.run();
                } finally {
                    sample.stop(processingTimer);
                }
            });
            scope.join();
            scope.throwIfFailed();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

This implementation ensures that timing samples are not corrupted by virtual thread scheduling. The use of StructuredTaskScope aligns with Java 21’s structured concurrency model.

Gauge and Distribution Summary: State and Latency Distributions

A Gauge captures instantaneous values, such as the number of pending orders in a queue. It is registered via a MeterBinder to ensure lifecycle alignment with the MeterRegistry.

// Example 3: Gauge for Pending Order Count
package com.logistics.core.metrics;

import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.binder.MeterBinder;
import org.springframework.stereotype.Component;

import java.util.Queue;

@Component
public record PendingOrderGauge(Queue<?> pendingOrders) implements MeterBinder {

    @Override
    public void bindTo(MeterRegistry registry) {
        Gauge.builder("logistics.orders.pending", pendingOrders, Queue::size)
            .description("Current number of pending orders in queue")
            .register(registry);
    }
}

A DistributionSummary is used when timing is not appropriate but distribution of values is needed (e.g., order item count per shipment). It is configured similarly to Timer but without time unit conversion.

Dimensional Metrics and Tagging Strategy

Tags enable multi-dimensional querying. In LogisticsCore, tags are restricted to bounded sets: region (e.g., “us-west-1”, “eu-central-1”), status (“created”, “shipped”, “delivered”). High-cardinality tags—such as userId, orderId, or sessionId—are prohibited. Each unique tag combination creates a new time series; unbounded cardinality leads to cardinality explosion, degrading monitoring system performance and increasing storage costs.

For example, a tag with 10,000 unique userId values, combined with 5 regions and 3 statuses, generates up to 150,000 time series. This is unacceptable. Instead, use application-level sampling or aggregate at ingestion if user-level detail is required.

@Timed Annotation and Proxy Limitations

Spring Boot supports the @Timed annotation for declarative metric collection. This relies on AOP proxies—specifically, CGLIB proxies when applied to classes, JDK dynamic proxies for interfaces. The proxy wraps the target bean and intercepts method calls to start and stop timers.

However, self-invocation bypasses the proxy. If a method within the same class calls another @Timed method directly, the annotation is ignored.

@Component
public class OrderService {

    public void processOrder(Order order) {
        validateOrder(order);
        // This call will NOT be timed if 'this' is the raw instance
        executeOrder(order);
    }

    @Timed(value = "logistics.order.execution.duration")
    public void executeOrder(Order order) { /* ... */ }
}

To enforce timing, inject the self-reference via the application context or use programmatic timing. There is no workaround within the proxy model.

Conclusion

Metrics in LogisticsCore are not optional instrumentation—they are required control points for system observability. Micrometer provides the mechanism, but correctness depends on adherence to dimensional modeling, cardinality constraints, and JVM-level timing semantics. Virtual threads require explicit sample scoping; proxies require awareness of interception boundaries. Use MeterBinder for stateful metrics, prefer programmatic registration for clarity, and never assume annotation-driven approaches are transparent. The monitoring backend will reflect the discipline of the implementation: sloppy tagging leads to unusable data, not insight.

Sources

[1] M. Allen et al., “Micrometer: Production-Ready Metrics for the JVM,” Micrometer Documentation. https://micrometer.io/docs

[2] Pivotal Software, “Spring Boot Actuator: Production-ready features,” Spring Boot Reference Documentation. https://docs.spring.io/spring-boot/docs/current/actuator-api/html/