Metrics with Micrometer

Record counters, timers and gauges with Micrometer, export them to Prometheus or OTLP, and keep tag cardinality under control.

SyntaxCounter.builder("orders.placed").tag("channel", channel).register(registry).increment(); Timer timer = registry.timer("payments.charge", "provider", "stripe"); timer.record(() -> charge()); registry.gauge("jobs.queue.size", queue, Queue::size);

Micrometer is the metrics facade Spring Boot uses, in the way SLF4J is a facade for logging. You record measurements against a MeterRegistry, and a registry implementation ships them to your monitoring system: Prometheus, OTLP, Datadog and many others. With Actuator on the classpath Spring Boot already records a lot for you: http.server.requests for every controller call, JVM memory and garbage collection, the connection pool, executors and caches.

Three meter types cover most needs

  • Counter: a number that only goes up. Orders placed, emails sent, payments declined. The monitoring system turns it into a rate.
  • Timer: how long something took and how often it happened. Use it for any call whose latency you care about.
  • Gauge: a current value sampled when it is read, such as queue depth or open sessions. The registry holds a weak reference to the object it samples, so keep a strong reference yourself or the gauge quietly reports nothing.

Tags and cardinality

Tags (Prometheus calls them labels) split one meter into series: orders.placed by channel=web|mobile. Every distinct combination of tag values is a separate time series stored by your monitoring system. Tag by user ID, order ID or raw URL and you create millions of series, which slows queries and can run up a large bill. Tag only with values from a small, fixed set. This is also why http.server.requests tags requests with the URI template, /orders/{id}, and not the actual path.

Exporting

For Prometheus add io.micrometer:micrometer-registry-prometheus and expose the prometheus endpoint; Prometheus then scrapes /actuator/prometheus. For push-based systems such as an OpenTelemetry collector, add micrometer-registry-otlp and set management.otlp.metrics.export.url. Add management.metrics.tags.application=${spring.application.name} so every meter carries the service name and dashboards can separate services.

Percentiles

Averages hide the slow requests people complain about. Turn on histograms for the timers you care about with management.metrics.distribution.percentiles-histogram.http.server.requests=true and compute p95 and p99 in the monitoring system, where they can be aggregated across instances. Client-side percentiles (percentiles=0.95) cannot be averaged across pods.

Annotations

@Timed, @Counted and @Observed work only when management.observations.annotations.enabled=true and AspectJ is on the classpath, and like every proxy-based feature they do nothing for calls within the same class. Recording in code with the registry, as below, has no such surprises.

Example

Example · java
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import java.math.BigDecimal;
import org.springframework.stereotype.Service;

record PlaceOrder(String channel, BigDecimal total) {
}

@Service
public class CheckoutService {

    private final MeterRegistry registry;
    private final Timer paymentTimer;

    CheckoutService(MeterRegistry registry) {
        this.registry = registry;
        this.paymentTimer = Timer.builder("checkout.payment")
                .description("Time spent charging the customer")
                .publishPercentileHistogram()
                .register(registry);
    }

    public void place(PlaceOrder order) {
        paymentTimer.record(() -> charge(order.total()));
        // channel has a handful of values, so it is safe as a tag.
        Counter.builder("checkout.orders")
                .tag("channel", order.channel())
                .register(registry)
                .increment();
    }

    private void charge(BigDecimal amount) {
        // Call the payment provider.
    }
}

When to use it

  • Alert when the rate of declined payments in the last five minutes doubles compared with the previous hour.
  • Plot p99 latency of every endpoint across all pods in Grafana from the http.server.requests histogram.
  • Watch the number of pending export jobs so autoscaling adds workers before the backlog grows.

More examples

Exporting to Prometheus

Prometheus scrapes <code>http://pod:9090/actuator/prometheus</code>. The keys under <code>percentiles-histogram</code> and <code>enable</code> are meter name prefixes, so one line covers every meter whose name starts with that text.

Example · properties
# pom.xml needs io.micrometer:micrometer-registry-prometheus (version managed by Spring Boot).
management.endpoints.web.exposure.include=health,info,prometheus
management.server.port=9090

# A common tag on every meter so dashboards can tell services apart.
spring.application.name=orders
management.metrics.tags.application=${spring.application.name}

# Histogram buckets so p95 and p99 can be computed across all instances.
management.metrics.distribution.percentiles-histogram.http.server.requests=true

# Stop recording a noisy meter family completely.
management.metrics.enable.jvm.threads=false

A gauge and a MeterBinder

Spring Boot binds every <code>MeterBinder</code> bean to the registry at startup. The gauge reads the queue size each time the registry is scraped, so there is nothing to update by hand. Because the queue is a field of a singleton bean it cannot be garbage collected, which avoids the classic gauge that drops to NaN.

Example · java
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.binder.MeterBinder;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import org.springframework.stereotype.Component;

@Component
public class ExportQueueMetrics implements MeterBinder {

    // The bean keeps this strong reference; the gauge only holds a weak one.
    private final BlockingQueue<String> pending = new LinkedBlockingQueue<>();

    public void submit(String exportId) {
        pending.add(exportId);
    }

    @Override
    public void bindTo(MeterRegistry registry) {
        Gauge.builder("exports.pending", pending, BlockingQueue::size)
                .description("Exports waiting to be processed")
                .register(registry);
    }
}

Timing with the Observation API

An observation is instrumented once and produces several signals: a timer named <code>reports.generate</code> and, when tracing is set up, a span. Low cardinality key values become metric tags and span attributes; high cardinality ones become span attributes only, which is where an ID belongs.

Example · java
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.stereotype.Service;

@Service
public class ReportService {

    private final ObservationRegistry observations;

    ReportService(ObservationRegistry observations) {
        this.observations = observations;
    }

    public byte[] monthly(String format) {
        return Observation.createNotStarted("reports.generate", observations)
                .lowCardinalityKeyValue("format", format)
                .observe(() -> render(format));
    }

    private byte[] render(String format) {
        return new byte[0];
    }
}

Discussion

  • Be the first to comment on this lesson.