Spring Boot Metrics with Micrometer

Quick Reference for Counters, Gauges, Timers, Distributions & Prometheus Integration

1. What is Micrometer?

  • Vendor-neutral metrics facade for JVM-based apps (like SLF4J for metrics)
  • Supports 15+ monitoring systems: Prometheus, InfluxDB, Datadog, New Relic, Cloudwatch, etc.
  • Built into Spring Boot Actuator since Spring Boot 2.x
  • Core concepts: MeterRegistry, Meter, Tags (dimensions)

Maven Dependency

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

application.yml Configuration

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health, info, prometheus, metrics
  metrics:
    export:
      prometheus:
        enabled: true
    tags:
      application: ${spring.application.name}
      environment: ${SPRING_PROFILES_ACTIVE:local}

2. Meter Types — When to Use What

TypePurposeUse CaseExample
CounterMonotonically increasing countRequest count, errors, eventsorders.placed.total
GaugeCurrent value (can go up/down)Queue size, active threads, cache sizethread.pool.active
TimerDuration + count of eventsRequest latency, method execution timehttp.server.requests
DistributionSummaryDistribution of values (no time)Payload sizes, batch sizeshttp.response.size
LongTaskTimerDuration of long-running tasksBatch jobs, file processingreport.generation
FunctionCounterCounter from external sourceJMX values, external counterscache.evictions
FunctionTimerTimer from external sourceExternal timing dataexternal.api.time
TimeGaugeGauge reporting time valuesUptime, last run timestampprocess.uptime

Decision Tree

"How many times did X happen?"

→ Counter

"What is the current value of X?"

→ Gauge

"How long did X take?"

→ Timer

"What is the distribution of X values?"

→ DistributionSummary

"How long has X been running (still in progress)?"

→ LongTaskTimer

3. Counter

// Simple counter
Counter counter = Counter.builder("orders.placed")
    .description("Total orders placed")
    .tag("channel", "web")
    .register(meterRegistry);
counter.increment();
counter.increment(5.0);

// With @Counted annotation (Spring AOP)
@Counted(value = "orders.placed", description = "Total orders placed")
public Order placeOrder(OrderRequest request) { ... }

// Functional counter (from existing value)
FunctionCounter.builder("cache.evictions", cache, Cache::evictionCount)
    .register(meterRegistry);

Required AOP Configuration for @Counted

@Counted will NOT work without registering the CountedAspect bean. Spring Boot does not auto-configure it.

// Required: Register CountedAspect bean
@Configuration
@EnableAspectJAutoProxy
public class MetricsConfig {

    @Bean
    public CountedAspect countedAspect(MeterRegistry registry) {
        return new CountedAspect(registry);
    }
}

// Maven dependency (if not already via spring-boot-starter-aop)
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

4. Gauge

// Simple gauge
Gauge.builder("queue.size", queue, Queue::size)
    .description("Current queue depth")
    .tag("queue", "orders")
    .register(meterRegistry);

// AtomicInteger gauge
AtomicInteger activeUsers = new AtomicInteger(0);
Gauge.builder("users.active", activeUsers, AtomicInteger::get)
    .register(meterRegistry);

// Collection size gauge
Gauge.builder("cache.entries", cache, Map::size)
    .register(meterRegistry);

// Multi-gauge (for dynamic values)
MultiGauge multiGauge = MultiGauge.builder("pool.connections")
    .register(meterRegistry);
multiGauge.register(
    pools.stream()
        .map(p -> Row.of(Tags.of("pool", p.getName()), p.getActiveCount()))
        .collect(Collectors.toList())
);

⚠️ Warning

Never create gauges in a loop or per-request! Gauges hold a weak reference to the observed object. The object must be held elsewhere (field, bean, etc.)

5. Timer

// Manual timer
Timer timer = Timer.builder("http.requests")
    .description("HTTP request duration")
    .tag("method", "GET")
    .tag("uri", "/api/orders")
    .publishPercentiles(0.5, 0.95, 0.99)   // client-side percentiles
    .publishPercentileHistogram()             // for Prometheus histogram_quantile
    .sla(Duration.ofMillis(100), Duration.ofMillis(500), Duration.ofSeconds(1))  // SLA buckets
    .register(meterRegistry);

