From 37e92ca4f927b10b5121dc3d5a331073e55a006d Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 23 Apr 2026 09:03:06 +0000 Subject: [PATCH 1/5] Add OM2 native histogram text output Signed-off-by: Gregor Zeitlinger --- .../OpenMetrics2TextFormatWriter.java | 156 +++++++++-- .../ExpositionFormatsTest.java | 41 +++ .../OpenMetrics2TextFormatWriterTest.java | 258 +++++++++++++++++- 3 files changed, 434 insertions(+), 21 deletions(-) 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 3ccdc80d8..35abcc064 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 @@ -15,6 +15,7 @@ import io.prometheus.metrics.model.snapshots.CounterSnapshot; import io.prometheus.metrics.model.snapshots.DataPointSnapshot; 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.HistogramSnapshot; import io.prometheus.metrics.model.snapshots.InfoSnapshot; @@ -22,6 +23,7 @@ import io.prometheus.metrics.model.snapshots.MetricMetadata; import io.prometheus.metrics.model.snapshots.MetricSnapshot; import io.prometheus.metrics.model.snapshots.MetricSnapshots; +import io.prometheus.metrics.model.snapshots.NativeHistogramBuckets; import io.prometheus.metrics.model.snapshots.PrometheusNaming; import io.prometheus.metrics.model.snapshots.Quantile; import io.prometheus.metrics.model.snapshots.SnapshotEscaper; @@ -200,7 +202,9 @@ private void writeGauge(Writer writer, GaugeSnapshot snapshot, EscapingScheme sc private void writeHistogram(Writer writer, HistogramSnapshot snapshot, EscapingScheme scheme) throws IOException { - if (!openMetrics2Properties.getCompositeValues() + boolean compositeHistogram = + openMetrics2Properties.getCompositeValues() || openMetrics2Properties.getNativeHistograms(); + if (!compositeHistogram && !openMetrics2Properties.getExemplarCompliance()) { om1Writer.writeHistogram(writer, snapshot, scheme); return; @@ -210,12 +214,20 @@ private void writeHistogram(Writer writer, HistogramSnapshot snapshot, EscapingS if (snapshot.isGaugeHistogram()) { writeMetadataWithName(writer, name, "gaugehistogram", metadata); for (HistogramSnapshot.HistogramDataPointSnapshot data : snapshot.getDataPoints()) { - writeCompositeHistogramDataPoint(writer, name, "gcount", "gsum", data, scheme); + if (openMetrics2Properties.getNativeHistograms() && data.hasNativeHistogramData()) { + writeNativeHistogramDataPoint(writer, name, "gcount", "gsum", data, scheme, false); + } else { + writeCompositeHistogramDataPoint(writer, name, "gcount", "gsum", data, scheme, false); + } } } else { writeMetadataWithName(writer, name, "histogram", metadata); for (HistogramSnapshot.HistogramDataPointSnapshot data : snapshot.getDataPoints()) { - writeCompositeHistogramDataPoint(writer, name, "count", "sum", data, scheme); + if (openMetrics2Properties.getNativeHistograms() && data.hasNativeHistogramData()) { + writeNativeHistogramDataPoint(writer, name, "count", "sum", data, scheme, true); + } else { + writeCompositeHistogramDataPoint(writer, name, "count", "sum", data, scheme, true); + } } } } @@ -226,7 +238,8 @@ private void writeCompositeHistogramDataPoint( String countKey, String sumKey, HistogramSnapshot.HistogramDataPointSnapshot data, - EscapingScheme scheme) + EscapingScheme scheme, + boolean includeStartTimestamp) throws IOException { writeNameAndLabels(writer, name, null, data.getLabels(), scheme); writer.write('{'); @@ -237,28 +250,59 @@ private void writeCompositeHistogramDataPoint( writer.write(sumKey); writer.write(':'); writeDouble(writer, data.getSum()); - writer.write(",bucket:["); - ClassicHistogramBuckets buckets = getClassicBuckets(data); - long cumulativeCount = 0; - for (int i = 0; i < buckets.size(); i++) { - if (i > 0) { - writer.write(','); - } - cumulativeCount += buckets.getCount(i); - writeDouble(writer, buckets.getUpperBound(i)); - writer.write(':'); - writeLong(writer, cumulativeCount); + writeClassicBucketsField(writer, data); + writer.write('}'); + if (data.hasScrapeTimestamp()) { + writer.write(' '); + writeOpenMetricsTimestamp(writer, data.getScrapeTimestampMillis()); + } + if (includeStartTimestamp && data.hasCreatedTimestamp()) { + writer.write(" st@"); + writeOpenMetricsTimestamp(writer, data.getCreatedTimestampMillis()); + } + writeExemplars(writer, data.getExemplars(), scheme); + writer.write('\n'); + } + + private void writeNativeHistogramDataPoint( + Writer writer, + String name, + String countKey, + String sumKey, + HistogramSnapshot.HistogramDataPointSnapshot data, + EscapingScheme scheme, + boolean includeStartTimestamp) + throws IOException { + writeNameAndLabels(writer, name, null, data.getLabels(), scheme); + writer.write('{'); + writer.write(countKey); + writer.write(':'); + writeLong(writer, data.getCount()); + writer.write(','); + writer.write(sumKey); + writer.write(':'); + writeDouble(writer, data.getSum()); + writer.write(",schema:"); + writer.write(Integer.toString(data.getNativeSchema())); + writer.write(",zero_threshold:"); + writeDouble(writer, data.getNativeZeroThreshold()); + writer.write(",zero_count:"); + writeLong(writer, data.getNativeZeroCount()); + writeNativeBucketFields(writer, "negative", data.getNativeBucketsForNegativeValues()); + writeNativeBucketFields(writer, "positive", data.getNativeBucketsForPositiveValues()); + if (data.hasClassicHistogramData()) { + writeClassicBucketsField(writer, data); } - writer.write("]}"); + writer.write('}'); if (data.hasScrapeTimestamp()) { writer.write(' '); writeOpenMetricsTimestamp(writer, data.getScrapeTimestampMillis()); } - if (data.hasCreatedTimestamp()) { + if (includeStartTimestamp && data.hasCreatedTimestamp()) { writer.write(" st@"); writeOpenMetricsTimestamp(writer, data.getCreatedTimestampMillis()); } - writeExemplar(writer, data.getExemplars().getLatest(), scheme); + writeExemplars(writer, data.getExemplars(), scheme); writer.write('\n'); } @@ -272,6 +316,75 @@ private ClassicHistogramBuckets getClassicBuckets( } } + private void writeClassicBucketsField( + Writer writer, HistogramSnapshot.HistogramDataPointSnapshot data) throws IOException { + writer.write(",bucket:["); + ClassicHistogramBuckets buckets = getClassicBuckets(data); + long cumulativeCount = 0; + for (int i = 0; i < buckets.size(); i++) { + if (i > 0) { + writer.write(','); + } + cumulativeCount += buckets.getCount(i); + writeDouble(writer, buckets.getUpperBound(i)); + writer.write(':'); + writeLong(writer, cumulativeCount); + } + writer.write(']'); + } + + private void writeNativeBucketFields( + Writer writer, String prefix, NativeHistogramBuckets buckets) throws IOException { + if (buckets.size() == 0) { + return; + } + writer.write(','); + writer.write(prefix); + writer.write("_spans:["); + writeNativeBucketSpans(writer, buckets); + writer.write("],"); + writer.write(prefix); + writer.write("_buckets:["); + for (int i = 0; i < buckets.size(); i++) { + if (i > 0) { + writer.write(','); + } + writeLong(writer, buckets.getCount(i)); + } + writer.write(']'); + } + + private void writeNativeBucketSpans(Writer writer, NativeHistogramBuckets buckets) + throws IOException { + int spanOffset = buckets.getBucketIndex(0); + int spanLength = 1; + int previousIndex = buckets.getBucketIndex(0); + boolean firstSpan = true; + for (int i = 1; i < buckets.size(); i++) { + int bucketIndex = buckets.getBucketIndex(i); + if (bucketIndex == previousIndex + 1) { + spanLength++; + } else { + firstSpan = writeNativeBucketSpan(writer, spanOffset, spanLength, firstSpan); + spanOffset = bucketIndex - previousIndex - 1; + spanLength = 1; + } + previousIndex = bucketIndex; + } + writeNativeBucketSpan(writer, spanOffset, spanLength, firstSpan); + } + + private boolean writeNativeBucketSpan( + Writer writer, int offset, int length, boolean firstSpan) throws IOException { + if (!firstSpan) { + writer.write(','); + } + writer.write(Integer.toString(offset)); + writer.write(':'); + writer.write(Integer.toString(length)); + return false; + } + private void writeSummary(Writer writer, SummarySnapshot snapshot, EscapingScheme scheme) throws IOException { if (!openMetrics2Properties.getCompositeValues() @@ -496,6 +609,13 @@ private void writeExemplar(Writer writer, @Nullable Exemplar exemplar, EscapingS } } + private void writeExemplars(Writer writer, Exemplars exemplars, EscapingScheme scheme) + throws IOException { + for (Exemplar exemplar : exemplars) { + writeExemplar(writer, exemplar, scheme); + } + } + private void writeMetadataWithName( Writer writer, String name, String typeName, MetricMetadata metadata) throws IOException { writer.write("# TYPE "); diff --git a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java index e0782251e..454b3ef7b 100644 --- a/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java +++ b/prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java @@ -160,6 +160,47 @@ void testOM2EnabledWithFeatureFlags() { assertThat(writer).isInstanceOf(OpenMetrics2TextFormatWriter.class); } + @Test + void testOM2ContentNegotiationWithNativeHistogramOutput() throws IOException { + PrometheusProperties props = + PrometheusProperties.builder() + .openMetrics2Properties( + OpenMetrics2Properties.builder() + .enabled(true) + .contentNegotiation(true) + .nativeHistograms(true) + .build()) + .build(); + ExpositionFormats formats = ExpositionFormats.init(props); + ExpositionFormatWriter writer = + formats.findWriter("application/openmetrics-text; version=2.0.0"); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writer.write( + out, + MetricSnapshots.of( + HistogramSnapshot.builder() + .name("latency_seconds") + .dataPoint( + HistogramSnapshot.HistogramDataPointSnapshot.builder() + .sum(1.5) + .nativeSchema(5) + .nativeZeroCount(1) + .nativeBucketsForPositiveValues( + NativeHistogramBuckets.builder().bucket(2, 3).build()) + .build()) + .build()), + EscapingScheme.ALLOW_UTF8); + + assertThat(writer).isInstanceOf(OpenMetrics2TextFormatWriter.class); + assertThat(out.toString(UTF_8)) + .isEqualTo( + "# TYPE latency_seconds histogram\n" + + "latency_seconds {count:4,sum:1.5,schema:5,zero_threshold:0.0,zero_count:1," + + "positive_spans:[2:1],positive_buckets:[3]}\n" + + "# EOF\n"); + } + @Test void testProtobufWriterTakesPrecedence() { PrometheusProperties props = 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 483bf1339..d7e87734b 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 @@ -13,6 +13,7 @@ import io.prometheus.metrics.model.snapshots.InfoSnapshot; import io.prometheus.metrics.model.snapshots.Labels; import io.prometheus.metrics.model.snapshots.MetricSnapshots; +import io.prometheus.metrics.model.snapshots.NativeHistogramBuckets; import io.prometheus.metrics.model.snapshots.Quantiles; import io.prometheus.metrics.model.snapshots.StateSetSnapshot; import io.prometheus.metrics.model.snapshots.SummarySnapshot; @@ -392,8 +393,10 @@ void testCompositeHistogramWithLabelsTimestampAndCreated() throws IOException { @Test void testCompositeHistogramWithExemplar() throws IOException { - Exemplar exemplar = + Exemplar exemplar1 = Exemplar.builder().value(0.67).traceId("shaZ8oxi").timestampMillis(1520879607789L).build(); + Exemplar exemplar2 = + Exemplar.builder().value(1.2).traceId("ookahn0M").timestampMillis(1520879608589L).build(); MetricSnapshots snapshots = MetricSnapshots.of( @@ -407,7 +410,7 @@ void testCompositeHistogramWithExemplar() throws IOException { .bucket(1.0, 1) .bucket(Double.POSITIVE_INFINITY, 0) .build()) - .exemplars(Exemplars.of(exemplar)) + .exemplars(Exemplars.of(exemplar1, exemplar2)) .build()) .build()); @@ -417,7 +420,8 @@ void testCompositeHistogramWithExemplar() throws IOException { .isEqualTo( "# TYPE foo histogram\n" + "foo {count:1,sum:1.5,bucket:[1.0:1,+Inf:1]}" - + " # {trace_id=\"shaZ8oxi\"} 0.67 1520879607.789\n" + + " # {trace_id=\"shaZ8oxi\"} 0.67 1520879607.789" + + " # {trace_id=\"ookahn0M\"} 1.2 1520879608.589\n" + "# EOF\n"); } @@ -450,6 +454,245 @@ void testCompositeGaugeHistogram() throws IOException { + "# EOF\n"); } + @Test + void testNativeHistogram() throws IOException { + Exemplar exemplar1 = + Exemplar.builder().value(0.67).traceId("shaZ8oxi").timestampMillis(1520879607789L).build(); + Exemplar exemplar2 = + Exemplar.builder().value(1.2).traceId("ookahn0M").timestampMillis(1520879608589L).build(); + + MetricSnapshots snapshots = + MetricSnapshots.of( + HistogramSnapshot.builder() + .name("foo") + .help("Native histogram") + .dataPoint( + HistogramSnapshot.HistogramDataPointSnapshot.builder() + .sum(324789.3) + .nativeSchema(0) + .nativeZeroThreshold(0.0001) + .nativeZeroCount(0) + .nativeBucketsForPositiveValues( + NativeHistogramBuckets.builder().bucket(0, 5).bucket(1, 12).build()) + .scrapeTimestampMillis(1520879609000L) + .createdTimestampMillis(1520430000000L) + .exemplars(Exemplars.of(exemplar1, exemplar2)) + .build()) + .build()); + + String output = writeWithNativeHistograms(snapshots); + + assertThat(output) + .isEqualTo( + "# TYPE foo histogram\n" + + "# HELP foo Native histogram\n" + + "foo {count:17,sum:324789.3,schema:0,zero_threshold:1.0E-4,zero_count:0," + + "positive_spans:[0:2],positive_buckets:[5,12]} 1520879609.000" + + " st@1520430000.000 # {trace_id=\"shaZ8oxi\"} 0.67 1520879607.789" + + " # {trace_id=\"ookahn0M\"} 1.2 1520879608.589\n" + + "# EOF\n"); + } + + @Test + void testNativeHistogramWithClassicBuckets() throws IOException { + MetricSnapshots snapshots = + MetricSnapshots.of( + HistogramSnapshot.builder() + .name("http_request_duration_seconds") + .dataPoint( + HistogramSnapshot.HistogramDataPointSnapshot.builder() + .sum(4.0) + .classicHistogramBuckets( + ClassicHistogramBuckets.builder() + .bucket(0.5, 1) + .bucket(1.0, 7) + .bucket(Double.POSITIVE_INFINITY, 14) + .build()) + .nativeSchema(3) + .nativeZeroThreshold(0.0) + .nativeZeroCount(1) + .nativeBucketsForNegativeValues( + NativeHistogramBuckets.builder().bucket(1, 2).bucket(2, 4).build()) + .nativeBucketsForPositiveValues( + NativeHistogramBuckets.builder() + .bucket(-1, 5) + .bucket(0, 7) + .bucket(4, 3) + .build()) + .build()) + .build()); + + String output = writeWithNativeHistograms(snapshots); + + assertThat(output) + .isEqualTo( + "# TYPE http_request_duration_seconds histogram\n" + + "http_request_duration_seconds" + + " {count:22,sum:4.0,schema:3,zero_threshold:0.0,zero_count:1," + + "negative_spans:[1:2],negative_buckets:[2,4]," + + "positive_spans:[-1:2,3:1],positive_buckets:[5,7,3]," + + "bucket:[0.5:1,1.0:8,+Inf:22]}\n" + + "# EOF\n"); + } + + @Test + void testNativeGaugeHistogram() throws IOException { + MetricSnapshots snapshots = + MetricSnapshots.of( + HistogramSnapshot.builder() + .name("queue_size") + .gaugeHistogram(true) + .dataPoint( + HistogramSnapshot.HistogramDataPointSnapshot.builder() + .sum(4.5) + .nativeSchema(2) + .nativeZeroThreshold(0.0) + .nativeZeroCount(1) + .nativeBucketsForPositiveValues( + NativeHistogramBuckets.builder().bucket(0, 1).bucket(1, 1).build()) + .createdTimestampMillis(1520430000000L) + .build()) + .build()); + + String output = writeWithNativeHistograms(snapshots); + + assertThat(output) + .isEqualTo( + "# TYPE queue_size gaugehistogram\n" + + "queue_size {gcount:3,gsum:4.5,schema:2,zero_threshold:0.0,zero_count:1," + + "positive_spans:[0:2],positive_buckets:[1,1]}\n" + + "# EOF\n"); + } + + @Test + void testNativeHistogramExemplarComplianceSkipsExemplarWithoutTimestamp() throws IOException { + Exemplar exemplarWithoutTs = Exemplar.builder().value(2.0).traceId("bbb").build(); + OpenMetrics2TextFormatWriter complianceWriter = + OpenMetrics2TextFormatWriter.builder() + .setOpenMetrics2Properties( + OpenMetrics2Properties.builder() + .nativeHistograms(true) + .exemplarCompliance(true) + .build()) + .build(); + + MetricSnapshots snapshots = + MetricSnapshots.of( + HistogramSnapshot.builder() + .name("requests") + .dataPoint( + HistogramSnapshot.HistogramDataPointSnapshot.builder() + .sum(2.0) + .nativeSchema(5) + .nativeBucketsForPositiveValues( + NativeHistogramBuckets.builder().bucket(0, 1).build()) + .exemplars(Exemplars.of(exemplarWithoutTs)) + .build()) + .build()); + + String output = write(snapshots, complianceWriter); + assertThat(output) + .isEqualTo( + "# TYPE requests histogram\n" + + "requests {count:1,sum:2.0,schema:5,zero_threshold:0.0,zero_count:0," + + "positive_spans:[0:1],positive_buckets:[1]}\n" + + "# EOF\n"); + } + + @Test + void testNativeHistogramMinimalOm2() throws IOException { + MetricSnapshots snapshots = + MetricSnapshots.of( + HistogramSnapshot.builder() + .name("latency_seconds") + .dataPoint( + HistogramSnapshot.HistogramDataPointSnapshot.builder() + .sum(0.0) + .nativeSchema(5) + .build()) + .build()); + + String output = writeWithNativeHistograms(snapshots); + + assertThat(output) + .isEqualTo( + "# TYPE latency_seconds histogram\n" + + "latency_seconds {count:0,sum:0.0,schema:5,zero_threshold:0.0,zero_count:0}\n" + + "# EOF\n"); + } + + @Test + void testNativeGaugeHistogramWithNegativeAndPositiveSpans() throws IOException { + MetricSnapshots snapshots = + MetricSnapshots.of( + HistogramSnapshot.builder() + .name("temperature_delta") + .gaugeHistogram(true) + .dataPoint( + HistogramSnapshot.HistogramDataPointSnapshot.builder() + .sum(0.5) + .nativeSchema(1) + .nativeZeroThreshold(0.25) + .nativeZeroCount(3) + .nativeBucketsForNegativeValues( + NativeHistogramBuckets.builder().bucket(0, 2).bucket(2, 4).build()) + .nativeBucketsForPositiveValues( + NativeHistogramBuckets.builder().bucket(1, 1).bucket(2, 5).build()) + .build()) + .build()); + + String output = writeWithNativeHistograms(snapshots); + + assertThat(output) + .isEqualTo( + "# TYPE temperature_delta gaugehistogram\n" + + "temperature_delta {gcount:15,gsum:0.5,schema:1,zero_threshold:0.25," + + "zero_count:3,negative_spans:[0:1,1:1],negative_buckets:[2,4]," + + "positive_spans:[1:2],positive_buckets:[1,5]}\n" + + "# 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 = @@ -610,6 +853,15 @@ private String writeWithCompositeValues(MetricSnapshots snapshots) throws IOExce return write(snapshots, writer); } + private String writeWithNativeHistograms(MetricSnapshots snapshots) throws IOException { + OpenMetrics2TextFormatWriter writer = + OpenMetrics2TextFormatWriter.builder() + .setOpenMetrics2Properties( + OpenMetrics2Properties.builder().nativeHistograms(true).build()) + .build(); + return write(snapshots, writer); + } + private String writeWithOM1(MetricSnapshots snapshots) throws IOException { OpenMetricsTextFormatWriter writer = OpenMetricsTextFormatWriter.create(); return write(snapshots, writer); From fc803e61b9254c5b9508ecfccd4d8ae5d69734bb Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 23 Apr 2026 09:04:08 +0000 Subject: [PATCH 2/5] Format OM2 native histogram changes Signed-off-by: Gregor Zeitlinger --- .../OpenMetrics2TextFormatWriter.java | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 35abcc064..81894fe97 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 @@ -204,8 +204,7 @@ private void writeHistogram(Writer writer, HistogramSnapshot snapshot, EscapingS throws IOException { boolean compositeHistogram = openMetrics2Properties.getCompositeValues() || openMetrics2Properties.getNativeHistograms(); - if (!compositeHistogram - && !openMetrics2Properties.getExemplarCompliance()) { + if (!compositeHistogram && !openMetrics2Properties.getExemplarCompliance()) { om1Writer.writeHistogram(writer, snapshot, scheme); return; } @@ -333,8 +332,8 @@ private void writeClassicBucketsField( writer.write(']'); } - private void writeNativeBucketFields( - Writer writer, String prefix, NativeHistogramBuckets buckets) throws IOException { + private void writeNativeBucketFields(Writer writer, String prefix, NativeHistogramBuckets buckets) + throws IOException { if (buckets.size() == 0) { return; } @@ -374,8 +373,8 @@ private void writeNativeBucketSpans(Writer writer, NativeHistogramBuckets bucket writeNativeBucketSpan(writer, spanOffset, spanLength, firstSpan); } - private boolean writeNativeBucketSpan( - Writer writer, int offset, int length, boolean firstSpan) throws IOException { + private boolean writeNativeBucketSpan(Writer writer, int offset, int length, boolean firstSpan) + throws IOException { if (!firstSpan) { writer.write(','); } From ad6766ab6de2f9ce8cab2d040274c9662543137d Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 24 Apr 2026 12:11:12 +0000 Subject: [PATCH 3/5] fix: align OM2 counters and summaries with current output Signed-off-by: Gregor Zeitlinger --- .../expositionformats/ExpositionFormats.java | 4 +- .../OpenMetrics2TextFormatWriter.java | 67 +++++++++---------- .../OpenMetrics2TextFormatWriterTest.java | 18 ++--- 3 files changed, 41 insertions(+), 48 deletions(-) diff --git a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormats.java b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormats.java index 3863b5b74..feabd89b8 100644 --- a/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormats.java +++ b/prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormats.java @@ -69,9 +69,9 @@ public ExpositionFormatWriter findWriter(@Nullable String acceptHeader) { if ("2.0.0".equals(version)) { return openMetrics2TextFormatWriter; } - // version=1.0.0 or no version: fall through to OM1 + // version=1.0.0 or no version: fall through to OM1. } else { - // contentNegotiation=false: OM2 handles all OpenMetrics requests + // contentNegotiation=false: OM2 handles all OpenMetrics requests. return openMetrics2TextFormatWriter; } } 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 81894fe97..06cb1e7b4 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 @@ -128,8 +128,8 @@ public boolean accepts(@Nullable String acceptHeader) { @Override public String getContentType() { - // When contentNegotiation=false (default), masquerade as OM1 for compatibility - // When contentNegotiation=true, use proper OM2 version + // When contentNegotiation=false (default), masquerade as OM1 for compatibility. + // When contentNegotiation=true, use proper OM2 version. if (openMetrics2Properties.getContentNegotiation()) { return CONTENT_TYPE; } else { @@ -179,8 +179,16 @@ private void writeCounter(Writer writer, CounterSnapshot snapshot, EscapingSchem for (CounterSnapshot.CounterDataPointSnapshot data : snapshot.getDataPoints()) { writeNameAndLabels(writer, counterName, null, data.getLabels(), scheme); writeDouble(writer, data.getValue()); - writeScrapeTimestampAndExemplar(writer, data, data.getExemplar(), scheme); - writeCreated(writer, counterName, data, scheme); + if (data.hasScrapeTimestamp()) { + writer.write(' '); + writeOpenMetricsTimestamp(writer, data.getScrapeTimestampMillis()); + } + if (createdTimestampsEnabled && data.hasCreatedTimestamp()) { + writer.write(" st@"); + writeOpenMetricsTimestamp(writer, data.getCreatedTimestampMillis()); + } + writeExemplar(writer, data.getExemplar(), scheme); + writer.write('\n'); } } @@ -204,7 +212,8 @@ private void writeHistogram(Writer writer, HistogramSnapshot snapshot, EscapingS throws IOException { boolean compositeHistogram = openMetrics2Properties.getCompositeValues() || openMetrics2Properties.getNativeHistograms(); - if (!compositeHistogram && !openMetrics2Properties.getExemplarCompliance()) { + if (!compositeHistogram + && !openMetrics2Properties.getExemplarCompliance()) { om1Writer.writeHistogram(writer, snapshot, scheme); return; } @@ -332,8 +341,8 @@ private void writeClassicBucketsField( writer.write(']'); } - private void writeNativeBucketFields(Writer writer, String prefix, NativeHistogramBuckets buckets) - throws IOException { + private void writeNativeBucketFields( + Writer writer, String prefix, NativeHistogramBuckets buckets) throws IOException { if (buckets.size() == 0) { return; } @@ -373,8 +382,8 @@ private void writeNativeBucketSpans(Writer writer, NativeHistogramBuckets bucket writeNativeBucketSpan(writer, spanOffset, spanLength, firstSpan); } - private boolean writeNativeBucketSpan(Writer writer, int offset, int length, boolean firstSpan) - throws IOException { + private boolean writeNativeBucketSpan( + Writer writer, int offset, int length, boolean firstSpan) throws IOException { if (!firstSpan) { writer.write(','); } @@ -428,22 +437,20 @@ private void writeCompositeSummaryDataPoint( writeDouble(writer, data.getSum()); first = false; } - if (data.getQuantiles().size() > 0) { - if (!first) { + if (!first) { + writer.write(','); + } + writer.write("quantile:["); + for (int i = 0; i < data.getQuantiles().size(); i++) { + if (i > 0) { writer.write(','); } - writer.write("quantile:["); - for (int i = 0; i < data.getQuantiles().size(); i++) { - if (i > 0) { - writer.write(','); - } - Quantile q = data.getQuantiles().get(i); - writeDouble(writer, q.getQuantile()); - writer.write(':'); - writeDouble(writer, q.getValue()); - } - writer.write(']'); + Quantile q = data.getQuantiles().get(i); + writeDouble(writer, q.getQuantile()); + writer.write(':'); + writeDouble(writer, q.getValue()); } + writer.write(']'); writer.write('}'); if (data.hasScrapeTimestamp()) { writer.write(' '); @@ -453,7 +460,7 @@ private void writeCompositeSummaryDataPoint( writer.write(" st@"); writeOpenMetricsTimestamp(writer, data.getCreatedTimestampMillis()); } - writeExemplar(writer, data.getExemplars().getLatest(), scheme); + writeExemplars(writer, data.getExemplars(), scheme); writer.write('\n'); } @@ -523,20 +530,6 @@ private void writeUnknown(Writer writer, UnknownSnapshot snapshot, EscapingSchem } } - private void writeCreated( - Writer writer, String name, DataPointSnapshot 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 writeNameAndLabels( Writer writer, String name, 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 d7e87734b..64da453bb 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 @@ -33,7 +33,6 @@ void testContentTypeWithContentNegotiationDisabled() { OpenMetrics2Properties.builder().contentNegotiation(false).build()) .build(); - // Should masquerade as OM1 when contentNegotiation is disabled assertThat(writer.getContentType()) .isEqualTo("application/openmetrics-text; version=1.0.0; charset=utf-8"); } @@ -54,7 +53,6 @@ void testContentTypeWithContentNegotiationEnabled() { void testContentTypeDefault() { OpenMetrics2TextFormatWriter writer = OpenMetrics2TextFormatWriter.create(); - // Default should masquerade as OM1 for compatibility assertThat(writer.getContentType()) .isEqualTo("application/openmetrics-text; version=1.0.0; charset=utf-8"); } @@ -308,13 +306,12 @@ void testCounterWithCreatedTimestamps() throws IOException { String om2Output = write(snapshots, om2Writer); - // OM2: no _total, _created uses the counter name directly + // OM2: no _total, start timestamp uses st@ inline. assertThat(om2Output) .isEqualTo( "# TYPE my_counter counter\n" + "# HELP my_counter Test counter\n" - + "my_counter 42.0\n" - + "my_counter_created 1672850385.800\n" + + "my_counter 42.0 st@1672850385.800\n" + "# EOF\n"); } @@ -722,8 +719,10 @@ void testCompositeSummary() throws IOException { @Test void testCompositeSummaryWithCreatedAndExemplar() throws IOException { - Exemplar exemplar = + Exemplar exemplar1 = Exemplar.builder().value(0.5).traceId("abc123").timestampMillis(1520879607000L).build(); + Exemplar exemplar2 = + Exemplar.builder().value(1.5).traceId("def456").timestampMillis(1520879608000L).build(); MetricSnapshots snapshots = MetricSnapshots.of( @@ -734,7 +733,7 @@ void testCompositeSummaryWithCreatedAndExemplar() throws IOException { .count(10) .sum(100.0) .createdTimestampMillis(1520430000000L) - .exemplars(Exemplars.of(exemplar)) + .exemplars(Exemplars.of(exemplar1, exemplar2)) .build()) .build()); @@ -743,8 +742,9 @@ void testCompositeSummaryWithCreatedAndExemplar() throws IOException { assertThat(output) .isEqualTo( "# TYPE rpc_duration_seconds summary\n" - + "rpc_duration_seconds {count:10,sum:100.0} st@1520430000.000" - + " # {trace_id=\"abc123\"} 0.5 1520879607.000\n" + + "rpc_duration_seconds {count:10,sum:100.0,quantile:[]} st@1520430000.000" + + " # {trace_id=\"abc123\"} 0.5 1520879607.000" + + " # {trace_id=\"def456\"} 1.5 1520879608.000\n" + "# EOF\n"); } From ca3571004c2afd7ca77997a407b7bc1fba5dcac4 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 24 Apr 2026 12:11:34 +0000 Subject: [PATCH 4/5] style: apply formatter fixes for OM2 writer Signed-off-by: Gregor Zeitlinger --- .../OpenMetrics2TextFormatWriter.java | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) 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 06cb1e7b4..4406ef7c4 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 @@ -212,8 +212,7 @@ private void writeHistogram(Writer writer, HistogramSnapshot snapshot, EscapingS throws IOException { boolean compositeHistogram = openMetrics2Properties.getCompositeValues() || openMetrics2Properties.getNativeHistograms(); - if (!compositeHistogram - && !openMetrics2Properties.getExemplarCompliance()) { + if (!compositeHistogram && !openMetrics2Properties.getExemplarCompliance()) { om1Writer.writeHistogram(writer, snapshot, scheme); return; } @@ -341,8 +340,8 @@ private void writeClassicBucketsField( writer.write(']'); } - private void writeNativeBucketFields( - Writer writer, String prefix, NativeHistogramBuckets buckets) throws IOException { + private void writeNativeBucketFields(Writer writer, String prefix, NativeHistogramBuckets buckets) + throws IOException { if (buckets.size() == 0) { return; } @@ -382,8 +381,8 @@ private void writeNativeBucketSpans(Writer writer, NativeHistogramBuckets bucket writeNativeBucketSpan(writer, spanOffset, spanLength, firstSpan); } - private boolean writeNativeBucketSpan( - Writer writer, int offset, int length, boolean firstSpan) throws IOException { + private boolean writeNativeBucketSpan(Writer writer, int offset, int length, boolean firstSpan) + throws IOException { if (!firstSpan) { writer.write(','); } From 307cbe3989cafe00b855f105843ffb6311eeffcb Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 24 Apr 2026 16:37:41 +0000 Subject: [PATCH 5/5] fix OM2 counter start timestamp output Signed-off-by: Gregor Zeitlinger --- .../OpenMetrics2TextFormatWriter.java | 9 ++-- .../OpenMetrics2TextFormatWriterTest.java | 45 ++++++++++++++++--- 2 files changed, 43 insertions(+), 11 deletions(-) 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 4406ef7c4..2c2d9f81f 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 @@ -64,7 +64,7 @@ public Builder setOpenMetrics2Properties(OpenMetrics2Properties openMetrics2Prop } /** - * @param createdTimestampsEnabled whether to include the _created timestamp in the output + * @param createdTimestampsEnabled whether delegated OM1 output includes _created metrics */ public Builder setCreatedTimestampsEnabled(boolean createdTimestampsEnabled) { this.createdTimestampsEnabled = createdTimestampsEnabled; @@ -89,14 +89,12 @@ 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; /** * @param openMetrics2Properties OpenMetrics 2.0 feature flags - * @param createdTimestampsEnabled whether to include the _created timestamp in the output - This - * will produce an invalid OpenMetrics output, but is kept for backwards compatibility. + * @param createdTimestampsEnabled whether delegated OM1 output includes _created metrics * @param exemplarsOnAllMetricTypesEnabled whether to include exemplars on all metric types */ public OpenMetrics2TextFormatWriter( @@ -104,7 +102,6 @@ public OpenMetrics2TextFormatWriter( boolean createdTimestampsEnabled, boolean exemplarsOnAllMetricTypesEnabled) { this.openMetrics2Properties = openMetrics2Properties; - this.createdTimestampsEnabled = createdTimestampsEnabled; this.exemplarsOnAllMetricTypesEnabled = exemplarsOnAllMetricTypesEnabled; this.om1Writer = new OpenMetricsTextFormatWriter(createdTimestampsEnabled, exemplarsOnAllMetricTypesEnabled); @@ -183,7 +180,7 @@ private void writeCounter(Writer writer, CounterSnapshot snapshot, EscapingSchem writer.write(' '); writeOpenMetricsTimestamp(writer, data.getScrapeTimestampMillis()); } - if (createdTimestampsEnabled && data.hasCreatedTimestamp()) { + if (data.hasCreatedTimestamp()) { writer.write(" st@"); writeOpenMetricsTimestamp(writer, data.getCreatedTimestampMillis()); } 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 64da453bb..504f16e1a 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 @@ -288,7 +288,7 @@ void testCounterWithExemplars() throws IOException { } @Test - void testCounterWithCreatedTimestamps() throws IOException { + void testCounterStartTimestampWithDefaultWriter() throws IOException { MetricSnapshots snapshots = MetricSnapshots.of( CounterSnapshot.builder() @@ -301,10 +301,7 @@ void testCounterWithCreatedTimestamps() throws IOException { .build()) .build()); - OpenMetrics2TextFormatWriter om2Writer = - OpenMetrics2TextFormatWriter.builder().setCreatedTimestampsEnabled(true).build(); - - String om2Output = write(snapshots, om2Writer); + String om2Output = writeWithOM2(snapshots); // OM2: no _total, start timestamp uses st@ inline. assertThat(om2Output) @@ -315,6 +312,44 @@ void testCounterWithCreatedTimestamps() throws IOException { + "# EOF\n"); } + @Test + void testCounterAndHistogramEmitStartTimestampConsistently() throws IOException { + long createdMs = 1672850385800L; + MetricSnapshots counterSnapshots = + MetricSnapshots.of( + CounterSnapshot.builder() + .name("my_counter") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .value(1.0) + .createdTimestampMillis(createdMs) + .build()) + .build()); + MetricSnapshots histogramSnapshots = + MetricSnapshots.of( + HistogramSnapshot.builder() + .name("my_histogram") + .dataPoint( + HistogramSnapshot.HistogramDataPointSnapshot.builder() + .sum(1.0) + .createdTimestampMillis(createdMs) + .classicHistogramBuckets( + ClassicHistogramBuckets.builder() + .bucket(Double.POSITIVE_INFINITY, 1) + .build()) + .build()) + .build()); + + OpenMetrics2TextFormatWriter writer = + OpenMetrics2TextFormatWriter.builder() + .setOpenMetrics2Properties( + OpenMetrics2Properties.builder().compositeValues(true).build()) + .build(); + + assertThat(write(counterSnapshots, writer)).contains("st@1672850385.800"); + assertThat(write(histogramSnapshots, writer)).contains("st@1672850385.800"); + } + @Test void testEmptySnapshot() throws IOException { MetricSnapshots snapshots = new MetricSnapshots();