diff --git a/prometheus-metrics-core/src/test/java/io/prometheus/metrics/core/metrics/OpenMetrics2TextFormatWriterTest.java b/prometheus-metrics-core/src/test/java/io/prometheus/metrics/core/metrics/OpenMetrics2TextFormatWriterTest.java new file mode 100644 index 000000000..38ba67270 --- /dev/null +++ b/prometheus-metrics-core/src/test/java/io/prometheus/metrics/core/metrics/OpenMetrics2TextFormatWriterTest.java @@ -0,0 +1,118 @@ +package io.prometheus.metrics.core.metrics; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.prometheus.metrics.config.EscapingScheme; +import io.prometheus.metrics.config.OpenMetrics2Properties; +import io.prometheus.metrics.expositionformats.ExpositionFormatWriter; +import io.prometheus.metrics.expositionformats.OpenMetrics2TextFormatWriter; +import io.prometheus.metrics.expositionformats.OpenMetricsTextFormatWriter; +import io.prometheus.metrics.model.snapshots.MetricSnapshots; +import io.prometheus.metrics.model.snapshots.Unit; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class OpenMetrics2TextFormatWriterTest { + + @Test + void counterPreservesOriginalNameWhenUnitIsConfigured() throws IOException { + Counter counter = + Counter.builder() + .name("my_counter") + .unit(Unit.SECONDS) + .help("Test counter") + .labelNames("method") + .build(); + counter.labelValues("GET").inc(42.0); + MetricSnapshots snapshots = MetricSnapshots.of(counter.collect()); + + String om1Output = writeWithOM1(snapshots); + String om2Output = writeWithOM2(snapshots); + + assertThat(om1Output).contains("my_counter_seconds_total{method=\"GET\"} 42.0"); + assertThat(om2Output) + .contains("# TYPE my_counter counter\n") + .contains("# UNIT my_counter seconds\n") + .contains("# HELP my_counter Test counter\n") + .containsPattern("(?m)^my_counter\\{method=\"GET\"} 42\\.0 st@\\d+\\.\\d{3}$") + .doesNotContain("my_counter_seconds"); + } + + @Test + void classicHistogramPreservesOriginalNameWhenUnitIsConfigured() throws IOException { + Histogram histogram = + Histogram.builder() + .name("request_duration") + .unit(Unit.SECONDS) + .help("Request duration in seconds") + .labelNames("path") + .classicOnly() + .classicUpperBounds(10.0) + .build(); + histogram.labelValues("/hello").observe(3.2); + MetricSnapshots snapshots = MetricSnapshots.of(histogram.collect()); + + String om1Output = writeWithOM1(snapshots); + String om2Output = writeWithOM2(snapshots); + + assertThat(om1Output).contains("request_duration_seconds_bucket"); + assertThat(om2Output) + .contains("# TYPE request_duration histogram\n") + .contains("# UNIT request_duration seconds\n") + .contains("# HELP request_duration Request duration in seconds\n") + .contains("request_duration_bucket{path=\"/hello\",le=\"10.0\"} 1\n") + .contains("request_duration_bucket{path=\"/hello\",le=\"+Inf\"} 1\n") + .contains("request_duration_count{path=\"/hello\"} 1\n") + .contains("request_duration_sum{path=\"/hello\"} 3.2\n") + .doesNotContain("request_duration_seconds"); + } + + @Test + void nativeHistogramPreservesOriginalNameWhenUnitIsConfigured() throws IOException { + Histogram histogram = + Histogram.builder() + .name("my.request.duration") + .unit(Unit.SECONDS) + .help("Request duration in seconds") + .labelNames("http.path") + .nativeOnly() + .build(); + histogram.labelValues("/hello").observe(3.2); + MetricSnapshots snapshots = MetricSnapshots.of(histogram.collect()); + + String om2Output = writeWithNativeHistograms(snapshots); + + assertThat(om2Output) + .contains("# TYPE \"my.request.duration\" histogram\n") + .contains("# UNIT \"my.request.duration\" seconds\n") + .contains("# HELP \"my.request.duration\" Request duration in seconds\n") + .contains("{\"my.request.duration\",\"http.path\"=\"/hello\"} {count:1,sum:3.2,") + .doesNotContain("my.request.duration_seconds"); + } + + private String writeWithOM1(MetricSnapshots snapshots) throws IOException { + return write(snapshots, OpenMetricsTextFormatWriter.create()); + } + + private String writeWithOM2(MetricSnapshots snapshots) throws IOException { + return write(snapshots, OpenMetrics2TextFormatWriter.create()); + } + + private String writeWithNativeHistograms(MetricSnapshots snapshots) throws IOException { + OpenMetrics2TextFormatWriter writer = + OpenMetrics2TextFormatWriter.builder() + .setOpenMetrics2Properties( + OpenMetrics2Properties.builder().nativeHistograms(true).build()) + .build(); + return write(snapshots, writer); + } + + private String write(MetricSnapshots snapshots, ExpositionFormatWriter writer) + throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writer.write(out, snapshots, EscapingScheme.ALLOW_UTF8); + return out.toString(StandardCharsets.UTF_8.name()); + } +} diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetrics2TextFormatWriter.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetrics2TextFormatWriter.java index 2c2d9f81f..963b18507 100644 --- a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetrics2TextFormatWriter.java +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetrics2TextFormatWriter.java @@ -6,7 +6,7 @@ import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeLong; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeName; import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeOpenMetricsTimestamp; -import static io.prometheus.metrics.model.snapshots.SnapshotEscaper.getExpositionBaseMetadataName; +import static io.prometheus.metrics.model.snapshots.SnapshotEscaper.getOriginalMetadataName; import static io.prometheus.metrics.model.snapshots.SnapshotEscaper.getSnapshotLabelName; import io.prometheus.metrics.config.EscapingScheme; @@ -40,8 +40,8 @@ /** * Write the OpenMetrics 2.0 text format. Unlike the OM1 writer, this writer outputs metric names as - * provided by the user — no {@code _total} or unit suffix appending. The {@code _info} suffix is - * enforced per the OM2 spec (MUST). This is experimental and subject to change as the OpenMetrics * 2.0 specification evolves. */ @@ -89,6 +89,7 @@ public OpenMetrics2TextFormatWriter build() { public static final String CONTENT_TYPE = "application/openmetrics-text; version=2.0.0; charset=utf-8"; private final OpenMetrics2Properties openMetrics2Properties; + private final boolean createdTimestampsEnabled; private final boolean exemplarsOnAllMetricTypesEnabled; private final OpenMetricsTextFormatWriter om1Writer; @@ -102,6 +103,7 @@ public OpenMetrics2TextFormatWriter( boolean createdTimestampsEnabled, boolean exemplarsOnAllMetricTypesEnabled) { this.openMetrics2Properties = openMetrics2Properties; + this.createdTimestampsEnabled = createdTimestampsEnabled; this.exemplarsOnAllMetricTypesEnabled = exemplarsOnAllMetricTypesEnabled; this.om1Writer = new OpenMetricsTextFormatWriter(createdTimestampsEnabled, exemplarsOnAllMetricTypesEnabled); @@ -170,8 +172,8 @@ public void write(OutputStream out, MetricSnapshots metricSnapshots, EscapingSch private void writeCounter(Writer writer, CounterSnapshot snapshot, EscapingScheme scheme) throws IOException { MetricMetadata metadata = snapshot.getMetadata(); - // OM2: use the name as provided by the user, no _total appending - String counterName = getExpositionBaseMetadataName(metadata, scheme); + // OM2: use the original name, no _total or unit suffix appending. + String counterName = getOriginalMetadataName(metadata, scheme); writeMetadataWithName(writer, counterName, "counter", metadata); for (CounterSnapshot.CounterDataPointSnapshot data : snapshot.getDataPoints()) { writeNameAndLabels(writer, counterName, null, data.getLabels(), scheme); @@ -192,7 +194,7 @@ private void writeCounter(Writer writer, CounterSnapshot snapshot, EscapingSchem private void writeGauge(Writer writer, GaugeSnapshot snapshot, EscapingScheme scheme) throws IOException { MetricMetadata metadata = snapshot.getMetadata(); - String name = getExpositionBaseMetadataName(metadata, scheme); + String name = getOriginalMetadataName(metadata, scheme); writeMetadataWithName(writer, name, "gauge", metadata); for (GaugeSnapshot.GaugeDataPointSnapshot data : snapshot.getDataPoints()) { writeNameAndLabels(writer, name, null, data.getLabels(), scheme); @@ -209,12 +211,12 @@ private void writeHistogram(Writer writer, HistogramSnapshot snapshot, EscapingS throws IOException { boolean compositeHistogram = openMetrics2Properties.getCompositeValues() || openMetrics2Properties.getNativeHistograms(); + MetricMetadata metadata = snapshot.getMetadata(); + String name = getOriginalMetadataName(metadata, scheme); if (!compositeHistogram && !openMetrics2Properties.getExemplarCompliance()) { - om1Writer.writeHistogram(writer, snapshot, scheme); + writeClassicHistogram(writer, name, snapshot, scheme); return; } - MetricMetadata metadata = snapshot.getMetadata(); - String name = getExpositionBaseMetadataName(metadata, scheme); if (snapshot.isGaugeHistogram()) { writeMetadataWithName(writer, name, "gaugehistogram", metadata); for (HistogramSnapshot.HistogramDataPointSnapshot data : snapshot.getDataPoints()) { @@ -236,6 +238,88 @@ private void writeHistogram(Writer writer, HistogramSnapshot snapshot, EscapingS } } + private void writeClassicHistogram( + Writer writer, String name, HistogramSnapshot snapshot, EscapingScheme scheme) + throws IOException { + if (snapshot.isGaugeHistogram()) { + writeMetadataWithName(writer, name, "gaugehistogram", snapshot.getMetadata()); + writeClassicHistogramDataPoints(writer, name, "_gcount", "_gsum", snapshot, scheme); + } else { + writeMetadataWithName(writer, name, "histogram", snapshot.getMetadata()); + writeClassicHistogramDataPoints(writer, name, "_count", "_sum", snapshot, scheme); + } + } + + private void writeClassicHistogramDataPoints( + Writer writer, + String name, + String countSuffix, + String sumSuffix, + HistogramSnapshot snapshot, + EscapingScheme scheme) + throws IOException { + for (HistogramSnapshot.HistogramDataPointSnapshot data : snapshot.getDataPoints()) { + ClassicHistogramBuckets buckets = getClassicBuckets(data); + Exemplars exemplars = data.getExemplars(); + long cumulativeCount = 0; + for (int i = 0; i < buckets.size(); i++) { + cumulativeCount += buckets.getCount(i); + writeNameAndLabels( + writer, name, "_bucket", data.getLabels(), scheme, "le", buckets.getUpperBound(i)); + writeLong(writer, cumulativeCount); + Exemplar exemplar; + if (i == 0) { + exemplar = exemplars.get(Double.NEGATIVE_INFINITY, buckets.getUpperBound(i)); + } else { + exemplar = exemplars.get(buckets.getUpperBound(i - 1), buckets.getUpperBound(i)); + } + writeScrapeTimestampAndExemplar(writer, data, exemplar, scheme); + } + if (data.hasCount() && data.hasSum()) { + writeClassicCountAndSum(writer, name, data, countSuffix, sumSuffix, exemplars, scheme); + } + writeClassicCreated(writer, name, data, scheme); + } + } + + private void writeClassicCountAndSum( + Writer writer, + String name, + HistogramSnapshot.HistogramDataPointSnapshot data, + String countSuffix, + String sumSuffix, + Exemplars exemplars, + EscapingScheme scheme) + throws IOException { + writeNameAndLabels(writer, name, countSuffix, data.getLabels(), scheme); + writeLong(writer, data.getCount()); + if (exemplarsOnAllMetricTypesEnabled) { + writeScrapeTimestampAndExemplar(writer, data, exemplars.getLatest(), scheme); + } else { + writeScrapeTimestampAndExemplar(writer, data, null, scheme); + } + writeNameAndLabels(writer, name, sumSuffix, data.getLabels(), scheme); + writeDouble(writer, data.getSum()); + writeScrapeTimestampAndExemplar(writer, data, null, scheme); + } + + private void writeClassicCreated( + Writer writer, + String name, + HistogramSnapshot.HistogramDataPointSnapshot data, + EscapingScheme scheme) + throws IOException { + if (createdTimestampsEnabled && data.hasCreatedTimestamp()) { + writeNameAndLabels(writer, name, "_created", data.getLabels(), scheme); + writeOpenMetricsTimestamp(writer, data.getCreatedTimestampMillis()); + if (data.hasScrapeTimestamp()) { + writer.write(' '); + writeOpenMetricsTimestamp(writer, data.getScrapeTimestampMillis()); + } + writer.write('\n'); + } + } + private void writeCompositeHistogramDataPoint( Writer writer, String name, @@ -398,7 +482,7 @@ private void writeSummary(Writer writer, SummarySnapshot snapshot, EscapingSchem } boolean metadataWritten = false; MetricMetadata metadata = snapshot.getMetadata(); - String name = getExpositionBaseMetadataName(metadata, scheme); + String name = getOriginalMetadataName(metadata, scheme); for (SummarySnapshot.SummaryDataPointSnapshot data : snapshot.getDataPoints()) { if (data.getQuantiles().size() == 0 && !data.hasCount() && !data.hasSum()) { continue; @@ -465,7 +549,7 @@ private void writeInfo(Writer writer, InfoSnapshot snapshot, EscapingScheme sche MetricMetadata metadata = snapshot.getMetadata(); // OM2 spec: Info MetricFamily name MUST end in _info. // In OM2, TYPE/HELP use the same name as the data lines. - String infoName = ensureSuffix(getExpositionBaseMetadataName(metadata, scheme), "_info"); + String infoName = ensureSuffix(getOriginalMetadataName(metadata, scheme), "_info"); writeMetadataWithName(writer, infoName, "info", metadata); for (InfoSnapshot.InfoDataPointSnapshot data : snapshot.getDataPoints()) { writeNameAndLabels(writer, infoName, null, data.getLabels(), scheme); @@ -477,7 +561,7 @@ private void writeInfo(Writer writer, InfoSnapshot snapshot, EscapingScheme sche private void writeStateSet(Writer writer, StateSetSnapshot snapshot, EscapingScheme scheme) throws IOException { MetricMetadata metadata = snapshot.getMetadata(); - String name = getExpositionBaseMetadataName(metadata, scheme); + String name = getOriginalMetadataName(metadata, scheme); writeMetadataWithName(writer, name, "stateset", metadata); for (StateSetSnapshot.StateSetDataPointSnapshot data : snapshot.getDataPoints()) { for (int i = 0; i < data.size(); i++) { @@ -513,7 +597,7 @@ private void writeStateSet(Writer writer, StateSetSnapshot snapshot, EscapingSch private void writeUnknown(Writer writer, UnknownSnapshot snapshot, EscapingScheme scheme) throws IOException { MetricMetadata metadata = snapshot.getMetadata(); - String name = getExpositionBaseMetadataName(metadata, scheme); + String name = getOriginalMetadataName(metadata, scheme); writeMetadataWithName(writer, name, "unknown", metadata); for (UnknownSnapshot.UnknownDataPointSnapshot data : snapshot.getDataPoints()) { writeNameAndLabels(writer, name, null, data.getLabels(), scheme); diff --git a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/OpenMetrics2TextFormatWriterTest.java b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/OpenMetrics2TextFormatWriterTest.java index 504f16e1a..83c815c00 100644 --- a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/OpenMetrics2TextFormatWriterTest.java +++ b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/OpenMetrics2TextFormatWriterTest.java @@ -17,7 +17,6 @@ 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.Unit; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -88,33 +87,6 @@ void testGetOpenMetrics2Properties() { assertThat(writer.getOpenMetrics2Properties().getCompositeValues()).isTrue(); } - @Test - void testCounterNoTotalSuffix() throws IOException { - MetricSnapshots snapshots = - MetricSnapshots.of( - CounterSnapshot.builder() - .name("my_counter_seconds") - .help("Test counter") - .unit(Unit.SECONDS) - .dataPoint( - CounterSnapshot.CounterDataPointSnapshot.builder() - .value(42.0) - .labels(Labels.of("method", "GET")) - .build()) - .build()); - - String om2Output = writeWithOM2(snapshots); - - // OM2: name as provided, no _total appending - assertThat(om2Output) - .isEqualTo( - "# TYPE my_counter_seconds counter\n" - + "# UNIT my_counter_seconds seconds\n" - + "# HELP my_counter_seconds Test counter\n" - + "my_counter_seconds{method=\"GET\"} 42.0\n" - + "# EOF\n"); - } - @Test void testCounterWithTotalSuffix() throws IOException { MetricSnapshots snapshots = @@ -684,47 +656,6 @@ void testNativeGaugeHistogramWithNegativeAndPositiveSpans() throws IOException { + "# EOF\n"); } - @Test - void testNativeHistogramWithDots() throws IOException { - Exemplar exemplar = - Exemplar.builder() - .labels(Labels.of("some.exemplar.key", "some value")) - .value(3.0) - .timestampMillis(1690298864383L) - .build(); - - MetricSnapshots snapshots = - MetricSnapshots.of( - HistogramSnapshot.builder() - .name("my.request.duration.seconds") - .help("Request duration in seconds") - .unit(Unit.SECONDS) - .dataPoint( - HistogramSnapshot.HistogramDataPointSnapshot.builder() - .labels(Labels.builder().label("http.path", "/hello").build()) - .sum(3.2) - .nativeSchema(5) - .nativeZeroCount(1) - .nativeBucketsForPositiveValues( - NativeHistogramBuckets.builder().bucket(2, 3).build()) - .exemplars(Exemplars.of(exemplar)) - .build()) - .build()); - - String output = writeWithNativeHistograms(snapshots); - - assertThat(output) - .isEqualTo( - "# TYPE \"my.request.duration.seconds\" histogram\n" - + "# UNIT \"my.request.duration.seconds\" seconds\n" - + "# HELP \"my.request.duration.seconds\" Request duration in seconds\n" - + "{\"my.request.duration.seconds\",\"http.path\"=\"/hello\"}" - + " {count:4,sum:3.2,schema:5,zero_threshold:0.0,zero_count:1," - + "positive_spans:[2:1],positive_buckets:[3]}" - + " # {\"some.exemplar.key\"=\"some value\"} 3.0 1690298864.383\n" - + "# EOF\n"); - } - @Test void testCompositeSummary() throws IOException { MetricSnapshots snapshots = @@ -911,6 +842,6 @@ private String write(MetricSnapshots snapshots, ExpositionFormatWriter writer) throws IOException { ByteArrayOutputStream out = new ByteArrayOutputStream(); writer.write(out, snapshots, EscapingScheme.ALLOW_UTF8); - return out.toString(StandardCharsets.UTF_8); + return out.toString(StandardCharsets.UTF_8.name()); } } diff --git a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/SnapshotEscaper.java b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/SnapshotEscaper.java index b4f69e9bb..c3336af17 100644 --- a/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/SnapshotEscaper.java +++ b/prometheus-metrics-model/src/main/java/io/prometheus/metrics/model/snapshots/SnapshotEscaper.java @@ -105,6 +105,14 @@ public static String getExpositionBaseMetadataName( } } + public static String getOriginalMetadataName(MetricMetadata metadata, EscapingScheme scheme) { + if (scheme == EscapingScheme.UNDERSCORE_ESCAPING) { + return PrometheusNaming.prometheusName(metadata.getOriginalName()); + } else { + return metadata.getOriginalName(); + } + } + public static Labels escapeLabels(Labels labels, EscapingScheme scheme) { Labels.Builder outLabelsBuilder = Labels.builder();