// Record execution
timer.record(() -> doSomething());
timer.record(Duration.ofMillis(250));

// With Sample (for async/split timing)
Timer.Sample sample = Timer.start(meterRegistry);
// ... async work ...
sample.stop(timer);

// With @Timed annotation
@Timed(value = "orders.process", percentiles = {0.5, 0.95, 0.99},
       histogram = true, description = "Order processing time")
public void processOrder(Order order) { ... }

Required AOP Configuration for @Timed

@Timed will NOT work without registering the TimedAspect bean. Spring Boot does not auto-configure it.

// Required: Register TimedAspect bean
@Configuration
@EnableAspectJAutoProxy
public class MetricsConfig {

    @Bean
    public TimedAspect timedAspect(MeterRegistry registry) {
        return new TimedAspect(registry);
    }

    @Bean
    public CountedAspect countedAspect(MeterRegistry registry) {
        return new CountedAspect(registry);
    }
}

// Both aspects in one config class — register once, use everywhere
// Imports:
// import io.micrometer.core.aop.TimedAspect;
// import io.micrometer.core.aop.CountedAspect;
// import io.micrometer.core.instrument.MeterRegistry;

Spring Boot Auto-Timed HTTP Requests

# application.yml
management:
  metrics:
    web:
      server:
        request:
          autotime:
            enabled: true
            percentiles: 0.5, 0.95, 0.99
            percentiles-histogram: true

6. DistributionSummary

// Track payload sizes
DistributionSummary summary = DistributionSummary.builder("http.response.size")
    .description("Response payload size in bytes")
    .baseUnit("bytes")
    .publishPercentiles(0.5, 0.95)
    .publishPercentileHistogram()
    .minimumExpectedValue(100.0)
    .maximumExpectedValue(10_000_000.0)
    .register(meterRegistry);

summary.record(responseBody.length());

// Track batch sizes
DistributionSummary.builder("batch.size")
    .tag("type", "import")
    .register(meterRegistry)
    .record(batch.size());

7. Tags (Dimensions) — Best Practices

// Tags add dimensions to metrics (like labels in Prometheus)
Counter.builder("http.requests")
    .tag("method", request.getMethod())
    .tag("uri", request.getRequestURI())
    .tag("status", String.valueOf(response.getStatus()))
    .tag("outcome", status >= 400 ? "error" : "success")
    .register(meterRegistry)
    .increment();

// Common tag patterns
@Bean
MeterRegistryCustomizer<MeterRegistry> commonTags() {
    return registry -> registry.config().commonTags(
        "application", applicationName,
        "environment", activeProfile,
        "region", region,
        "instance", instanceId
    );
}

Tag Cardinality Rules

✅ LOW Cardinality (Good)

  • method (GET/POST)
  • status (200/404/500)
  • env (prod/staging)

❌ HIGH Cardinality (BAD)

  • userId, requestId, email
  • Full URL path with IDs
  • Rule: If >100 distinct values, DON'T use it

⚠️ High cardinality = memory explosion + slow queries

8. Spring Boot Auto-Configured Metrics

Built-in metrics you get for free:

# JVM
jvm.memory.used, jvm.memory.max, jvm.memory.committed
jvm.gc.pause, jvm.gc.memory.allocated
jvm.threads.live, jvm.threads.daemon, jvm.threads.peak
jvm.buffer.memory.used, jvm.buffer.count

# HTTP Server (Spring MVC / WebFlux)
http.server.requests (timer: method, uri, status, outcome)

# HTTP Client (RestTemplate / WebClient)
http.client.requests (timer: method, uri, status)

# DataSource / Connection Pool
hikaricp.connections.active, hikaricp.connections.idle
hikaricp.connections.max, hikaricp.connections.usage

# Cache (if Spring Cache enabled)
cache.gets, cache.puts, cache.evictions, cache.size

# Spring Integration
spring.integration.send (channels)
spring.integration.receive (channels)

# Reactor (WebFlux / Project Reactor)
reactor.scheduler.tasks.active
reactor.flow.duration

# System
process.cpu.usage, system.cpu.usage
process.uptime, process.start.time
disk.free, disk.total

# Logback
logback.events (tag: level)

9. Custom Metrics Patterns

Business Metrics

