diff --git a/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/TextFormatUtilBenchmark.java b/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/TextFormatUtilBenchmark.java index dcacf9a76..04352a829 100644 --- a/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/TextFormatUtilBenchmark.java +++ b/benchmarks/src/main/java/io/prometheus/metrics/benchmarks/TextFormatUtilBenchmark.java @@ -3,6 +3,7 @@ import io.prometheus.metrics.expositionformats.ExpositionFormatWriter; import io.prometheus.metrics.expositionformats.OpenMetricsTextFormatWriter; import io.prometheus.metrics.expositionformats.PrometheusTextFormatWriter; +import io.prometheus.metrics.model.snapshots.EscapingScheme; import io.prometheus.metrics.model.snapshots.GaugeSnapshot; import io.prometheus.metrics.model.snapshots.GaugeSnapshot.GaugeDataPointSnapshot; import io.prometheus.metrics.model.snapshots.Labels; @@ -69,14 +70,15 @@ public OutputStream openMetricsWriteToByteArray(WriterState writerState) throws // avoid growing the array ByteArrayOutputStream byteArrayOutputStream = writerState.byteArrayOutputStream; byteArrayOutputStream.reset(); - OPEN_METRICS_TEXT_FORMAT_WRITER.write(byteArrayOutputStream, SNAPSHOTS); + OPEN_METRICS_TEXT_FORMAT_WRITER.write( + byteArrayOutputStream, SNAPSHOTS, EscapingScheme.NO_ESCAPING); return byteArrayOutputStream; } @Benchmark public OutputStream openMetricsWriteToNull() throws IOException { OutputStream nullOutputStream = NullOutputStream.INSTANCE; - OPEN_METRICS_TEXT_FORMAT_WRITER.write(nullOutputStream, SNAPSHOTS); + OPEN_METRICS_TEXT_FORMAT_WRITER.write(nullOutputStream, SNAPSHOTS, EscapingScheme.NO_ESCAPING); return nullOutputStream; } @@ -85,14 +87,15 @@ public OutputStream prometheusWriteToByteArray(WriterState writerState) throws I // avoid growing the array ByteArrayOutputStream byteArrayOutputStream = writerState.byteArrayOutputStream; byteArrayOutputStream.reset(); - PROMETHEUS_TEXT_FORMAT_WRITER.write(byteArrayOutputStream, SNAPSHOTS); + PROMETHEUS_TEXT_FORMAT_WRITER.write( + byteArrayOutputStream, SNAPSHOTS, EscapingScheme.NO_ESCAPING); return byteArrayOutputStream; } @Benchmark public OutputStream prometheusWriteToNull() throws IOException { OutputStream nullOutputStream = NullOutputStream.INSTANCE; - PROMETHEUS_TEXT_FORMAT_WRITER.write(nullOutputStream, SNAPSHOTS); + PROMETHEUS_TEXT_FORMAT_WRITER.write(nullOutputStream, SNAPSHOTS, EscapingScheme.NO_ESCAPING); return nullOutputStream; } diff --git a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/ExporterPushgatewayProperties.java b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/ExporterPushgatewayProperties.java index 8aafba3a4..03325e722 100644 --- a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/ExporterPushgatewayProperties.java +++ b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/ExporterPushgatewayProperties.java @@ -7,15 +7,19 @@ public class ExporterPushgatewayProperties { private static final String ADDRESS = "address"; private static final String JOB = "job"; private static final String SCHEME = "scheme"; + private static final String ESCAPING_SCHEME = "escapingScheme"; private static final String PREFIX = "io.prometheus.exporter.pushgateway"; private final String scheme; private final String address; private final String job; + private final String escapingScheme; - private ExporterPushgatewayProperties(String address, String job, String scheme) { + private ExporterPushgatewayProperties( + String address, String job, String scheme, String escapingScheme) { this.address = address; this.job = job; this.scheme = scheme; + this.escapingScheme = escapingScheme; } /** Address of the Pushgateway in the form {@code host:port}. Default is {@code localhost:9091} */ @@ -39,6 +43,14 @@ public String getScheme() { return scheme; } + /** + * Escaping scheme to be used when pushing metric data to the pushgateway. Valid values: + * "no-escaping", "values", "underscores", "dots". Default is "no-escaping". + */ + public String getEscapingScheme() { + return escapingScheme; + } + /** * Note that this will remove entries from {@code properties}. This is because we want to know if * there are unused properties remaining after all properties have been loaded. @@ -48,6 +60,8 @@ static ExporterPushgatewayProperties load(Map properties) String address = Util.loadString(PREFIX + "." + ADDRESS, properties); String job = Util.loadString(PREFIX + "." + JOB, properties); String scheme = Util.loadString(PREFIX + "." + SCHEME, properties); + String escapingScheme = Util.loadString(PREFIX + "." + ESCAPING_SCHEME, properties); + if (scheme != null) { if (!scheme.equals("http") && !scheme.equals("https")) { throw new PrometheusPropertiesException( @@ -56,6 +70,20 @@ static ExporterPushgatewayProperties load(Map properties) PREFIX, SCHEME, scheme)); } } - return new ExporterPushgatewayProperties(address, job, scheme); + + if (escapingScheme != null) { + if (!escapingScheme.equals("no-escaping") + && !escapingScheme.equals("values") + && !escapingScheme.equals("underscores") + && !escapingScheme.equals("dots")) { + throw new PrometheusPropertiesException( + String.format( + "%s.%s: Illegal value. Expecting 'no-escaping', 'values', 'underscores', " + + "or 'dots'. Found: %s", + PREFIX, ESCAPING_SCHEME, escapingScheme)); + } + } + + return new ExporterPushgatewayProperties(address, job, scheme, escapingScheme); } } diff --git a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/NamingProperties.java b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/NamingProperties.java new file mode 100644 index 000000000..102f65503 --- /dev/null +++ b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/NamingProperties.java @@ -0,0 +1,61 @@ +package io.prometheus.metrics.config; + +import java.util.Map; +import javax.annotation.Nullable; + +public class NamingProperties { + + private static final String PREFIX = "io.prometheus.naming"; + private static final String VALIDATION_SCHEME = "validationScheme"; + private final ValidationScheme validationScheme; + + private NamingProperties(ValidationScheme validation) { + this.validationScheme = validation; + } + + public ValidationScheme getValidationScheme() { + return validationScheme; + } + + static NamingProperties load(Map properties) + throws PrometheusPropertiesException { + String validationScheme = Util.loadString(PREFIX + "." + VALIDATION_SCHEME, properties); + return new NamingProperties(parseValidationScheme(validationScheme)); + } + + static ValidationScheme parseValidationScheme(@Nullable String scheme) { + if (scheme == null || scheme.isEmpty()) { + return ValidationScheme.LEGACY_VALIDATION; + } + + switch (scheme) { + case "utf-8": + return ValidationScheme.UTF_8_VALIDATION; + case "legacy": + return ValidationScheme.LEGACY_VALIDATION; + default: + throw new PrometheusPropertiesException( + "Unknown validation scheme: " + scheme + ". Valid values are: utf-8, legacy."); + } + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private ValidationScheme validationScheme; + + private Builder() {} + + public Builder validation(ValidationScheme validationScheme) { + this.validationScheme = validationScheme; + return this; + } + + public NamingProperties build() { + return new NamingProperties(validationScheme); + } + } +} diff --git a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/PrometheusProperties.java b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/PrometheusProperties.java index 6d9a37594..49b03fb16 100644 --- a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/PrometheusProperties.java +++ b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/PrometheusProperties.java @@ -20,6 +20,7 @@ public class PrometheusProperties { private final ExporterHttpServerProperties exporterHttpServerProperties; private final ExporterOpenTelemetryProperties exporterOpenTelemetryProperties; private final ExporterPushgatewayProperties exporterPushgatewayProperties; + private final NamingProperties namingProperties; /** * Get the properties instance. When called for the first time, {@code get()} loads the properties @@ -48,7 +49,8 @@ public PrometheusProperties( ExporterFilterProperties exporterFilterProperties, ExporterHttpServerProperties httpServerConfig, ExporterPushgatewayProperties pushgatewayProperties, - ExporterOpenTelemetryProperties otelConfig) { + ExporterOpenTelemetryProperties otelConfig, + NamingProperties namingProperties) { this.defaultMetricsProperties = defaultMetricsProperties; this.metricProperties.putAll(metricProperties); this.exemplarProperties = exemplarProperties; @@ -57,6 +59,7 @@ public PrometheusProperties( this.exporterHttpServerProperties = httpServerConfig; this.exporterPushgatewayProperties = pushgatewayProperties; this.exporterOpenTelemetryProperties = otelConfig; + this.namingProperties = namingProperties; } /** @@ -100,6 +103,10 @@ public ExporterOpenTelemetryProperties getExporterOpenTelemetryProperties() { return exporterOpenTelemetryProperties; } + public NamingProperties getNamingProperties() { + return namingProperties; + } + public static class Builder { private MetricsProperties defaultMetricsProperties; private Map metricProperties = new HashMap<>(); @@ -109,6 +116,7 @@ public static class Builder { private ExporterHttpServerProperties exporterHttpServerProperties; private ExporterPushgatewayProperties pushgatewayProperties; private ExporterOpenTelemetryProperties otelConfig; + private NamingProperties namingProperties; private Builder() {} @@ -160,6 +168,11 @@ public Builder exporterOpenTelemetryProperties( return this; } + public Builder namingProperties(NamingProperties namingProperties) { + this.namingProperties = namingProperties; + return this; + } + public PrometheusProperties build() { return new PrometheusProperties( defaultMetricsProperties, @@ -169,7 +182,8 @@ public PrometheusProperties build() { exporterFilterProperties, exporterHttpServerProperties, pushgatewayProperties, - otelConfig); + otelConfig, + namingProperties); } } } diff --git a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/PrometheusPropertiesLoader.java b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/PrometheusPropertiesLoader.java index a847a8dba..cd4ba3e33 100644 --- a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/PrometheusPropertiesLoader.java +++ b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/PrometheusPropertiesLoader.java @@ -42,6 +42,7 @@ public static PrometheusProperties load(Map externalProperties) ExporterPushgatewayProperties.load(properties); ExporterOpenTelemetryProperties exporterOpenTelemetryProperties = ExporterOpenTelemetryProperties.load(properties); + NamingProperties namingProperties = NamingProperties.load(properties); validateAllPropertiesProcessed(properties); return new PrometheusProperties( defaultMetricsProperties, @@ -51,7 +52,8 @@ public static PrometheusProperties load(Map externalProperties) exporterFilterProperties, exporterHttpServerProperties, exporterPushgatewayProperties, - exporterOpenTelemetryProperties); + exporterOpenTelemetryProperties, + namingProperties); } // This will remove entries from properties when they are processed. diff --git a/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/ValidationScheme.java b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/ValidationScheme.java new file mode 100644 index 000000000..c0429256a --- /dev/null +++ b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/ValidationScheme.java @@ -0,0 +1,16 @@ +package io.prometheus.metrics.config; + +/** + * ValidationScheme is an enum for determining how metric and label names will be validated by this + * library. + */ +public enum ValidationScheme { + /** + * LEGACY_VALIDATION is a setting that requires that metric and label names conform to the + * original character requirements. + */ + LEGACY_VALIDATION, + + /** UTF_8_VALIDATION only requires that metric and label names be valid UTF-8 strings. */ + UTF_8_VALIDATION +} diff --git a/prometheus-metrics-config/src/test/java/io/prometheus/metrics/config/NamingPropertiesTest.java b/prometheus-metrics-config/src/test/java/io/prometheus/metrics/config/NamingPropertiesTest.java new file mode 100644 index 000000000..83548c11c --- /dev/null +++ b/prometheus-metrics-config/src/test/java/io/prometheus/metrics/config/NamingPropertiesTest.java @@ -0,0 +1,29 @@ +package io.prometheus.metrics.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import org.junit.jupiter.api.Test; + +class NamingPropertiesTest { + @Test + void testBuilder() { + NamingProperties properties = + NamingProperties.builder().validation(ValidationScheme.UTF_8_VALIDATION).build(); + assertThat(properties.getValidationScheme()).isEqualTo(ValidationScheme.UTF_8_VALIDATION); + } + + @Test + void parseValidationScheme() { + assertThat(NamingProperties.parseValidationScheme("utf-8")) + .isEqualTo(ValidationScheme.UTF_8_VALIDATION); + assertThat(NamingProperties.parseValidationScheme("legacy")) + .isEqualTo(ValidationScheme.LEGACY_VALIDATION); + assertThat(NamingProperties.parseValidationScheme(null)) + .isEqualTo(ValidationScheme.LEGACY_VALIDATION); + assertThatCode(() -> NamingProperties.parseValidationScheme("unknown")) + .isInstanceOf(PrometheusPropertiesException.class) + .hasMessageContaining( + "Unknown validation scheme: unknown. Valid values are: utf-8, legacy."); + } +} diff --git a/prometheus-metrics-config/src/test/java/io/prometheus/metrics/config/PrometheusPropertiesTest.java b/prometheus-metrics-config/src/test/java/io/prometheus/metrics/config/PrometheusPropertiesTest.java index a01e05248..18b66fa6f 100644 --- a/prometheus-metrics-config/src/test/java/io/prometheus/metrics/config/PrometheusPropertiesTest.java +++ b/prometheus-metrics-config/src/test/java/io/prometheus/metrics/config/PrometheusPropertiesTest.java @@ -4,6 +4,7 @@ import java.io.IOException; import java.io.InputStream; +import java.util.Collections; import java.util.HashMap; import java.util.Properties; import org.junit.jupiter.api.Test; @@ -37,15 +38,20 @@ public void testBuilder() { PrometheusProperties defaults = PrometheusPropertiesLoader.load(new HashMap<>()); PrometheusProperties.Builder builder = PrometheusProperties.builder(); builder.defaultMetricsProperties(defaults.getDefaultMetricProperties()); + builder.metricProperties( + Collections.singletonMap( + "http_duration_seconds", + MetricsProperties.builder().histogramClassicUpperBounds(0.1, 0.2, 0.5, 1.0).build())); builder.exemplarProperties(defaults.getExemplarProperties()); builder.defaultMetricsProperties(defaults.getDefaultMetricProperties()); builder.exporterFilterProperties(defaults.getExporterFilterProperties()); builder.exporterHttpServerProperties(defaults.getExporterHttpServerProperties()); builder.exporterOpenTelemetryProperties(defaults.getExporterOpenTelemetryProperties()); builder.pushgatewayProperties(defaults.getExporterPushgatewayProperties()); + builder.exporterProperties(defaults.getExporterProperties()); + builder.namingProperties(defaults.getNamingProperties()); PrometheusProperties result = builder.build(); assertThat(result.getDefaultMetricProperties()).isSameAs(defaults.getDefaultMetricProperties()); - assertThat(result.getDefaultMetricProperties()).isSameAs(defaults.getDefaultMetricProperties()); assertThat(result.getExemplarProperties()).isSameAs(defaults.getExemplarProperties()); assertThat(result.getExporterFilterProperties()) .isSameAs(defaults.getExporterFilterProperties()); @@ -55,5 +61,12 @@ public void testBuilder() { .isSameAs(defaults.getExporterOpenTelemetryProperties()); assertThat(result.getExporterPushgatewayProperties()) .isSameAs(defaults.getExporterPushgatewayProperties()); + assertThat(result.getMetricProperties("http_duration_seconds")) + .usingRecursiveComparison() + .isEqualTo( + MetricsProperties.builder().histogramClassicUpperBounds(0.1, 0.2, 0.5, 1.0).build()); + assertThat(result.getMetricProperties("unknown_metric")).isNull(); + assertThat(result.getExporterProperties()).isSameAs(defaults.getExporterProperties()); + assertThat(result.getNamingProperties()).isSameAs(defaults.getNamingProperties()); } } diff --git a/prometheus-metrics-core/src/test/java/io/prometheus/metrics/core/metrics/HistogramTest.java b/prometheus-metrics-core/src/test/java/io/prometheus/metrics/core/metrics/HistogramTest.java index 70419ea8d..29518b463 100644 --- a/prometheus-metrics-core/src/test/java/io/prometheus/metrics/core/metrics/HistogramTest.java +++ b/prometheus-metrics-core/src/test/java/io/prometheus/metrics/core/metrics/HistogramTest.java @@ -13,12 +13,7 @@ import io.prometheus.metrics.expositionformats.generated.com_google_protobuf_4_31_1.Metrics; import io.prometheus.metrics.expositionformats.internal.PrometheusProtobufWriterImpl; import io.prometheus.metrics.expositionformats.internal.ProtobufUtil; -import io.prometheus.metrics.model.snapshots.ClassicHistogramBucket; -import io.prometheus.metrics.model.snapshots.Exemplar; -import io.prometheus.metrics.model.snapshots.Exemplars; -import io.prometheus.metrics.model.snapshots.HistogramSnapshot; -import io.prometheus.metrics.model.snapshots.Labels; -import io.prometheus.metrics.model.snapshots.MetricSnapshots; +import io.prometheus.metrics.model.snapshots.*; import io.prometheus.metrics.tracer.common.SpanContext; import io.prometheus.metrics.tracer.initializer.SpanContextSupplier; import java.io.ByteArrayOutputStream; @@ -952,7 +947,7 @@ public void testDefaults() throws IOException { // text ByteArrayOutputStream out = new ByteArrayOutputStream(); OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(false, true); - writer.write(out, MetricSnapshots.of(snapshot)); + writer.write(out, MetricSnapshots.of(snapshot), EscapingScheme.NO_ESCAPING); assertThat(out).hasToString(expectedTextFormat); } diff --git a/prometheus-metrics-core/src/test/java/io/prometheus/metrics/core/metrics/InfoTest.java b/prometheus-metrics-core/src/test/java/io/prometheus/metrics/core/metrics/InfoTest.java index f09988091..b21288d5f 100644 --- a/prometheus-metrics-core/src/test/java/io/prometheus/metrics/core/metrics/InfoTest.java +++ b/prometheus-metrics-core/src/test/java/io/prometheus/metrics/core/metrics/InfoTest.java @@ -7,6 +7,7 @@ import io.prometheus.metrics.expositionformats.generated.com_google_protobuf_4_31_1.Metrics; import io.prometheus.metrics.expositionformats.internal.PrometheusProtobufWriterImpl; import io.prometheus.metrics.expositionformats.internal.ProtobufUtil; +import io.prometheus.metrics.model.snapshots.EscapingScheme; import io.prometheus.metrics.model.snapshots.Labels; import io.prometheus.metrics.model.snapshots.MetricSnapshots; import io.prometheus.metrics.model.snapshots.Unit; @@ -126,7 +127,7 @@ public void testConstLabelsDuplicate2() { private void assertTextFormat(String expected, Info info) throws IOException { OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, true); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - writer.write(outputStream, MetricSnapshots.of(info.collect())); + writer.write(outputStream, MetricSnapshots.of(info.collect()), EscapingScheme.NO_ESCAPING); String result = outputStream.toString(StandardCharsets.UTF_8.name()); if (!result.contains(expected)) { throw new AssertionError(expected + " is not contained in the following output:\n" + result); 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 6aa70a162..7510f6978 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 @@ -6,6 +6,7 @@ import io.prometheus.metrics.expositionformats.ExpositionFormats; import io.prometheus.metrics.model.registry.MetricNameFilter; import io.prometheus.metrics.model.registry.PrometheusRegistry; +import io.prometheus.metrics.model.snapshots.EscapingScheme; import io.prometheus.metrics.model.snapshots.MetricSnapshots; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -54,10 +55,11 @@ public void handleRequest(PrometheusHttpExchange exchange) throws IOException { try { PrometheusHttpRequest request = exchange.getRequest(); MetricSnapshots snapshots = scrape(request); - if (writeDebugResponse(snapshots, exchange)) { + String acceptHeader = request.getHeader("Accept"); + EscapingScheme escapingScheme = EscapingScheme.fromAcceptHeader(acceptHeader); + if (writeDebugResponse(snapshots, escapingScheme, exchange)) { return; } - String acceptHeader = request.getHeader("Accept"); ExpositionFormatWriter writer = expositionFormats.findWriter(acceptHeader); PrometheusHttpResponse response = exchange.getResponse(); response.setHeader("Content-Type", writer.getContentType()); @@ -66,12 +68,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); + writer.write(gzipOutputStream, snapshots, escapingScheme); } } else { ByteArrayOutputStream responseBuffer = new ByteArrayOutputStream(lastResponseSize.get() + 1024); - writer.write(responseBuffer, snapshots); + writer.write(responseBuffer, snapshots, escapingScheme); lastResponseSize.set(responseBuffer.size()); int contentLength = responseBuffer.size(); if (contentLength > 0) { @@ -136,7 +138,8 @@ private MetricSnapshots scrape(PrometheusHttpRequest request) { } } - private boolean writeDebugResponse(MetricSnapshots snapshots, PrometheusHttpExchange exchange) + private boolean writeDebugResponse( + MetricSnapshots snapshots, EscapingScheme escapingScheme, PrometheusHttpExchange exchange) throws IOException { String debugParam = exchange.getRequest().getParameter("debug"); PrometheusHttpResponse response = exchange.getResponse(); @@ -148,14 +151,16 @@ private boolean writeDebugResponse(MetricSnapshots snapshots, PrometheusHttpExch OutputStream body = response.sendHeadersAndGetBody(responseStatus, 0); switch (debugParam) { case "openmetrics": - expositionFormats.getOpenMetricsTextFormatWriter().write(body, snapshots); + expositionFormats.getOpenMetricsTextFormatWriter().write(body, snapshots, escapingScheme); break; case "text": - expositionFormats.getPrometheusTextFormatWriter().write(body, snapshots); + expositionFormats.getPrometheusTextFormatWriter().write(body, snapshots, escapingScheme); break; case "prometheus-protobuf": String debugString = - expositionFormats.getPrometheusProtobufWriter().toDebugString(snapshots); + expositionFormats + .getPrometheusProtobufWriter() + .toDebugString(snapshots, escapingScheme); body.write(debugString.getBytes(StandardCharsets.UTF_8)); break; default: diff --git a/prometheus-metrics-exporter-pushgateway/src/main/java/io/prometheus/metrics/exporter/pushgateway/PushGateway.java b/prometheus-metrics-exporter-pushgateway/src/main/java/io/prometheus/metrics/exporter/pushgateway/PushGateway.java index 1f7262368..596657340 100644 --- a/prometheus-metrics-exporter-pushgateway/src/main/java/io/prometheus/metrics/exporter/pushgateway/PushGateway.java +++ b/prometheus-metrics-exporter-pushgateway/src/main/java/io/prometheus/metrics/exporter/pushgateway/PushGateway.java @@ -1,6 +1,7 @@ package io.prometheus.metrics.exporter.pushgateway; import static io.prometheus.metrics.exporter.pushgateway.Scheme.HTTP; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.escapeName; import io.prometheus.metrics.config.ExporterPushgatewayProperties; import io.prometheus.metrics.config.PrometheusProperties; @@ -11,6 +12,7 @@ import io.prometheus.metrics.model.registry.Collector; import io.prometheus.metrics.model.registry.MultiCollector; import io.prometheus.metrics.model.registry.PrometheusRegistry; +import io.prometheus.metrics.model.snapshots.EscapingScheme; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -84,6 +86,7 @@ public class PushGateway { private final Map requestHeaders; private final PrometheusRegistry registry; private final HttpConnectionFactory connectionFactory; + private final EscapingScheme escapingScheme; private PushGateway( PrometheusRegistry registry, @@ -91,12 +94,14 @@ private PushGateway( URL url, HttpConnectionFactory connectionFactory, Map requestHeaders, - boolean prometheusTimestampsInMs) { + boolean prometheusTimestampsInMs, + EscapingScheme escapingScheme) { this.registry = registry; this.url = url; this.requestHeaders = Collections.unmodifiableMap(new HashMap<>(requestHeaders)); this.connectionFactory = connectionFactory; this.prometheusTimestampsInMs = prometheusTimestampsInMs; + this.escapingScheme = escapingScheme; writer = getWriter(format); if (!writer.isAvailable()) { throw new RuntimeException(writer.getClass() + " is not available"); @@ -206,7 +211,7 @@ private void doRequest(PrometheusRegistry registry, String method) throws IOExce try { if (!method.equals("DELETE")) { OutputStream outputStream = connection.getOutputStream(); - writer.write(outputStream, registry.scrape()); + writer.write(outputStream, registry.scrape(), this.escapingScheme); outputStream.flush(); outputStream.close(); } @@ -432,6 +437,25 @@ private String getJob(ExporterPushgatewayProperties properties) { } } + private EscapingScheme getEscapingScheme(ExporterPushgatewayProperties properties) { + if (properties != null && properties.getEscapingScheme() != null) { + String scheme = properties.getEscapingScheme(); + switch (scheme) { + case "no-escaping": + return EscapingScheme.NO_ESCAPING; + case "values": + return EscapingScheme.VALUE_ENCODING_ESCAPING; + case "underscores": + return EscapingScheme.UNDERSCORE_ESCAPING; + case "dots": + return EscapingScheme.DOTS_ESCAPING; + default: + return EscapingScheme.NO_ESCAPING; + } + } + return EscapingScheme.NO_ESCAPING; + } + private Format getFormat() { // currently not configurable via properties if (this.format != null) { @@ -442,25 +466,34 @@ private Format getFormat() { private URL makeUrl(ExporterPushgatewayProperties properties) throws UnsupportedEncodingException, MalformedURLException { - String url = getScheme(properties) + "://" + getAddress(properties) + "/metrics/"; + StringBuilder url = + new StringBuilder(getScheme(properties) + "://" + getAddress(properties) + "/metrics/"); String job = getJob(properties); if (job.contains("/")) { - url += "job@base64/" + base64url(job); + url.append("job@base64/").append(base64url(job)); } else { - url += "job/" + URLEncoder.encode(job, "UTF-8"); + url.append("job/").append(URLEncoder.encode(job, "UTF-8")); } if (groupingKey != null) { for (Map.Entry entry : groupingKey.entrySet()) { if (entry.getValue().isEmpty()) { - url += "/" + entry.getKey() + "@base64/="; + url.append("/") + .append(escapeName(entry.getKey(), EscapingScheme.VALUE_ENCODING_ESCAPING)) + .append("@base64/="); } else if (entry.getValue().contains("/")) { - url += "/" + entry.getKey() + "@base64/" + base64url(entry.getValue()); + url.append("/") + .append(escapeName(entry.getKey(), EscapingScheme.VALUE_ENCODING_ESCAPING)) + .append("@base64/") + .append(base64url(entry.getValue())); } else { - url += "/" + entry.getKey() + "/" + URLEncoder.encode(entry.getValue(), "UTF-8"); + url.append("/") + .append(escapeName(entry.getKey(), EscapingScheme.VALUE_ENCODING_ESCAPING)) + .append("/") + .append(URLEncoder.encode(entry.getValue(), "UTF-8")); } } } - return URI.create(url).normalize().toURL(); + return URI.create(url.toString()).normalize().toURL(); } private String base64url(String v) { @@ -480,7 +513,8 @@ public PushGateway build() { makeUrl(properties), connectionFactory, requestHeaders, - getPrometheusTimestampsInMs()); + getPrometheusTimestampsInMs(), + getEscapingScheme(properties)); } catch (MalformedURLException e) { throw new PrometheusPropertiesException( address + ": Invalid address. Expecting :"); diff --git a/prometheus-metrics-exporter-pushgateway/src/test/java/io/prometheus/metrics/exporter/pushgateway/PushGatewayTest.java b/prometheus-metrics-exporter-pushgateway/src/test/java/io/prometheus/metrics/exporter/pushgateway/PushGatewayTest.java index 27617913b..8e574d5fa 100644 --- a/prometheus-metrics-exporter-pushgateway/src/test/java/io/prometheus/metrics/exporter/pushgateway/PushGatewayTest.java +++ b/prometheus-metrics-exporter-pushgateway/src/test/java/io/prometheus/metrics/exporter/pushgateway/PushGatewayTest.java @@ -1,12 +1,14 @@ package io.prometheus.metrics.exporter.pushgateway; -import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.mockserver.model.HttpRequest.request; import static org.mockserver.model.HttpResponse.response; import io.prometheus.metrics.core.metrics.Gauge; import io.prometheus.metrics.model.registry.PrometheusRegistry; +import io.prometheus.metrics.model.snapshots.EscapingScheme; +import io.prometheus.metrics.model.snapshots.PrometheusNaming; import java.io.IOException; import java.lang.reflect.Field; import java.net.InetAddress; @@ -14,6 +16,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.SetSystemProperty; import org.mockserver.client.MockServerClient; import org.mockserver.integration.ClientAndServer; @@ -34,6 +37,8 @@ public void setUp() { @AfterEach void tearDown() { mockServerClient.stop(); + System.clearProperty("io.prometheus.naming.validationScheme"); + PrometheusNaming.resetForTest(); } @Test @@ -141,6 +146,23 @@ public void testPushWithGroupingKey() throws IOException { pg.push(); } + @SetSystemProperty(key = "io.prometheus.naming.validationScheme", value = "utf-8") + @Test + public void testPushWithEscapedGroupingKey() throws IOException { + PrometheusNaming.resetForTest(); + mockServerClient + .when(request().withMethod("PUT").withPath("/metrics/job/j/U__l_2e_1/v1")) + .respond(response().withStatusCode(202)); + PushGateway pg = + PushGateway.builder() + .address("localhost:" + mockServerClient.getPort()) + .registry(registry) + .job("j") + .groupingKey("l.1", "v1") + .build(); + pg.push(); + } + @Test public void testPushWithMultiGroupingKey() throws IOException { mockServerClient @@ -157,6 +179,25 @@ public void testPushWithMultiGroupingKey() throws IOException { pg.push(); } + @SetSystemProperty(key = "io.prometheus.naming.validationScheme", value = "utf-8") + @Test + public void testPushWithMultiEscapedGroupingKey() throws IOException { + PrometheusNaming.resetForTest(); + + mockServerClient + .when(request().withMethod("PUT").withPath("/metrics/job/j/U__l_2e_1/v1/U__l_2e_2/v2")) + .respond(response().withStatusCode(202)); + PushGateway pg = + PushGateway.builder() + .address("localhost:" + mockServerClient.getPort()) + .registry(registry) + .job("j") + .groupingKey("l.1", "v1") + .groupingKey("l.2", "v2") + .build(); + pg.push(); + } + @Test public void testPushWithEmptyLabelGroupingKey() throws IOException { mockServerClient @@ -205,6 +246,23 @@ public void testPushCollectorWithGroupingKey() throws IOException { pg.push(gauge); } + @SetSystemProperty(key = "io.prometheus.naming.validationScheme", value = "utf-8") + @Test + public void testPushCollectorWithEscapedGroupingKey() throws IOException { + PrometheusNaming.resetForTest(); + mockServerClient + .when(request().withMethod("PUT").withPath("/metrics/job/j/U__l_2e_1/v1")) + .respond(response().withStatusCode(202)); + PushGateway pg = + PushGateway.builder() + .address("localhost:" + mockServerClient.getPort()) + .registry(registry) + .job("j") + .groupingKey("l.1", "v1") + .build(); + pg.push(gauge); + } + @Test public void testPushAdd() throws IOException { mockServerClient @@ -244,6 +302,23 @@ public void testPushAddWithGroupingKey() throws IOException { pg.pushAdd(); } + @SetSystemProperty(key = "io.prometheus.naming.validationScheme", value = "utf-8") + @Test + public void testPushAddWithEscapedGroupingKey() throws IOException { + PrometheusNaming.resetForTest(); + mockServerClient + .when(request().withMethod("POST").withPath("/metrics/job/j/U__l_2e_1/v1")) + .respond(response().withStatusCode(202)); + PushGateway pg = + PushGateway.builder() + .address("localhost:" + mockServerClient.getPort()) + .registry(registry) + .groupingKey("l.1", "v1") + .job("j") + .build(); + pg.pushAdd(); + } + @Test public void testPushAddCollectorWithGroupingKey() throws IOException { mockServerClient @@ -259,6 +334,24 @@ public void testPushAddCollectorWithGroupingKey() throws IOException { pg.pushAdd(gauge); } + @SetSystemProperty(key = "io.prometheus.naming.validationScheme", value = "utf-8") + @Test + public void testPushAddCollectorWithEscapedGroupingKey() throws IOException { + PrometheusNaming.resetForTest(); + + mockServerClient + .when(request().withMethod("POST").withPath("/metrics/job/j/U__l_2e_1/v1")) + .respond(response().withStatusCode(202)); + PushGateway pg = + PushGateway.builder() + .address("localhost:" + mockServerClient.getPort()) + .registry(registry) + .groupingKey("l.1", "v1") + .job("j") + .build(); + pg.pushAdd(gauge); + } + @Test public void testDelete() throws IOException { mockServerClient @@ -283,6 +376,23 @@ public void testDeleteWithGroupingKey() throws IOException { pg.delete(); } + @SetSystemProperty(key = "io.prometheus.naming.validationScheme", value = "utf-8") + @Test + public void testDeleteWithEscapedGroupingKey() throws IOException { + PrometheusNaming.resetForTest(); + + mockServerClient + .when(request().withMethod("DELETE").withPath("/metrics/job/j/U__l_2e_1/v1")) + .respond(response().withStatusCode(202)); + PushGateway pg = + PushGateway.builder() + .address("localhost:" + mockServerClient.getPort()) + .job("j") + .groupingKey("l.1", "v1") + .build(); + pg.delete(); + } + @Test public void testInstanceIpGroupingKey() throws IOException { String ip = InetAddress.getLocalHost().getHostAddress(); @@ -299,4 +409,42 @@ public void testInstanceIpGroupingKey() throws IOException { .build(); pg.delete(); } + + @SetSystemProperty(key = "io.prometheus.naming.validationScheme", value = "utf-8") + @Test + public void testInstanceIpEscapedGroupingKey() throws IOException { + PrometheusNaming.resetForTest(); + + String ip = InetAddress.getLocalHost().getHostAddress(); + assertThat(ip).isNotEmpty(); + mockServerClient + .when( + request() + .withMethod("DELETE") + .withPath("/metrics/job/j/instance/" + ip + "/U__l_2e_1/v1")) + .respond(response().withStatusCode(202)); + PushGateway pg = + PushGateway.builder() + .address("localhost:" + mockServerClient.getPort()) + .job("j") + .groupingKey("l.1", "v1") + .instanceIpGroupingKey() + .build(); + pg.delete(); + } + + @Test + public void testEscapingSchemeDefaultValue() throws IllegalAccessException, NoSuchFieldException { + PushGateway pg = + PushGateway.builder() + .address("localhost:" + mockServerClient.getPort()) + .job("test") + .build(); + + Field escapingSchemeField = pg.getClass().getDeclaredField("escapingScheme"); + escapingSchemeField.setAccessible(true); + EscapingScheme scheme = (EscapingScheme) escapingSchemeField.get(pg); + + assertThat(scheme).isEqualTo(EscapingScheme.NO_ESCAPING); + } } diff --git a/prometheus-metrics-exposition-formats/src/main/java/io/prometheus/metrics/expositionformats/internal/PrometheusProtobufWriterImpl.java b/prometheus-metrics-exposition-formats/src/main/java/io/prometheus/metrics/expositionformats/internal/PrometheusProtobufWriterImpl.java index e3e450a28..114fdb502 100644 --- a/prometheus-metrics-exposition-formats/src/main/java/io/prometheus/metrics/expositionformats/internal/PrometheusProtobufWriterImpl.java +++ b/prometheus-metrics-exposition-formats/src/main/java/io/prometheus/metrics/expositionformats/internal/PrometheusProtobufWriterImpl.java @@ -9,6 +9,7 @@ import io.prometheus.metrics.model.snapshots.CounterSnapshot; import io.prometheus.metrics.model.snapshots.CounterSnapshot.CounterDataPointSnapshot; import io.prometheus.metrics.model.snapshots.DataPointSnapshot; +import io.prometheus.metrics.model.snapshots.EscapingScheme; import io.prometheus.metrics.model.snapshots.Exemplar; import io.prometheus.metrics.model.snapshots.GaugeSnapshot; import io.prometheus.metrics.model.snapshots.HistogramSnapshot; @@ -18,6 +19,7 @@ import io.prometheus.metrics.model.snapshots.MetricSnapshot; import io.prometheus.metrics.model.snapshots.MetricSnapshots; import io.prometheus.metrics.model.snapshots.NativeHistogramBuckets; +import io.prometheus.metrics.model.snapshots.PrometheusNaming; import io.prometheus.metrics.model.snapshots.Quantiles; import io.prometheus.metrics.model.snapshots.StateSetSnapshot; import io.prometheus.metrics.model.snapshots.SummarySnapshot; @@ -38,9 +40,10 @@ public String getContentType() { } @Override - public String toDebugString(MetricSnapshots metricSnapshots) { + public String toDebugString(MetricSnapshots metricSnapshots, EscapingScheme escapingScheme) { StringBuilder stringBuilder = new StringBuilder(); - for (MetricSnapshot snapshot : metricSnapshots) { + for (MetricSnapshot s : metricSnapshots) { + MetricSnapshot snapshot = PrometheusNaming.escapeMetricSnapshot(s, escapingScheme); if (!snapshot.getDataPoints().isEmpty()) { stringBuilder.append(TextFormat.printer().printToString(convert(snapshot))); } @@ -49,8 +52,11 @@ public String toDebugString(MetricSnapshots metricSnapshots) { } @Override - public void write(OutputStream out, MetricSnapshots metricSnapshots) throws IOException { - for (MetricSnapshot snapshot : metricSnapshots) { + public void write( + OutputStream out, MetricSnapshots metricSnapshots, EscapingScheme escapingScheme) + throws IOException { + for (MetricSnapshot s : metricSnapshots) { + MetricSnapshot snapshot = PrometheusNaming.escapeMetricSnapshot(s, escapingScheme); if (!snapshot.getDataPoints().isEmpty()) { convert(snapshot).writeDelimitedTo(out); } diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormatWriter.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormatWriter.java index b472af0e1..287b4108a 100644 --- a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormatWriter.java +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormatWriter.java @@ -1,6 +1,8 @@ package io.prometheus.metrics.expositionformats; +import io.prometheus.metrics.model.snapshots.EscapingScheme; import io.prometheus.metrics.model.snapshots.MetricSnapshots; +import io.prometheus.metrics.model.snapshots.PrometheusNaming; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; @@ -8,19 +10,31 @@ public interface ExpositionFormatWriter { boolean accepts(String acceptHeader); - /** Text formats use UTF-8 encoding. */ - void write(OutputStream out, MetricSnapshots metricSnapshots) throws IOException; + /** Writes the given metric snapshots to the output stream using the specified escaping scheme. */ + void write(OutputStream out, MetricSnapshots metricSnapshots, EscapingScheme escapingScheme) + throws IOException; - default String toDebugString(MetricSnapshots metricSnapshots) { + /** Writes the given metric snapshots to the output stream using the default escaping scheme. */ + default void write(OutputStream out, MetricSnapshots metricSnapshots) throws IOException { + write(out, metricSnapshots, PrometheusNaming.DEFAULT_ESCAPING_SCHEME); + } + + /** Converts the metric snapshots to a debug string using the specified escaping scheme. */ + default String toDebugString(MetricSnapshots metricSnapshots, EscapingScheme escapingScheme) { ByteArrayOutputStream out = new ByteArrayOutputStream(); try { - write(out, metricSnapshots); + write(out, metricSnapshots, escapingScheme); return out.toString("UTF-8"); } catch (IOException e) { throw new RuntimeException(e); } } + /** Converts the metric snapshots to a debug string using the default escaping scheme. */ + default String toDebugString(MetricSnapshots metricSnapshots) { + return toDebugString(metricSnapshots, PrometheusNaming.DEFAULT_ESCAPING_SCHEME); + } + String getContentType(); /** diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/NameType.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/NameType.java new file mode 100644 index 000000000..1a9a813aa --- /dev/null +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/NameType.java @@ -0,0 +1,6 @@ +package io.prometheus.metrics.expositionformats; + +enum NameType { + Metric, + Label +} diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetricsTextFormatWriter.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetricsTextFormatWriter.java index 914b28515..7e163095c 100644 --- a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetricsTextFormatWriter.java +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetricsTextFormatWriter.java @@ -1,15 +1,17 @@ package io.prometheus.metrics.expositionformats; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeDouble; -import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeEscapedLabelValue; +import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeEscapedString; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeLabels; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeLong; +import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeName; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeOpenMetricsTimestamp; import io.prometheus.metrics.model.snapshots.ClassicHistogramBuckets; import io.prometheus.metrics.model.snapshots.CounterSnapshot; import io.prometheus.metrics.model.snapshots.DataPointSnapshot; import io.prometheus.metrics.model.snapshots.DistributionDataPointSnapshot; +import io.prometheus.metrics.model.snapshots.EscapingScheme; import io.prometheus.metrics.model.snapshots.Exemplar; import io.prometheus.metrics.model.snapshots.Exemplars; import io.prometheus.metrics.model.snapshots.GaugeSnapshot; @@ -19,6 +21,7 @@ import io.prometheus.metrics.model.snapshots.MetricMetadata; import io.prometheus.metrics.model.snapshots.MetricSnapshot; import io.prometheus.metrics.model.snapshots.MetricSnapshots; +import io.prometheus.metrics.model.snapshots.PrometheusNaming; import io.prometheus.metrics.model.snapshots.Quantile; import io.prometheus.metrics.model.snapshots.StateSetSnapshot; import io.prometheus.metrics.model.snapshots.SummarySnapshot; @@ -103,9 +106,12 @@ public String getContentType() { } @Override - public void write(OutputStream out, MetricSnapshots metricSnapshots) throws IOException { + public void write( + OutputStream out, MetricSnapshots metricSnapshots, EscapingScheme escapingScheme) + throws IOException { Writer writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8)); - for (MetricSnapshot snapshot : metricSnapshots) { + for (MetricSnapshot s : metricSnapshots) { + MetricSnapshot snapshot = PrometheusNaming.escapeMetricSnapshot(s, escapingScheme); if (!snapshot.getDataPoints().isEmpty()) { if (snapshot instanceof CounterSnapshot) { writeCounter(writer, (CounterSnapshot) snapshot); @@ -274,7 +280,7 @@ private void writeStateSet(Writer writer, StateSetSnapshot snapshot) throws IOEx } writer.write(data.getLabels().getPrometheusName(j)); writer.write("=\""); - writeEscapedLabelValue(writer, data.getLabels().getValue(j)); + writeEscapedString(writer, data.getLabels().getValue(j)); writer.write("\""); } if (!data.getLabels().isEmpty()) { @@ -282,7 +288,7 @@ private void writeStateSet(Writer writer, StateSetSnapshot snapshot) throws IOEx } writer.write(metadata.getPrometheusName()); writer.write("=\""); - writeEscapedLabelValue(writer, data.getName(i)); + writeEscapedString(writer, data.getName(i)); writer.write("\"} "); if (data.isTrue(i)) { writer.write("1"); @@ -358,12 +364,18 @@ private void writeNameAndLabels( String additionalLabelName, double additionalLabelValue) throws IOException { - writer.write(name); - if (suffix != null) { - writer.write(suffix); + boolean metricInsideBraces = false; + // If the name does not pass the legacy validity check, we must put the + // metric name inside the braces. + if (PrometheusNaming.validateLegacyMetricName(name) != null) { + metricInsideBraces = true; + writer.write('{'); } + writeName(writer, name + (suffix != null ? suffix : ""), NameType.Metric); if (!labels.isEmpty() || additionalLabelName != null) { - writeLabels(writer, labels, additionalLabelName, additionalLabelValue); + writeLabels(writer, labels, additionalLabelName, additionalLabelValue, metricInsideBraces); + } else if (metricInsideBraces) { + writer.write('}'); } writer.write(' '); } @@ -376,7 +388,7 @@ private void writeScrapeTimestampAndExemplar( } if (exemplar != null) { writer.write(" # "); - writeLabels(writer, exemplar.getLabels(), null, 0); + writeLabels(writer, exemplar.getLabels(), null, 0, false); writer.write(' '); writeDouble(writer, exemplar.getValue()); if (exemplar.hasTimestamp()) { @@ -390,22 +402,22 @@ private void writeScrapeTimestampAndExemplar( private void writeMetadata(Writer writer, String typeName, MetricMetadata metadata) throws IOException { writer.write("# TYPE "); - writer.write(metadata.getPrometheusName()); + writeName(writer, metadata.getPrometheusName(), NameType.Metric); writer.write(' '); writer.write(typeName); writer.write('\n'); if (metadata.getUnit() != null) { writer.write("# UNIT "); - writer.write(metadata.getPrometheusName()); + writeName(writer, metadata.getPrometheusName(), NameType.Metric); writer.write(' '); - writeEscapedLabelValue(writer, metadata.getUnit().toString()); + writeEscapedString(writer, metadata.getUnit().toString()); writer.write('\n'); } if (metadata.getHelp() != null && !metadata.getHelp().isEmpty()) { writer.write("# HELP "); - writer.write(metadata.getPrometheusName()); + writeName(writer, metadata.getPrometheusName(), NameType.Metric); writer.write(' '); - writeEscapedLabelValue(writer, metadata.getHelp()); + writeEscapedString(writer, metadata.getHelp()); writer.write('\n'); } } diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/PrometheusProtobufWriter.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/PrometheusProtobufWriter.java index 0572a99a7..77f439d23 100644 --- a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/PrometheusProtobufWriter.java +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/PrometheusProtobufWriter.java @@ -1,5 +1,6 @@ package io.prometheus.metrics.expositionformats; +import io.prometheus.metrics.model.snapshots.EscapingScheme; import io.prometheus.metrics.model.snapshots.MetricSnapshots; import java.io.IOException; import java.io.OutputStream; @@ -54,15 +55,17 @@ public boolean isAvailable() { } @Override - public String toDebugString(MetricSnapshots metricSnapshots) { + public String toDebugString(MetricSnapshots metricSnapshots, EscapingScheme escapingScheme) { checkAvailable(); - return DELEGATE.toDebugString(metricSnapshots); + return DELEGATE.toDebugString(metricSnapshots, escapingScheme); } @Override - public void write(OutputStream out, MetricSnapshots metricSnapshots) throws IOException { + public void write( + OutputStream out, MetricSnapshots metricSnapshots, EscapingScheme escapingScheme) + throws IOException { checkAvailable(); - DELEGATE.write(out, metricSnapshots); + DELEGATE.write(out, metricSnapshots, escapingScheme); } private void checkAvailable() { diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/PrometheusTextFormatWriter.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/PrometheusTextFormatWriter.java index 76a5e4228..8efc2dab3 100644 --- a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/PrometheusTextFormatWriter.java +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/PrometheusTextFormatWriter.java @@ -1,14 +1,16 @@ package io.prometheus.metrics.expositionformats; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeDouble; -import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeEscapedLabelValue; +import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeEscapedString; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeLabels; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeLong; +import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeName; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writePrometheusTimestamp; import io.prometheus.metrics.model.snapshots.ClassicHistogramBuckets; import io.prometheus.metrics.model.snapshots.CounterSnapshot; import io.prometheus.metrics.model.snapshots.DataPointSnapshot; +import io.prometheus.metrics.model.snapshots.EscapingScheme; import io.prometheus.metrics.model.snapshots.GaugeSnapshot; import io.prometheus.metrics.model.snapshots.HistogramSnapshot; import io.prometheus.metrics.model.snapshots.InfoSnapshot; @@ -16,6 +18,7 @@ import io.prometheus.metrics.model.snapshots.MetricMetadata; import io.prometheus.metrics.model.snapshots.MetricSnapshot; import io.prometheus.metrics.model.snapshots.MetricSnapshots; +import io.prometheus.metrics.model.snapshots.PrometheusNaming; import io.prometheus.metrics.model.snapshots.Quantile; import io.prometheus.metrics.model.snapshots.StateSetSnapshot; import io.prometheus.metrics.model.snapshots.SummarySnapshot; @@ -102,12 +105,15 @@ public String getContentType() { } @Override - public void write(OutputStream out, MetricSnapshots metricSnapshots) throws IOException { + public void write( + OutputStream out, MetricSnapshots metricSnapshots, EscapingScheme escapingScheme) + throws IOException { // See https://prometheus.io/docs/instrumenting/exposition_formats/ // "unknown", "gauge", "counter", "stateset", "info", "histogram", "gaugehistogram", and // "summary". Writer writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8)); - for (MetricSnapshot snapshot : metricSnapshots) { + for (MetricSnapshot s : metricSnapshots) { + MetricSnapshot snapshot = PrometheusNaming.escapeMetricSnapshot(s, escapingScheme); if (!snapshot.getDataPoints().isEmpty()) { if (snapshot instanceof CounterSnapshot) { writeCounter(writer, (CounterSnapshot) snapshot); @@ -127,7 +133,8 @@ public void write(OutputStream out, MetricSnapshots metricSnapshots) throws IOEx } } if (writeCreatedTimestamps) { - for (MetricSnapshot snapshot : metricSnapshots) { + for (MetricSnapshot s : metricSnapshots) { + MetricSnapshot snapshot = PrometheusNaming.escapeMetricSnapshot(s, escapingScheme); if (!snapshot.getDataPoints().isEmpty()) { if (snapshot instanceof CounterSnapshot) { writeCreated(writer, snapshot); @@ -314,7 +321,7 @@ private void writeStateSet(Writer writer, StateSetSnapshot snapshot) throws IOEx } writer.write(data.getLabels().getPrometheusName(j)); writer.write("=\""); - writeEscapedLabelValue(writer, data.getLabels().getValue(j)); + writeEscapedString(writer, data.getLabels().getValue(j)); writer.write("\""); } if (!data.getLabels().isEmpty()) { @@ -322,7 +329,7 @@ private void writeStateSet(Writer writer, StateSetSnapshot snapshot) throws IOEx } writer.write(metadata.getPrometheusName()); writer.write("=\""); - writeEscapedLabelValue(writer, data.getName(i)); + writeEscapedString(writer, data.getName(i)); writer.write("\"} "); if (data.isTrue(i)) { writer.write("1"); @@ -357,12 +364,18 @@ private void writeNameAndLabels( String additionalLabelName, double additionalLabelValue) throws IOException { - writer.write(name); - if (suffix != null) { - writer.write(suffix); + boolean metricInsideBraces = false; + // If the name does not pass the legacy validity check, we must put the + // metric name inside the braces. + if (PrometheusNaming.validateLegacyMetricName(name) != null) { + metricInsideBraces = true; + writer.write('{'); } + writeName(writer, name + (suffix != null ? suffix : ""), NameType.Metric); if (!labels.isEmpty() || additionalLabelName != null) { - writeLabels(writer, labels, additionalLabelName, additionalLabelValue); + writeLabels(writer, labels, additionalLabelName, additionalLabelValue, metricInsideBraces); + } else if (metricInsideBraces) { + writer.write('}'); } writer.write(' '); } @@ -371,19 +384,15 @@ private void writeMetadata( Writer writer, String suffix, String typeString, MetricMetadata metadata) throws IOException { if (metadata.getHelp() != null && !metadata.getHelp().isEmpty()) { writer.write("# HELP "); - writer.write(metadata.getPrometheusName()); - if (suffix != null) { - writer.write(suffix); - } + writeName( + writer, metadata.getPrometheusName() + (suffix != null ? suffix : ""), NameType.Metric); writer.write(' '); writeEscapedHelp(writer, metadata.getHelp()); writer.write('\n'); } writer.write("# TYPE "); - writer.write(metadata.getPrometheusName()); - if (suffix != null) { - writer.write(suffix); - } + writeName( + writer, metadata.getPrometheusName() + (suffix != null ? suffix : ""), NameType.Metric); writer.write(' '); writer.write(typeString); writer.write('\n'); diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/TextFormatUtil.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/TextFormatUtil.java index 787a11c01..330790797 100644 --- a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/TextFormatUtil.java +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/TextFormatUtil.java @@ -1,6 +1,8 @@ package io.prometheus.metrics.expositionformats; +import io.prometheus.metrics.config.ValidationScheme; import io.prometheus.metrics.model.snapshots.Labels; +import io.prometheus.metrics.model.snapshots.PrometheusNaming; import java.io.IOException; import java.io.Writer; @@ -47,7 +49,7 @@ static void writeOpenMetricsTimestamp(Writer writer, long timestampMs) throws IO writer.write(Long.toString(ms)); } - static void writeEscapedLabelValue(Writer writer, String s) throws IOException { + static void writeEscapedString(Writer writer, String s) throws IOException { // optimize for the common case where no escaping is needed int start = 0; // #indexOf is a vectorized intrinsic @@ -99,16 +101,22 @@ static void writeEscapedLabelValue(Writer writer, String s) throws IOException { } static void writeLabels( - Writer writer, Labels labels, String additionalLabelName, double additionalLabelValue) + Writer writer, + Labels labels, + String additionalLabelName, + double additionalLabelValue, + boolean metricInsideBraces) throws IOException { - writer.write('{'); + if (!metricInsideBraces) { + writer.write('{'); + } for (int i = 0; i < labels.size(); i++) { - if (i > 0) { + if (i > 0 || metricInsideBraces) { writer.write(","); } - writer.write(labels.getPrometheusName(i)); + writeName(writer, labels.getPrometheusName(i), NameType.Label); writer.write("=\""); - writeEscapedLabelValue(writer, labels.getValue(i)); + writeEscapedString(writer, labels.getValue(i)); writer.write("\""); } if (additionalLabelName != null) { @@ -122,4 +130,27 @@ static void writeLabels( } writer.write('}'); } + + static void writeName(Writer writer, String name, NameType nameType) throws IOException { + switch (nameType) { + case Metric: + if (PrometheusNaming.isValidLegacyMetricName(name)) { + writer.write(name); + return; + } + break; + case Label: + if (PrometheusNaming.isValidLegacyLabelName(name) + && PrometheusNaming.getValidationScheme() == ValidationScheme.LEGACY_VALIDATION) { + writer.write(name); + return; + } + break; + default: + throw new RuntimeException("Invalid name type requested: " + nameType); + } + writer.write('"'); + writeEscapedString(writer, name); + writer.write('"'); + } } diff --git a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatWriterTest.java b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatWriterTest.java index aa50d9876..9f30868ea 100644 --- a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatWriterTest.java +++ b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatWriterTest.java @@ -2,15 +2,32 @@ import static org.assertj.core.api.Assertions.assertThat; +import io.prometheus.metrics.model.snapshots.EscapingScheme; import io.prometheus.metrics.model.snapshots.MetricSnapshots; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import org.junit.jupiter.api.Test; class ExpositionFormatWriterTest { private final ExpositionFormatWriter writer = OpenMetricsTextFormatWriter.create(); + @Test + void write() throws IOException { + MetricSnapshots snapshots = new MetricSnapshots(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writer.write(out, snapshots, EscapingScheme.NO_ESCAPING); + assertThat(out).hasToString("# EOF\n"); + + out.reset(); + writer.write(out, snapshots); + assertThat(out).hasToString("# EOF\n"); + } + @Test void toDebugString() { + assertThat(writer.toDebugString(new MetricSnapshots(), EscapingScheme.NO_ESCAPING)) + .isEqualTo("# EOF\n"); assertThat(writer.toDebugString(new MetricSnapshots())).isEqualTo("# EOF\n"); } 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 d6da09537..af3cbe888 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 @@ -1,10 +1,12 @@ package io.prometheus.metrics.expositionformats; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; import io.prometheus.metrics.model.snapshots.ClassicHistogramBuckets; import io.prometheus.metrics.model.snapshots.CounterSnapshot; import io.prometheus.metrics.model.snapshots.CounterSnapshot.CounterDataPointSnapshot; +import io.prometheus.metrics.model.snapshots.EscapingScheme; import io.prometheus.metrics.model.snapshots.Exemplar; import io.prometheus.metrics.model.snapshots.Exemplars; import io.prometheus.metrics.model.snapshots.GaugeSnapshot; @@ -25,7 +27,12 @@ import io.prometheus.metrics.model.snapshots.UnknownSnapshot.UnknownDataPointSnapshot; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junitpioneer.jupiter.SetSystemProperty; class ExpositionFormatsTest { @@ -92,6 +99,12 @@ class ExpositionFormatsTest { .timestampMillis(1690298864383L) .build(); + @AfterEach + void tearDown() { + System.clearProperty("io.prometheus.naming.validationScheme"); + PrometheusNaming.resetForTest(); + } + @Test void init() { ExpositionFormats formats = ExpositionFormats.init(); @@ -464,6 +477,39 @@ public void testGaugeWithDots() throws IOException { assertPrometheusProtobuf(prometheusProtobuf, gauge); } + @SetSystemProperty(key = "io.prometheus.naming.validationScheme", value = "utf-8") + @Test + public void testGaugeUTF8() throws IOException { + PrometheusNaming.resetForTest(); + String prometheusText = + """ + # HELP "gauge.name" gauge\\ndoc\\nstr"ing + # TYPE "gauge.name" gauge + {"gauge.name","name*2"="val with \\\\backslash and \\"quotes\\"","name.1"="val with\\nnew line"} +Inf + {"gauge.name","name*2"="佖佥","name.1"="Björn"} 3.14E42 + """; + GaugeSnapshot gauge = + GaugeSnapshot.builder() + .name("gauge.name") + .help("gauge\ndoc\nstr\"ing") + .dataPoint( + GaugeDataPointSnapshot.builder() + .value(Double.POSITIVE_INFINITY) + .labels( + Labels.builder() + .label("name.1", "val with\nnew line") + .label("name*2", "val with \\backslash and \"quotes\"") + .build()) + .build()) + .dataPoint( + GaugeDataPointSnapshot.builder() + .value(3.14e42) + .labels(Labels.builder().label("name.1", "Björn").label("name*2", "佖佥").build()) + .build()) + .build(); + assertPrometheusText(prometheusText, gauge); + } + @Test public void testSummaryComplete() throws IOException { String openMetricsText = @@ -2737,11 +2783,54 @@ public void testLabelValueEscape() throws IOException { assertPrometheusText(prometheus, counter); } + @ParameterizedTest + @CsvSource({ + "'application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily;encoding=delimited', 'application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=values'", + "'text/plain;version=0.0.4', 'text/plain; version=0.0.4; charset=utf-8; escaping=values'", + "'application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily;encoding=delimited; escaping=allow-utf-8', 'application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=allow-utf-8'", + "'application/openmetrics-text', 'application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=values'", + "'application/openmetrics-text;version=0.0.1; escaping=underscores', 'application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores'", + "'text/plain;version=0.0.4; escaping=allow-utf-8', 'text/plain; version=0.0.4; charset=utf-8; escaping=allow-utf-8'" + }) + public void testFindWriter(String acceptHeaderValue, String expectedFmt) { + ExpositionFormats expositionFormats = ExpositionFormats.init(); + EscapingScheme escapingScheme = EscapingScheme.fromAcceptHeader(acceptHeaderValue); + ExpositionFormatWriter writer = expositionFormats.findWriter(acceptHeaderValue); + assertThat(writer.getContentType() + escapingScheme.toHeaderFormat()).hasToString(expectedFmt); + } + + @Test + public void testWrite() throws IOException { + ByteArrayOutputStream buff = new ByteArrayOutputStream(new AtomicInteger(2 << 9).get() + 1024); + ExpositionFormats expositionFormats = ExpositionFormats.init(); + UnknownSnapshot unknown = + UnknownSnapshot.builder() + .name("foo_metric") + .dataPoint(UnknownDataPointSnapshot.builder().value(1.234).build()) + .build(); + + String acceptHeaderValue = "text/plain; version=0.0.4; charset=utf-8"; + EscapingScheme escapingScheme = EscapingScheme.fromAcceptHeader(acceptHeaderValue); + ExpositionFormatWriter textWriter = expositionFormats.findWriter(acceptHeaderValue); + + textWriter.write(buff, MetricSnapshots.of(unknown), escapingScheme); + byte[] out = buff.toByteArray(); + assertThat(out.length).isNotEqualTo(0); + + String expected = + """ + # TYPE foo_metric untyped + foo_metric 1.234 + """; + + assertThat(new String(out, UTF_8)).hasToString(expected); + } + private void assertOpenMetricsText(String expected, MetricSnapshot snapshot) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); OpenMetricsTextFormatWriter writer = OpenMetricsTextFormatWriter.builder().setCreatedTimestampsEnabled(true).build(); - writer.write(out, MetricSnapshots.of(snapshot)); + writer.write(out, MetricSnapshots.of(snapshot), EscapingScheme.NO_ESCAPING); assertThat(out).hasToString(expected); } @@ -2753,7 +2842,7 @@ private void assertOpenMetricsTextWithExemplarsOnAllTimeSeries( .setCreatedTimestampsEnabled(true) .setExemplarsOnAllMetricTypesEnabled(true) .build(); - writer.write(out, MetricSnapshots.of(snapshot)); + writer.write(out, MetricSnapshots.of(snapshot), EscapingScheme.NO_ESCAPING); assertThat(out).hasToString(expected); } @@ -2761,15 +2850,14 @@ private void assertOpenMetricsTextWithoutCreated(String expected, MetricSnapshot throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); OpenMetricsTextFormatWriter writer = OpenMetricsTextFormatWriter.create(); - writer.write(out, MetricSnapshots.of(snapshot)); + writer.write(out, MetricSnapshots.of(snapshot), EscapingScheme.NO_ESCAPING); assertThat(out).hasToString(expected); } private void assertPrometheusText(String expected, MetricSnapshot snapshot) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); - getPrometheusWriter(PrometheusTextFormatWriter.builder().setIncludeCreatedTimestamps(true)) - .write(out, MetricSnapshots.of(snapshot)); + .write(out, MetricSnapshots.of(snapshot), EscapingScheme.NO_ESCAPING); assertThat(out).hasToString(expected); } @@ -2783,7 +2871,7 @@ private void assertPrometheusTextWithoutCreated(String expected, MetricSnapshot throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); getPrometheusWriter(PrometheusTextFormatWriter.builder()) - .write(out, MetricSnapshots.of(snapshot)); + .write(out, MetricSnapshots.of(snapshot), EscapingScheme.NO_ESCAPING); assertThat(out).hasToString(expected); } diff --git a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/PrometheusProtobufWriterTest.java b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/PrometheusProtobufWriterTest.java index 814154325..4b8411eaa 100644 --- a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/PrometheusProtobufWriterTest.java +++ b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/PrometheusProtobufWriterTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import io.prometheus.metrics.model.snapshots.EscapingScheme; import org.junit.jupiter.api.Test; class PrometheusProtobufWriterTest { @@ -24,13 +25,13 @@ void getContentType() { @Test void write() { - assertThatCode(() -> writer.write(null, null)) + assertThatCode(() -> writer.write(null, null, EscapingScheme.NO_ESCAPING)) .isInstanceOf(UnsupportedOperationException.class); } @Test void toDebugString() { - assertThatCode(() -> writer.toDebugString(null)) + assertThatCode(() -> writer.toDebugString(null, EscapingScheme.NO_ESCAPING)) .isInstanceOf(UnsupportedOperationException.class); } } diff --git a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/TextFormatUtilTest.java b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/TextFormatUtilTest.java index 3f3558160..dbb707f51 100644 --- a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/TextFormatUtilTest.java +++ b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/TextFormatUtilTest.java @@ -19,7 +19,7 @@ void writeEscapedLabelValue() throws IOException { private static String escape(String s) throws IOException { StringWriter writer = new StringWriter(); - TextFormatUtil.writeEscapedLabelValue(writer, s); + TextFormatUtil.writeEscapedString(writer, s); return writer.toString(); } diff --git a/prometheus-metrics-instrumentation-caffeine/src/test/java/io/prometheus/metrics/instrumentation/caffeine/CacheMetricsCollectorTest.java b/prometheus-metrics-instrumentation-caffeine/src/test/java/io/prometheus/metrics/instrumentation/caffeine/CacheMetricsCollectorTest.java index 347990f70..f6b6ae482 100644 --- a/prometheus-metrics-instrumentation-caffeine/src/test/java/io/prometheus/metrics/instrumentation/caffeine/CacheMetricsCollectorTest.java +++ b/prometheus-metrics-instrumentation-caffeine/src/test/java/io/prometheus/metrics/instrumentation/caffeine/CacheMetricsCollectorTest.java @@ -11,12 +11,7 @@ import com.github.benmanes.caffeine.cache.LoadingCache; import io.prometheus.metrics.expositionformats.OpenMetricsTextFormatWriter; import io.prometheus.metrics.model.registry.PrometheusRegistry; -import io.prometheus.metrics.model.snapshots.CounterSnapshot; -import io.prometheus.metrics.model.snapshots.DataPointSnapshot; -import io.prometheus.metrics.model.snapshots.GaugeSnapshot; -import io.prometheus.metrics.model.snapshots.Labels; -import io.prometheus.metrics.model.snapshots.MetricSnapshots; -import io.prometheus.metrics.model.snapshots.SummarySnapshot; +import io.prometheus.metrics.model.snapshots.*; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.UncheckedIOException; @@ -316,7 +311,7 @@ private String convertToOpenMetricsFormat(PrometheusRegistry registry) { final ByteArrayOutputStream out = new ByteArrayOutputStream(); final OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, true); try { - writer.write(out, registry.scrape()); + writer.write(out, registry.scrape(), EscapingScheme.NO_ESCAPING); return out.toString(StandardCharsets.UTF_8.name()); } catch (IOException e) { throw new UncheckedIOException(e); diff --git a/prometheus-metrics-instrumentation-dropwizard/src/test/java/io/prometheus/metrics/instrumentation/dropwizard/DropwizardExportsTest.java b/prometheus-metrics-instrumentation-dropwizard/src/test/java/io/prometheus/metrics/instrumentation/dropwizard/DropwizardExportsTest.java index 7625771b0..8b4ac999c 100644 --- a/prometheus-metrics-instrumentation-dropwizard/src/test/java/io/prometheus/metrics/instrumentation/dropwizard/DropwizardExportsTest.java +++ b/prometheus-metrics-instrumentation-dropwizard/src/test/java/io/prometheus/metrics/instrumentation/dropwizard/DropwizardExportsTest.java @@ -8,6 +8,7 @@ import io.prometheus.metrics.expositionformats.OpenMetricsTextFormatWriter; import io.prometheus.metrics.instrumentation.dropwizard5.InvalidMetricHandler; import io.prometheus.metrics.model.registry.PrometheusRegistry; +import io.prometheus.metrics.model.snapshots.EscapingScheme; import io.prometheus.metrics.model.snapshots.SummarySnapshot; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -340,7 +341,7 @@ private String convertToOpenMetricsFormat(PrometheusRegistry _registry) { ByteArrayOutputStream out = new ByteArrayOutputStream(); OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, true); try { - writer.write(out, _registry.scrape()); + writer.write(out, _registry.scrape(), EscapingScheme.NO_ESCAPING); return out.toString(StandardCharsets.UTF_8); } catch (IOException e) { throw new RuntimeException(e); diff --git a/prometheus-metrics-instrumentation-dropwizard5/src/test/java/io/prometheus/metrics/instrumentation/dropwizard5/DropwizardExportsTest.java b/prometheus-metrics-instrumentation-dropwizard5/src/test/java/io/prometheus/metrics/instrumentation/dropwizard5/DropwizardExportsTest.java index 3feb5e69c..4ade85707 100644 --- a/prometheus-metrics-instrumentation-dropwizard5/src/test/java/io/prometheus/metrics/instrumentation/dropwizard5/DropwizardExportsTest.java +++ b/prometheus-metrics-instrumentation-dropwizard5/src/test/java/io/prometheus/metrics/instrumentation/dropwizard5/DropwizardExportsTest.java @@ -7,6 +7,7 @@ import io.dropwizard.metrics5.*; import io.prometheus.metrics.expositionformats.OpenMetricsTextFormatWriter; import io.prometheus.metrics.model.registry.PrometheusRegistry; +import io.prometheus.metrics.model.snapshots.EscapingScheme; import io.prometheus.metrics.model.snapshots.MetricSnapshots; import io.prometheus.metrics.model.snapshots.Quantiles; import io.prometheus.metrics.model.snapshots.SummarySnapshot; @@ -349,7 +350,7 @@ private String convertToOpenMetricsFormat(PrometheusRegistry _registry) { ByteArrayOutputStream out = new ByteArrayOutputStream(); OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, true); try { - writer.write(out, _registry.scrape()); + writer.write(out, _registry.scrape(), EscapingScheme.NO_ESCAPING); return out.toString(StandardCharsets.UTF_8.name()); } catch (IOException e) { throw new RuntimeException(e); diff --git a/prometheus-metrics-instrumentation-dropwizard5/src/test/java/io/prometheus/metrics/instrumentation/dropwizard5/labels/CustomLabelMapperTest.java b/prometheus-metrics-instrumentation-dropwizard5/src/test/java/io/prometheus/metrics/instrumentation/dropwizard5/labels/CustomLabelMapperTest.java index ea9636e0c..0623ea531 100644 --- a/prometheus-metrics-instrumentation-dropwizard5/src/test/java/io/prometheus/metrics/instrumentation/dropwizard5/labels/CustomLabelMapperTest.java +++ b/prometheus-metrics-instrumentation-dropwizard5/src/test/java/io/prometheus/metrics/instrumentation/dropwizard5/labels/CustomLabelMapperTest.java @@ -7,6 +7,7 @@ import io.dropwizard.metrics5.MetricRegistry; import io.prometheus.metrics.expositionformats.OpenMetricsTextFormatWriter; import io.prometheus.metrics.instrumentation.dropwizard5.DropwizardExports; +import io.prometheus.metrics.model.snapshots.EscapingScheme; import io.prometheus.metrics.model.snapshots.MetricSnapshots; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -218,7 +219,7 @@ private String convertToOpenMetricsFormat(MetricSnapshots snapshots) { ByteArrayOutputStream out = new ByteArrayOutputStream(); OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, true); try { - writer.write(out, snapshots); + writer.write(out, snapshots, EscapingScheme.NO_ESCAPING); return out.toString(StandardCharsets.UTF_8.name()); } catch (IOException e) { throw new RuntimeException(e); diff --git a/prometheus-metrics-instrumentation-guava/src/test/java/io/prometheus/metrics/instrumentation/guava/CacheMetricsCollectorTest.java b/prometheus-metrics-instrumentation-guava/src/test/java/io/prometheus/metrics/instrumentation/guava/CacheMetricsCollectorTest.java index c751e9ec4..edd423d6c 100644 --- a/prometheus-metrics-instrumentation-guava/src/test/java/io/prometheus/metrics/instrumentation/guava/CacheMetricsCollectorTest.java +++ b/prometheus-metrics-instrumentation-guava/src/test/java/io/prometheus/metrics/instrumentation/guava/CacheMetricsCollectorTest.java @@ -11,11 +11,7 @@ import com.google.common.cache.LoadingCache; import io.prometheus.metrics.expositionformats.OpenMetricsTextFormatWriter; import io.prometheus.metrics.model.registry.PrometheusRegistry; -import io.prometheus.metrics.model.snapshots.CounterSnapshot; -import io.prometheus.metrics.model.snapshots.DataPointSnapshot; -import io.prometheus.metrics.model.snapshots.Labels; -import io.prometheus.metrics.model.snapshots.MetricSnapshots; -import io.prometheus.metrics.model.snapshots.SummarySnapshot; +import io.prometheus.metrics.model.snapshots.*; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.UncheckedIOException; @@ -165,7 +161,7 @@ private String convertToOpenMetricsFormat(PrometheusRegistry registry) { final ByteArrayOutputStream out = new ByteArrayOutputStream(); final OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, true); try { - writer.write(out, registry.scrape()); + writer.write(out, registry.scrape(), EscapingScheme.NO_ESCAPING); return out.toString(StandardCharsets.UTF_8.name()); } catch (IOException e) { throw new UncheckedIOException(e); diff --git a/prometheus-metrics-instrumentation-jvm/src/test/java/io/prometheus/metrics/instrumentation/jvm/TestUtil.java b/prometheus-metrics-instrumentation-jvm/src/test/java/io/prometheus/metrics/instrumentation/jvm/TestUtil.java index 70a093f4b..56683f75a 100644 --- a/prometheus-metrics-instrumentation-jvm/src/test/java/io/prometheus/metrics/instrumentation/jvm/TestUtil.java +++ b/prometheus-metrics-instrumentation-jvm/src/test/java/io/prometheus/metrics/instrumentation/jvm/TestUtil.java @@ -1,6 +1,7 @@ package io.prometheus.metrics.instrumentation.jvm; import io.prometheus.metrics.expositionformats.OpenMetricsTextFormatWriter; +import io.prometheus.metrics.model.snapshots.EscapingScheme; import io.prometheus.metrics.model.snapshots.MetricSnapshots; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -11,7 +12,7 @@ class TestUtil { static String convertToOpenMetricsFormat(MetricSnapshots snapshots) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, true); - writer.write(out, snapshots); + writer.write(out, snapshots, EscapingScheme.NO_ESCAPING); return out.toString(StandardCharsets.UTF_8.name()); } } diff --git a/prometheus-metrics-model/pom.xml b/prometheus-metrics-model/pom.xml index c582a279b..74e54578c 100644 --- a/prometheus-metrics-model/pom.xml +++ b/prometheus-metrics-model/pom.xml @@ -21,4 +21,12 @@ io.prometheus.metrics.model + + + + io.prometheus + prometheus-metrics-config + ${project.version} + + diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/EscapingScheme.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/EscapingScheme.java new file mode 100644 index 000000000..783029a9b --- /dev/null +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/EscapingScheme.java @@ -0,0 +1,82 @@ +package io.prometheus.metrics.model.snapshots; + +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.DEFAULT_ESCAPING_SCHEME; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.ESCAPING_KEY; + +public enum EscapingScheme { + /** NO_ESCAPING indicates that a name will not be escaped. */ + NO_ESCAPING("allow-utf-8"), + + /** UNDERSCORE_ESCAPING replaces all legacy-invalid characters with underscores. */ + UNDERSCORE_ESCAPING("underscores"), + + /** + * DOTS_ESCAPING is similar to UNDERSCORE_ESCAPING, except that dots are converted to `_dot_` and + * pre-existing underscores are converted to `__`. + */ + DOTS_ESCAPING("dots"), + + /** + * VALUE_ENCODING_ESCAPING prepends the name with `U__` and replaces all invalid characters with + * the Unicode value, surrounded by underscores. Single underscores are replaced with double + * underscores. + */ + VALUE_ENCODING_ESCAPING("values"), + ; + + public final String getValue() { + return value; + } + + private final String value; + + EscapingScheme(String value) { + this.value = value; + } + + /** + * fromAcceptHeader returns an EscapingScheme depending on the Accept header. Iff the header + * contains an escaping=allow-utf-8 term, it will select NO_ESCAPING. If a valid "escaping" term + * exists, that will be used. Otherwise, the global default will be returned. + */ + public static EscapingScheme fromAcceptHeader(String acceptHeader) { + if (acceptHeader != null) { + for (String p : acceptHeader.split(";")) { + String[] toks = p.split("="); + if (toks.length != 2) { + continue; + } + String key = toks[0].trim(); + String value = toks[1].trim(); + if (key.equals(ESCAPING_KEY)) { + try { + return EscapingScheme.forString(value); + } catch (IllegalArgumentException e) { + // If the escaping parameter is unknown, ignore it. + return DEFAULT_ESCAPING_SCHEME; + } + } + } + } + return DEFAULT_ESCAPING_SCHEME; + } + + static EscapingScheme forString(String value) { + switch (value) { + case "allow-utf-8": + return NO_ESCAPING; + case "underscores": + return UNDERSCORE_ESCAPING; + case "dots": + return DOTS_ESCAPING; + case "values": + return VALUE_ENCODING_ESCAPING; + default: + throw new IllegalArgumentException("Unknown escaping scheme: " + value); + } + } + + public String toHeaderFormat() { + return "; " + ESCAPING_KEY + "=" + value; + } +} diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/Labels.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/Labels.java index 97cbfd43a..a355f283c 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/Labels.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/Labels.java @@ -3,6 +3,7 @@ import static io.prometheus.metrics.model.snapshots.PrometheusNaming.isValidLabelName; import static io.prometheus.metrics.model.snapshots.PrometheusNaming.prometheusName; +import io.prometheus.metrics.config.ValidationScheme; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -119,7 +120,8 @@ public static Labels of(String[] names, String[] values) { static String[] makePrometheusNames(String[] names) { String[] prometheusNames = names; for (int i = 0; i < names.length; i++) { - if (names[i].contains(".")) { + if (names[i].contains(".") + && PrometheusNaming.getValidationScheme() == ValidationScheme.LEGACY_VALIDATION) { if (prometheusNames == names) { prometheusNames = Arrays.copyOf(names, names.length); } diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricMetadata.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricMetadata.java index 581cb9143..10b99f03c 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricMetadata.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/MetricMetadata.java @@ -1,5 +1,7 @@ package io.prometheus.metrics.model.snapshots; +import io.prometheus.metrics.config.ValidationScheme; + /** Immutable container for metric metadata: name, help, unit. */ public final class MetricMetadata { @@ -53,7 +55,11 @@ public MetricMetadata(String name, String help, Unit unit) { this.help = help; this.unit = unit; validate(); - this.prometheusName = name.contains(".") ? PrometheusNaming.prometheusName(name) : name; + this.prometheusName = + name.contains(".") + && PrometheusNaming.getValidationScheme() == ValidationScheme.LEGACY_VALIDATION + ? PrometheusNaming.prometheusName(name) + : name; } /** diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/PrometheusNaming.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/PrometheusNaming.java index 5cb1604d1..b84486e74 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/PrometheusNaming.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/PrometheusNaming.java @@ -1,5 +1,16 @@ package io.prometheus.metrics.model.snapshots; +import static java.lang.Character.MAX_CODE_POINT; +import static java.lang.Character.MAX_LOW_SURROGATE; +import static java.lang.Character.MIN_HIGH_SURROGATE; + +import io.prometheus.metrics.config.PrometheusProperties; +import io.prometheus.metrics.config.PrometheusPropertiesLoader; +import io.prometheus.metrics.config.ValidationScheme; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; import java.util.regex.Pattern; /** @@ -11,12 +22,38 @@ */ public class PrometheusNaming { + /** + * nameValidationScheme determines the method of name validation to be used by all calls to + * validateMetricName() and isValidMetricName(). Setting UTF-8 mode in isolation from other + * components that don't support UTF-8 may result in bugs or other undefined behavior. This value + * is intended to be set by UTF-8-aware binaries as part of their startup via a properties file. + */ + public static ValidationScheme nameValidationScheme = + PrometheusProperties.get().getNamingProperties().getValidationScheme(); + + /** Default escaping scheme for names when not specified. */ + public static final EscapingScheme DEFAULT_ESCAPING_SCHEME = + EscapingScheme.VALUE_ENCODING_ESCAPING; + + /** + * ESCAPING_KEY is the key in an Accept header that defines how metric and label names that do not + * conform to the legacy character requirements should be escaped when being scraped by a legacy + * Prometheus system. If a system does not explicitly pass an escaping parameter in the Accept + * header, the default escaping scheme will be used. + */ + public static final String ESCAPING_KEY = "escaping"; + + private static final String METRIC_NAME_LABEL = "__name__"; + /** Legal characters for metric names, including dot. */ - private static final Pattern METRIC_NAME_PATTERN = + private static final Pattern LEGACY_METRIC_NAME_PATTERN = Pattern.compile("^[a-zA-Z_.:][a-zA-Z0-9_.:]*$"); + private static final Pattern METRIC_NAME_PATTERN = Pattern.compile("^[a-zA-Z_:][a-zA-Z0-9_:]*$"); + /** Legal characters for label names, including dot. */ - private static final Pattern LABEL_NAME_PATTERN = Pattern.compile("^[a-zA-Z_.][a-zA-Z0-9_.]*$"); + private static final Pattern LEGACY_LABEL_NAME_PATTERN = + Pattern.compile("^[a-zA-Z_.][a-zA-Z0-9_.]*$"); /** Legal characters for unit names, including dot. */ private static final Pattern UNIT_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_.:]+$"); @@ -41,11 +78,25 @@ public class PrometheusNaming { ".total", ".created", ".bucket", ".info" }; + // VisibleForTesting + public static void resetForTest() { + nameValidationScheme = + PrometheusPropertiesLoader.load().getNamingProperties().getValidationScheme(); + } + + /** + * Get the current validation scheme. This method exists primarily to enable testing different + * validation behaviors while keeping the validation scheme field final and immutable. + */ + public static ValidationScheme getValidationScheme() { + return nameValidationScheme; + } + /** * Test if a metric name is valid. Rules: * *
    - *
  • The name must match {@link #METRIC_NAME_PATTERN}. + *
  • The name must match {@link #LEGACY_METRIC_NAME_PATTERN}. *
  • The name MUST NOT end with one of the {@link #RESERVED_METRIC_NAME_SUFFIXES}. *
* @@ -65,25 +116,68 @@ public static boolean isValidMetricName(String name) { return validateMetricName(name) == null; } + public static String validateMetricName(String name) { + switch (getValidationScheme()) { + case LEGACY_VALIDATION: + return validateLegacyMetricName(name); + case UTF_8_VALIDATION: + if (!isValidUtf8(name)) { + return "The metric name contains unsupported characters"; + } + return null; + default: + throw new RuntimeException( + "Invalid name validation scheme requested: " + getValidationScheme()); + } + } + /** * Same as {@link #isValidMetricName(String)}, but produces an error message. * *

The name is valid if the error message is {@code null}. */ - public static String validateMetricName(String name) { + public static String validateLegacyMetricName(String name) { for (String reservedSuffix : RESERVED_METRIC_NAME_SUFFIXES) { if (name.endsWith(reservedSuffix)) { return "The metric name must not include the '" + reservedSuffix + "' suffix."; } } - if (!METRIC_NAME_PATTERN.matcher(name).matches()) { + if (!isValidLegacyMetricName(name)) { return "The metric name contains unsupported characters"; } return null; } + public static boolean isValidLegacyMetricName(String name) { + switch (getValidationScheme()) { + case LEGACY_VALIDATION: + return LEGACY_METRIC_NAME_PATTERN.matcher(name).matches(); + case UTF_8_VALIDATION: + return METRIC_NAME_PATTERN.matcher(name).matches(); + default: + throw new RuntimeException( + "Invalid name validation scheme requested: " + getValidationScheme()); + } + } + public static boolean isValidLabelName(String name) { - return LABEL_NAME_PATTERN.matcher(name).matches() + switch (getValidationScheme()) { + case LEGACY_VALIDATION: + return isValidLegacyLabelName(name); + case UTF_8_VALIDATION: + return isValidUtf8(name); + default: + throw new RuntimeException( + "Invalid name validation scheme requested: " + getValidationScheme()); + } + } + + private static boolean isValidUtf8(String name) { + return !name.isEmpty() && StandardCharsets.UTF_8.newEncoder().canEncode(name); + } + + public static boolean isValidLegacyLabelName(String name) { + return LEGACY_LABEL_NAME_PATTERN.matcher(name).matches() && !(name.startsWith("__") || name.startsWith("._") || name.startsWith("..") @@ -226,7 +320,7 @@ public static String sanitizeUnitName(String unitName) { return sanitizedName; } - /** Returns a string that matches {@link #METRIC_NAME_PATTERN}. */ + /** Returns a string that matches {@link #LEGACY_METRIC_NAME_PATTERN}. */ private static String replaceIllegalCharsInMetricName(String name) { int length = name.length(); char[] sanitized = new char[length]; @@ -244,7 +338,7 @@ private static String replaceIllegalCharsInMetricName(String name) { return new String(sanitized); } - /** Returns a string that matches {@link #LABEL_NAME_PATTERN}. */ + /** Returns a string that matches {@link #LEGACY_LABEL_NAME_PATTERN}. */ private static String replaceIllegalCharsInLabelName(String name) { int length = name.length(); char[] sanitized = new char[length]; @@ -280,4 +374,384 @@ private static String replaceIllegalCharsInUnitName(String name) { } return new String(sanitized); } + + /** Escapes the given metric names and labels with the given escaping scheme. */ + public static MetricSnapshot escapeMetricSnapshot(MetricSnapshot v, EscapingScheme scheme) { + if (v == null) { + return null; + } + + if (scheme == EscapingScheme.NO_ESCAPING) { + return v; + } + + String outName; + + // If the name is null, copy as-is, don't try to escape. + if (v.getMetadata().getPrometheusName() == null + || isValidLegacyMetricName(v.getMetadata().getPrometheusName())) { + outName = v.getMetadata().getPrometheusName(); + } else { + outName = escapeName(v.getMetadata().getPrometheusName(), scheme); + } + + List outDataPoints = new ArrayList<>(); + + for (DataPointSnapshot d : v.getDataPoints()) { + if (!metricNeedsEscaping(d)) { + outDataPoints.add(d); + continue; + } + + Labels.Builder outLabelsBuilder = Labels.builder(); + + for (Label l : d.getLabels()) { + if (METRIC_NAME_LABEL.equals(l.getName())) { + if (l.getValue() == null || isValidLegacyMetricName(l.getValue())) { + outLabelsBuilder.label(l.getName(), l.getValue()); + continue; + } + outLabelsBuilder.label(l.getName(), escapeName(l.getValue(), scheme)); + continue; + } + if (l.getName() == null || isValidLegacyMetricName(l.getName())) { + outLabelsBuilder.label(l.getName(), l.getValue()); + continue; + } + outLabelsBuilder.label(escapeName(l.getName(), scheme), l.getValue()); + } + + Labels outLabels = outLabelsBuilder.build(); + DataPointSnapshot outDataPointSnapshot = createEscapedDataPointSnapshot(v, d, outLabels); + outDataPoints.add(outDataPointSnapshot); + } + + return createEscapedMetricSnapshot(v, outName, outDataPoints); + } + + static boolean metricNeedsEscaping(DataPointSnapshot d) { + Labels labels = d.getLabels(); + for (Label l : labels) { + if (l.getName().equals(METRIC_NAME_LABEL) && !isValidLegacyMetricName(l.getValue())) { + return true; + } + if (!isValidLegacyMetricName(l.getName())) { + return true; + } + } + return false; + } + + private static DataPointSnapshot createEscapedDataPointSnapshot( + MetricSnapshot v, DataPointSnapshot d, Labels outLabels) { + if (v instanceof CounterSnapshot) { + return CounterSnapshot.CounterDataPointSnapshot.builder() + .value(((CounterSnapshot.CounterDataPointSnapshot) d).getValue()) + .exemplar(((CounterSnapshot.CounterDataPointSnapshot) d).getExemplar()) + .labels(outLabels) + .createdTimestampMillis(d.getCreatedTimestampMillis()) + .scrapeTimestampMillis(d.getScrapeTimestampMillis()) + .build(); + } else if (v instanceof GaugeSnapshot) { + return GaugeSnapshot.GaugeDataPointSnapshot.builder() + .value(((GaugeSnapshot.GaugeDataPointSnapshot) d).getValue()) + .exemplar(((GaugeSnapshot.GaugeDataPointSnapshot) d).getExemplar()) + .labels(outLabels) + .scrapeTimestampMillis(d.getScrapeTimestampMillis()) + .build(); + } else if (v instanceof HistogramSnapshot) { + return HistogramSnapshot.HistogramDataPointSnapshot.builder() + .classicHistogramBuckets( + ((HistogramSnapshot.HistogramDataPointSnapshot) d).getClassicBuckets()) + .nativeSchema(((HistogramSnapshot.HistogramDataPointSnapshot) d).getNativeSchema()) + .nativeZeroCount(((HistogramSnapshot.HistogramDataPointSnapshot) d).getNativeZeroCount()) + .nativeZeroThreshold( + ((HistogramSnapshot.HistogramDataPointSnapshot) d).getNativeZeroThreshold()) + .nativeBucketsForPositiveValues( + ((HistogramSnapshot.HistogramDataPointSnapshot) d) + .getNativeBucketsForPositiveValues()) + .nativeBucketsForNegativeValues( + ((HistogramSnapshot.HistogramDataPointSnapshot) d) + .getNativeBucketsForNegativeValues()) + .count(((HistogramSnapshot.HistogramDataPointSnapshot) d).getCount()) + .sum(((HistogramSnapshot.HistogramDataPointSnapshot) d).getSum()) + .exemplars(((HistogramSnapshot.HistogramDataPointSnapshot) d).getExemplars()) + .labels(outLabels) + .createdTimestampMillis(d.getCreatedTimestampMillis()) + .scrapeTimestampMillis(d.getScrapeTimestampMillis()) + .build(); + } else if (v instanceof SummarySnapshot) { + return SummarySnapshot.SummaryDataPointSnapshot.builder() + .quantiles(((SummarySnapshot.SummaryDataPointSnapshot) d).getQuantiles()) + .count(((SummarySnapshot.SummaryDataPointSnapshot) d).getCount()) + .sum(((SummarySnapshot.SummaryDataPointSnapshot) d).getSum()) + .exemplars(((SummarySnapshot.SummaryDataPointSnapshot) d).getExemplars()) + .labels(outLabels) + .createdTimestampMillis(d.getCreatedTimestampMillis()) + .scrapeTimestampMillis(d.getScrapeTimestampMillis()) + .build(); + } else if (v instanceof InfoSnapshot) { + return InfoSnapshot.InfoDataPointSnapshot.builder() + .labels(outLabels) + .scrapeTimestampMillis(d.getScrapeTimestampMillis()) + .build(); + } else if (v instanceof StateSetSnapshot) { + StateSetSnapshot.StateSetDataPointSnapshot.Builder builder = + StateSetSnapshot.StateSetDataPointSnapshot.builder() + .labels(outLabels) + .scrapeTimestampMillis(d.getScrapeTimestampMillis()); + for (StateSetSnapshot.State state : ((StateSetSnapshot.StateSetDataPointSnapshot) d)) { + builder.state(state.getName(), state.isTrue()); + } + return builder.build(); + } else if (v instanceof UnknownSnapshot) { + return UnknownSnapshot.UnknownDataPointSnapshot.builder() + .labels(outLabels) + .value(((UnknownSnapshot.UnknownDataPointSnapshot) d).getValue()) + .exemplar(((UnknownSnapshot.UnknownDataPointSnapshot) d).getExemplar()) + .scrapeTimestampMillis(d.getScrapeTimestampMillis()) + .build(); + } else { + throw new IllegalArgumentException("Unknown MetricSnapshot type: " + v.getClass()); + } + } + + private static MetricSnapshot createEscapedMetricSnapshot( + MetricSnapshot v, String outName, List outDataPoints) { + if (v instanceof CounterSnapshot) { + CounterSnapshot.Builder builder = + CounterSnapshot.builder() + .name(outName) + .help(v.getMetadata().getHelp()) + .unit(v.getMetadata().getUnit()); + for (DataPointSnapshot d : outDataPoints) { + builder.dataPoint((CounterSnapshot.CounterDataPointSnapshot) d); + } + return builder.build(); + } else if (v instanceof GaugeSnapshot) { + GaugeSnapshot.Builder builder = + GaugeSnapshot.builder() + .name(outName) + .help(v.getMetadata().getHelp()) + .unit(v.getMetadata().getUnit()); + for (DataPointSnapshot d : outDataPoints) { + builder.dataPoint((GaugeSnapshot.GaugeDataPointSnapshot) d); + } + return builder.build(); + } else if (v instanceof HistogramSnapshot) { + HistogramSnapshot.Builder builder = + HistogramSnapshot.builder() + .name(outName) + .help(v.getMetadata().getHelp()) + .unit(v.getMetadata().getUnit()) + .gaugeHistogram(((HistogramSnapshot) v).isGaugeHistogram()); + for (DataPointSnapshot d : outDataPoints) { + builder.dataPoint((HistogramSnapshot.HistogramDataPointSnapshot) d); + } + return builder.build(); + } else if (v instanceof SummarySnapshot) { + SummarySnapshot.Builder builder = + SummarySnapshot.builder() + .name(outName) + .help(v.getMetadata().getHelp()) + .unit(v.getMetadata().getUnit()); + for (DataPointSnapshot d : outDataPoints) { + builder.dataPoint((SummarySnapshot.SummaryDataPointSnapshot) d); + } + return builder.build(); + } else if (v instanceof InfoSnapshot) { + InfoSnapshot.Builder builder = + InfoSnapshot.builder().name(outName).help(v.getMetadata().getHelp()); + for (DataPointSnapshot d : outDataPoints) { + builder.dataPoint((InfoSnapshot.InfoDataPointSnapshot) d); + } + return builder.build(); + } else if (v instanceof StateSetSnapshot) { + StateSetSnapshot.Builder builder = + StateSetSnapshot.builder().name(outName).help(v.getMetadata().getHelp()); + for (DataPointSnapshot d : outDataPoints) { + builder.dataPoint((StateSetSnapshot.StateSetDataPointSnapshot) d); + } + return builder.build(); + } else if (v instanceof UnknownSnapshot) { + UnknownSnapshot.Builder builder = + UnknownSnapshot.builder() + .name(outName) + .help(v.getMetadata().getHelp()) + .unit(v.getMetadata().getUnit()); + for (DataPointSnapshot d : outDataPoints) { + builder.dataPoint((UnknownSnapshot.UnknownDataPointSnapshot) d); + } + return builder.build(); + } else { + throw new IllegalArgumentException("Unknown MetricSnapshot type: " + v.getClass()); + } + } + + /** + * Escapes the incoming name according to the provided escaping scheme. Depending on the rules of + * escaping, this may cause no change in the string that is returned (especially NO_ESCAPING, + * which by definition is a noop). This method does not do any validation of the name. + */ + public static String escapeName(String name, EscapingScheme scheme) { + if (name.isEmpty()) { + return name; + } + StringBuilder escaped = new StringBuilder(); + switch (scheme) { + case NO_ESCAPING: + return name; + case UNDERSCORE_ESCAPING: + if (isValidLegacyMetricName(name)) { + return name; + } + for (int i = 0; i < name.length(); ) { + int c = name.codePointAt(i); + if (isValidLegacyChar(c, i)) { + escaped.appendCodePoint(c); + } else { + escaped.append('_'); + } + i += Character.charCount(c); + } + return escaped.toString(); + case DOTS_ESCAPING: + // Do not early return for legacy valid names, we still escape underscores. + for (int i = 0; i < name.length(); ) { + int c = name.codePointAt(i); + if (c == '_') { + escaped.append("__"); + } else if (c == '.') { + escaped.append("_dot_"); + } else if (isValidLegacyChar(c, i)) { + escaped.appendCodePoint(c); + } else { + escaped.append("__"); + } + i += Character.charCount(c); + } + return escaped.toString(); + case VALUE_ENCODING_ESCAPING: + if (isValidLegacyMetricName(name)) { + return name; + } + escaped.append("U__"); + for (int i = 0; i < name.length(); ) { + int c = name.codePointAt(i); + if (c == '_') { + escaped.append("__"); + } else if (isValidLegacyChar(c, i)) { + escaped.appendCodePoint(c); + } else if (!isValidUtf8Char(c)) { + escaped.append("_FFFD_"); + } else { + escaped.append('_'); + escaped.append(Integer.toHexString(c)); + escaped.append('_'); + } + i += Character.charCount(c); + } + return escaped.toString(); + default: + throw new IllegalArgumentException("Invalid escaping scheme " + scheme); + } + } + + /** + * Unescapes the incoming name according to the provided escaping scheme if possible. Some schemes + * are partially or totally non-roundtripable. If any error is encountered, returns the original + * input. + */ + @SuppressWarnings("IncrementInForLoopAndHeader") + static String unescapeName(String name, EscapingScheme scheme) { + if (name.isEmpty()) { + return name; + } + switch (scheme) { + case NO_ESCAPING: + return name; + case UNDERSCORE_ESCAPING: + // It is not possible to unescape from underscore replacement. + return name; + case DOTS_ESCAPING: + name = name.replaceAll("_dot_", "."); + name = name.replaceAll("__", "_"); + return name; + case VALUE_ENCODING_ESCAPING: + Matcher matcher = Pattern.compile("U__").matcher(name); + if (matcher.find()) { + String escapedName = name.substring(matcher.end()); + StringBuilder unescaped = new StringBuilder(); + for (int i = 0; i < escapedName.length(); ) { + // All non-underscores are treated normally. + int c = escapedName.codePointAt(i); + if (c != '_') { + unescaped.appendCodePoint(c); + i += Character.charCount(c); + continue; + } + i++; + if (i >= escapedName.length()) { + return name; + } + // A double underscore is a single underscore. + if (escapedName.codePointAt(i) == '_') { + unescaped.append('_'); + i++; + continue; + } + // We think we are in a UTF-8 code, process it. + int utf8Val = 0; + boolean foundClosingUnderscore = false; + for (int j = 0; i < escapedName.length(); j++) { + // This is too many characters for a UTF-8 value. + if (j >= 6) { + return name; + } + // Found a closing underscore, convert to a char, check validity, and append. + if (escapedName.codePointAt(i) == '_') { + // char utf8Char = (char) utf8Val; + foundClosingUnderscore = true; + if (!isValidUtf8Char(utf8Val)) { + return name; + } + unescaped.appendCodePoint(utf8Val); + i++; + break; + } + char r = Character.toLowerCase(escapedName.charAt(i)); + utf8Val *= 16; + if (r >= '0' && r <= '9') { + utf8Val += r - '0'; + } else if (r >= 'a' && r <= 'f') { + utf8Val += r - 'a' + 10; + } else { + return name; + } + i++; + } + if (!foundClosingUnderscore) { + return name; + } + } + return unescaped.toString(); + } else { + return name; + } + default: + throw new IllegalArgumentException("Invalid escaping scheme " + scheme); + } + } + + static boolean isValidLegacyChar(int c, int i) { + return (c >= 'a' && c <= 'z') + || (c >= 'A' && c <= 'Z') + || c == '_' + || c == ':' + || (c >= '0' && c <= '9' && i > 0); + } + + private static boolean isValidUtf8Char(int c) { + return (0 <= c && c < MIN_HIGH_SURROGATE) || (MAX_LOW_SURROGATE < c && c <= MAX_CODE_POINT); + } } diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/EscapingSchemeTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/EscapingSchemeTest.java new file mode 100644 index 000000000..7bbd6ed1c --- /dev/null +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/EscapingSchemeTest.java @@ -0,0 +1,35 @@ +package io.prometheus.metrics.model.snapshots; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import org.junit.jupiter.api.Test; + +class EscapingSchemeTest { + + @Test + void forString() { + assertThat(EscapingScheme.forString("allow-utf-8")).isEqualTo(EscapingScheme.NO_ESCAPING); + assertThat(EscapingScheme.forString("underscores")) + .isEqualTo(EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(EscapingScheme.forString("dots")).isEqualTo(EscapingScheme.DOTS_ESCAPING); + assertThat(EscapingScheme.forString("values")) + .isEqualTo(EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThatCode(() -> EscapingScheme.forString("unknown")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void fromAcceptHeader() { + assertThat(EscapingScheme.fromAcceptHeader("application/json; escaping=allow-utf-8")) + .isEqualTo(EscapingScheme.NO_ESCAPING); + assertThat(EscapingScheme.fromAcceptHeader("application/json; escaping=underscores")) + .isEqualTo(EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(EscapingScheme.fromAcceptHeader("application/json; escaping=dots")) + .isEqualTo(EscapingScheme.DOTS_ESCAPING); + assertThat(EscapingScheme.fromAcceptHeader("application/json; escaping=values")) + .isEqualTo(EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(EscapingScheme.fromAcceptHeader("application/json")) + .isEqualTo(PrometheusNaming.DEFAULT_ESCAPING_SCHEME); + } +} diff --git a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/PrometheusNamingTest.java b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/PrometheusNamingTest.java index fad55e0ac..56fb61261 100644 --- a/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/PrometheusNamingTest.java +++ b/prometheus-metrics-model/src/test/java/io/prometheus/metrics/model/snapshots/PrometheusNamingTest.java @@ -1,13 +1,35 @@ package io.prometheus.metrics.model.snapshots; -import static io.prometheus.metrics.model.snapshots.PrometheusNaming.*; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.escapeMetricSnapshot; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.escapeName; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.isValidLabelName; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.prometheusName; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.sanitizeLabelName; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.sanitizeMetricName; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.sanitizeUnitName; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.unescapeName; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.validateMetricName; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.validateUnitName; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junitpioneer.jupiter.SetSystemProperty; class PrometheusNamingTest { + @AfterEach + void tearDown() { + System.clearProperty("io.prometheus.naming.validationScheme"); + PrometheusNaming.resetForTest(); + } + @Test public void testSanitizeMetricName() { assertThat(prometheusName(sanitizeMetricName("0abc.def"))).isEqualTo("_abc_def"); @@ -96,4 +118,331 @@ public void testEmptyUnitName() { assertThatExceptionOfType(IllegalArgumentException.class) .isThrownBy(() -> sanitizeUnitName("")); } + + @SuppressWarnings("unused") + @SetSystemProperty(key = "io.prometheus.naming.validationScheme", value = "utf-8") + @ParameterizedTest + @MethodSource("nameIsValid") + public void testLabelNameIsValidUtf8( + String labelName, boolean legacyValid, boolean utf8Valid, boolean legacyCharsetValid) { + PrometheusNaming.resetForTest(); + assertMetricName(labelName, utf8Valid); + assertLabelName(labelName, utf8Valid); + } + + @SuppressWarnings("unused") + @ParameterizedTest + @MethodSource("nameIsValid") + public void testLabelNameIsValidLegacy( + String labelName, boolean legacyValid, boolean utf8Valid, boolean legacyCharsetValid) { + assertMetricName(labelName, legacyCharsetValid); + assertLabelName(labelName, legacyValid); + } + + private static void assertLabelName(String labelName, boolean legacyValid) { + assertThat(isValidLabelName(labelName)) + .describedAs("isValidLabelName(%s)", labelName) + .isEqualTo(legacyValid); + } + + private static void assertMetricName(String labelName, boolean valid) { + assertThat(validateMetricName(labelName)) + .describedAs("validateMetricName(%s)", labelName) + .isEqualTo(valid ? null : "The metric name contains unsupported characters"); + } + + static Stream nameIsValid() { + return Stream.of( + Arguments.of("", false, false, false), + Arguments.of("Avalid_23name", true, true, true), + Arguments.of("_Avalid_23name", true, true, true), + Arguments.of("1valid_23name", false, true, false), + Arguments.of("avalid_23name", true, true, true), + Arguments.of("Ava:lid_23name", false, true, true), + Arguments.of("a lid_23name", false, true, false), + Arguments.of(":leading_colon", false, true, true), + Arguments.of("colon:in:the:middle", false, true, true), + Arguments.of("aΩz", false, true, false), + Arguments.of("a\ud800z", false, false, false)); + } + + @ParameterizedTest + @MethodSource("escapeNameLegacyTestCases") + public void testEscapeNameLegacy( + String input, EscapingScheme escapingScheme, String expected, String unescapeExpected) { + assertEscape(input, escapingScheme, expected, unescapeExpected); + } + + @SetSystemProperty(key = "io.prometheus.naming.validationScheme", value = "utf-8") + @ParameterizedTest + @MethodSource("escapeNameUtf8TestCases") + public void testEscapeNameUtf8( + String input, EscapingScheme escapingScheme, String expected, String unescapeExpected) { + PrometheusNaming.resetForTest(); + assertEscape(input, escapingScheme, expected, unescapeExpected); + } + + private static void assertEscape( + String input, EscapingScheme escapingScheme, String expected, String unescapeExpected) { + String escaped = escapeName(input, escapingScheme); + assertThat(escaped).isEqualTo(expected); + assertThat(unescapeName(escaped, escapingScheme)).isEqualTo(unescapeExpected); + } + + static Stream escapeNameLegacyTestCases() { + return Stream.of( + Arguments.of("", EscapingScheme.UNDERSCORE_ESCAPING, "", ""), + Arguments.of("", EscapingScheme.DOTS_ESCAPING, "", ""), + Arguments.of("", EscapingScheme.VALUE_ENCODING_ESCAPING, "", ""), + Arguments.of( + "no:escaping_required", + EscapingScheme.UNDERSCORE_ESCAPING, + "no:escaping_required", + "no:escaping_required"), + // Dots escaping will escape underscores even though it's not strictly + // necessary for compatibility. + Arguments.of( + "no:escaping_required", + EscapingScheme.DOTS_ESCAPING, + "no:escaping__required", + "no:escaping_required"), + Arguments.of( + "no:escaping_required", + EscapingScheme.VALUE_ENCODING_ESCAPING, + "no:escaping_required", + "no:escaping_required"), + Arguments.of( + "no:escaping_required", + EscapingScheme.UNDERSCORE_ESCAPING, + "no:escaping_required", + "no:escaping_required"), + Arguments.of( + "mysystem.prod.west.cpu.load", + EscapingScheme.DOTS_ESCAPING, + "mysystem_dot_prod_dot_west_dot_cpu_dot_load", + "mysystem.prod.west.cpu.load"), + Arguments.of( + "mysystem.prod.west.cpu.load_total", + EscapingScheme.DOTS_ESCAPING, + "mysystem_dot_prod_dot_west_dot_cpu_dot_load__total", + "mysystem.prod.west.cpu.load_total"), + Arguments.of( + "http.status:sum", + EscapingScheme.DOTS_ESCAPING, + "http_dot_status:sum", + "http.status:sum"), + Arguments.of( + "label with 😱", EscapingScheme.UNDERSCORE_ESCAPING, "label_with__", "label_with__"), + Arguments.of( + "label with 😱", EscapingScheme.DOTS_ESCAPING, "label__with____", "label_with__"), + Arguments.of( + "label with 😱", + EscapingScheme.VALUE_ENCODING_ESCAPING, + "U__label_20_with_20__1f631_", + "label with 😱"), + // name with unicode characters > 0x100 + Arguments.of("花火", EscapingScheme.UNDERSCORE_ESCAPING, "__", "__"), + // Dots-replacement does not know the difference between two replaced + Arguments.of("花火", EscapingScheme.DOTS_ESCAPING, "____", "__"), + Arguments.of("花火", EscapingScheme.VALUE_ENCODING_ESCAPING, "U___82b1__706b_", "花火"), + // name with spaces and edge-case value + Arguments.of( + "label with Ā", EscapingScheme.UNDERSCORE_ESCAPING, "label_with__", "label_with__"), + Arguments.of( + "label with Ā", EscapingScheme.DOTS_ESCAPING, "label__with____", "label_with__"), + Arguments.of( + "label with Ā", + EscapingScheme.VALUE_ENCODING_ESCAPING, + "U__label_20_with_20__100_", + "label with Ā")); + } + + static Stream escapeNameUtf8TestCases() { + return Stream.of( + // name with dots - needs UTF-8 validation for escaping to occur + Arguments.of( + "mysystem.prod.west.cpu.load", + EscapingScheme.UNDERSCORE_ESCAPING, + "mysystem_prod_west_cpu_load", + "mysystem_prod_west_cpu_load"), + Arguments.of( + "mysystem.prod.west.cpu.load", + EscapingScheme.VALUE_ENCODING_ESCAPING, + "U__mysystem_2e_prod_2e_west_2e_cpu_2e_load", + "mysystem.prod.west.cpu.load"), + Arguments.of( + "mysystem.prod.west.cpu.load_total", + EscapingScheme.UNDERSCORE_ESCAPING, + "mysystem_prod_west_cpu_load_total", + "mysystem_prod_west_cpu_load_total"), + Arguments.of( + "mysystem.prod.west.cpu.load_total", + EscapingScheme.VALUE_ENCODING_ESCAPING, + "U__mysystem_2e_prod_2e_west_2e_cpu_2e_load__total", + "mysystem.prod.west.cpu.load_total"), + Arguments.of( + "http.status:sum", + EscapingScheme.UNDERSCORE_ESCAPING, + "http_status:sum", + "http_status:sum"), + Arguments.of( + "http.status:sum", + EscapingScheme.VALUE_ENCODING_ESCAPING, + "U__http_2e_status:sum", + "http.status:sum")); + } + + @ParameterizedTest + @CsvSource( + value = { + // empty string + "'',''", + // basic case, no error + "U__no:unescapingrequired,no:unescapingrequired", + // capitals ok, no error + "U__capitals_2E_ok,capitals.ok", + // underscores, no error + "U__underscores__doubled__,underscores_doubled_", + // invalid single underscore + "U__underscores_doubled_,U__underscores_doubled_", + // invalid single underscore, 2 + "U__underscores__doubled_,U__underscores__doubled_", + // giant fake UTF-8 code + "U__my__hack_2e_attempt_872348732fabdabbab_,U__my__hack_2e_attempt_872348732fabdabbab_", + // trailing UTF-8 + "U__my__hack_2e,U__my__hack_2e", + // invalid UTF-8 value + "U__bad__utf_2eg_,U__bad__utf_2eg_", + // surrogate UTF-8 value + "U__bad__utf_D900_,U__bad__utf_D900_", + }) + public void testValueUnescapeErrors(String escapedName, String expectedUnescapedName) { + assertThat(unescapeName(escapedName, EscapingScheme.VALUE_ENCODING_ESCAPING)) + .isEqualTo(expectedUnescapedName); + } + + @Test + public void testEscapeMetricSnapshotEmpty() { + MetricSnapshot original = CounterSnapshot.builder().name("empty").build(); + MetricSnapshot got = escapeMetricSnapshot(original, EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got.getMetadata().getName()).isEqualTo("empty"); + assertThat(original.getMetadata().getName()).isEqualTo("empty"); + } + + @SetSystemProperty(key = "io.prometheus.naming.validationScheme", value = "utf-8") + @Test + public void testEscapeMetricSnapshotSimpleNoEscapingNeeded() { + testEscapeMetricSnapshot( + "my_metric", + "some_label", + "labelvalue", + "my_metric", + "some_label", + "labelvalue", + EscapingScheme.VALUE_ENCODING_ESCAPING, + CounterSnapshot.class); + } + + @SetSystemProperty(key = "io.prometheus.naming.validationScheme", value = "utf-8") + @Test + public void testEscapeMetricSnapshotLabelNameEscapingNeeded() { + testEscapeMetricSnapshot( + "my_metric", + "some.label", + "labelvalue", + "my_metric", + "U__some_2e_label", + "labelvalue", + EscapingScheme.VALUE_ENCODING_ESCAPING, + CounterSnapshot.class); + } + + @SetSystemProperty(key = "io.prometheus.naming.validationScheme", value = "utf-8") + @Test + public void testEscapeMetricSnapshotCounterEscapingNeeded() { + testEscapeMetricSnapshot( + "my.metric", + "some?label", + "label??value", + "U__my_2e_metric", + "U__some_3f_label", + "label??value", + EscapingScheme.VALUE_ENCODING_ESCAPING, + CounterSnapshot.class); + } + + @SetSystemProperty(key = "io.prometheus.naming.validationScheme", value = "utf-8") + @Test + public void testEscapeMetricSnapshotGaugeEscapingNeeded() { + testEscapeMetricSnapshot( + "unicode.and.dots.花火", + "some_label", + "label??value", + "unicode_dot_and_dot_dots_dot_____", + "some_label", + "label??value", + EscapingScheme.DOTS_ESCAPING, + GaugeSnapshot.class); + } + + private void testEscapeMetricSnapshot( + String name, + String labelName, + String labelValue, + String expectedName, + String expectedLabelName, + String expectedLabelValue, + EscapingScheme escapingScheme, + Class snapshotType) { + PrometheusNaming.resetForTest(); + + MetricSnapshot original = createTestSnapshot(name, labelName, labelValue, snapshotType); + MetricSnapshot got = escapeMetricSnapshot(original, escapingScheme); + + assertThat(got.getMetadata().getName()).isEqualTo(expectedName); + assertThat(got.getMetadata().getHelp()).isEqualTo("some help text"); + assertThat(got.getDataPoints()).hasSize(1); + + DataPointSnapshot escapedData = got.getDataPoints().get(0); + assertThat((Iterable) escapedData.getLabels()) + .isEqualTo( + Labels.builder() + .label("__name__", expectedName) + .label(expectedLabelName, expectedLabelValue) + .build()); + + assertThat(original.getMetadata().getName()).isEqualTo(name); + assertThat(original.getMetadata().getHelp()).isEqualTo("some help text"); + assertThat(original.getDataPoints()).hasSize(1); + + DataPointSnapshot originalData = original.getDataPoints().get(0); + assertThat((Iterable) originalData.getLabels()) + .isEqualTo(Labels.builder().label("__name__", name).label(labelName, labelValue).build()); + } + + private MetricSnapshot createTestSnapshot( + String name, + String labelName, + String labelValue, + Class snapshotType) { + Labels labels = Labels.builder().label("__name__", name).label(labelName, labelValue).build(); + + if (snapshotType.equals(CounterSnapshot.class)) { + return CounterSnapshot.builder() + .name(name) + .help("some help text") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder().value(34.2).labels(labels).build()) + .build(); + } else if (snapshotType.equals(GaugeSnapshot.class)) { + return GaugeSnapshot.builder() + .name(name) + .help("some help text") + .dataPoint( + GaugeSnapshot.GaugeDataPointSnapshot.builder().value(34.2).labels(labels).build()) + .build(); + } + + throw new IllegalArgumentException("Unsupported snapshot type: " + snapshotType); + } } diff --git a/prometheus-metrics-model/src/test/resources/prometheus.properties b/prometheus-metrics-model/src/test/resources/prometheus.properties new file mode 100644 index 000000000..4ce7f8487 --- /dev/null +++ b/prometheus-metrics-model/src/test/resources/prometheus.properties @@ -0,0 +1 @@ +io.prometheus.naming.validationScheme=legacy diff --git a/prometheus-metrics-simpleclient-bridge/src/test/java/io/prometheus/metrics/simpleclient/bridge/SimpleclientCollectorTest.java b/prometheus-metrics-simpleclient-bridge/src/test/java/io/prometheus/metrics/simpleclient/bridge/SimpleclientCollectorTest.java index 89cae65ba..76cb3f67c 100644 --- a/prometheus-metrics-simpleclient-bridge/src/test/java/io/prometheus/metrics/simpleclient/bridge/SimpleclientCollectorTest.java +++ b/prometheus-metrics-simpleclient-bridge/src/test/java/io/prometheus/metrics/simpleclient/bridge/SimpleclientCollectorTest.java @@ -12,6 +12,7 @@ import io.prometheus.client.exporter.common.TextFormat; import io.prometheus.metrics.expositionformats.OpenMetricsTextFormatWriter; import io.prometheus.metrics.model.registry.PrometheusRegistry; +import io.prometheus.metrics.model.snapshots.EscapingScheme; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.StringWriter; @@ -266,7 +267,7 @@ private String origOpenMetrics() throws IOException { private String newOpenMetrics() throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, false); - writer.write(out, newRegistry.scrape()); + writer.write(out, newRegistry.scrape(), EscapingScheme.NO_ESCAPING); return out.toString(StandardCharsets.UTF_8.name()); } }