Custom Metrics in Spring Boot 3.5 with Micrometer and Prometheus: Names, Exposure, Cardinality, and the Test That Returns 404

Code tested with Spring Boot 3.5.16, Micrometer 1.15.12, Prometheus Java client 1.3.10, Java 17; 14 tests, a recorded curl session, and a scrape by prom/prometheus:v3.14.0

Revision note (2026-09-15). The earlier version of this article was written against Spring Boot 2.7 and Java 11, set management.metrics.export.prometheus.enabled, which Spring Boot 3 renamed and now rejects with an error-level deprecation, secured the endpoint with WebSecurityConfigurerAdapter, a class that no longer exists in the Spring Security version Boot 3.5 ships, and showed a "pending orders" gauge that was decremented in the same synchronous method that incremented it, so a scrape could only ever read 0. It also claimed a Prometheus setup without showing a scrape. This version is rebuilt around a small project with 14 tests, a recorded curl session, and a Prometheus 3.14 container that actually scraped the application.

What this covers and what was run

Three custom meters in a Spring Boot 3.5.16 service (a counter, a gauge, a timer), exposed at /actuator/prometheus and scraped by Prometheus. Along the way: how Micrometer rewrites meter names for Prometheus, the one property without which the endpoint is a 404, a cardinality problem measured in series counts and capped with a MeterFilter, and a testing trap that made the first run of the lab fail 8 of 11 tests.

Tested versions: Spring Boot 3.5.16 (spring-boot-actuator 3.5.16), Micrometer 1.15.12 with micrometer-registry-prometheus (Prometheus Java client 1.3.10), Java 17 (Amazon Corretto 17.0.14), Gradle 8.8, Prometheus prom/prometheus:v3.14.0 in Docker 27. The lab is examples/spring-boot-micrometer-prometheus in the site repository; every output below is pasted from it.

Dependencies and the exposure property

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'io.micrometer:micrometer-registry-prometheus' // version managed by Boot: 1.15.12
}
server.port=8100
management.endpoints.web.exposure.include=health,prometheus

That second line is the whole configuration. The Prometheus registry is auto-configured as soon as micrometer-registry-prometheus is on the classpath; the endpoint exists but is not served over HTTP until it is listed in management.endpoints.web.exposure.include. The lab pins this with a second context that exposes only health:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@AutoConfigureObservability
@TestPropertySource(properties = {
    "server.port=8101",
    "management.endpoints.web.exposure.include=health"
})
class ExposureRequiredTests {

    @Test
    void prometheusEndpointIs404WhenNotExposed() {
        assertThat(rest.getForEntity("/actuator/prometheus", String.class).getStatusCode().value()).isEqualTo(404);
        assertThat(rest.getForEntity("/actuator/health", String.class).getStatusCode().value()).isEqualTo(200);
    }
}

Two properties from the Boot 2 era do nothing useful here. management.endpoint.prometheus.enabled defaults to true. management.metrics.export.prometheus.enabled was renamed in Spring Boot 3.0; the configuration metadata shipped inside spring-boot-actuator-autoconfigure-3.5.16.jar marks it as a deprecation with level: error and replacement: management.prometheus.metrics.export.enabled. A test in the lab reads that metadata from the classpath and asserts exactly those two fields, and that the replacement exists, defaults to true, and is not itself deprecated.

The three meters

@Component
public class OrderMetrics {

    private final Counter placed;
    private final AtomicInteger pending = new AtomicInteger();
    private final Timer processing;

    public OrderMetrics(MeterRegistry registry) {
        this.placed = Counter.builder("orders.placed")
            .description("Orders accepted by the API")
            .tag("service", "order")
            .register(registry);

        Gauge.builder("orders.pending", pending, AtomicInteger::get)
            .description("Orders accepted but not yet processed")
            .tag("service", "order")
            .register(registry);

        this.processing = Timer.builder("orders.processing")
            .description("Time spent processing one order")
            .tag("service", "order")
            .register(registry);
    }

    void orderPlaced() {
        placed.increment();
        pending.incrementAndGet();
    }

    void orderProcessed(Runnable work) {
        processing.record(work);
        pending.decrementAndGet();
    }
}

Names are dotted and lower case; that is Micrometer's convention, and each registry translates it. The service places orders into a queue (POST /orders) and processes them in a separate call (POST /orders/process?workMillis=). That split is what makes the gauge worth having: a gauge that is incremented and decremented inside one synchronous method is always 0 when someone looks.

