From 6f96ad9e7590072b7ae172f241471b9ab79f2e8c Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 3 Nov 2026 13:12:30 +0000 Subject: [PATCH 1/5] feat: bind exposition writers to negotiated escaping Signed-off-by: Gregor Zeitlinger --- .../common/PrometheusScrapeHandler.java | 46 +----- .../ExpositionFormatWriterWithEscaping.java | 45 ++++++ .../expositionformats/ExpositionFormats.java | 33 +++++ .../ExpositionFormatsTest.java | 134 ++++++++++++++++++ 4 files changed, 217 insertions(+), 41 deletions(-) create mode 100644 prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormatWriterWithEscaping.java diff --git a/prometheus-metrics-exporter-common/src/main/java/io/prometheus/metrics/exporter/common/PrometheusScrapeHandler.java b/prometheus-metrics-exporter-common/src/main/java/io/prometheus/metrics/exporter/common/PrometheusScrapeHandler.java index b68b11c891..3a9ee13df6 100644 --- a/prometheus-metrics-exporter-common/src/main/java/io/prometheus/metrics/exporter/common/PrometheusScrapeHandler.java +++ b/prometheus-metrics-exporter-common/src/main/java/io/prometheus/metrics/exporter/common/PrometheusScrapeHandler.java @@ -4,9 +4,8 @@ import io.prometheus.metrics.config.EscapingScheme; import io.prometheus.metrics.config.ExporterFilterProperties; import io.prometheus.metrics.config.PrometheusProperties; -import io.prometheus.metrics.expositionformats.ExpositionFormatWriter; +import io.prometheus.metrics.expositionformats.ExpositionFormatWriterWithEscaping; import io.prometheus.metrics.expositionformats.ExpositionFormats; -import io.prometheus.metrics.expositionformats.OpenMetrics2TextFormatWriter; import io.prometheus.metrics.model.registry.MetricNameFilter; import io.prometheus.metrics.model.registry.PrometheusRegistry; import io.prometheus.metrics.model.snapshots.MetricSnapshots; @@ -76,10 +75,8 @@ public void handleRequest(PrometheusHttpExchange exchange) throws IOException { snapshots, EscapingScheme.fromAcceptHeader(acceptHeader), debugParam, exchange)) { return; } - ExpositionFormatWriter writer = expositionFormats.findWriter(acceptHeader); - EscapingScheme escapingScheme = - EscapingScheme.fromAcceptHeader( - acceptHeader, defaultEscapingScheme(writer, acceptHeader)); + ExpositionFormatWriterWithEscaping writer = + expositionFormats.findWriterWithEscaping(acceptHeader); PrometheusHttpResponse response = exchange.getResponse(); response.setHeader("Content-Type", writer.getContentType()); @@ -87,12 +84,12 @@ public void handleRequest(PrometheusHttpExchange exchange) throws IOException { response.setHeader("Content-Encoding", "gzip"); try (GZIPOutputStream gzipOutputStream = new GZIPOutputStream(response.sendHeadersAndGetBody(200, 0))) { - writer.write(gzipOutputStream, snapshots, escapingScheme); + writer.write(gzipOutputStream, snapshots); } } else { ByteArrayOutputStream responseBuffer = new ByteArrayOutputStream(lastResponseSize.get() + 1024); - writer.write(responseBuffer, snapshots, escapingScheme); + writer.write(responseBuffer, snapshots); lastResponseSize.set(responseBuffer.size()); int contentLength = responseBuffer.size(); if (contentLength > 0) { @@ -118,39 +115,6 @@ public void handleRequest(PrometheusHttpExchange exchange) throws IOException { } } - /** - * OpenMetrics 2.0 supports UTF-8 names and has no escaping parameter, so names are not escaped - * unless the scraper asks for it. With contentNegotiation=false the OM2 writer also serves OM 1.0 - * and unversioned requests, so 2.0.0 must have been requested explicitly. - */ - private EscapingScheme defaultEscapingScheme( - ExpositionFormatWriter writer, @Nullable String acceptHeader) { - if (writer instanceof OpenMetrics2TextFormatWriter - && "2.0.0".equals(parseOpenMetricsVersion(acceptHeader))) { - return EscapingScheme.ALLOW_UTF8; - } - return EscapingScheme.DEFAULT; - } - - @Nullable - private static String parseOpenMetricsVersion(@Nullable String acceptHeader) { - if (acceptHeader == null) { - return null; - } - for (String mediaType : acceptHeader.split(",")) { - if (mediaType.contains("application/openmetrics-text")) { - for (String param : mediaType.split(";")) { - String[] tokens = param.split("="); - if (tokens.length == 2 && tokens[0].trim().equals("version")) { - return tokens[1].trim(); - } - } - return null; - } - } - return null; - } - @Nullable private Predicate makeNameFilter(ExporterFilterProperties props) { if (props.getAllowedMetricNames() == null diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormatWriterWithEscaping.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormatWriterWithEscaping.java new file mode 100644 index 0000000000..765643ab96 --- /dev/null +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormatWriterWithEscaping.java @@ -0,0 +1,45 @@ +package io.prometheus.metrics.expositionformats; + +import io.prometheus.metrics.annotations.StableApi; +import io.prometheus.metrics.config.EscapingScheme; +import io.prometheus.metrics.model.snapshots.MetricSnapshots; +import java.io.IOException; +import java.io.OutputStream; + +/** + * An exposition format writer paired with the escaping scheme selected from an Accept header. + * + *

Instances are immutable and delegate to the underlying format writer. The content type is + * the format's standard content type and does not include an escaping parameter. + */ +@StableApi +public final class ExpositionFormatWriterWithEscaping { + + private final ExpositionFormatWriter writer; + private final EscapingScheme escapingScheme; + + ExpositionFormatWriterWithEscaping(ExpositionFormatWriter writer, EscapingScheme escapingScheme) { + this.writer = writer; + this.escapingScheme = escapingScheme; + } + + /** Writes the given metric snapshots using the negotiated escaping scheme. */ + public void write(OutputStream out, MetricSnapshots metricSnapshots) throws IOException { + writer.write(out, metricSnapshots, escapingScheme); + } + + /** Converts the metric snapshots to a debug string using the negotiated escaping scheme. */ + public String toDebugString(MetricSnapshots metricSnapshots) { + return writer.toDebugString(metricSnapshots, escapingScheme); + } + + /** Returns the standard content type for the selected exposition format. */ + public String getContentType() { + return writer.getContentType(); + } + + /** Returns the escaping scheme selected from the Accept header. */ + public EscapingScheme getEscapingScheme() { + return escapingScheme; + } +} diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormats.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormats.java index 0cfce9b5f5..a62e244f97 100644 --- a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormats.java +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormats.java @@ -1,6 +1,7 @@ package io.prometheus.metrics.expositionformats; import io.prometheus.metrics.annotations.StableApi; +import io.prometheus.metrics.config.EscapingScheme; import io.prometheus.metrics.config.ExporterProperties; import io.prometheus.metrics.config.OpenMetrics2Properties; import io.prometheus.metrics.config.PrometheusProperties; @@ -60,6 +61,11 @@ public static ExpositionFormats init(ExporterProperties properties) { return init(PrometheusProperties.builder().exporterProperties(properties).build()); } + /** + * Finds the exposition format writer for the given Accept header. + * + * @see #findWriterWithEscaping(String) to select a format and bind its escaping scheme together + */ public ExpositionFormatWriter findWriter(@Nullable String acceptHeader) { if (prometheusProtobufWriter.accepts(acceptHeader)) { return prometheusProtobufWriter; @@ -85,6 +91,33 @@ public ExpositionFormatWriter findWriter(@Nullable String acceptHeader) { return prometheusTextFormatWriter; } + /** + * Finds the exposition format writer and escaping scheme negotiated by the given Accept header. + * + *

Use this method when writing a scrape response to ensure the selected format and escaping + * scheme come from the same header. The returned writer's content type is the format's standard + * content type; as with {@link #findWriter(String)}, it does not include an escaping parameter. + * + *

{@code
+   * ExpositionFormats.init().findWriterWithEscaping(acceptHeader).write(out, registry.scrape());
+   * }
+ * + * @see #findWriter(String) the legacy API for selecting only a format writer + */ + public ExpositionFormatWriterWithEscaping findWriterWithEscaping(@Nullable String acceptHeader) { + ExpositionFormatWriter writer = findWriter(acceptHeader); + EscapingScheme fallback = EscapingScheme.DEFAULT; + // OpenMetrics 2.0 has no escaping parameter and permits UTF-8 names. Only an explicit request + // for version 2.0.0 selects unescaped names; the OM2 writer may also serve OM1/unversioned + // requests when content negotiation is disabled. + if (writer instanceof OpenMetrics2TextFormatWriter + && "2.0.0".equals(parseOpenMetricsVersion(acceptHeader))) { + fallback = EscapingScheme.ALLOW_UTF8; + } + return new ExpositionFormatWriterWithEscaping( + writer, EscapingScheme.fromAcceptHeader(acceptHeader, fallback)); + } + private boolean isOpenMetrics2Enabled() { return openMetrics2TextFormatWriter.getOpenMetrics2Properties().getEnabled(); } diff --git a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java index c875b94b0a..9680dd15aa 100644 --- a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java +++ b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java @@ -2,6 +2,10 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import io.prometheus.metrics.config.EscapingScheme; import io.prometheus.metrics.config.OpenMetrics2Properties; @@ -272,6 +276,136 @@ void testOM2ContentNegotiationDisabled() { assertThat(writer3).isInstanceOf(OpenMetrics2TextFormatWriter.class); } + @ParameterizedTest + @CsvSource({ + "allow-utf-8, ALLOW_UTF8", + "underscores, UNDERSCORE_ESCAPING", + "dots, DOTS_ESCAPING", + "values, VALUE_ENCODING_ESCAPING" + }) + void testNegotiatedWriterBindsEscapingScheme(String escaping, EscapingScheme expected) + throws IOException { + ExpositionFormats formats = ExpositionFormats.init(); + ExpositionFormatWriterWithEscaping negotiated = + formats.findWriterWithEscaping("text/plain; escaping=" + escaping); + MetricSnapshots snapshots = + MetricSnapshots.of( + GaugeSnapshot.builder() + .name("legacy.name") + .dataPoint(GaugeDataPointSnapshot.builder().value(1).build()) + .build()); + + assertThat(negotiated.getEscapingScheme()).isEqualTo(expected); + assertThat(negotiated.getContentType()) + .isEqualTo(formats.getPrometheusTextFormatWriter().getContentType()); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + negotiated.write(out, snapshots); + assertThat(out.toString(UTF_8)) + .isEqualTo(formats.getPrometheusTextFormatWriter().toDebugString(snapshots, expected)); + + // Negotiation binds a scheme to this wrapper without changing the cached format writer. + assertThat(formats.findWriterWithEscaping("text/plain").getEscapingScheme()) + .isEqualTo(EscapingScheme.DEFAULT); + } + + @Test + void testNegotiatedWriterDefaultsForUnknownEscapingAndOpenMetricsVersion() { + ExpositionFormats formats = + ExpositionFormats.init( + PrometheusProperties.builder() + .openMetrics2Properties( + OpenMetrics2Properties.builder().enabled(true).contentNegotiation(true).build()) + .build()); + + assertThat(formats.findWriterWithEscaping(null).getEscapingScheme()) + .isEqualTo(EscapingScheme.DEFAULT); + assertThat(formats.findWriterWithEscaping("text/plain; escaping=unknown").getEscapingScheme()) + .isEqualTo(EscapingScheme.DEFAULT); + assertThat( + formats + .findWriterWithEscaping("application/openmetrics-text; version=2.0.0") + .getEscapingScheme()) + .isEqualTo(EscapingScheme.ALLOW_UTF8); + assertThat( + formats + .findWriterWithEscaping( + "application/openmetrics-text; version=2.0.0; escaping=underscores") + .getEscapingScheme()) + .isEqualTo(EscapingScheme.UNDERSCORE_ESCAPING); + assertThat( + formats + .findWriterWithEscaping( + "application/openmetrics-text; version=2.0.0; escaping=unknown") + .getEscapingScheme()) + .isEqualTo(EscapingScheme.ALLOW_UTF8); + assertThat(formats.findWriterWithEscaping("application/openmetrics-text").getEscapingScheme()) + .isEqualTo(EscapingScheme.DEFAULT); + assertThat( + formats + .findWriterWithEscaping("application/openmetrics-text; version=1.0.0") + .getContentType()) + .isEqualTo(formats.getOpenMetricsTextFormatWriter().getContentType()); + } + + @Test + void testNegotiatedWriterUsesOpenMetrics2DefaultWhenContentNegotiationIsDisabled() { + ExpositionFormats formats = + ExpositionFormats.init( + PrometheusProperties.builder() + .openMetrics2Properties( + OpenMetrics2Properties.builder() + .enabled(true) + .contentNegotiation(false) + .build()) + .build()); + + assertThat( + formats + .findWriterWithEscaping("application/openmetrics-text; version=2.0.0") + .getEscapingScheme()) + .isEqualTo(EscapingScheme.ALLOW_UTF8); + assertThat(formats.findWriterWithEscaping("application/openmetrics-text").getEscapingScheme()) + .isEqualTo(EscapingScheme.DEFAULT); + assertThat( + formats + .findWriterWithEscaping("application/openmetrics-text; version=1.0.0") + .getEscapingScheme()) + .isEqualTo(EscapingScheme.DEFAULT); + } + + @Test + void testNegotiatedWriterUsesOpenMetrics1WhenOpenMetrics2IsDisabled() { + ExpositionFormats formats = ExpositionFormats.init(); + ExpositionFormatWriterWithEscaping negotiated = + formats.findWriterWithEscaping("application/openmetrics-text; version=2.0.0"); + + assertThat(negotiated.getContentType()) + .isEqualTo(formats.getOpenMetricsTextFormatWriter().getContentType()); + assertThat(negotiated.getEscapingScheme()).isEqualTo(EscapingScheme.DEFAULT); + } + + @Test + void testNegotiatedWriterSelectsProtobuf() { + ExpositionFormatWriterWithEscaping negotiated = + ExpositionFormats.init() + .findWriterWithEscaping( + "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily"); + assertThat(negotiated.getContentType()).contains("application/vnd.google.protobuf"); + assertThat(negotiated.getEscapingScheme()).isEqualTo(EscapingScheme.DEFAULT); + } + + @Test + void testNegotiatedDebugStringDelegatesToFormatWriter() { + ExpositionFormatWriter writer = mock(ExpositionFormatWriter.class); + when(writer.toDebugString(any(), any())).thenReturn("format-specific debug output"); + MetricSnapshots snapshots = MetricSnapshots.of(); + ExpositionFormatWriterWithEscaping negotiated = + new ExpositionFormatWriterWithEscaping(writer, EscapingScheme.VALUE_ENCODING_ESCAPING); + + assertThat(negotiated.toDebugString(snapshots)).isEqualTo("format-specific debug output"); + verify(writer).toDebugString(snapshots, EscapingScheme.VALUE_ENCODING_ESCAPING); + } + @Test void testOM2ContentNegotiationMultiTypeAcceptHeaderWithoutOMVersion() { // When the Accept header has multiple media types and only text/plain has a version, From 287d88393268d63d5e2a91daad26fca5c461660f Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 3 Nov 2026 13:12:58 +0000 Subject: [PATCH 2/5] style: format negotiated exposition writer Signed-off-by: Gregor Zeitlinger --- .../expositionformats/ExpositionFormatWriterWithEscaping.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormatWriterWithEscaping.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormatWriterWithEscaping.java index 765643ab96..2d4f75afc9 100644 --- a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormatWriterWithEscaping.java +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormatWriterWithEscaping.java @@ -9,8 +9,8 @@ /** * An exposition format writer paired with the escaping scheme selected from an Accept header. * - *

Instances are immutable and delegate to the underlying format writer. The content type is - * the format's standard content type and does not include an escaping parameter. + *

Instances are immutable and delegate to the underlying format writer. The content type is the + * format's standard content type and does not include an escaping parameter. */ @StableApi public final class ExpositionFormatWriterWithEscaping { From 95ff84c951aa07bdbf086ef0387eccccf4efbe04 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 3 Nov 2026 13:18:20 +0000 Subject: [PATCH 3/5] docs: update exposition text formats API diff Signed-off-by: Gregor Zeitlinger --- .../prometheus-metrics-exposition-textformats.txt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/apidiffs/current_vs_latest/prometheus-metrics-exposition-textformats.txt b/docs/apidiffs/current_vs_latest/prometheus-metrics-exposition-textformats.txt index 49f81c5d80..672c27e410 100644 --- a/docs/apidiffs/current_vs_latest/prometheus-metrics-exposition-textformats.txt +++ b/docs/apidiffs/current_vs_latest/prometheus-metrics-exposition-textformats.txt @@ -1,2 +1,12 @@ Comparing source compatibility of prometheus-metrics-exposition-textformats-1.10.1-SNAPSHOT.jar against prometheus-metrics-exposition-textformats-1.10.0.jar -No changes. +*** MODIFIED CLASS: PUBLIC io.prometheus.metrics.expositionformats.ExpositionFormats (not serializable) + === CLASS FILE FORMAT VERSION: 52.0 <- 52.0 + +++ NEW METHOD: PUBLIC(+) io.prometheus.metrics.expositionformats.ExpositionFormatWriterWithEscaping findWriterWithEscaping(java.lang.String) ++++ NEW CLASS: PUBLIC(+) FINAL(+) io.prometheus.metrics.expositionformats.ExpositionFormatWriterWithEscaping (not serializable) + +++ CLASS FILE FORMAT VERSION: 52.0 <- n.a. + +++ NEW SUPERCLASS: java.lang.Object + +++ NEW METHOD: PUBLIC(+) java.lang.String getContentType() + +++ NEW METHOD: PUBLIC(+) io.prometheus.metrics.config.EscapingScheme getEscapingScheme() + +++ NEW METHOD: PUBLIC(+) java.lang.String toDebugString(io.prometheus.metrics.model.snapshots.MetricSnapshots) + +++ NEW METHOD: PUBLIC(+) void write(java.io.OutputStream, io.prometheus.metrics.model.snapshots.MetricSnapshots) + +++ NEW EXCEPTION: java.io.IOException From 83099af9cc61207019b8ed9a52d232c9e35c99e5 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Tue, 3 Nov 2026 13:22:59 +0000 Subject: [PATCH 4/5] fix: normalize generated API diff reports Signed-off-by: Gregor Zeitlinger --- .github/scripts/sync-api-diffs.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/scripts/sync-api-diffs.sh b/.github/scripts/sync-api-diffs.sh index 34fe18ff55..08a294da03 100755 --- a/.github/scripts/sync-api-diffs.sh +++ b/.github/scripts/sync-api-diffs.sh @@ -31,8 +31,15 @@ mkdir -p "$out_dir" for report in "${reports[@]}"; do # report path: //target/japicmp/api-diff.diff module="$(basename "$(dirname "$(dirname "$(dirname "$report")")")")" + # Japicmp emits a trailing blank line, which flint rejects in committed reports. grep -vE '^(WARNING: You are using the option|Semantic versioning suggestion)' \ - "$report" >"$out_dir/$module.txt" + "$report" | awk ' + NF { last = NR } + { lines[NR] = $0 } + END { + for (i = 1; i <= last; i++) print lines[i] + } + ' >"$out_dir/$module.txt" done echo "Wrote ${#reports[@]} API diff report(s) to docs/apidiffs/current_vs_latest/." From 6ca7c6d339d3c2d09027f6747196503513287f52 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 5 Nov 2026 14:40:48 +0000 Subject: [PATCH 5/5] docs: explain negotiated exposition writers Signed-off-by: Gregor Zeitlinger --- docs/content/exporters/formats.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/content/exporters/formats.md b/docs/content/exporters/formats.md index b842220335..e469dc5207 100644 --- a/docs/content/exporters/formats.md +++ b/docs/content/exporters/formats.md @@ -23,6 +23,31 @@ Prometheus server will scrape OpenMetrics text format with gzip encoding. If the is started with `--enable-feature=native-histograms`, it will scrape Prometheus protobuf format instead. +## Writing a negotiated response + +Applications that write exposition responses themselves can use `findWriterWithEscaping` to select +the format and escaping scheme together from one `Accept` header. Create and reuse an +`ExpositionFormats` instance instead of initializing it for each response: + +```java +import io.prometheus.metrics.expositionformats.ExpositionFormatWriterWithEscaping; +import io.prometheus.metrics.expositionformats.ExpositionFormats; + +ExpositionFormats formats = ExpositionFormats.init(); // Create once and reuse. + +// In response-writing code, acceptHeader, registry, and outputStream are supplied by the app. +ExpositionFormatWriterWithEscaping writer = formats.findWriterWithEscaping(acceptHeader); +String contentType = writer.getContentType(); // Set the response header before writing the body. +writer.write(outputStream, registry.scrape()); +``` + +The returned writer binds the negotiated format and escaping scheme, so there is no need to parse +the `Accept` header again when writing. `getContentType()` returns the selected format's standard +content type; it does not append an escaping parameter. The existing `findWriter` and writer methods +remain available when you need to choose the escaping scheme explicitly. For details, see the +[Unicode escaping documentation]({{< relref "./unicode.md" >}}) and +[OpenMetrics 2.0 preview]({{< relref "./openmetrics2.md" >}}). + ## Viewing with a Web Browser If you view the `/metrics` endpoint with your Web browser you will see Prometheus text format. For