Setup

Let's setup a simple spring-boot app:

build.gradle.kts

plugins {
    java
    id("org.springframework.boot") version "3.3.4"
    id("io.spring.dependency-management") version "1.1.6"
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(23)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.springframework.boot:spring-boot-starter-web")
    runtimeOnly("io.micrometer:micrometer-registry-prometheus")
}

App.java

@SpringBootApplication
public class TestPrometheusMetricsApplication {

    public static void main(String[] args) {
        SpringApplication.run(TestPrometheusMetricsApplication.class, args);
    }

    @Configuration
    static class TestConfiguration {

        @Bean
        public String test(MeterRegistry meterRegistry) {
            meterRegistry.counter("test_counter", "tag1", "value1");
            meterRegistry.counter("test_counter", "tag1", "valueA", "tag2", "valueB");
            return "ok";
        }
    }
}

application.properties

spring.application.name=test-prometheus-metrics
management.endpoints.web.exposure.include=*

The problem

When visiting http://localhost:8080/actuator/metrics/test_counter, we can see that availableTags for test_counterwas merged (tag1 and tag2). That seems a sensible thing to do in our example.

{
    "name": "test_counter",
    "measurements": [
        {
            "statistic": "COUNT",
            "value": 0
        }
    ],
    "availableTags": [
        {
            "tag": "tag1",
            "values": [
                "value1",
                "valueA"
            ]
        },
        {
            "tag": "tag2",
            "values": [
                "valueB"
            ]
        }
    ]
}

But Prometheus scrape endpoint http://localhost:8080/actuator/prometheus has only one first counter registered!

# HELP test_counter_total  
# TYPE test_counter_total counter
test_counter_total{tag1="value1"} 1.0

Expected behavior

This behavior seems unintuitive since /actuator/prometheus returns different data than /actuator/metrics/test_counter. There is no log (on the debug level) that the second meter was ignored.

We feel that /actuator/prometheus should behave the same as /actuator/metrics/test_counter and include merged tags for each registered meter.

If it's a limitation of Prometheus itself, we would expect some kind of feedback for spring boot users—an exception that it's not allowed or a log saying that the meter was overridden/ignored.

Comment From: wilkinsona

Spring Boot itself knows very little about Prometheus and any limitations of its scrape format as it is handled by Micrometer and the underlying Prometheus library.

You can observe the behavior that you've described by using Micrometer's PrometheusMeterRegistry directly:

PrometheusMeterRegistry meterRegistry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT);
        meterRegistry.counter("test_counter", "tag1", "value1");
meterRegistry.counter("test_counter", "tag1", "valueA", "tag2", "valueB");

System.out.println(meterRegistry.scrape());

meterRegistry.getMeters().forEach((meter) -> System.out.println(meter.getId()));

The above will produce the following output:

# HELP test_counter_total  
# TYPE test_counter_total counter
test_counter_total{tag1="value1"} 0.0

MeterId{name='test_counter', tags=[tag(tag1=value1)]}
MeterId{name='test_counter', tags=[tag(tag1=valueA),tag(tag2=valueB)]}

As you can see, the scrape result includes test_counter only once whereas looking at the meters individual, as Boot's MetricsEndpoint does, shows both.

If you believe that some improvements could be made in this area, please open a Micrometer issue.

Comment From: jonatan-ivanov

fyi: https://github.com/micrometer-metrics/micrometer/issues/877 In your case you can do this instead:

meterRegistry.counter("test_counter", "tag1", "value1", "tag2", "none");
meterRegistry.counter("test_counter", "tag1", "valueA", "tag2", "valueB");

Or you can use na, null, etc. to indicate that the value does not exists.

Comment From: bgalek

@jonatan-ivanov @wilkinsona Thank you for the context.

Still, it could make sense to inform the user of spring-boot what happened. There could be an autoconfiguration could add onMeterRegistrationFailed handler to throw an error or at least log when

public class PrometheusReporterConfiguration {
private static final Logger logger = LoggerFactory.getLogger(PrometheusReporterConfiguration.class);
...
private void configureLoggerMetrics(PrometheusMeterRegistry prometheusMeterRegistry) {
        prometheusMeterRegistry.config()
            .onMeterRegistrationFailed((id, msg) -> logger.error("Failed to register meter: {}, message: {}", id, msg));
        ...
    }
}

WDYT?

Comment From: wilkinsona

I don't think such functionality belongs in Boot as the behaviour isn't specific to Boot. As demonstrated above, the behavior is reproducible with only Micrometer. If some logging is to be done anywhere, IMO, it should be done in Micrometer.

Comment From: bgalek

@wilkinsona ok, thx! I'll go there :)

Comment From: jonatan-ivanov

Sorry for increasing the noise here but what you need might be already done in Micrometer, see: https://github.com/micrometer-metrics/micrometer/pull/5228 You can try Micrometer 1.14.0-RC1 (Boot 3.4.0-RC1) or wait till 1.14.0 (around 11th November) and try that:

Comment From: bgalek

omg @jonatan-ivanov that's perfect, thank you for that link :)