What Prometheus sees

Recorded with the packaged jar on port 8100:

$ for i in 1 2 3; do curl -s -X POST http://localhost:8100/orders; echo; done
{"id":"order-2776979244667000","pending":1}
{"id":"order-2776979260495208","pending":2}
{"id":"order-2776979272135416","pending":3}
$ curl -s -X POST "http://localhost:8100/orders/process?workMillis=30"
{"processed":true,"pending":2}
$ curl -s http://localhost:8100/actuator/prometheus | grep -E "^(# (HELP|TYPE) )?orders_"
# HELP orders_pending Orders accepted but not yet processed
# TYPE orders_pending gauge
orders_pending{service="order"} 2.0
# HELP orders_placed_total Orders accepted by the API
# TYPE orders_placed_total counter
orders_placed_total{service="order"} 3.0
# HELP orders_processing_seconds Time spent processing one order
# TYPE orders_processing_seconds summary
orders_processing_seconds_count{service="order"} 1
orders_processing_seconds_sum{service="order"} 0.035046375
# HELP orders_processing_seconds_max Time spent processing one order
# TYPE orders_processing_seconds_max gauge
orders_processing_seconds_max{service="order"} 0.035046375

The naming rules, each pinned by a test against a bare PrometheusMeterRegistry in the lab:

Micrometer meterPrometheus outputRule
Counter orders.placedorders_placed_totaldots become underscores, counters get _total
Counter legacy_orders_totallegacy_orders_totalan existing _total is not doubled
Gauge orders.pendingorders_pendinggauges get no suffix
Gauge queue.depth with baseUnit("items")queue_depth_itemsthe base unit becomes the suffix
Timer orders.processingorders_processing_seconds_count, _sum, _maxtimers are always rendered in seconds, type summary, plus a separate _max gauge

Two details from the same tests. First, without publishPercentiles a timer produces no quantile series, only count, sum and max. Second, a collision: registering orders.placed with a description and, in the same registry, a counter literally named orders_placed_total without one (the earlier version of this article used that style) yields both series under one # TYPE orders_placed_total counter line, and the # HELP text is empty. The description of the first meter is dropped. Two Micrometer names that collapse to the same Prometheus family share one metadata block, so pick one naming style.

The default response is text/plain (Prometheus text format 0.0.4). Sending Accept: application/openmetrics-text; version=1.0.0 returns OpenMetrics, recognizable by the trailing # EOF. Both are asserted in the lab.

Prometheus scraping it

The scrape configuration is the same shape as before; the only thing that matters is metrics_path:

global:
  scrape_interval: 5s

scrape_configs:
  - job_name: 'orders-demo'
    metrics_path: /actuator/prometheus
    static_configs:
      - targets: ['host.docker.internal:8100']

host.docker.internal is how a container on Docker Desktop reaches the host; on a server you put the application's address there. The file was checked and then used:

$ docker run --rm --entrypoint promtool -v $PWD/prometheus:/cfg:ro prom/prometheus:v3.14.0 check config /cfg/prometheus-lab.yml
Checking /cfg/prometheus-lab.yml
 SUCCESS: /cfg/prometheus-lab.yml is valid prometheus config file syntax

$ docker run -d --rm --name devdrunk-prom-lab -v $PWD/prometheus/prometheus-lab.yml:/etc/prometheus/prometheus.yml:ro prom/prometheus:v3.14.0
$ sleep 12
$ docker exec devdrunk-prom-lab wget -qO- 'http://localhost:9090/api/v1/targets'      # activeTargets[0]
{'scrapeUrl': 'http://host.docker.internal:8100/actuator/prometheus', 'health': 'up', 'lastError': '', 'lastScrapeDuration': 0.031752459}
$ docker exec devdrunk-prom-lab wget -qO- 'http://localhost:9090/api/v1/query?query=orders_placed_total%7Bservice%3D%22order%22%7D'
[{'metric': {'__name__': 'orders_placed_total', 'instance': 'host.docker.internal:8100', 'job': 'orders-demo', 'service': 'order'}, 'value': [1789521163.482, '3']}]

The value is 3, the three orders placed above, with the service tag carried through and job and instance added by Prometheus.

The test that returns 404

