Repository navigation
feat: bind exposition writers to negotiated escaping #2642
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6f96ad9
287d883
95ff84c
83099af
6ca7c6d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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; | ||||||
|
|
@@ -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. | ||||||
| * | ||||||
| * <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 | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| */ | ||||||
| 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)); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
It also lets an |
||||||
| } | ||||||
|
|
||||||
| private boolean isOpenMetrics2Enabled() { | ||||||
| return openMetrics2TextFormatWriter.getOpenMetrics2Properties().getEnabled(); | ||||||
| } | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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); | ||||||||||||||
|
Comment on lines
+306
to
+308
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The assertion checks a new wrapper's scheme, not that the cached writer is unchanged. To test
Suggested change
|
||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| @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, | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since this is
@StableApi: was leaving out an accessor for the underlyingExpositionFormatWriterdeliberate? Without one, callers can't check
isAvailable()or which format was picked otherthan by comparing content types. Adding it later is non-breaking, so fine either way.