Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/scripts/sync-api-diffs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,15 @@ mkdir -p "$out_dir"
for report in "${reports[@]}"; do
# report path: <repo>/<module 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/."

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions docs/content/exporters/formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -76,23 +75,21 @@ 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());

if (shouldUseCompression(request)) {
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) {
Expand All @@ -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<String> makeNameFilter(ExporterFilterProperties props) {
if (props.getAllowedMetricNames() == null
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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.
*
* <p>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.
*
* <pre>{@code
* ExpositionFormats.init().findWriterWithEscaping(acceptHeader).write(out, registry.scrape());
* }</pre>
*
* @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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading