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..ba91ea774 --- /dev/null +++ b/prometheus-metrics-config/src/main/java/io/prometheus/metrics/config/NamingProperties.java @@ -0,0 +1,43 @@ +package io.prometheus.metrics.config; + +import java.util.Map; + +public class NamingProperties { + + private static final String VALIDATION_SCHEME = "validationScheme"; + private final String validationScheme; + + private NamingProperties(String validation) { + this.validationScheme = validation; + } + + public String getValidationScheme() { + return validationScheme; + } + + static NamingProperties load(String prefix, Map properties) throws PrometheusPropertiesException { + String validationScheme = Util.loadString(prefix + "." + VALIDATION_SCHEME, properties); + return new NamingProperties(validationScheme); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private String validationScheme; + + private Builder() {} + + public Builder validation(String validationScheme) { + this.validationScheme = validationScheme; + return this; + } + + public NamingProperties build() { + return new NamingProperties(validationScheme); + } + } + +} \ No newline at end of file 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 da31fe8cc..40b69718e 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 @@ -44,7 +45,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; @@ -53,6 +55,7 @@ public PrometheusProperties( this.exporterHttpServerProperties = httpServerConfig; this.exporterPushgatewayProperties = pushgatewayProperties; this.exporterOpenTelemetryProperties = otelConfig; + this.namingProperties = namingProperties; } /** @@ -95,4 +98,8 @@ public ExporterPushgatewayProperties getExporterPushgatewayProperties() { public ExporterOpenTelemetryProperties getExporterOpenTelemetryProperties() { return exporterOpenTelemetryProperties; } + + public NamingProperties getNamingProperties() { + return 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..af280df9a 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 @@ -4,6 +4,7 @@ import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Paths; +import java.rmi.Naming; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -42,6 +43,7 @@ public static PrometheusProperties load(Map externalProperties) ExporterPushgatewayProperties.load(properties); ExporterOpenTelemetryProperties exporterOpenTelemetryProperties = ExporterOpenTelemetryProperties.load(properties); + NamingProperties namingProperties = NamingProperties.load("io.prometheus.naming", properties); validateAllPropertiesProcessed(properties); return new PrometheusProperties( defaultMetricsProperties, @@ -51,7 +53,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-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 514fd5b34..bf28f62b2 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 @@ -1,6 +1,7 @@ package io.prometheus.metrics.core.metrics; import static io.prometheus.metrics.core.metrics.TestUtil.assertExemplarEquals; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.nameEscapingScheme; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.data.Offset.offset; @@ -11,12 +12,7 @@ import io.prometheus.metrics.expositionformats.generated.com_google_protobuf_4_29_3.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; @@ -946,6 +942,7 @@ public void testDefaults() throws IOException { // text ByteArrayOutputStream out = new ByteArrayOutputStream(); OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(false, true); + nameEscapingScheme = EscapingScheme.NO_ESCAPING; writer.write(out, MetricSnapshots.of(snapshot)); 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 f78372545..a6147be26 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 @@ -1,5 +1,6 @@ package io.prometheus.metrics.core.metrics; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.nameEscapingScheme; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -7,6 +8,7 @@ import io.prometheus.metrics.expositionformats.generated.com_google_protobuf_4_29_3.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; @@ -121,6 +123,7 @@ public void testConstLabelsDuplicate2() { private void assertTextFormat(String expected, Info info) throws IOException { OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, true); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + nameEscapingScheme = EscapingScheme.NO_ESCAPING; writer.write(outputStream, MetricSnapshots.of(info.collect())); String result = outputStream.toString(StandardCharsets.UTF_8.name()); if (!result.contains(expected)) { 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 f978a9b36..d7943c831 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; @@ -19,6 +20,8 @@ import java.util.function.Predicate; import java.util.zip.GZIPOutputStream; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.nameEscapingScheme; + /** Prometheus scrape endpoint. */ public class PrometheusScrapeHandler { @@ -54,12 +57,13 @@ public void handleRequest(PrometheusHttpExchange exchange) throws IOException { try { PrometheusHttpRequest request = exchange.getRequest(); MetricSnapshots snapshots = scrape(request); + String acceptHeader = request.getHeader("Accept"); + nameEscapingScheme = EscapingScheme.fromAcceptHeader(acceptHeader); if (writeDebugResponse(snapshots, exchange)) { return; } ByteArrayOutputStream responseBuffer = new ByteArrayOutputStream(lastResponseSize.get() + 1024); - String acceptHeader = request.getHeader("Accept"); ExpositionFormatWriter writer = expositionFormats.findWriter(acceptHeader); writer.write(responseBuffer, snapshots); lastResponseSize.set(responseBuffer.size()); 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 6c89185f1..0a4fce0fb 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.*; import io.prometheus.metrics.config.ExporterPushgatewayProperties; import io.prometheus.metrics.config.PrometheusProperties; @@ -11,6 +12,8 @@ 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; @@ -200,6 +203,7 @@ private void doRequest(PrometheusRegistry registry, String method) throws IOExce try { if (!method.equals("DELETE")) { OutputStream outputStream = connection.getOutputStream(); + nameEscapingScheme = EscapingScheme.NO_ESCAPING; writer.write(outputStream, registry.scrape()); outputStream.flush(); outputStream.close(); @@ -430,11 +434,11 @@ private URL makeUrl(ExporterPushgatewayProperties properties) if (groupingKey != null) { for (Map.Entry entry : groupingKey.entrySet()) { if (entry.getValue().isEmpty()) { - url += "/" + entry.getKey() + "@base64/="; + url += "/" + escapeName(entry.getKey(), EscapingScheme.VALUE_ENCODING_ESCAPING) + "@base64/="; } else if (entry.getValue().contains("/")) { - url += "/" + entry.getKey() + "@base64/" + base64url(entry.getValue()); + url += "/" + escapeName(entry.getKey(), EscapingScheme.VALUE_ENCODING_ESCAPING) + "@base64/" + base64url(entry.getValue()); } else { - url += "/" + entry.getKey() + "/" + URLEncoder.encode(entry.getValue(), "UTF-8"); + url += "/" + escapeName(entry.getKey(), EscapingScheme.VALUE_ENCODING_ESCAPING) + "/" + URLEncoder.encode(entry.getValue(), "UTF-8"); } } } 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..268c1c49e 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,5 +1,6 @@ package io.prometheus.metrics.exporter.pushgateway; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.nameValidationScheme; import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.assertThat; import static org.mockserver.model.HttpRequest.request; @@ -11,6 +12,8 @@ import java.lang.reflect.Field; import java.net.InetAddress; import java.net.URL; + +import io.prometheus.metrics.model.snapshots.ValidationScheme; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -141,6 +144,23 @@ public void testPushWithGroupingKey() throws IOException { pg.push(); } + @Test + public void testPushWithEscapedGroupingKey() throws IOException { + nameValidationScheme = ValidationScheme.UTF_8_VALIDATION; + 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(); + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; + } + @Test public void testPushWithMultiGroupingKey() throws IOException { mockServerClient @@ -157,6 +177,24 @@ public void testPushWithMultiGroupingKey() throws IOException { pg.push(); } + @Test + public void testPushWithMultiEscapedGroupingKey() throws IOException { + nameValidationScheme = ValidationScheme.UTF_8_VALIDATION; + 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(); + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; + } + @Test public void testPushWithEmptyLabelGroupingKey() throws IOException { mockServerClient @@ -205,6 +243,23 @@ public void testPushCollectorWithGroupingKey() throws IOException { pg.push(gauge); } + @Test + public void testPushCollectorWithEscapedGroupingKey() throws IOException { + nameValidationScheme = ValidationScheme.UTF_8_VALIDATION; + 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); + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; + } + @Test public void testPushAdd() throws IOException { mockServerClient @@ -244,6 +299,23 @@ public void testPushAddWithGroupingKey() throws IOException { pg.pushAdd(); } + @Test + public void testPushAddWithEscapedGroupingKey() throws IOException { + nameValidationScheme = ValidationScheme.UTF_8_VALIDATION; + 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(); + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; + } + @Test public void testPushAddCollectorWithGroupingKey() throws IOException { mockServerClient @@ -259,6 +331,23 @@ public void testPushAddCollectorWithGroupingKey() throws IOException { pg.pushAdd(gauge); } + @Test + public void testPushAddCollectorWithEscapedGroupingKey() throws IOException { + nameValidationScheme = ValidationScheme.UTF_8_VALIDATION; + 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); + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; + } + @Test public void testDelete() throws IOException { mockServerClient @@ -283,6 +372,22 @@ public void testDeleteWithGroupingKey() throws IOException { pg.delete(); } + @Test + public void testDeleteWithEscapedGroupingKey() throws IOException { + nameValidationScheme = ValidationScheme.UTF_8_VALIDATION; + 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(); + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; + } + @Test public void testInstanceIpGroupingKey() throws IOException { String ip = InetAddress.getLocalHost().getHostAddress(); @@ -299,4 +404,23 @@ public void testInstanceIpGroupingKey() throws IOException { .build(); pg.delete(); } + + @Test + public void testInstanceIpEscapedGroupingKey() throws IOException { + nameValidationScheme = ValidationScheme.UTF_8_VALIDATION; + 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(); + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; + } } diff --git a/prometheus-metrics-exposition-formats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java b/prometheus-metrics-exposition-formats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java index 4c4ae3059..82a68e596 100644 --- a/prometheus-metrics-exposition-formats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java +++ b/prometheus-metrics-exposition-formats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java @@ -1,33 +1,22 @@ package io.prometheus.metrics.expositionformats; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.nameEscapingScheme; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; import io.prometheus.metrics.expositionformats.generated.com_google_protobuf_4_29_3.Metrics; import io.prometheus.metrics.expositionformats.internal.PrometheusProtobufWriterImpl; import io.prometheus.metrics.expositionformats.internal.ProtobufUtil; -import io.prometheus.metrics.model.snapshots.ClassicHistogramBuckets; -import io.prometheus.metrics.model.snapshots.CounterSnapshot; +import io.prometheus.metrics.model.snapshots.*; import io.prometheus.metrics.model.snapshots.CounterSnapshot.CounterDataPointSnapshot; -import io.prometheus.metrics.model.snapshots.Exemplar; -import io.prometheus.metrics.model.snapshots.Exemplars; -import io.prometheus.metrics.model.snapshots.GaugeSnapshot; import io.prometheus.metrics.model.snapshots.GaugeSnapshot.GaugeDataPointSnapshot; -import io.prometheus.metrics.model.snapshots.HistogramSnapshot; -import io.prometheus.metrics.model.snapshots.InfoSnapshot; -import io.prometheus.metrics.model.snapshots.Labels; -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; import io.prometheus.metrics.model.snapshots.SummarySnapshot.SummaryDataPointSnapshot; -import io.prometheus.metrics.model.snapshots.Unit; -import io.prometheus.metrics.model.snapshots.UnknownSnapshot; import io.prometheus.metrics.model.snapshots.UnknownSnapshot.UnknownDataPointSnapshot; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; + import org.junit.jupiter.api.Test; class ExpositionFormatsTest { @@ -205,6 +194,7 @@ public void testCounterComplete() throws IOException { + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; CounterSnapshot counter = CounterSnapshot.builder() .name("service_time_seconds") @@ -240,6 +230,7 @@ public void testCounterMinimal() throws IOException { String prometheusText = "# TYPE my_counter_total counter\n" + "my_counter_total 1.1\n"; String prometheusProtobuf = "name: \"my_counter_total\" type: COUNTER metric { counter { value: 1.1 } }"; + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; CounterSnapshot counter = CounterSnapshot.builder() .name("my_counter") @@ -277,6 +268,7 @@ public void testCounterWithDots() throws IOException { + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; CounterSnapshot counter = CounterSnapshot.builder() .name("my.request.count") @@ -344,6 +336,7 @@ public void testGaugeComplete() throws IOException { + "timestamp_ms: 1672850585820 " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; GaugeSnapshot gauge = GaugeSnapshot.builder() .name("disk_usage_ratio") @@ -381,6 +374,7 @@ public void testGaugeMinimal() throws IOException { "# TYPE temperature_centigrade gauge\n" + "temperature_centigrade 22.3\n"; String prometheusProtobuf = "name: \"temperature_centigrade\" type: GAUGE metric { gauge { value: 22.3 } }"; + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; GaugeSnapshot gauge = GaugeSnapshot.builder() .name("temperature_centigrade") @@ -426,6 +420,7 @@ public void testGaugeWithDots() throws IOException { + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; GaugeSnapshot gauge = GaugeSnapshot.builder() .name("my.temperature.celsius") @@ -445,6 +440,38 @@ public void testGaugeWithDots() throws IOException { assertPrometheusProtobuf(prometheusProtobuf, gauge); } + @Test + public void testGaugeUTF8() throws IOException { + String prometheusText = + "# HELP \"gauge.name\" gauge\\ndoc\\nstr\"ing\n" + + "# TYPE \"gauge.name\" gauge\n" + + "{\"gauge.name\",\"name*2\"=\"val with \\\\backslash and \\\"quotes\\\"\",\"name.1\"=\"val with\\nnew line\"} +Inf\n" + + "{\"gauge.name\",\"name*2\"=\"佖佥\",\"name.1\"=\"Björn\"} 3.14E42\n"; + PrometheusNaming.nameValidationScheme = ValidationScheme.UTF_8_VALIDATION; + + 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); + + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; + } + @Test public void testSummaryComplete() throws IOException { String openMetricsText = @@ -693,6 +720,7 @@ public void testSummaryComplete() throws IOException { + "timestamp_ms: 1672850585820 " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; SummarySnapshot summary = SummarySnapshot.builder() .name("http_request_duration_seconds") @@ -764,6 +792,7 @@ public void testSummaryWithoutQuantiles() throws IOException { + "} " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; SummarySnapshot summary = SummarySnapshot.builder() .name("latency_seconds") @@ -796,6 +825,7 @@ public void testSummaryNoCountAndSum() throws IOException { + "} " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; SummarySnapshot summary = SummarySnapshot.builder() .name("latency_seconds") @@ -826,6 +856,7 @@ public void testSummaryJustCount() throws IOException { + "} " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; SummarySnapshot summary = SummarySnapshot.builder() .name("latency_seconds") @@ -853,6 +884,7 @@ public void testSummaryJustSum() throws IOException { + "} " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; SummarySnapshot summary = SummarySnapshot.builder() .name("latency_seconds") @@ -869,6 +901,7 @@ public void testSummaryJustSum() throws IOException { public void testSummaryEmptyData() throws IOException { // SummaryData can be present but empty (no count, no sum, no quantiles). // This should be treated like no data is present. + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; SummarySnapshot summary = SummarySnapshot.builder() .name("latency_seconds") @@ -906,6 +939,7 @@ public void testSummaryEmptyAndNonEmpty() throws IOException { + "} " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; SummarySnapshot summary = SummarySnapshot.builder() .name("latency_seconds") @@ -959,6 +993,7 @@ public void testSummaryWithDots() throws IOException { + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; SummarySnapshot summary = SummarySnapshot.builder() .name("my.request.duration.seconds") @@ -1243,6 +1278,7 @@ public void testClassicHistogramComplete() throws Exception { + "} " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; HistogramSnapshot histogram = HistogramSnapshot.builder() .name("response_size_bytes") @@ -1311,6 +1347,7 @@ public void testClassicHistogramMinimal() throws Exception { + "} " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; HistogramSnapshot histogram = HistogramSnapshot.builder() .name("request_latency_seconds") @@ -1357,6 +1394,7 @@ public void testClassicHistogramCountAndSum() throws Exception { + "} " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; HistogramSnapshot histogram = HistogramSnapshot.builder() .name("request_latency_seconds") @@ -1630,6 +1668,7 @@ public void testClassicGaugeHistogramComplete() throws IOException { + "} " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; HistogramSnapshot gaugeHistogram = HistogramSnapshot.builder() .gaugeHistogram(true) @@ -1699,6 +1738,7 @@ public void testClassicGaugeHistogramMinimal() throws IOException { + "} " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; HistogramSnapshot gaugeHistogram = HistogramSnapshot.builder() .gaugeHistogram(true) @@ -1748,6 +1788,7 @@ public void testClassicGaugeHistogramCountAndSum() throws IOException { + "} " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; HistogramSnapshot gaugeHistogram = HistogramSnapshot.builder() .gaugeHistogram(true) @@ -1815,6 +1856,7 @@ public void testClassicHistogramWithDots() throws IOException { + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; HistogramSnapshot histogram = HistogramSnapshot.builder() .name("my.request.duration.seconds") @@ -2086,6 +2128,7 @@ public void testNativeHistogramComplete() throws IOException { "} " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; HistogramSnapshot nativeHistogram = HistogramSnapshot.builder() .name("response_size_bytes") @@ -2172,6 +2215,7 @@ public void testNativeHistogramMinimal() throws IOException { + "} " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; HistogramSnapshot nativeHistogram = HistogramSnapshot.builder() .name("latency_seconds") @@ -2235,6 +2279,7 @@ public void testNativeHistogramWithDots() throws IOException { + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; HistogramSnapshot histogram = HistogramSnapshot.builder() .name("my.request.duration.seconds") @@ -2271,6 +2316,7 @@ public void testInfo() throws IOException { "# HELP version_info version information\n" + "# TYPE version_info gauge\n" + "version_info{version=\"1.2.3\"} 1\n"; + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; InfoSnapshot info = InfoSnapshot.builder() .name("version") @@ -2307,6 +2353,7 @@ public void testInfoWithDots() throws IOException { + "gauge { value: 1.0 } " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; InfoSnapshot info = InfoSnapshot.builder() .name("jvm.status") @@ -2354,6 +2401,7 @@ public void testStateSetComplete() throws IOException { + "state{env=\"prod\",state=\"state2\"} 1 " + scrapeTimestamp2s + "\n"; + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; StateSetSnapshot stateSet = StateSetSnapshot.builder() .name("state") @@ -2388,6 +2436,7 @@ public void testStateSetMinimal() throws IOException { + "# EOF\n"; String prometheus = "# TYPE state gauge\n" + "state{state=\"a\"} 1\n" + "state{state=\"bb\"} 0\n"; + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; StateSetSnapshot stateSet = StateSetSnapshot.builder() .name("state") @@ -2431,6 +2480,7 @@ public void testStateSetWithDots() throws IOException { + "gauge { value: 0.0 } " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; StateSetSnapshot stateSet = StateSetSnapshot.builder() .name("my.application.state") @@ -2484,6 +2534,7 @@ public void testUnknownComplete() throws IOException { + "my_special_thing_bytes{env=\"prod\"} 0.7 " + scrapeTimestamp2s + "\n"; + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; UnknownSnapshot unknown = UnknownSnapshot.builder() .name("my_special_thing_bytes") @@ -2516,6 +2567,7 @@ public void testUnknownComplete() throws IOException { public void testUnknownMinimal() throws IOException { String openMetrics = "# TYPE other unknown\n" + "other 22.3\n" + "# EOF\n"; String prometheus = "# TYPE other untyped\n" + "other 22.3\n"; + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; UnknownSnapshot unknown = UnknownSnapshot.builder() .name("other") @@ -2557,6 +2609,7 @@ public void testUnknownWithDots() throws IOException { + "untyped { value: 0.7 } " + "}"; // @formatter:on + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; UnknownSnapshot unknown = UnknownSnapshot.builder() .name(PrometheusNaming.sanitizeMetricName("some.unknown.metric", Unit.BYTES)) @@ -2587,6 +2640,7 @@ public void testHelpEscape() throws IOException { "# HELP test_total Some text and \\n some \" escaping\n" + "# TYPE test_total counter\n" + "test_total 1.0\n"; + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; CounterSnapshot counter = CounterSnapshot.builder() .name("test") @@ -2607,6 +2661,7 @@ public void testLabelValueEscape() throws IOException { + "# EOF\n"; String prometheus = "# TYPE test_total counter\n" + "test_total{a=\"x\",b=\"escaping\\\" example \\n \"} 1.0\n"; + PrometheusNaming.nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; CounterSnapshot counter = CounterSnapshot.builder() .name("test") @@ -2621,9 +2676,112 @@ public void testLabelValueEscape() throws IOException { assertPrometheusText(prometheus, counter); } + @Test + public void testFindWriter() { + EscapingScheme oldDefault = nameEscapingScheme; + nameEscapingScheme = EscapingScheme.UNDERSCORE_ESCAPING; + ExpositionFormats expositionFormats = ExpositionFormats.init(); + + // delimited format + String acceptHeaderValue = "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily;encoding=delimited"; + String expectedFmt = "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=underscores"; + EscapingScheme escapingScheme = EscapingScheme.fromAcceptHeader(acceptHeaderValue); + ExpositionFormatWriter writer = expositionFormats.findWriter(acceptHeaderValue); + assertThat(writer.getContentType() + escapingScheme.toHeaderFormat()).hasToString(expectedFmt); + + // plain text format + acceptHeaderValue = "text/plain;version=0.0.4"; + expectedFmt = "text/plain; version=0.0.4; charset=utf-8; escaping=underscores"; + escapingScheme = EscapingScheme.fromAcceptHeader(acceptHeaderValue); + writer = expositionFormats.findWriter(acceptHeaderValue); + assertThat(writer.getContentType() + escapingScheme.toHeaderFormat()).hasToString(expectedFmt); + + // delimited format UTF-8 + acceptHeaderValue = "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily;encoding=delimited; escaping=allow-utf-8"; + expectedFmt = "application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited; escaping=allow-utf-8"; + escapingScheme = EscapingScheme.fromAcceptHeader(acceptHeaderValue); + writer = expositionFormats.findWriter(acceptHeaderValue); + assertThat(writer.getContentType() + escapingScheme.toHeaderFormat()).hasToString(expectedFmt); + + nameEscapingScheme = EscapingScheme.VALUE_ENCODING_ESCAPING; + + // OM format, no version + acceptHeaderValue = "application/openmetrics-text"; + expectedFmt = "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=values"; + escapingScheme = EscapingScheme.fromAcceptHeader(acceptHeaderValue); + writer = expositionFormats.findWriter(acceptHeaderValue); + assertThat(writer.getContentType() + escapingScheme.toHeaderFormat()).hasToString(expectedFmt); + + // OM format, 0.0.1 version + acceptHeaderValue = "application/openmetrics-text;version=0.0.1; escaping=underscores"; + expectedFmt = "application/openmetrics-text; version=1.0.0; charset=utf-8; escaping=underscores"; + escapingScheme = EscapingScheme.fromAcceptHeader(acceptHeaderValue); + writer = expositionFormats.findWriter(acceptHeaderValue); + assertThat(writer.getContentType() + escapingScheme.toHeaderFormat()).hasToString(expectedFmt); + + // plain text format + acceptHeaderValue = "text/plain;version=0.0.4"; + expectedFmt = "text/plain; version=0.0.4; charset=utf-8; escaping=values"; + escapingScheme = EscapingScheme.fromAcceptHeader(acceptHeaderValue); + writer = expositionFormats.findWriter(acceptHeaderValue); + assertThat(writer.getContentType() + escapingScheme.toHeaderFormat()).hasToString(expectedFmt); + + // plain text format UTF-8 + acceptHeaderValue = "text/plain;version=0.0.4; escaping=allow-utf-8"; + expectedFmt = "text/plain; version=0.0.4; charset=utf-8; escaping=allow-utf-8"; + escapingScheme = EscapingScheme.fromAcceptHeader(acceptHeaderValue); + writer = expositionFormats.findWriter(acceptHeaderValue); + assertThat(writer.getContentType() + escapingScheme.toHeaderFormat()).hasToString(expectedFmt); + + // delimited format UTF-8 + acceptHeaderValue = "text/plain;version=0.0.4; escaping=allow-utf-8"; + expectedFmt = "text/plain; version=0.0.4; charset=utf-8; escaping=allow-utf-8"; + escapingScheme = EscapingScheme.fromAcceptHeader(acceptHeaderValue); + writer = expositionFormats.findWriter(acceptHeaderValue); + assertThat(writer.getContentType() + escapingScheme.toHeaderFormat()).hasToString(expectedFmt); + + nameEscapingScheme = oldDefault; + } + + @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 = "application/vnd.google.protobuf;proto=io.prometheus.client.MetricFamily;encoding=delimited"; + nameEscapingScheme = EscapingScheme.fromAcceptHeader(acceptHeaderValue); + ExpositionFormatWriter protoWriter = expositionFormats.findWriter(acceptHeaderValue); + + protoWriter.write(buff, MetricSnapshots.of(unknown)); + byte[] out = buff.toByteArray(); + assertThat(out.length).isNotEqualTo(0); + + buff.reset(); + + acceptHeaderValue = "text/plain; version=0.0.4; charset=utf-8"; + nameEscapingScheme = EscapingScheme.fromAcceptHeader(acceptHeaderValue); + ExpositionFormatWriter textWriter = expositionFormats.findWriter(acceptHeaderValue); + + textWriter.write(buff, MetricSnapshots.of(unknown)); + out = buff.toByteArray(); + assertThat(out.length).isNotEqualTo(0); + + String expected = "# TYPE foo_metric untyped\n" + + "foo_metric 1.234\n"; + + assertThat(new String(out, UTF_8)).hasToString(expected); + } + private void assertOpenMetricsText(String expected, MetricSnapshot snapshot) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, false); + nameEscapingScheme = EscapingScheme.NO_ESCAPING; writer.write(out, MetricSnapshots.of(snapshot)); assertThat(out).hasToString(expected); } @@ -2632,6 +2790,7 @@ private void assertOpenMetricsTextWithExemplarsOnAllTimeSeries( String expected, MetricSnapshot snapshot) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, true); + nameEscapingScheme = EscapingScheme.NO_ESCAPING; writer.write(out, MetricSnapshots.of(snapshot)); assertThat(out).hasToString(expected); } @@ -2640,6 +2799,7 @@ private void assertOpenMetricsTextWithoutCreated(String expected, MetricSnapshot throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(false, false); + nameEscapingScheme = EscapingScheme.NO_ESCAPING; writer.write(out, MetricSnapshots.of(snapshot)); assertThat(out).hasToString(expected); } @@ -2647,6 +2807,7 @@ private void assertOpenMetricsTextWithoutCreated(String expected, MetricSnapshot private void assertPrometheusText(String expected, MetricSnapshot snapshot) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); PrometheusTextFormatWriter writer = new PrometheusTextFormatWriter(true); + nameEscapingScheme = EscapingScheme.NO_ESCAPING; writer.write(out, MetricSnapshots.of(snapshot)); assertThat(out).hasToString(expected); } @@ -2655,6 +2816,7 @@ private void assertPrometheusTextWithoutCreated(String expected, MetricSnapshot throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); PrometheusTextFormatWriter writer = new PrometheusTextFormatWriter(false); + nameEscapingScheme = EscapingScheme.NO_ESCAPING; writer.write(out, MetricSnapshots.of(snapshot)); assertThat(out).hasToString(expected); } 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..5041e5323 --- /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; + +public 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 0e34934d7..342ae3a65 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,10 +1,6 @@ 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.writeLabels; -import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeLong; -import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeTimestamp; +import io.prometheus.metrics.model.snapshots.*; import io.prometheus.metrics.model.snapshots.ClassicHistogramBuckets; import io.prometheus.metrics.model.snapshots.CounterSnapshot; @@ -31,6 +27,9 @@ import java.nio.charset.StandardCharsets; import java.util.List; +import static io.prometheus.metrics.expositionformats.TextFormatUtil.*; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.nameEscapingScheme; + /** * Write the OpenMetrics text format as defined on https://openmetrics.io. @@ -68,7 +67,8 @@ public String getContentType() { @Override public void write(OutputStream out, MetricSnapshots metricSnapshots) 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, nameEscapingScheme); if (!snapshot.getDataPoints().isEmpty()) { if (snapshot instanceof CounterSnapshot) { writeCounter(writer, (CounterSnapshot) snapshot); @@ -237,7 +237,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()) { @@ -245,7 +245,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"); @@ -321,12 +321,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(' '); } @@ -339,7 +345,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()) { @@ -353,22 +359,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/PrometheusTextFormatWriter.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/PrometheusTextFormatWriter.java index 8602b0ba5..9d4aa0a70 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,10 +1,6 @@ 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.writeLabels; -import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeLong; -import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeTimestamp; +import io.prometheus.metrics.model.snapshots.*; import io.prometheus.metrics.model.snapshots.ClassicHistogramBuckets; import io.prometheus.metrics.model.snapshots.CounterSnapshot; @@ -27,6 +23,9 @@ import java.io.Writer; import java.nio.charset.StandardCharsets; +import static io.prometheus.metrics.expositionformats.TextFormatUtil.*; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.nameEscapingScheme; + /** * Write the Prometheus text format. This is the default if you view a Prometheus endpoint with your * Web browser. @@ -61,7 +60,8 @@ public void write(OutputStream out, MetricSnapshots metricSnapshots) throws IOEx // "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, nameEscapingScheme); if (snapshot.getDataPoints().size() > 0) { if (snapshot instanceof CounterSnapshot) { writeCounter(writer, (CounterSnapshot) snapshot); @@ -81,7 +81,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, nameEscapingScheme); if (snapshot.getDataPoints().size() > 0) { if (snapshot instanceof CounterSnapshot) { writeCreated(writer, snapshot); @@ -268,7 +269,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()) { @@ -276,7 +277,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"); @@ -311,12 +312,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(' '); } @@ -325,19 +332,13 @@ 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 e48f545c5..33cafc828 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.model.snapshots.Labels; +import io.prometheus.metrics.model.snapshots.PrometheusNaming; +import io.prometheus.metrics.model.snapshots.ValidationScheme; import java.io.IOException; import java.io.Writer; @@ -34,7 +36,7 @@ static void writeTimestamp(Writer writer, long timestampMs) throws IOException { writer.write(Long.toString(ms)); } - static void writeEscapedLabelValue(Writer writer, String s) throws IOException { + static void writeEscapedString(Writer writer, String s) throws IOException { for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); switch (c) { @@ -54,16 +56,18 @@ 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) { @@ -77,4 +81,26 @@ 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.nameValidationScheme == 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-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 ac5ec9e2a..371829a9e 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 @@ -1,5 +1,6 @@ package io.prometheus.metrics.instrumentation.caffeine; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.nameEscapingScheme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @@ -11,12 +12,8 @@ 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; @@ -304,6 +301,7 @@ private String convertToOpenMetricsFormat(PrometheusRegistry registry) { final ByteArrayOutputStream out = new ByteArrayOutputStream(); final OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, true); try { + nameEscapingScheme = EscapingScheme.NO_ESCAPING; writer.write(out, registry.scrape()); return out.toString(StandardCharsets.UTF_8.name()); } catch (IOException 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 ff658ad41..7e4765c14 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 @@ -1,5 +1,6 @@ package io.prometheus.metrics.instrumentation.dropwizard; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.nameEscapingScheme; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -7,6 +8,7 @@ import com.codahale.metrics.*; 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.SummarySnapshot; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -289,6 +291,7 @@ private String convertToOpenMetricsFormat(PrometheusRegistry _registry) { ByteArrayOutputStream out = new ByteArrayOutputStream(); OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, true); try { + nameEscapingScheme = EscapingScheme.NO_ESCAPING; writer.write(out, _registry.scrape()); return out.toString(StandardCharsets.UTF_8); } catch (IOException 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 8f99cb327..3be8ba502 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 @@ -1,11 +1,13 @@ package io.prometheus.metrics.instrumentation.dropwizard5; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.nameEscapingScheme; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.data.Offset.offset; 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; @@ -288,6 +290,7 @@ private String convertToOpenMetricsFormat(PrometheusRegistry _registry) { ByteArrayOutputStream out = new ByteArrayOutputStream(); OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, true); try { + nameEscapingScheme = EscapingScheme.NO_ESCAPING; writer.write(out, _registry.scrape()); return out.toString(StandardCharsets.UTF_8.name()); } catch (IOException 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 ac0b0a328..55af802d2 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 @@ -1,5 +1,6 @@ package io.prometheus.metrics.instrumentation.dropwizard5.labels; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.nameEscapingScheme; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -7,6 +8,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; @@ -206,6 +208,7 @@ private String convertToOpenMetricsFormat(MetricSnapshots snapshots) { ByteArrayOutputStream out = new ByteArrayOutputStream(); OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, true); try { + nameEscapingScheme = EscapingScheme.NO_ESCAPING; writer.write(out, snapshots); return out.toString(StandardCharsets.UTF_8.name()); } catch (IOException 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 a03fcdb0c..8639ca0f0 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 @@ -1,5 +1,6 @@ package io.prometheus.metrics.instrumentation.guava; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.nameEscapingScheme; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @@ -11,11 +12,8 @@ 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; @@ -163,6 +161,7 @@ private String convertToOpenMetricsFormat(PrometheusRegistry registry) { final ByteArrayOutputStream out = new ByteArrayOutputStream(); final OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, true); try { + nameEscapingScheme = EscapingScheme.NO_ESCAPING; writer.write(out, registry.scrape()); return out.toString(StandardCharsets.UTF_8.name()); } catch (IOException 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..0a2ce5f9d 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,16 +1,20 @@ 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; import java.nio.charset.StandardCharsets; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.nameEscapingScheme; + class TestUtil { static String convertToOpenMetricsFormat(MetricSnapshots snapshots) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, true); + nameEscapingScheme = EscapingScheme.NO_ESCAPING; writer.write(out, snapshots); return out.toString(StandardCharsets.UTF_8.name()); } diff --git a/prometheus-metrics-model/pom.xml b/prometheus-metrics-model/pom.xml index 4e803ee5a..215549b58 100644 --- a/prometheus-metrics-model/pom.xml +++ b/prometheus-metrics-model/pom.xml @@ -19,4 +19,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..4299c6283 --- /dev/null +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/EscapingScheme.java @@ -0,0 +1,77 @@ +package io.prometheus.metrics.model.snapshots; + +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.ESCAPING_KEY; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.nameEscapingScheme; + +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 nameEscapingScheme; + } + } + } + } + return nameEscapingScheme; + } + + private 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; + } +} \ No newline at end of file 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..bb846ce68 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 @@ -119,7 +119,7 @@ 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.nameValidationScheme == 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..79ef32bbd 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 @@ -53,7 +53,7 @@ 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.nameValidationScheme == 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..8511beb71 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,7 +1,15 @@ package io.prometheus.metrics.model.snapshots; +import io.prometheus.metrics.config.PrometheusProperties; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; import java.util.regex.Pattern; +import static java.lang.Character.*; + /** * Utility for Prometheus Metric and Label naming. * @@ -11,12 +19,42 @@ */ 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 = initValidationScheme(); + + /** + * nameEscapingScheme defines the default way that names will be + * escaped when presented to systems that do not support UTF-8 names. If the + * Accept "escaping" term is specified, that will override this value. + */ + public static EscapingScheme nameEscapingScheme = 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 nameEscapingScheme 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 +79,21 @@ public class PrometheusNaming { ".total", ".created", ".bucket", ".info" }; + static ValidationScheme initValidationScheme() { + if (PrometheusProperties.get() != null && PrometheusProperties.get().getNamingProperties() != null) { + String validationScheme = PrometheusProperties.get().getNamingProperties().getValidationScheme(); + if (validationScheme != null && validationScheme.equals("utf-8")) { + return ValidationScheme.UTF_8_VALIDATION; + } + } + return ValidationScheme.LEGACY_VALIDATION; + } + /** * 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 +113,61 @@ public static boolean isValidMetricName(String name) { return validateMetricName(name) == null; } + public static String validateMetricName(String name) { + switch (nameValidationScheme) { + case LEGACY_VALIDATION: + return validateLegacyMetricName(name); + case UTF_8_VALIDATION: + if(name.isEmpty() || !StandardCharsets.UTF_8.newEncoder().canEncode(name)) { + return "The metric name contains unsupported characters"; + } + return null; + default: + throw new RuntimeException("Invalid name validation scheme requested: " + nameValidationScheme); + } + } + /** * 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 (nameValidationScheme) { + 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: " + nameValidationScheme); + } + } + public static boolean isValidLabelName(String name) { - return LABEL_NAME_PATTERN.matcher(name).matches() + switch (nameValidationScheme) { + case LEGACY_VALIDATION: + return isValidLegacyLabelName(name); + case UTF_8_VALIDATION: + return StandardCharsets.UTF_8.newEncoder().canEncode(name); + default: + throw new RuntimeException("Invalid name validation scheme requested: " + nameValidationScheme); + } + } + + public static boolean isValidLegacyLabelName(String name) { + return LEGACY_LABEL_NAME_PATTERN.matcher(name).matches() && !(name.startsWith("__") || name.startsWith("._") || name.startsWith("..") @@ -226,7 +310,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 +328,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 +364,368 @@ 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 = null; + + if (v instanceof CounterSnapshot) { + outDataPointSnapshot = 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) { + outDataPointSnapshot = GaugeSnapshot.GaugeDataPointSnapshot.builder() + .value(((GaugeSnapshot.GaugeDataPointSnapshot) d).getValue()) + .exemplar(((GaugeSnapshot.GaugeDataPointSnapshot) d).getExemplar()) + .labels(outLabels) + .scrapeTimestampMillis(d.getScrapeTimestampMillis()) + .build(); + } else if (v instanceof HistogramSnapshot) { + outDataPointSnapshot = 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) { + outDataPointSnapshot = 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) { + outDataPointSnapshot = 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()); + } + outDataPointSnapshot = builder.build(); + } else if (v instanceof UnknownSnapshot) { + outDataPointSnapshot = UnknownSnapshot.UnknownDataPointSnapshot.builder() + .labels(outLabels) + .value(((UnknownSnapshot.UnknownDataPointSnapshot) d).getValue()) + .exemplar(((UnknownSnapshot.UnknownDataPointSnapshot) d).getExemplar()) + .scrapeTimestampMillis(d.getScrapeTimestampMillis()) + .build(); + } + + outDataPoints.add(outDataPointSnapshot); + } + + MetricSnapshot out; + + 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); + } + out = 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); + } + out = 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); + } + out = 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); + } + out = 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); + } + out = 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); + } + out = 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); + } + out = builder.build(); + } else { + throw new IllegalArgumentException("Unknown MetricSnapshot type: " + v.getClass()); + } + + return out; + } + + 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; + } + + /** + * 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/main/java/io/prometheus/metrics/model/snapshots/ValidationScheme.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/ValidationScheme.java new file mode 100644 index 000000000..4c547bb12 --- /dev/null +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/ValidationScheme.java @@ -0,0 +1,13 @@ +package io.prometheus.metrics.model.snapshots; + +// 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 +} \ No newline at end of file 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..587fa801f 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 @@ -6,10 +6,13 @@ import org.junit.jupiter.api.Test; +import java.util.Optional; + class PrometheusNamingTest { @Test public void testSanitizeMetricName() { + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; assertThat(prometheusName(sanitizeMetricName("0abc.def"))).isEqualTo("_abc_def"); assertThat(prometheusName(sanitizeMetricName("___ab.:c0"))).isEqualTo("___ab__c0"); assertThat(sanitizeMetricName("my_prefix/my_metric")).isEqualTo("my_prefix_my_metric"); @@ -24,6 +27,7 @@ public void testSanitizeMetricName() { @Test public void testSanitizeMetricNameWithUnit() { + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; assertThat(prometheusName(sanitizeMetricName("0abc.def", Unit.RATIO))) .isEqualTo("_abc_def_" + Unit.RATIO); assertThat(prometheusName(sanitizeMetricName("___ab.:c0", Unit.RATIO))) @@ -42,6 +46,7 @@ public void testSanitizeMetricNameWithUnit() { @Test public void testSanitizeLabelName() { + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; assertThat(prometheusName(sanitizeLabelName("0abc.def"))).isEqualTo("_abc_def"); assertThat(prometheusName(sanitizeLabelName("_abc"))).isEqualTo("_abc"); assertThat(prometheusName(sanitizeLabelName("__abc"))).isEqualTo("_abc"); @@ -96,4 +101,407 @@ public void testEmptyUnitName() { assertThatExceptionOfType(IllegalArgumentException.class) .isThrownBy(() -> sanitizeUnitName("")); } + + @Test + public void testMetricNameIsValid() { + nameValidationScheme = ValidationScheme.UTF_8_VALIDATION; + assertThat(validateMetricName("Avalid_23name")).isNull(); + assertThat(validateMetricName("_Avalid_23name")).isNull(); + assertThat(validateMetricName("1valid_23name")).isNull(); + assertThat(validateMetricName("avalid_23name")).isNull(); + assertThat(validateMetricName("Ava:lid_23name")).isNull(); + assertThat(validateMetricName("a lid_23name")).isNull(); + assertThat(validateMetricName(":leading_colon")).isNull(); + assertThat(validateMetricName("colon:in:the:middle")).isNull(); + assertThat(validateMetricName("")).isEqualTo("The metric name contains unsupported characters"); + assertThat(validateMetricName("a\ud800z")).isEqualTo("The metric name contains unsupported characters"); + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; + assertThat(validateMetricName("Avalid_23name")).isNull(); + assertThat(validateMetricName("_Avalid_23name")).isNull(); + assertThat(validateMetricName("1valid_23name")).isEqualTo("The metric name contains unsupported characters"); + assertThat(validateMetricName("avalid_23name")).isNull(); + assertThat(validateMetricName("Ava:lid_23name")).isNull(); + assertThat(validateMetricName("a lid_23name")).isEqualTo("The metric name contains unsupported characters"); + assertThat(validateMetricName(":leading_colon")).isNull(); + assertThat(validateMetricName("colon:in:the:middle")).isNull(); + assertThat(validateMetricName("")).isEqualTo("The metric name contains unsupported characters"); + assertThat(validateMetricName("a\ud800z")).isEqualTo("The metric name contains unsupported characters"); + } + + @Test + public void testLabelNameIsValid() { + nameValidationScheme = ValidationScheme.UTF_8_VALIDATION; + assertThat(isValidLabelName("Avalid_23name")).isTrue(); + assertThat(isValidLabelName("_Avalid_23name")).isTrue(); + assertThat(isValidLabelName("1valid_23name")).isTrue(); + assertThat(isValidLabelName("avalid_23name")).isTrue(); + assertThat(isValidLabelName("Ava:lid_23name")).isTrue(); + assertThat(isValidLabelName("a lid_23name")).isTrue(); + assertThat(isValidLabelName(":leading_colon")).isTrue(); + assertThat(isValidLabelName("colon:in:the:middle")).isTrue(); + assertThat(isValidLabelName("a\ud800z")).isFalse(); + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; + assertThat(isValidLabelName("Avalid_23name")).isTrue(); + assertThat(isValidLabelName("_Avalid_23name")).isTrue(); + assertThat(isValidLabelName("1valid_23name")).isFalse(); + assertThat(isValidLabelName("avalid_23name")).isTrue(); + assertThat(isValidLabelName("Ava:lid_23name")).isFalse(); + assertThat(isValidLabelName("a lid_23name")).isFalse(); + assertThat(isValidLabelName(":leading_colon")).isFalse(); + assertThat(isValidLabelName("colon:in:the:middle")).isFalse(); + assertThat(isValidLabelName("a\ud800z")).isFalse(); + } + + @Test + public void testEscapeName() { + nameValidationScheme = ValidationScheme.UTF_8_VALIDATION; + + // empty string + String got = escapeName("", EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(got).isEqualTo(""); + got = unescapeName(got, EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(got).isEqualTo(""); + + got = escapeName("", EscapingScheme.DOTS_ESCAPING); + assertThat(got).isEqualTo(""); + got = unescapeName(got, EscapingScheme.DOTS_ESCAPING); + assertThat(got).isEqualTo(""); + + got = escapeName("", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo(""); + got = unescapeName(got, EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo(""); + + // legacy valid name + got = escapeName("no:escaping_required", EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(got).isEqualTo("no:escaping_required"); + got = unescapeName(got, EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(got).isEqualTo("no:escaping_required"); + + got = escapeName("no:escaping_required", EscapingScheme.DOTS_ESCAPING); + // Dots escaping will escape underscores even though it's not strictly + // necessary for compatibility. + assertThat(got).isEqualTo("no:escaping__required"); + got = unescapeName(got, EscapingScheme.DOTS_ESCAPING); + assertThat(got).isEqualTo("no:escaping_required"); + + got = escapeName("no:escaping_required", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("no:escaping_required"); + got = unescapeName(got, EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("no:escaping_required"); + + // name with dots + got = escapeName("mysystem.prod.west.cpu.load", EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(got).isEqualTo("mysystem_prod_west_cpu_load"); + got = unescapeName(got, EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(got).isEqualTo("mysystem_prod_west_cpu_load"); + + got = escapeName("mysystem.prod.west.cpu.load", EscapingScheme.DOTS_ESCAPING); + assertThat(got).isEqualTo("mysystem_dot_prod_dot_west_dot_cpu_dot_load"); + got = unescapeName(got, EscapingScheme.DOTS_ESCAPING); + assertThat(got).isEqualTo("mysystem.prod.west.cpu.load"); + + got = escapeName("mysystem.prod.west.cpu.load", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("U__mysystem_2e_prod_2e_west_2e_cpu_2e_load"); + got = unescapeName(got, EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("mysystem.prod.west.cpu.load"); + + // name with dots and underscore + got = escapeName("mysystem.prod.west.cpu.load_total", EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(got).isEqualTo("mysystem_prod_west_cpu_load_total"); + got = unescapeName(got, EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(got).isEqualTo("mysystem_prod_west_cpu_load_total"); + + got = escapeName("mysystem.prod.west.cpu.load_total", EscapingScheme.DOTS_ESCAPING); + assertThat(got).isEqualTo("mysystem_dot_prod_dot_west_dot_cpu_dot_load__total"); + got = unescapeName(got, EscapingScheme.DOTS_ESCAPING); + assertThat(got).isEqualTo("mysystem.prod.west.cpu.load_total"); + + got = escapeName("mysystem.prod.west.cpu.load_total", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("U__mysystem_2e_prod_2e_west_2e_cpu_2e_load__total"); + got = unescapeName(got, EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("mysystem.prod.west.cpu.load_total"); + + // name with dots and colon + got = escapeName("http.status:sum", EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(got).isEqualTo("http_status:sum"); + got = unescapeName(got, EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(got).isEqualTo("http_status:sum"); + + got = escapeName("http.status:sum", EscapingScheme.DOTS_ESCAPING); + assertThat(got).isEqualTo("http_dot_status:sum"); + got = unescapeName(got, EscapingScheme.DOTS_ESCAPING); + assertThat(got).isEqualTo("http.status:sum"); + + got = escapeName("http.status:sum", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("U__http_2e_status:sum"); + got = unescapeName(got, EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("http.status:sum"); + + // name with spaces and emoji + got = escapeName("label with 😱", EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(got).isEqualTo("label_with__"); + got = unescapeName(got, EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(got).isEqualTo("label_with__"); + + got = escapeName("label with 😱", EscapingScheme.DOTS_ESCAPING); + assertThat(got).isEqualTo("label__with____"); + got = unescapeName(got, EscapingScheme.DOTS_ESCAPING); + assertThat(got).isEqualTo("label_with__"); + + got = escapeName("label with 😱", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("U__label_20_with_20__1f631_"); + got = unescapeName(got, EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("label with 😱"); + + // name with unicode characters > 0x100 + got = escapeName("花火", EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(got).isEqualTo("__"); + got = unescapeName(got, EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(got).isEqualTo("__"); + + got = escapeName("花火", EscapingScheme.DOTS_ESCAPING); + assertThat(got).isEqualTo("____"); + got = unescapeName(got, EscapingScheme.DOTS_ESCAPING); + // Dots-replacement does not know the difference between two replaced + // characters and a single underscore. + assertThat(got).isEqualTo("__"); + + got = escapeName("花火", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("U___82b1__706b_"); + got = unescapeName(got, EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("花火"); + + // name with spaces and edge-case value + got = escapeName("label with Ā", EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(got).isEqualTo("label_with__"); + got = unescapeName(got, EscapingScheme.UNDERSCORE_ESCAPING); + assertThat(got).isEqualTo("label_with__"); + + got = escapeName("label with Ā", EscapingScheme.DOTS_ESCAPING); + assertThat(got).isEqualTo("label__with____"); + got = unescapeName(got, EscapingScheme.DOTS_ESCAPING); + assertThat(got).isEqualTo("label_with__"); + + got = escapeName("label with Ā", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("U__label_20_with_20__100_"); + got = unescapeName(got, EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("label with Ā"); + + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; + } + + @Test + public void testValueUnescapeErrors() { + nameValidationScheme = ValidationScheme.UTF_8_VALIDATION; + String got; + + // empty string + got = unescapeName("", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo(""); + + // basic case, no error + got = unescapeName("U__no:unescapingrequired", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("no:unescapingrequired"); + + // capitals ok, no error + got = unescapeName("U__capitals_2E_ok", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("capitals.ok"); + + // underscores, no error + got = unescapeName("U__underscores__doubled__", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("underscores_doubled_"); + + // invalid single underscore + got = unescapeName("U__underscores_doubled_", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("U__underscores_doubled_"); + + // invalid single underscore, 2 + got = unescapeName("U__underscores__doubled_", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("U__underscores__doubled_"); + + // giant fake UTF-8 code + got = unescapeName("U__my__hack_2e_attempt_872348732fabdabbab_", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("U__my__hack_2e_attempt_872348732fabdabbab_"); + + // trailing UTF-8 + got = unescapeName("U__my__hack_2e", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("U__my__hack_2e"); + + // invalid UTF-8 value + got = unescapeName("U__bad__utf_2eg_", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("U__bad__utf_2eg_"); + + // surrogate UTF-8 value + got = unescapeName("U__bad__utf_D900_", EscapingScheme.VALUE_ENCODING_ESCAPING); + assertThat(got).isEqualTo("U__bad__utf_D900_"); + + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; + } + + @Test + public void testEscapeMetricSnapshotEmpty() { + nameValidationScheme = ValidationScheme.UTF_8_VALIDATION; + 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"); + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; + } + + @Test + public void testEscapeMetricSnapshotSimpleNoEscapingNeeded() { + nameValidationScheme = ValidationScheme.UTF_8_VALIDATION; + MetricSnapshot original = CounterSnapshot.builder() + .name("my_metric") + .help("some help text") + .dataPoint(CounterSnapshot.CounterDataPointSnapshot.builder() + .value(34.2) + .labels(Labels.builder() + .label("__name__", "my_metric") + .label("some_label", "labelvalue") + .build()) + .build() + ) + .build(); + MetricSnapshot got = escapeMetricSnapshot(original, EscapingScheme.VALUE_ENCODING_ESCAPING); + + assertThat(got.getMetadata().getName()).isEqualTo("my_metric"); + assertThat(got.getMetadata().getHelp()).isEqualTo("some help text"); + assertThat(got.getDataPoints().size()).isEqualTo(1); + CounterSnapshot.CounterDataPointSnapshot data = (CounterSnapshot.CounterDataPointSnapshot) got.getDataPoints().get(0); + assertThat(data.getValue()).isEqualTo(34.2); + assertThat((Iterable) data.getLabels()).isEqualTo(Labels.builder() + .label("__name__", "my_metric") + .label("some_label", "labelvalue") + .build()); + assertThat(original.getMetadata().getName()).isEqualTo("my_metric"); + assertThat(original.getMetadata().getHelp()).isEqualTo("some help text"); + assertThat(original.getDataPoints().size()).isEqualTo(1); + data = (CounterSnapshot.CounterDataPointSnapshot) original.getDataPoints().get(0); + assertThat(data.getValue()).isEqualTo(34.2); + assertThat((Iterable) data.getLabels()).isEqualTo(Labels.builder() + .label("__name__", "my_metric") + .label("some_label", "labelvalue") + .build()); + + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; + } + + @Test + public void testEscapeMetricSnapshotLabelNameEscapingNeeded() { + nameValidationScheme = ValidationScheme.UTF_8_VALIDATION; + MetricSnapshot original = CounterSnapshot.builder() + .name("my_metric") + .help("some help text") + .dataPoint(CounterSnapshot.CounterDataPointSnapshot.builder() + .value(34.2) + .labels(Labels.builder() + .label("__name__", "my_metric") + .label("some.label", "labelvalue") + .build()) + .build() + ) + .build(); + MetricSnapshot got = escapeMetricSnapshot(original, EscapingScheme.VALUE_ENCODING_ESCAPING); + + assertThat(got.getMetadata().getName()).isEqualTo("my_metric"); + assertThat(got.getMetadata().getHelp()).isEqualTo("some help text"); + assertThat(got.getDataPoints().size()).isEqualTo(1); + CounterSnapshot.CounterDataPointSnapshot data = (CounterSnapshot.CounterDataPointSnapshot) got.getDataPoints().get(0); + assertThat(data.getValue()).isEqualTo(34.2); + assertThat((Iterable) data.getLabels()).isEqualTo(Labels.builder() + .label("__name__", "my_metric") + .label("U__some_2e_label", "labelvalue") + .build()); + assertThat(original.getMetadata().getName()).isEqualTo("my_metric"); + assertThat(original.getMetadata().getHelp()).isEqualTo("some help text"); + assertThat(original.getDataPoints().size()).isEqualTo(1); + data = (CounterSnapshot.CounterDataPointSnapshot) original.getDataPoints().get(0); + assertThat(data.getValue()).isEqualTo(34.2); + assertThat((Iterable) data.getLabels()).isEqualTo(Labels.builder() + .label("__name__", "my_metric") + .label("some.label", "labelvalue") + .build()); + + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; + } + + @Test + public void testEscapeMetricSnapshotCounterEscapingNeeded() { + nameValidationScheme = ValidationScheme.UTF_8_VALIDATION; + MetricSnapshot original = CounterSnapshot.builder() + .name("my.metric") + .help("some help text") + .dataPoint(CounterSnapshot.CounterDataPointSnapshot.builder() + .value(34.2) + .labels(Labels.builder() + .label("__name__", "my.metric") + .label("some?label", "label??value") + .build()) + .build() + ) + .build(); + MetricSnapshot got = escapeMetricSnapshot(original, EscapingScheme.VALUE_ENCODING_ESCAPING); + + assertThat(got.getMetadata().getName()).isEqualTo("U__my_2e_metric"); + assertThat(got.getMetadata().getHelp()).isEqualTo("some help text"); + assertThat(got.getDataPoints().size()).isEqualTo(1); + CounterSnapshot.CounterDataPointSnapshot data = (CounterSnapshot.CounterDataPointSnapshot) got.getDataPoints().get(0); + assertThat(data.getValue()).isEqualTo(34.2); + assertThat((Iterable) data.getLabels()).isEqualTo(Labels.builder() + .label("__name__", "U__my_2e_metric") + .label("U__some_3f_label", "label??value") + .build()); + assertThat(original.getMetadata().getName()).isEqualTo("my.metric"); + assertThat(original.getMetadata().getHelp()).isEqualTo("some help text"); + assertThat(original.getDataPoints().size()).isEqualTo(1); + data = (CounterSnapshot.CounterDataPointSnapshot) original.getDataPoints().get(0); + assertThat(data.getValue()).isEqualTo(34.2); + assertThat((Iterable) data.getLabels()).isEqualTo(Labels.builder() + .label("__name__", "my.metric") + .label("some?label", "label??value") + .build()); + + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; + } + + @Test + public void testEscapeMetricSnapshotGaugeEscapingNeeded() { + nameValidationScheme = ValidationScheme.UTF_8_VALIDATION; + MetricSnapshot original = GaugeSnapshot.builder() + .name("unicode.and.dots.花火") + .help("some help text") + .dataPoint(GaugeSnapshot.GaugeDataPointSnapshot.builder() + .value(34.2) + .labels(Labels.builder() + .label("__name__", "unicode.and.dots.花火") + .label("some_label", "label??value") + .build()) + .build() + ) + .build(); + MetricSnapshot got = escapeMetricSnapshot(original, EscapingScheme.DOTS_ESCAPING); + + assertThat(got.getMetadata().getName()).isEqualTo("unicode_dot_and_dot_dots_dot_____"); + assertThat(got.getMetadata().getHelp()).isEqualTo("some help text"); + assertThat(got.getDataPoints().size()).isEqualTo(1); + GaugeSnapshot.GaugeDataPointSnapshot data = (GaugeSnapshot.GaugeDataPointSnapshot) got.getDataPoints().get(0); + assertThat(data.getValue()).isEqualTo(34.2); + assertThat((Iterable) data.getLabels()).isEqualTo(Labels.builder() + .label("__name__", "unicode_dot_and_dot_dots_dot_____") + .label("some_label", "label??value") + .build()); + assertThat(original.getMetadata().getName()).isEqualTo("unicode.and.dots.花火"); + assertThat(original.getMetadata().getHelp()).isEqualTo("some help text"); + assertThat(original.getDataPoints().size()).isEqualTo(1); + data = (GaugeSnapshot.GaugeDataPointSnapshot) original.getDataPoints().get(0); + assertThat(data.getValue()).isEqualTo(34.2); + assertThat((Iterable) data.getLabels()).isEqualTo(Labels.builder() + .label("__name__", "unicode.and.dots.花火") + .label("some_label", "label??value") + .build()); + + nameValidationScheme = ValidationScheme.LEGACY_VALIDATION; + } } 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..28d68b2e1 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 @@ -1,5 +1,6 @@ package io.prometheus.metrics.simpleclient.bridge; +import static io.prometheus.metrics.model.snapshots.PrometheusNaming.nameEscapingScheme; import static org.assertj.core.api.Assertions.assertThat; import io.prometheus.client.Collector; @@ -20,6 +21,8 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; + +import io.prometheus.metrics.model.snapshots.EscapingScheme; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -266,6 +269,7 @@ private String origOpenMetrics() throws IOException { private String newOpenMetrics() throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); OpenMetricsTextFormatWriter writer = new OpenMetricsTextFormatWriter(true, false); + nameEscapingScheme = EscapingScheme.NO_ESCAPING; writer.write(out, newRegistry.scrape()); return out.toString(StandardCharsets.UTF_8.name()); }