Spring Boot
Metrics Monitoring
Metrics are cheap, aggregated numbers (request rates, latency distributions, pool usage, business counters) that tell you when something is wrong and roughly where. Spring Boot exposes them through Micrometer, a facade over backends such as Prometheus, Datadog, and CloudWatch. This entry covers choosing the right meter type, wiring up Prometheus, writing custom metrics that behave correctly, and the mistakes that produce misleading dashboards: unaggregatable percentiles, double _total suffixes, and tag cardinality blow-ups.
Choosing the Right Meter Type
Micrometer gives you five meter types, and most dashboard confusion comes from picking the wrong one. A quick rule of thumb: if you would ask "how many", use a Counter; "how much right now", a Gauge; "how long", a Timer; "how big", a DistributionSummary; and "how long has this been running so far", a LongTaskTimer.
Two details that catch people out. A Gauge is sampled when the backend scrapes it, and Micrometer holds only a weak reference to the object it reads from. If that object gets garbage collected, the gauge silently reports NaN, so always back it with a field on a long-lived bean, never a local variable. And a Counter only goes up; if you need to show "in flight right now", that is a Gauge, even though both are just numbers.
Java
@Component
public class MeterExamples {
private final MeterRegistry registry;
private final AtomicInteger inFlight = new AtomicInteger(); // long-lived field: safe for a gauge
public MeterExamples(MeterRegistry registry) {
this.registry = registry;
// Gauge: current state, read at scrape time.
registry.gauge("orders.in.flight", inFlight);
}
void examples() {
// Counter: things that happened.
registry.counter("orders.placed", "channel", "web").increment();
// Timer: how long something took (also gives you a count).
Timer.builder("payment.call.duration")
.register(registry)
.record(Duration.ofMillis(180));
// DistributionSummary: how big, for anything that is not time.
DistributionSummary.builder("upload.size")
.baseUnit("bytes")
.register(registry)
.record(52_431);
}
}Exposing Metrics to Prometheus
Micrometer collects; something else has to store and query. For Prometheus you add one dependency and expose one endpoint. Prometheus then pulls from /actuator/prometheus on its own schedule.
Set a common application tag from day one. Without it, metrics from ten services land in the same Prometheus and you cannot tell them apart. Naming matters too: write names in dotted form (orders.placed) and let the registry translate. Prometheus will emit orders_placed_total for counters and append base units such as _seconds to timers, so do not add _total or _seconds yourself or you will end up with orders_placed_total_total.
yaml
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<scope>runtime</scope>
</dependency>
# application.yml
management:
endpoints:
web:
exposure:
include: health,info,prometheus
metrics:
tags:
application: ${spring.application.name}
env: ${APP_ENV:local}
# prometheus.yml (scrape config)
scrape_configs:
- job_name: orders-service
metrics_path: /actuator/prometheus
scrape_interval: 15s
static_configs:
- targets: ["orders-service:8080"]What You Get for Free
With Actuator and a registry on the classpath, Boot instruments the JVM, the embedded server, Hikari, caches, and more, with no code. The HTTP server metric, http.server.requests, is the one you will use most. It carries uri (the route template, not the raw path, so /orders/{id} rather than /orders/42), method, status, outcome, and exception.
By default HTTP timers publish only count, sum, and max. That is enough for average latency, but you cannot compute percentiles from it. To get p95 and p99 across all instances, turn on percentile histograms, which publish bucketed counts that Prometheus can aggregate. The metric name in the config must match the meter exactly; a typo is silently ignored.
yaml
# application.yml
management:
metrics:
distribution:
percentiles-histogram:
http.server.requests: true
orders.placement.duration: true # must match the Timer name exactly
slo:
http.server.requests: 100ms, 250ms, 500ms, 1s
# Useful built-in meters (names as seen in Actuator; Prometheus rewrites the dots):
# jvm.memory.used, jvm.memory.max, jvm.gc.pause, jvm.threads.live
# hikaricp.connections.active / .idle / .pending / .max
# process.cpu.usage, system.cpu.usage, process.uptime
# cache.gets{result=hit|miss}, cache.evictions
# http.server.requests, http.client.requests
# PromQL you will actually use
# Error ratio over 5 minutes:
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m]))
/ sum(rate(http_server_requests_seconds_count[5m]))
# p99 latency across all instances (note the sum by (le), which is required):
histogram_quantile(0.99,
sum by (le) (rate(http_server_requests_seconds_bucket[5m])))
# Threads waiting for a DB connection (the pool is too small if this is above 0 for long):
max(hikaricp_connections_pending)Custom Business Metrics
Technical metrics tell you the system is struggling; business metrics tell you whether anyone cares. Put orders placed, payments failed, and queue depth next to your latency graphs and an incident becomes much easier to size.
Three habits keep custom metrics trustworthy. Use publishPercentileHistogram() rather than publishPercentiles() for anything running on more than one instance: client-side percentiles are computed per instance and cannot be averaged or merged, so a dashboard that averages p99 across pods is wrong. Keep units consistent: the summary below records cents and declares cents. And never read a Gauge from something nothing updates; the in-flight gauge below is paired with the code that moves it.
Registering a meter on every call works because the registry returns the existing one, but it does a lookup each time. On hot paths, build the meter once and reuse it.
Java
@Service
public class OrderMetrics {
private final MeterRegistry registry;
private final AtomicInteger inFlight = new AtomicInteger();
private final LongTaskTimer batchTimer;
private final Timer placementTimer;
public OrderMetrics(MeterRegistry registry) {
this.registry = registry;
Gauge.builder("orders.in.flight", inFlight, AtomicInteger::get)
.description("Orders currently being processed")
.register(registry);
this.batchTimer = LongTaskTimer.builder("batch.job.active")
.description("Batch jobs currently running")
.register(registry); // use the injected registry, not Metrics.globalRegistry
this.placementTimer = Timer.builder("orders.placement.duration")
.description("End-to-end order placement time")
.publishPercentileHistogram() // aggregatable across instances
.register(registry);
}
public OrderResponse place(CreateOrderRequest req, Supplier<OrderResponse> work) {
inFlight.incrementAndGet();
try {
OrderResponse result = placementTimer.record(work);
registry.counter("orders.placed", "channel", req.channel()).increment();
registry.summary("orders.value.cents").record(req.totalCents());
return result;
} catch (PaymentException e) {
registry.counter("orders.failed", "reason", "payment_failed").increment();
throw e;
} finally {
inFlight.decrementAndGet();
}
}
public void runBatch(Runnable job) {
LongTaskTimer.Sample sample = batchTimer.start();
try { job.run(); } finally { sample.stop(); }
}
}
// Why the LongTaskTimer is created in the constructor: a field initialiser such as
// private final LongTaskTimer t = LongTaskTimer.builder(...).register(meterRegistry);
// runs before Lombok's generated constructor assigns meterRegistry, so it would hit a null.Annotations: @Timed, @Counted, @Observed
Annotations remove boilerplate for simple cases, but they come with rules. Spring Boot does not register the aspects for you (apart from @Timed on controller methods, which Boot's web instrumentation already handles), so you declare TimedAspect and CountedAspect beans and add spring-boot-starter-aop. They are proxy-based, which means calling an annotated method from inside the same class bypasses the proxy and records nothing.
One naming trap: with Prometheus, @Counted(value = "product.views") exports product_views_total. Naming it "product.views.total" gives you product_views_total_total.
On Spring Boot 3 you can use the Observation API instead: @Observed with an ObservedAspect produces a timer and a trace span from a single annotation, which is the better choice if you already run distributed tracing.
Java
@Configuration
public class MetricsConfig {
@Bean TimedAspect timedAspect(MeterRegistry registry) { return new TimedAspect(registry); }
@Bean CountedAspect countedAspect(MeterRegistry registry) { return new CountedAspect(registry); }
}
@Service
public class ProductService {
@Timed(value = "product.search.duration",
description = "Product search time",
histogram = true)
public List<ProductResponse> search(ProductSearchRequest request) { ... }
// Counts successes and failures separately (result and exception tags are added for you).
@Counted(value = "product.views", description = "Product detail views")
public ProductDetailResponse getDetail(Long id) { ... }
public ProductDetailResponse featured() {
return getDetail(1L); // self-invocation: the annotation is NOT applied here
}
}
// Boot 3 alternative: one annotation, metrics plus a trace span.
// @Bean ObservedAspect observedAspect(ObservationRegistry r) { return new ObservedAspect(r); }
// @Observed(name = "product.search", contextualName = "product-search")Tags and Cardinality
Every unique combination of tag values creates a separate time series, and each one costs memory in your app and storage in your backend. Tags like channel, status, or region have a handful of values and are fine. Tags like userId, orderId, email, or a raw URL path have millions and will eventually take down Prometheus or inflate your Datadog bill. If you want per-order detail, that belongs in logs or traces, not metrics.
Rule of thumb: a tag should have fewer than a few dozen possible values, and those values should be known in advance. Micrometer lets you enforce this centrally with a MeterFilter, which is a good safety net for tags that accept user-influenced input.
Java
@Configuration
public class MeterFilters {
// Cap a risky tag: once more than 100 distinct values appear, stop creating new series.
@Bean
MeterFilter limitUriCardinality() {
return MeterFilter.maximumAllowableTags("http.client.requests", "uri", 100, MeterFilter.deny());
}
// Drop a metric you do not want to pay for.
@Bean
MeterFilter dropNoisyMetric() {
return MeterFilter.denyNameStartsWith("jvm.buffer");
}
}
// Good tags: channel=web|mobile|api status=success|failed region=eu-west-1
// Bad tags: userId=48213 orderId=A-10293 path=/orders/48213 exception message textTesting Your Metrics
Metrics are easy to get subtly wrong and nobody notices until an incident, so assert on the important ones. SimpleMeterRegistry is an in-memory registry for tests: no backend, no Spring context needed.
Java
class OrderMetricsTest {
private final SimpleMeterRegistry registry = new SimpleMeterRegistry();
private final OrderMetrics metrics = new OrderMetrics(registry);
@Test
void successfulOrderIncrementsCounterAndRecordsTiming() {
metrics.place(new CreateOrderRequest("web", 2_500), () -> new OrderResponse("ok"));
assertThat(registry.get("orders.placed").tag("channel", "web").counter().count())
.isEqualTo(1.0);
assertThat(registry.get("orders.placement.duration").timer().count())
.isEqualTo(1);
assertThat(registry.get("orders.in.flight").gauge().value())
.isZero(); // gauge returns to 0 after completion
}
@Test
void paymentFailureIsCountedAndInFlightIsReleased() {
assertThatThrownBy(() ->
metrics.place(new CreateOrderRequest("web", 2_500), () -> { throw new PaymentException("declined"); }))
.isInstanceOf(PaymentException.class);
assertThat(registry.get("orders.failed").tag("reason", "payment_failed").counter().count())
.isEqualTo(1.0);
assertThat(registry.get("orders.in.flight").gauge().value()).isZero();
}
}