Hire Me
← All Writing Java

Custom Metrics with Micrometer — Counters, Gauges, and Distribution Summaries

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.

The Micrometer registry

@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.

Counter: count events

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])

Pre-registering counters for zero baseline

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);
    }
}

Gauge: measure current state

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: measure latency

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.

Distribution summary: arbitrary measurements

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))

Functional counters and timers

For wrapping existing code cleanly:

// Counts method calls automatically
registry.gauge("trading.orders.inFlight",
    Tags.of("market", marketId),
    inFlightOrders,
    Collection::size);

Common tags across all metrics

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.

A dashboard-ready example

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.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 20+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.