The first run of the lab failed 8 of 11 tests with expected: 200 but was: 404 on /actuator/prometheus, with the endpoint exposed and the dependency present. The cause is in Spring Boot's test support: for @SpringBootTest, a context customizer sets every management.<registry>.metrics.export.enabled property to false so tests do not ship metrics anywhere. No Prometheus registry means no scrape endpoint. The switch that turns it back on is one annotation:

import org.springframework.boot.test.autoconfigure.actuate.observability.AutoConfigureObservability;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
@AutoConfigureObservability
class PrometheusScrapeTests {
    // ...
}

With it, all tests in the lab pass. If your integration test of /actuator/prometheus is 404 for no visible reason, this is the first thing to check.

Cardinality, measured

Tagging a meter with a user id creates one time series per user. The lab keeps that mistake on purpose so it can be counted:

@Component
public class PerUserCounter {
    private final MeterRegistry registry;

    void increment(String userId) {
        Counter.builder("orders.by.user").tag("user_id", userId).register(registry).increment();
    }
}

The test posts 50 orders with distinct user ids and counts the lines of the scrape that start with orders_by_user_total: exactly 50, while orders_placed_total is still one line. It then continues to 200 distinct ids. Without protection that would be 200 series; the lab has a registry-wide cap:

@Bean
MeterFilter perUserSeriesCap(@Value("${demo.metrics.max-user-series}") int maxSeries) {
    return MeterFilter.maximumAllowableTags("orders.by.user", "user_id", maxSeries, MeterFilter.deny());
}

Spring Boot applies every MeterFilter bean to the auto-configured registry. With the cap at 100, the scrape has exactly 100 orders_by_user_total lines after 200 distinct ids; registry.find("orders.by.user").tag("user_id", "user-199").counter() is null, so increments past the cap are dropped rather than lumped somewhere, and user-0 still reads 1.0. The cap is a fuse, not a design: a per-user counter belongs in a database or a log, and the metric should carry a bounded tag such as a plan or a region.

The same run exposed a second source of series, from the test itself. The TestRestTemplate is instrumented by Boot, and the first version of the test built the URL as "/orders?userId=user-" + i. That produced one http_client_requests_seconds_count series per literal URL. Passing the URL as a template, rest.postForEntity("/orders?userId={id}", null, String.class, "user-" + i), collapsed them into a single series with uri="/orders?userId={id}". The same applies to RestClient and RestTemplate in production code: use URI variables, never string concatenation, or your client metrics will have one series per customer.

Securing the endpoint

The earlier version used WebSecurityConfigurerAdapter with antMatchers. That class is not in spring-security-config-6.5.11.jar, the version Spring Boot 3.5.16 manages (checked by listing the jar; the 5.8.16 jar still has it). Spring Security 6 configures a SecurityFilterChain bean instead, and Actuator provides EndpointRequest.to(PrometheusScrapeEndpoint.class) style matchers for it. The lab does not include Spring Security and no security configuration was tested here, so this article does not show one; the Spring Security 6.5 Java configuration reference and the Actuator section of the Spring Boot reference are the places to copy from.

What was not measured

  • Overhead of the meters under load; the lab runs a handful of requests.
  • Prometheus-side memory or query cost of the cardinality pitfall; only exporter-side series counts were measured.
  • Grafana, alerting rules, or any recording rules.
  • Any authentication on the endpoint.

Sources

  • Spring Boot 3.5 reference, Actuator metrics (Prometheus section and MeterFilter customization): https://docs.spring.io/spring-boot/3.5/reference/actuator/metrics.html
  • Spring Boot 3.5 reference, testing with metrics (@AutoConfigureObservability): https://docs.spring.io/spring-boot/3.5/reference/testing/spring-boot-applications.html#testing.spring-boot-applications.metrics
  • Spring Boot 3.0 migration guide, Micrometer and metrics property changes: https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.0-Migration-Guide#micrometer-and-metrics-changes
  • Micrometer reference, naming conventions: https://docs.micrometer.io/micrometer/reference/concepts/naming.html
  • Micrometer reference, meter filters: https://docs.micrometer.io/micrometer/reference/concepts/meter-filters.html
  • Micrometer reference, Prometheus registry: https://docs.micrometer.io/micrometer/reference/implementations/prometheus.html
  • Prometheus, metric and label naming: https://prometheus.io/docs/practices/naming/
  • Spring Security 6.5 reference, Java configuration: https://docs.spring.io/spring-security/reference/6.5/servlet/configuration/java.html