How to instrument a Spring Boot service with custom Micrometer metrics — publishing counters for events, gauges for current state, timers for latency, and distribution summaries for arbitrary measurements.
Spring Boot Actuator auto-configures Micrometer and publishes JVM, HTTP, and system metrics out of the box. For business-specific observability — order placement rates, market data latency, error categories, queue depths — you instrument your own code with the Micrometer API. The same code works with Prometheus, CloudWatch, Datadog, or any other backend by swapping the registry implementation.
@Service
@RequiredArgsConstructor
public class OrderService {
private final MeterRegistry registry;
// ...
}
MeterRegistry is injected by Spring Boot — no configuration needed. In tests, use SimpleMeterRegistry for inspection, or new PrometheusMeterRegistry(...) to verify Prometheus output format.
A counter increments and never decreases. Use it for: orders placed, errors occurred, messages processed.
@Service
@RequiredArgsConstructor
public class OrderService {
private final MeterRegistry registry;
public void placeOrder(OrderRequest req) {
// ... place the order ...
registry.counter("trading.orders.placed",
"market", req.marketId(),
"side", req.side()
).increment();
}
public void onOrderError(OrderRequest req, Exception e) {
registry.counter("trading.orders.errors",
"market", req.marketId(),
"error_type", e.getClass().getSimpleName()
).increment();
}
}
Tags (key-value pairs) enable multi-dimensional querying. In Prometheus:
rate(trading_orders_placed_total{side="BACK"}[5m])
A counter that has never been incremented does not appear in Prometheus — which means rate() returns null rather than 0. Pre-register counters at startup:
@PostConstruct
public void initMetrics() {
for (String side : List.of("BACK", "LAY")) {
Counter.builder("trading.orders.placed")
.tag("side", side)
.description("Number of orders placed")
.register(registry);
}
}
A gauge reports the current value of something that goes up and down — queue depth, active connections, in-progress operations.
// Track queue depth automatically
AtomicInteger pendingOrders = new AtomicInteger(0);
Gauge.builder("trading.orders.pending", pendingOrders, AtomicInteger::get)
.description("Number of orders awaiting confirmation")
.register(registry);
// Increment/decrement around processing
pendingOrders.incrementAndGet();
try {
processOrder(order);
} finally {
pendingOrders.decrementAndGet();
}
Or gauge a collection size:
Gauge.builder("trading.markets.subscribed", marketStateMap, Map::size)
.description("Number of markets currently subscribed")
.register(registry);
The gauge pulls the value lazily on each scrape — the Map::size function is called by the registry, not by your code.
Timer betfairApiTimer = Timer.builder("trading.betfair.api.latency")
.description("Betfair API response time")
.tag("operation", "placeOrder")
.publishPercentiles(0.5, 0.95, 0.99)
.register(registry);
MarketBook book = betfairApiTimer.record(() -> betfairClient.getMarketBook(marketId));
Or with Timer.Sample for code that might throw:
Timer.Sample sample = Timer.start(registry);
try {
return betfairClient.placeOrder(request);
} finally {
sample.stop(registry.timer("trading.betfair.api.latency",
"operation", "placeOrder",
"status", success ? "ok" : "error"));
}
publishPercentiles writes p50/p95/p99 as separate gauge metrics. For percentiles across a fleet, use publishPercentileHistogram(true) instead — this publishes histogram buckets compatible with Prometheus histogram_quantile.
For values with units other than time — stake sizes, price levels, WoM values:
DistributionSummary stakeSummary = DistributionSummary.builder("trading.orders.stake")
.description("Distribution of order stake sizes in GBP")
.baseUnit("GBP")
.publishPercentiles(0.5, 0.75, 0.95)
.register(registry);
stakeSummary.record(order.stake());
Prometheus query to see the p95 stake:
histogram_quantile(0.95, sum(rate(trading_orders_stake_bucket[5m])) by (le))
For wrapping existing code cleanly:
// Counts method calls automatically
registry.gauge("trading.orders.inFlight",
Tags.of("market", marketId),
inFlightOrders,
Collection::size);
Apply a common tag to every metric in a service (e.g., the application name or environment):
@Bean
public MeterRegistryCustomizer<MeterRegistry> commonTags(
@Value("${spring.application.name}") String appName) {
return registry -> registry.config()
.commonTags("application", appName, "env", "production");
}
All metrics from this service include application=trading-service,env=production — essential for filtering in multi-service Grafana dashboards.
For a Betfair trading service, a minimal dashboard needs:
# Order placement rate
rate(trading_orders_placed_total[1m])
# Error rate
rate(trading_orders_errors_total[1m]) / rate(trading_orders_placed_total[1m])
# API p99 latency
histogram_quantile(0.99, sum(rate(trading_betfair_api_latency_seconds_bucket[5m])) by (le, operation))
# Subscribed markets
trading_markets_subscribed
With these four metrics surfaced, the trading system’s health is visible at a glance without opening application logs.
If you’re instrumenting a Java service with observability metrics and want a review of your metrics design, get in touch.