@Component
public class OrderMetrics {
    private final Counter ordersPlaced;
    private final Counter ordersFailed;
    private final Timer orderProcessingTime;
    private final DistributionSummary orderValue;
    private final AtomicInteger pendingOrders;

    public OrderMetrics(MeterRegistry registry) {
        ordersPlaced = Counter.builder("orders.placed.total")
            .description("Total successful orders").register(registry);
        ordersFailed = Counter.builder("orders.failed.total")
            .description("Total failed orders").register(registry);
        orderProcessingTime = Timer.builder("orders.processing.duration")
            .publishPercentileHistogram().register(registry);
        orderValue = DistributionSummary.builder("orders.value")
            .baseUnit("CHF").publishPercentiles(0.5, 0.95).register(registry);
        Gauge.builder("orders.pending", pendingOrders = new AtomicInteger(),
            AtomicInteger::get).register(registry);
    }

    public void recordOrder(Order order, Duration processingTime) {
        ordersPlaced.increment();
        orderProcessingTime.record(processingTime);
        orderValue.record(order.getTotal().doubleValue());
    }
}

Circuit Breaker Metrics (Resilience4j)

// Auto-configured with micrometer-registry + resilience4j
resilience4j.circuitbreaker.state           // gauge: 0=closed, 1=open, 2=half-open
resilience4j.circuitbreaker.calls           // timer: kind=successful|failed|ignored
resilience4j.circuitbreaker.failure.rate    // gauge
resilience4j.retry.calls                    // counter: kind=successful|failed

Caching Metrics

@Bean
public CacheMetricsRegistrar cacheMetrics(MeterRegistry registry) {
    // Automatically registers cache.gets, cache.puts, cache.evictions
}

10. Prometheus Query Examples (PromQL)

# Request rate (per second, 5m window)
rate(http_server_requests_seconds_count{application="my-app"}[5m])

# Error rate percentage
rate(http_server_requests_seconds_count{status=~"5.."}[5m])
/ rate(http_server_requests_seconds_count[5m]) * 100

# 95th percentile response time (from histogram)
histogram_quantile(0.95, rate(http_server_requests_seconds_bucket[5m]))

# Average response time
rate(http_server_requests_seconds_sum[5m])
/ rate(http_server_requests_seconds_count[5m])

# JVM heap usage percentage
jvm_memory_used_bytes{area="heap"}
/ jvm_memory_max_bytes{area="heap"} * 100

# Active HikariCP connections
hikaricp_connections_active{pool="HikariPool-1"}

# Orders per minute
rate(orders_placed_total[1m]) * 60

# Pending orders across all instances
sum(orders_pending)

# Top 5 slowest endpoints
topk(5, rate(http_server_requests_seconds_sum[5m])
/ rate(http_server_requests_seconds_count[5m]))

11. Reactor / WebFlux Specific Metrics

// Enable Reactor metrics
@Bean
public ReactorMetricsAutoConfiguration reactorMetrics() { ... }

// Or manually:
Hooks.onEachOperator(Metrics.METRIC_KEY,
    OperatorTracingHook.newBuilder().build());

// WebClient metrics
WebClient.builder()
    .filter(ExchangeFilterFunctions.metricsFilter(meterRegistry))
    .build();

// Custom Reactor metrics
Flux.range(1, 100)
    .name("my.flux.pipeline")
    .metrics()
    .subscribe();

// Key Reactor metrics:
// reactor.flow.duration — time spent in operator
// reactor.scheduler.tasks.active — active scheduled tasks
// reactor.onNext.delay — subscriber signal delays

12. Spring Integration Metrics

// Auto-configured when micrometer is on classpath
// Metrics for channels and handlers:
spring.integration.send          // timer: channel name, result
spring.integration.receive       // timer: channel name
spring.integration.handler       // timer: handler name

// Custom metrics in IntegrationFlow
@Bean
public IntegrationFlow myFlow() {
    return IntegrationFlows.from("inputChannel")
        .handle((payload, headers) -> {
            meterRegistry.counter("messages.processed",
                "type", headers.get("type").toString()
            ).increment();
            return payload;
        })
        .channel("outputChannel")
        .get();
}

// Wire-tap for metrics without modifying main flow
.wireTap(sf -> sf.handle(msg ->
    meterRegistry.counter("messages.observed").increment()
))