From 2128d879b903f30742fdd054aa264de557807e6e Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Thu, 9 Apr 2026 14:54:04 +0000 Subject: [PATCH 1/3] feat: compositeValues and exemplarCompliance flags for OM2 writer - compositeValues=true: write Histogram/GaugeHistogram/Summary as a single composite-value line per the OM2 spec, e.g. foo {count:17,sum:324789.3,bucket:[0.1:8,0.25:10,+Inf:17]} st@0.5 GaugeHistogram uses gcount/gsum per spec. Replaces separate _bucket, _count, _sum, _created lines; created timestamp moves inline as st@. Exemplar (latest) is appended inline when present. - exemplarCompliance=true: skip exemplars without a timestamp, as the OM2 spec mandates timestamps on all exemplars (MUST). Signed-off-by: Gregor Zeitlinger --- .../OpenMetrics2TextFormatWriter.java | 190 +++++++++++--- .../OpenMetrics2TextFormatWriterTest.java | 233 ++++++++++++++++++ 2 files changed, 391 insertions(+), 32 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 02f59b527..7f67ed1c0 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,13 +204,67 @@ private void writeHistogram(Writer writer, HistogramSnapshot snapshot, EscapingS String name = getExpositionBaseMetadataName(metadata, scheme); if (snapshot.isGaugeHistogram()) { writeMetadataWithName(writer, name, "gaugehistogram", metadata); - writeClassicHistogramBuckets( - writer, name, "_gcount", "_gsum", snapshot.getDataPoints(), scheme); + if (openMetrics2Properties.getCompositeValues()) { + for (HistogramSnapshot.HistogramDataPointSnapshot data : snapshot.getDataPoints()) { + writeCompositeHistogramDataPoint(writer, name, "gcount", "gsum", data, scheme); + } + } else { + writeClassicHistogramBuckets( + writer, name, "_gcount", "_gsum", snapshot.getDataPoints(), scheme); + } } else { writeMetadataWithName(writer, name, "histogram", metadata); - writeClassicHistogramBuckets( - writer, name, "_count", "_sum", snapshot.getDataPoints(), scheme); + if (openMetrics2Properties.getCompositeValues()) { + for (HistogramSnapshot.HistogramDataPointSnapshot data : snapshot.getDataPoints()) { + writeCompositeHistogramDataPoint(writer, name, "count", "sum", data, scheme); + } + } else { + writeClassicHistogramBuckets( + writer, name, "_count", "_sum", snapshot.getDataPoints(), scheme); + } + } + } + + private void writeCompositeHistogramDataPoint( + Writer writer, + String name, + String countKey, + String sumKey, + HistogramSnapshot.HistogramDataPointSnapshot data, + EscapingScheme scheme) + 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(",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("]}"); + if (data.hasScrapeTimestamp()) { + writer.write(' '); + writeOpenMetricsTimestamp(writer, data.getScrapeTimestampMillis()); + } + if (data.hasCreatedTimestamp()) { + writer.write(" st@"); + writeOpenMetricsTimestamp(writer, data.getCreatedTimestampMillis()); } + writeExemplarIfAllowed(writer, data.getExemplars().getLatest(), scheme); + writer.write('\n'); } private void writeClassicHistogramBuckets( @@ -269,28 +323,90 @@ private void writeSummary(Writer writer, SummarySnapshot snapshot, EscapingSchem writeMetadataWithName(writer, name, "summary", metadata); metadataWritten = true; } - Exemplars exemplars = data.getExemplars(); - // Exemplars for summaries are new, and there's no best practice yet which Exemplars to choose - // for which - // time series. We select exemplars[0] for _count, exemplars[1] for _sum, and exemplars[2...] - // for the - // quantiles, all indexes modulo exemplars.length. - int exemplarIndex = 1; - for (Quantile quantile : data.getQuantiles()) { - writeNameAndLabels( - writer, name, null, data.getLabels(), scheme, "quantile", quantile.getQuantile()); - writeDouble(writer, quantile.getValue()); - if (exemplars.size() > 0 && exemplarsOnAllMetricTypesEnabled) { - exemplarIndex = (exemplarIndex + 1) % exemplars.size(); - writeScrapeTimestampAndExemplar(writer, data, exemplars.get(exemplarIndex), scheme); - } else { - writeScrapeTimestampAndExemplar(writer, data, null, scheme); + if (openMetrics2Properties.getCompositeValues()) { + writeCompositeSummaryDataPoint(writer, name, data, scheme); + } else { + writeNonCompositeSummaryDataPoint(writer, name, data, scheme); + } + } + } + + private void writeCompositeSummaryDataPoint( + Writer writer, + String name, + SummarySnapshot.SummaryDataPointSnapshot data, + EscapingScheme scheme) + throws IOException { + writeNameAndLabels(writer, name, null, data.getLabels(), scheme); + writer.write('{'); + boolean first = true; + if (data.hasCount()) { + writer.write("count:"); + writeLong(writer, data.getCount()); + first = false; + } + if (data.hasSum()) { + if (!first) { + writer.write(','); + } + writer.write("sum:"); + writeDouble(writer, data.getSum()); + first = false; + } + if (data.getQuantiles().size() > 0) { + if (!first) { + 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(']'); + } + writer.write('}'); + if (data.hasScrapeTimestamp()) { + writer.write(' '); + writeOpenMetricsTimestamp(writer, data.getScrapeTimestampMillis()); + } + if (data.hasCreatedTimestamp()) { + writer.write(" st@"); + writeOpenMetricsTimestamp(writer, data.getCreatedTimestampMillis()); + } + writeExemplarIfAllowed(writer, data.getExemplars().getLatest(), scheme); + writer.write('\n'); + } + + private void writeNonCompositeSummaryDataPoint( + Writer writer, + String name, + SummarySnapshot.SummaryDataPointSnapshot data, + EscapingScheme scheme) + throws IOException { + Exemplars exemplars = data.getExemplars(); + // Exemplars for summaries are new, and there's no best practice yet which Exemplars to choose + // for which time series. We select exemplars[0] for _count, exemplars[1] for _sum, and + // exemplars[2...] for the quantiles, all indexes modulo exemplars.length. + int exemplarIndex = 1; + for (Quantile quantile : data.getQuantiles()) { + writeNameAndLabels( + writer, name, null, data.getLabels(), scheme, "quantile", quantile.getQuantile()); + writeDouble(writer, quantile.getValue()); + if (exemplars.size() > 0 && exemplarsOnAllMetricTypesEnabled) { + exemplarIndex = (exemplarIndex + 1) % exemplars.size(); + writeScrapeTimestampAndExemplar(writer, data, exemplars.get(exemplarIndex), scheme); + } else { + writeScrapeTimestampAndExemplar(writer, data, null, scheme); } - // Unlike histograms, summaries can have only a count or only a sum according to OpenMetrics. - writeCountAndSum(writer, name, data, "_count", "_sum", exemplars, scheme); - writeCreated(writer, name, data, scheme); } + // Unlike histograms, summaries can have only a count or only a sum according to OpenMetrics. + writeCountAndSum(writer, name, data, "_count", "_sum", exemplars, scheme); + writeCreated(writer, name, data, scheme); } private void writeInfo(Writer writer, InfoSnapshot snapshot, EscapingScheme scheme) @@ -446,17 +562,27 @@ private void writeScrapeTimestampAndExemplar( writer.write(' '); writeOpenMetricsTimestamp(writer, data.getScrapeTimestampMillis()); } - if (exemplar != null) { - writer.write(" # "); - writeLabels(writer, exemplar.getLabels(), null, 0, false, scheme); + writeExemplarIfAllowed(writer, exemplar, scheme); + writer.write('\n'); + } + + private void writeExemplarIfAllowed( + Writer writer, @Nullable Exemplar exemplar, EscapingScheme scheme) throws IOException { + if (exemplar == null) { + return; + } + // In exemplarCompliance mode, exemplars MUST have a timestamp per the OM2 spec. + if (openMetrics2Properties.getExemplarCompliance() && !exemplar.hasTimestamp()) { + return; + } + writer.write(" # "); + writeLabels(writer, exemplar.getLabels(), null, 0, false, scheme); + writer.write(' '); + writeDouble(writer, exemplar.getValue()); + if (exemplar.hasTimestamp()) { writer.write(' '); - writeDouble(writer, exemplar.getValue()); - if (exemplar.hasTimestamp()) { - writer.write(' '); - writeOpenMetricsTimestamp(writer, exemplar.getTimestampMillis()); - } + writeOpenMetricsTimestamp(writer, exemplar.getTimestampMillis()); } - writer.write('\n'); } private void writeMetadataWithName( 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 efa803db0..75af79954 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 @@ -4,13 +4,16 @@ import io.prometheus.metrics.config.EscapingScheme; import io.prometheus.metrics.config.OpenMetrics2Properties; +import io.prometheus.metrics.model.snapshots.ClassicHistogramBuckets; import io.prometheus.metrics.model.snapshots.CounterSnapshot; 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; import io.prometheus.metrics.model.snapshots.Labels; import io.prometheus.metrics.model.snapshots.MetricSnapshots; +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; @@ -325,6 +328,236 @@ void testEmptySnapshot() throws IOException { assertThat(om2Output).isEqualTo("# EOF\n"); } + @Test + void testCompositeHistogram() throws IOException { + MetricSnapshots snapshots = + MetricSnapshots.of( + HistogramSnapshot.builder() + .name("http_request_duration_seconds") + .help("Request duration") + .dataPoint( + HistogramSnapshot.HistogramDataPointSnapshot.builder() + .sum(324789.3) + .classicHistogramBuckets( + ClassicHistogramBuckets.builder() + .bucket(0.1, 8) + .bucket(0.25, 2) + .bucket(0.5, 1) + .bucket(1.0, 3) + .bucket(Double.POSITIVE_INFINITY, 3) + .build()) + .build()) + .build()); + + String output = writeWithCompositeValues(snapshots); + + assertThat(output) + .isEqualTo( + "# TYPE http_request_duration_seconds histogram\n" + + "# HELP http_request_duration_seconds Request duration\n" + + "http_request_duration_seconds" + + " {count:17,sum:324789.3,bucket:[0.1:8,0.25:10,0.5:11,1.0:14,+Inf:17]}\n" + + "# EOF\n"); + } + + @Test + void testCompositeHistogramWithLabelsTimestampAndCreated() throws IOException { + MetricSnapshots snapshots = + MetricSnapshots.of( + HistogramSnapshot.builder() + .name("foo") + .dataPoint( + HistogramSnapshot.HistogramDataPointSnapshot.builder() + .labels(Labels.of("method", "GET")) + .sum(324789.3) + .classicHistogramBuckets( + ClassicHistogramBuckets.builder() + .bucket(0.1, 8) + .bucket(Double.POSITIVE_INFINITY, 9) + .build()) + .createdTimestampMillis(1520430000123L) + .scrapeTimestampMillis(1520879607789L) + .build()) + .build()); + + String output = writeWithCompositeValues(snapshots); + + assertThat(output) + .isEqualTo( + "# TYPE foo histogram\n" + + "foo{method=\"GET\"} {count:17,sum:324789.3,bucket:[0.1:8,+Inf:17]}" + + " 1520879607.789 st@1520430000.123\n" + + "# EOF\n"); + } + + @Test + void testCompositeHistogramWithExemplar() throws IOException { + Exemplar exemplar = + Exemplar.builder().value(0.67).traceId("shaZ8oxi").timestampMillis(1520879607789L).build(); + + MetricSnapshots snapshots = + MetricSnapshots.of( + HistogramSnapshot.builder() + .name("foo") + .dataPoint( + HistogramSnapshot.HistogramDataPointSnapshot.builder() + .sum(1.5) + .classicHistogramBuckets( + ClassicHistogramBuckets.builder() + .bucket(1.0, 1) + .bucket(Double.POSITIVE_INFINITY, 0) + .build()) + .exemplars(Exemplars.of(exemplar)) + .build()) + .build()); + + String output = writeWithCompositeValues(snapshots); + + assertThat(output) + .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" + + "# EOF\n"); + } + + @Test + void testCompositeGaugeHistogram() throws IOException { + MetricSnapshots snapshots = + MetricSnapshots.of( + HistogramSnapshot.builder() + .name("queue_size") + .gaugeHistogram(true) + .dataPoint( + HistogramSnapshot.HistogramDataPointSnapshot.builder() + .sum(3289.3) + .classicHistogramBuckets( + ClassicHistogramBuckets.builder() + .bucket(0.1, 20) + .bucket(1.0, 14) + .bucket(Double.POSITIVE_INFINITY, 8) + .build()) + .build()) + .build()); + + String output = writeWithCompositeValues(snapshots); + + // GaugeHistogram uses gcount/gsum per spec + assertThat(output) + .isEqualTo( + "# TYPE queue_size gaugehistogram\n" + + "queue_size {gcount:42,gsum:3289.3,bucket:[0.1:20,1.0:34,+Inf:42]}\n" + + "# EOF\n"); + } + + @Test + void testCompositeSummary() throws IOException { + MetricSnapshots snapshots = + MetricSnapshots.of( + SummarySnapshot.builder() + .name("rpc_duration_seconds") + .help("RPC duration") + .dataPoint( + SummarySnapshot.SummaryDataPointSnapshot.builder() + .count(17) + .sum(324789.3) + .quantiles( + Quantiles.builder().quantile(0.95, 123.7).quantile(0.99, 150.0).build()) + .build()) + .build()); + + String output = writeWithCompositeValues(snapshots); + + assertThat(output) + .isEqualTo( + "# TYPE rpc_duration_seconds summary\n" + + "# HELP rpc_duration_seconds RPC duration\n" + + "rpc_duration_seconds" + + " {count:17,sum:324789.3,quantile:[0.95:123.7,0.99:150.0]}\n" + + "# EOF\n"); + } + + @Test + void testCompositeSummaryWithCreatedAndExemplar() throws IOException { + Exemplar exemplar = + Exemplar.builder().value(0.5).traceId("abc123").timestampMillis(1520879607000L).build(); + + MetricSnapshots snapshots = + MetricSnapshots.of( + SummarySnapshot.builder() + .name("rpc_duration_seconds") + .dataPoint( + SummarySnapshot.SummaryDataPointSnapshot.builder() + .count(10) + .sum(100.0) + .createdTimestampMillis(1520430000000L) + .exemplars(Exemplars.of(exemplar)) + .build()) + .build()); + + String output = writeWithCompositeValues(snapshots); + + 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" + + "# EOF\n"); + } + + @Test + void testExemplarComplianceSkipsExemplarWithoutTimestamp() throws IOException { + Exemplar exemplarWithTs = + Exemplar.builder().value(1.0).traceId("aaa").timestampMillis(1672850685829L).build(); + Exemplar exemplarWithoutTs = Exemplar.builder().value(2.0).traceId("bbb").build(); + + OpenMetrics2TextFormatWriter complianceWriter = + OpenMetrics2TextFormatWriter.builder() + .setOpenMetrics2Properties( + OpenMetrics2Properties.builder().exemplarCompliance(true).build()) + .build(); + OpenMetrics2TextFormatWriter defaultWriter = OpenMetrics2TextFormatWriter.create(); + + MetricSnapshots withTs = + MetricSnapshots.of( + CounterSnapshot.builder() + .name("requests") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .value(1.0) + .exemplar(exemplarWithTs) + .build()) + .build()); + MetricSnapshots withoutTs = + MetricSnapshots.of( + CounterSnapshot.builder() + .name("requests") + .dataPoint( + CounterSnapshot.CounterDataPointSnapshot.builder() + .value(1.0) + .exemplar(exemplarWithoutTs) + .build()) + .build()); + + // Compliance mode: exemplar WITH timestamp is emitted + assertThat(write(withTs, complianceWriter)).contains("# {trace_id=\"aaa\"} 1.0 1672850685.829"); + + // Compliance mode: exemplar WITHOUT timestamp is skipped + assertThat(write(withoutTs, complianceWriter)).doesNotContain("# {"); + + // Default mode: exemplar without timestamp is still emitted (just no timestamp) + assertThat(write(withoutTs, defaultWriter)).contains("# {trace_id=\"bbb\"} 2.0\n"); + } + + private String writeWithCompositeValues(MetricSnapshots snapshots) throws IOException { + OpenMetrics2TextFormatWriter writer = + OpenMetrics2TextFormatWriter.builder() + .setOpenMetrics2Properties( + OpenMetrics2Properties.builder().compositeValues(true).build()) + .build(); + return write(snapshots, writer); + } + private String writeWithOM1(MetricSnapshots snapshots) throws IOException { OpenMetricsTextFormatWriter writer = OpenMetricsTextFormatWriter.create(); return write(snapshots, writer); From 6e683d33c9f10cf0e92214ceb45acb1aa994f24d Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 11:45:52 +0000 Subject: [PATCH 2/3] refactor: delegate non-composite histogram/summary to OM1 writer When compositeValues=false, OM2 histogram and summary output is identical to OM1 format. Delegate to om1Writer instead of duplicating writeClassicHistogramBuckets, writeNonCompositeSummaryDataPoint, and writeCountAndSum. Signed-off-by: Gregor Zeitlinger --- .../OpenMetrics2TextFormatWriter.java | 123 +++--------------- .../OpenMetricsTextFormatWriter.java | 4 +- 2 files changed, 18 insertions(+), 109 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 7f67ed1c0..1916d631e 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 @@ -14,9 +14,7 @@ import io.prometheus.metrics.model.snapshots.ClassicHistogramBuckets; import io.prometheus.metrics.model.snapshots.CounterSnapshot; import io.prometheus.metrics.model.snapshots.DataPointSnapshot; -import io.prometheus.metrics.model.snapshots.DistributionDataPointSnapshot; import io.prometheus.metrics.model.snapshots.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; @@ -36,7 +34,6 @@ import java.io.OutputStreamWriter; import java.io.Writer; import java.nio.charset.StandardCharsets; -import java.util.List; import javax.annotation.Nullable; /** @@ -92,6 +89,7 @@ public OpenMetrics2TextFormatWriter build() { private final OpenMetrics2Properties openMetrics2Properties; private final boolean createdTimestampsEnabled; private final boolean exemplarsOnAllMetricTypesEnabled; + private final OpenMetricsTextFormatWriter om1Writer; /** * @param openMetrics2Properties OpenMetrics 2.0 feature flags @@ -106,6 +104,8 @@ public OpenMetrics2TextFormatWriter( this.openMetrics2Properties = openMetrics2Properties; this.createdTimestampsEnabled = createdTimestampsEnabled; this.exemplarsOnAllMetricTypesEnabled = exemplarsOnAllMetricTypesEnabled; + this.om1Writer = + new OpenMetricsTextFormatWriter(createdTimestampsEnabled, exemplarsOnAllMetricTypesEnabled); } public static Builder builder() { @@ -200,27 +200,21 @@ private void writeGauge(Writer writer, GaugeSnapshot snapshot, EscapingScheme sc private void writeHistogram(Writer writer, HistogramSnapshot snapshot, EscapingScheme scheme) throws IOException { + if (!openMetrics2Properties.getCompositeValues()) { + om1Writer.writeHistogram(writer, snapshot, scheme); + return; + } MetricMetadata metadata = snapshot.getMetadata(); String name = getExpositionBaseMetadataName(metadata, scheme); if (snapshot.isGaugeHistogram()) { writeMetadataWithName(writer, name, "gaugehistogram", metadata); - if (openMetrics2Properties.getCompositeValues()) { - for (HistogramSnapshot.HistogramDataPointSnapshot data : snapshot.getDataPoints()) { - writeCompositeHistogramDataPoint(writer, name, "gcount", "gsum", data, scheme); - } - } else { - writeClassicHistogramBuckets( - writer, name, "_gcount", "_gsum", snapshot.getDataPoints(), scheme); + for (HistogramSnapshot.HistogramDataPointSnapshot data : snapshot.getDataPoints()) { + writeCompositeHistogramDataPoint(writer, name, "gcount", "gsum", data, scheme); } } else { writeMetadataWithName(writer, name, "histogram", metadata); - if (openMetrics2Properties.getCompositeValues()) { - for (HistogramSnapshot.HistogramDataPointSnapshot data : snapshot.getDataPoints()) { - writeCompositeHistogramDataPoint(writer, name, "count", "sum", data, scheme); - } - } else { - writeClassicHistogramBuckets( - writer, name, "_count", "_sum", snapshot.getDataPoints(), scheme); + for (HistogramSnapshot.HistogramDataPointSnapshot data : snapshot.getDataPoints()) { + writeCompositeHistogramDataPoint(writer, name, "count", "sum", data, scheme); } } } @@ -267,39 +261,6 @@ private void writeCompositeHistogramDataPoint( writer.write('\n'); } - private void writeClassicHistogramBuckets( - Writer writer, - String name, - String countSuffix, - String sumSuffix, - List dataList, - EscapingScheme scheme) - throws IOException { - for (HistogramSnapshot.HistogramDataPointSnapshot data : dataList) { - 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); - } - // In OpenMetrics format, histogram _count and _sum are either both present or both absent. - if (data.hasCount() && data.hasSum()) { - writeCountAndSum(writer, name, data, countSuffix, sumSuffix, exemplars, scheme); - } - writeCreated(writer, name, data, scheme); - } - } - private ClassicHistogramBuckets getClassicBuckets( HistogramSnapshot.HistogramDataPointSnapshot data) { if (data.getClassicBuckets().isEmpty()) { @@ -312,6 +273,10 @@ private ClassicHistogramBuckets getClassicBuckets( private void writeSummary(Writer writer, SummarySnapshot snapshot, EscapingScheme scheme) throws IOException { + if (!openMetrics2Properties.getCompositeValues()) { + om1Writer.writeSummary(writer, snapshot, scheme); + return; + } boolean metadataWritten = false; MetricMetadata metadata = snapshot.getMetadata(); String name = getExpositionBaseMetadataName(metadata, scheme); @@ -323,11 +288,7 @@ private void writeSummary(Writer writer, SummarySnapshot snapshot, EscapingSchem writeMetadataWithName(writer, name, "summary", metadata); metadataWritten = true; } - if (openMetrics2Properties.getCompositeValues()) { - writeCompositeSummaryDataPoint(writer, name, data, scheme); - } else { - writeNonCompositeSummaryDataPoint(writer, name, data, scheme); - } + writeCompositeSummaryDataPoint(writer, name, data, scheme); } } @@ -382,33 +343,6 @@ private void writeCompositeSummaryDataPoint( writer.write('\n'); } - private void writeNonCompositeSummaryDataPoint( - Writer writer, - String name, - SummarySnapshot.SummaryDataPointSnapshot data, - EscapingScheme scheme) - throws IOException { - Exemplars exemplars = data.getExemplars(); - // Exemplars for summaries are new, and there's no best practice yet which Exemplars to choose - // for which time series. We select exemplars[0] for _count, exemplars[1] for _sum, and - // exemplars[2...] for the quantiles, all indexes modulo exemplars.length. - int exemplarIndex = 1; - for (Quantile quantile : data.getQuantiles()) { - writeNameAndLabels( - writer, name, null, data.getLabels(), scheme, "quantile", quantile.getQuantile()); - writeDouble(writer, quantile.getValue()); - if (exemplars.size() > 0 && exemplarsOnAllMetricTypesEnabled) { - exemplarIndex = (exemplarIndex + 1) % exemplars.size(); - writeScrapeTimestampAndExemplar(writer, data, exemplars.get(exemplarIndex), scheme); - } else { - writeScrapeTimestampAndExemplar(writer, data, null, scheme); - } - } - // Unlike histograms, summaries can have only a count or only a sum according to OpenMetrics. - writeCountAndSum(writer, name, data, "_count", "_sum", exemplars, scheme); - writeCreated(writer, name, data, scheme); - } - private void writeInfo(Writer writer, InfoSnapshot snapshot, EscapingScheme scheme) throws IOException { MetricMetadata metadata = snapshot.getMetadata(); @@ -475,31 +409,6 @@ private void writeUnknown(Writer writer, UnknownSnapshot snapshot, EscapingSchem } } - private void writeCountAndSum( - Writer writer, - String name, - DistributionDataPointSnapshot data, - String countSuffix, - String sumSuffix, - Exemplars exemplars, - EscapingScheme scheme) - throws IOException { - if (data.hasCount()) { - 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); - } - } - if (data.hasSum()) { - writeNameAndLabels(writer, name, sumSuffix, data.getLabels(), scheme); - writeDouble(writer, data.getSum()); - writeScrapeTimestampAndExemplar(writer, data, null, scheme); - } - } - private void writeCreated( Writer writer, String name, DataPointSnapshot data, EscapingScheme scheme) throws IOException { 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 bffb60c14..4f9aa9b0c 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 @@ -168,7 +168,7 @@ private void writeGauge(Writer writer, GaugeSnapshot snapshot, EscapingScheme sc } } - private void writeHistogram(Writer writer, HistogramSnapshot snapshot, EscapingScheme scheme) + void writeHistogram(Writer writer, HistogramSnapshot snapshot, EscapingScheme scheme) throws IOException { MetricMetadata metadata = snapshot.getMetadata(); if (snapshot.isGaugeHistogram()) { @@ -231,7 +231,7 @@ private ClassicHistogramBuckets getClassicBuckets( } } - private void writeSummary(Writer writer, SummarySnapshot snapshot, EscapingScheme scheme) + void writeSummary(Writer writer, SummarySnapshot snapshot, EscapingScheme scheme) throws IOException { boolean metadataWritten = false; MetricMetadata metadata = snapshot.getMetadata(); From f1eba6fabb1e9b2743994f5356cd4d231d7ba7e1 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 12:06:26 +0000 Subject: [PATCH 3/3] refactor: delegate exemplar writing to OM1 writer for consistency Apply the same delegation pattern as histogram/summary: when exemplarCompliance=false (default), delegate writeScrapeTimestampAndExemplar and writeExemplar to om1Writer. When exemplarCompliance=true, apply the OM2 spec requirement (drop exemplars without timestamps) then delegate the actual writing to om1Writer.writeExemplar. Extracts writeExemplar from om1Writer.writeScrapeTimestampAndExemplar so OM2 can delegate exemplar formatting without duplicating it. Signed-off-by: Gregor Zeitlinger --- .../OpenMetrics2TextFormatWriter.java | 26 +++++++++---------- .../OpenMetricsTextFormatWriter.java | 22 +++++++++------- 2 files changed, 26 insertions(+), 22 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 1916d631e..e2b2eb90e 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 @@ -257,7 +257,7 @@ private void writeCompositeHistogramDataPoint( writer.write(" st@"); writeOpenMetricsTimestamp(writer, data.getCreatedTimestampMillis()); } - writeExemplarIfAllowed(writer, data.getExemplars().getLatest(), scheme); + writeExemplar(writer, data.getExemplars().getLatest(), scheme); writer.write('\n'); } @@ -339,7 +339,7 @@ private void writeCompositeSummaryDataPoint( writer.write(" st@"); writeOpenMetricsTimestamp(writer, data.getCreatedTimestampMillis()); } - writeExemplarIfAllowed(writer, data.getExemplars().getLatest(), scheme); + writeExemplar(writer, data.getExemplars().getLatest(), scheme); writer.write('\n'); } @@ -467,30 +467,30 @@ private void writeNameAndLabels( private void writeScrapeTimestampAndExemplar( Writer writer, DataPointSnapshot data, @Nullable Exemplar exemplar, EscapingScheme scheme) throws IOException { + if (!openMetrics2Properties.getExemplarCompliance()) { + om1Writer.writeScrapeTimestampAndExemplar(writer, data, exemplar, scheme); + return; + } if (data.hasScrapeTimestamp()) { writer.write(' '); writeOpenMetricsTimestamp(writer, data.getScrapeTimestampMillis()); } - writeExemplarIfAllowed(writer, exemplar, scheme); + writeExemplar(writer, exemplar, scheme); writer.write('\n'); } - private void writeExemplarIfAllowed( - Writer writer, @Nullable Exemplar exemplar, EscapingScheme scheme) throws IOException { + private void writeExemplar(Writer writer, @Nullable Exemplar exemplar, EscapingScheme scheme) + throws IOException { if (exemplar == null) { return; } - // In exemplarCompliance mode, exemplars MUST have a timestamp per the OM2 spec. - if (openMetrics2Properties.getExemplarCompliance() && !exemplar.hasTimestamp()) { + if (!openMetrics2Properties.getExemplarCompliance()) { + om1Writer.writeExemplar(writer, exemplar, scheme); return; } - writer.write(" # "); - writeLabels(writer, exemplar.getLabels(), null, 0, false, scheme); - writer.write(' '); - writeDouble(writer, exemplar.getValue()); + // exemplarCompliance=true: exemplars MUST have a timestamp per the OM2 spec. if (exemplar.hasTimestamp()) { - writer.write(' '); - writeOpenMetricsTimestamp(writer, exemplar.getTimestampMillis()); + om1Writer.writeExemplar(writer, exemplar, scheme); } } 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 4f9aa9b0c..1bc7e101f 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 @@ -424,7 +424,7 @@ private void writeNameAndLabels( writer.write(' '); } - private void writeScrapeTimestampAndExemplar( + void writeScrapeTimestampAndExemplar( Writer writer, DataPointSnapshot data, @Nullable Exemplar exemplar, EscapingScheme scheme) throws IOException { if (data.hasScrapeTimestamp()) { @@ -432,18 +432,22 @@ private void writeScrapeTimestampAndExemplar( writeOpenMetricsTimestamp(writer, data.getScrapeTimestampMillis()); } if (exemplar != null) { - writer.write(" # "); - writeLabels(writer, exemplar.getLabels(), null, 0, false, scheme); - writer.write(' '); - writeDouble(writer, exemplar.getValue()); - if (exemplar.hasTimestamp()) { - writer.write(' '); - writeOpenMetricsTimestamp(writer, exemplar.getTimestampMillis()); - } + writeExemplar(writer, exemplar, scheme); } writer.write('\n'); } + void writeExemplar(Writer writer, Exemplar exemplar, EscapingScheme scheme) throws IOException { + writer.write(" # "); + writeLabels(writer, exemplar.getLabels(), null, 0, false, scheme); + writer.write(' '); + writeDouble(writer, exemplar.getValue()); + if (exemplar.hasTimestamp()) { + writer.write(' '); + writeOpenMetricsTimestamp(writer, exemplar.getTimestampMillis()); + } + } + /** * Returns the full exposition name for a metric. If the original name already ends with the given * suffix (e.g. "_total" for counters), uses the original name directly. Otherwise, appends the