From 11b4a19dee3a54fbb5df16fbcf5bd6413968a5b2 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Thu, 18 Mar 2021 19:11:02 -0700 Subject: [PATCH] Support OpenMetrics text format with Prometheus Update `PrometheusScrapeEndpoint` so that it can produce both classic Prometheus text output as well as Openmetrics output. See gh-25564 --- .../docs/asciidoc/endpoints/prometheus.adoc | 9 ++++ ...theusScrapeEndpointDocumentationTests.java | 12 ++++- .../prometheus/ProducibleTextFormat.java | 54 +++++++++++++++++++ .../prometheus/PrometheusScrapeEndpoint.java | 22 ++++++-- ...metheusScrapeEndpointIntegrationTests.java | 12 ++++- 5 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/export/prometheus/ProducibleTextFormat.java diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/prometheus.adoc b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/prometheus.adoc index 4ecc936708..4b58ea0f8a 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/prometheus.adoc +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/prometheus.adoc @@ -16,6 +16,15 @@ The resulting response is similar to the following: include::{snippets}/prometheus/all/http-response.adoc[] +The default response content type is `text/plain;version=0.0.4`. +The endpoint can also produce `application/openmetrics-text;version=1.0.0` when called with an appropriate `Accept` header, as shown in the following curl-based example: + +include::{snippets}/prometheus/openmetrics/curl-request.adoc[] + +The resulting response is similar to the following: + +include::{snippets}/prometheus/openmetrics/http-response.adoc[] + [[prometheus-retrieving-query-parameters]] diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/PrometheusScrapeEndpointDocumentationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/PrometheusScrapeEndpointDocumentationTests.java index 9e7c338d7c..94da4bf4b5 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/PrometheusScrapeEndpointDocumentationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/PrometheusScrapeEndpointDocumentationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import io.micrometer.core.instrument.Clock; import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics; import io.micrometer.prometheus.PrometheusMeterRegistry; import io.prometheus.client.CollectorRegistry; +import io.prometheus.client.exporter.common.TextFormat; import org.junit.jupiter.api.Test; import org.springframework.boot.actuate.metrics.export.prometheus.PrometheusScrapeEndpoint; @@ -31,6 +32,7 @@ import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.docu import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.requestParameters; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** @@ -46,6 +48,14 @@ class PrometheusScrapeEndpointDocumentationTests extends MockMvcEndpointDocument this.mockMvc.perform(get("/actuator/prometheus")).andExpect(status().isOk()).andDo(document("prometheus/all")); } + @Test + void prometheusOpenmetrics() throws Exception { + this.mockMvc.perform(get("/actuator/prometheus").accept(TextFormat.CONTENT_TYPE_OPENMETRICS_100)) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Type", "application/openmetrics-text;version=1.0.0;charset=utf-8")) + .andDo(document("prometheus/openmetrics")); + } + @Test void filteredPrometheus() throws Exception { this.mockMvc diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/export/prometheus/ProducibleTextFormat.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/export/prometheus/ProducibleTextFormat.java new file mode 100644 index 0000000000..31142f3563 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/export/prometheus/ProducibleTextFormat.java @@ -0,0 +1,54 @@ +/* + * Copyright 2012-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.metrics.export.prometheus; + +import io.prometheus.client.exporter.common.TextFormat; + +import org.springframework.boot.actuate.endpoint.http.Producible; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +/** + * A {@link Producible} for Prometheus's {@link TextFormat}. + * + * @author Andy Wilkinson + * @since 2.5.0 + */ +public enum ProducibleTextFormat implements Producible { + + /** + * Openmetrics text version 1.0.0. + */ + CONTENT_TYPE_OPENMETRICS_100(TextFormat.CONTENT_TYPE_OPENMETRICS_100), + + /** + * Prometheus text version 0.0.4. + */ + CONTENT_TYPE_004(TextFormat.CONTENT_TYPE_004); + + private final MimeType mimeType; + + ProducibleTextFormat(String mimeType) { + this.mimeType = MimeTypeUtils.parseMimeType(mimeType); + } + + @Override + public MimeType getMimeType() { + return this.mimeType; + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusScrapeEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusScrapeEndpoint.java index 6624f29389..1cc2236334 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusScrapeEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusScrapeEndpoint.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -28,8 +28,10 @@ import io.prometheus.client.exporter.common.TextFormat; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpoint; import org.springframework.lang.Nullable; +import org.springframework.util.MimeType; /** * {@link Endpoint @Endpoint} that outputs metrics in a format that can be scraped by the @@ -48,15 +50,25 @@ public class PrometheusScrapeEndpoint { this.collectorRegistry = collectorRegistry; } - @ReadOperation(produces = TextFormat.CONTENT_TYPE_004) - public String scrape(@Nullable Set includedNames) { + @ReadOperation(produces = { TextFormat.CONTENT_TYPE_004, TextFormat.CONTENT_TYPE_OPENMETRICS_100 }) + public WebEndpointResponse scrape(ProducibleTextFormat producibleTextFormat, + @Nullable Set includedNames) { try { Writer writer = new StringWriter(); Enumeration samples = (includedNames != null) ? this.collectorRegistry.filteredMetricFamilySamples(includedNames) : this.collectorRegistry.metricFamilySamples(); - TextFormat.write004(writer, samples); - return writer.toString(); + MimeType contentType = producibleTextFormat.getMimeType(); + if (producibleTextFormat == ProducibleTextFormat.CONTENT_TYPE_004) { + TextFormat.write004(writer, samples); + } + else if (producibleTextFormat == ProducibleTextFormat.CONTENT_TYPE_OPENMETRICS_100) { + TextFormat.writeOpenMetrics100(writer, samples); + } + else { + throw new RuntimeException("Unsupported text format '" + producibleTextFormat.getMimeType() + "'"); + } + return new WebEndpointResponse<>(writer.toString(), contentType); } catch (IOException ex) { // This actually never happens since StringWriter::write() doesn't throw any diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusScrapeEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusScrapeEndpointIntegrationTests.java index 4ece6adf0f..9b0419efee 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusScrapeEndpointIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/export/prometheus/PrometheusScrapeEndpointIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2020 the original author or authors. + * Copyright 2012-2021 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -40,13 +40,21 @@ import static org.assertj.core.api.Assertions.assertThat; class PrometheusScrapeEndpointIntegrationTests { @WebEndpointTest - void scrapeHasContentTypeText004(WebTestClient client) { + void scrapeHasContentTypeText004ByDefault(WebTestClient client) { client.get().uri("/actuator/prometheus").exchange().expectStatus().isOk().expectHeader() .contentType(MediaType.parseMediaType(TextFormat.CONTENT_TYPE_004)).expectBody(String.class) .value((body) -> assertThat(body).contains("counter1_total").contains("counter2_total") .contains("counter3_total")); } + @WebEndpointTest + void scrapeCanProduceOpenMetrics100(WebTestClient client) { + MediaType openMetrics = MediaType.parseMediaType(TextFormat.CONTENT_TYPE_OPENMETRICS_100); + client.get().uri("/actuator/prometheus").accept(openMetrics).exchange().expectStatus().isOk().expectHeader() + .contentType(openMetrics).expectBody(String.class).value((body) -> assertThat(body) + .contains("counter1_total").contains("counter2_total").contains("counter3_total")); + } + @WebEndpointTest void scrapeWithIncludedNames(WebTestClient client) { client.get().uri("/actuator/prometheus?includedNames=counter1_total,counter2_total").exchange().expectStatus()