Skip to content

Commit dec8e5b

Browse files
authored
feat: Add OM2 native histogram text output (#2042)
## Summary Adds OpenMetrics 2.0 text output for native histograms behind the `nativeHistograms` flag. - serializes native histogram fields (`schema`, `zero_threshold`, `zero_count`, native spans/buckets) - supports native gauge histograms with `gcount`/`gsum` - supports mixed native + classic histograms with classic `bucket` output last - emits all histogram exemplars on composite/native histogram samples - adds unit/integration coverage for native histogram content negotiation and edge cases ## Validation - `./mvnw test -pl prometheus-metrics-exposition-textformats -Dtest=OpenMetrics2TextFormatWriterTest,ExpositionFormatsTest -Dcoverage.skip=true -Dcheckstyle.skip=true` - `mise run build` --------- Signed-off-by: Gregor Zeitlinger <gregor.zeitlinger@grafana.com>
1 parent e898720 commit dec8e5b

4 files changed

Lines changed: 473 additions & 38 deletions

File tree

prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/ExpositionFormats.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,9 @@ public ExpositionFormatWriter findWriter(@Nullable String acceptHeader) {
6969
if ("2.0.0".equals(version)) {
7070
return openMetrics2TextFormatWriter;
7171
}
72-
// version=1.0.0 or no version: fall through to OM1
72+
// version=1.0.0 or no version: fall through to OM1.
7373
} else {
74-
// contentNegotiation=false: OM2 handles all OpenMetrics requests
74+
// contentNegotiation=false: OM2 handles all OpenMetrics requests.
7575
return openMetrics2TextFormatWriter;
7676
}
7777
}

prometheus-metrics-exposition-textformats/src/main/java/io/prometheus/metrics/expositionformats/OpenMetrics2TextFormatWriter.java

Lines changed: 135 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import io.prometheus.metrics.model.snapshots.MetricMetadata;
2424
import io.prometheus.metrics.model.snapshots.MetricSnapshot;
2525
import io.prometheus.metrics.model.snapshots.MetricSnapshots;
26+
import io.prometheus.metrics.model.snapshots.NativeHistogramBuckets;
2627
import io.prometheus.metrics.model.snapshots.PrometheusNaming;
2728
import io.prometheus.metrics.model.snapshots.Quantile;
2829
import io.prometheus.metrics.model.snapshots.SnapshotEscaper;
@@ -63,7 +64,7 @@ public Builder setOpenMetrics2Properties(OpenMetrics2Properties openMetrics2Prop
6364
}
6465

6566
/**
66-
* @param createdTimestampsEnabled whether to include the start timestamp in the output
67+
* @param createdTimestampsEnabled whether delegated OM1 output includes _created metrics
6768
*/
6869
public Builder setCreatedTimestampsEnabled(boolean createdTimestampsEnabled) {
6970
this.createdTimestampsEnabled = createdTimestampsEnabled;
@@ -88,21 +89,19 @@ public OpenMetrics2TextFormatWriter build() {
8889
public static final String CONTENT_TYPE =
8990
"application/openmetrics-text; version=2.0.0; charset=utf-8";
9091
private final OpenMetrics2Properties openMetrics2Properties;
91-
private final boolean createdTimestampsEnabled;
9292
private final boolean exemplarsOnAllMetricTypesEnabled;
9393
private final OpenMetricsTextFormatWriter om1Writer;
9494

9595
/**
9696
* @param openMetrics2Properties OpenMetrics 2.0 feature flags
97-
* @param createdTimestampsEnabled whether to include the start timestamp in the output.
97+
* @param createdTimestampsEnabled whether delegated OM1 output includes _created metrics
9898
* @param exemplarsOnAllMetricTypesEnabled whether to include exemplars on all metric types
9999
*/
100100
public OpenMetrics2TextFormatWriter(
101101
OpenMetrics2Properties openMetrics2Properties,
102102
boolean createdTimestampsEnabled,
103103
boolean exemplarsOnAllMetricTypesEnabled) {
104104
this.openMetrics2Properties = openMetrics2Properties;
105-
this.createdTimestampsEnabled = createdTimestampsEnabled;
106105
this.exemplarsOnAllMetricTypesEnabled = exemplarsOnAllMetricTypesEnabled;
107106
this.om1Writer =
108107
new OpenMetricsTextFormatWriter(createdTimestampsEnabled, exemplarsOnAllMetricTypesEnabled);
@@ -126,8 +125,8 @@ public boolean accepts(@Nullable String acceptHeader) {
126125

127126
@Override
128127
public String getContentType() {
129-
// When contentNegotiation=false (default), masquerade as OM1 for compatibility
130-
// When contentNegotiation=true, use proper OM2 version
128+
// When contentNegotiation=false (default), masquerade as OM1 for compatibility.
129+
// When contentNegotiation=true, use proper OM2 version.
131130
if (openMetrics2Properties.getContentNegotiation()) {
132131
return CONTENT_TYPE;
133132
} else {
@@ -181,7 +180,7 @@ private void writeCounter(Writer writer, CounterSnapshot snapshot, EscapingSchem
181180
writer.write(' ');
182181
writeOpenMetricsTimestamp(writer, data.getScrapeTimestampMillis());
183182
}
184-
if (createdTimestampsEnabled && data.hasCreatedTimestamp()) {
183+
if (data.hasCreatedTimestamp()) {
185184
writer.write(" st@");
186185
writeOpenMetricsTimestamp(writer, data.getCreatedTimestampMillis());
187186
}
@@ -208,8 +207,9 @@ private void writeGauge(Writer writer, GaugeSnapshot snapshot, EscapingScheme sc
208207

209208
private void writeHistogram(Writer writer, HistogramSnapshot snapshot, EscapingScheme scheme)
210209
throws IOException {
211-
if (!openMetrics2Properties.getCompositeValues()
212-
&& !openMetrics2Properties.getExemplarCompliance()) {
210+
boolean compositeHistogram =
211+
openMetrics2Properties.getCompositeValues() || openMetrics2Properties.getNativeHistograms();
212+
if (!compositeHistogram && !openMetrics2Properties.getExemplarCompliance()) {
213213
om1Writer.writeHistogram(writer, snapshot, scheme);
214214
return;
215215
}
@@ -218,12 +218,20 @@ private void writeHistogram(Writer writer, HistogramSnapshot snapshot, EscapingS
218218
if (snapshot.isGaugeHistogram()) {
219219
writeMetadataWithName(writer, name, "gaugehistogram", metadata);
220220
for (HistogramSnapshot.HistogramDataPointSnapshot data : snapshot.getDataPoints()) {
221-
writeCompositeHistogramDataPoint(writer, name, "gcount", "gsum", data, scheme);
221+
if (openMetrics2Properties.getNativeHistograms() && data.hasNativeHistogramData()) {
222+
writeNativeHistogramDataPoint(writer, name, "gcount", "gsum", data, scheme, false);
223+
} else {
224+
writeCompositeHistogramDataPoint(writer, name, "gcount", "gsum", data, scheme, false);
225+
}
222226
}
223227
} else {
224228
writeMetadataWithName(writer, name, "histogram", metadata);
225229
for (HistogramSnapshot.HistogramDataPointSnapshot data : snapshot.getDataPoints()) {
226-
writeCompositeHistogramDataPoint(writer, name, "count", "sum", data, scheme);
230+
if (openMetrics2Properties.getNativeHistograms() && data.hasNativeHistogramData()) {
231+
writeNativeHistogramDataPoint(writer, name, "count", "sum", data, scheme, true);
232+
} else {
233+
writeCompositeHistogramDataPoint(writer, name, "count", "sum", data, scheme, true);
234+
}
227235
}
228236
}
229237
}
@@ -234,7 +242,8 @@ private void writeCompositeHistogramDataPoint(
234242
String countKey,
235243
String sumKey,
236244
HistogramSnapshot.HistogramDataPointSnapshot data,
237-
EscapingScheme scheme)
245+
EscapingScheme scheme,
246+
boolean includeStartTimestamp)
238247
throws IOException {
239248
writeNameAndLabels(writer, name, null, data.getLabels(), scheme);
240249
writer.write('{');
@@ -245,28 +254,59 @@ private void writeCompositeHistogramDataPoint(
245254
writer.write(sumKey);
246255
writer.write(':');
247256
writeDouble(writer, data.getSum());
248-
writer.write(",bucket:[");
249-
ClassicHistogramBuckets buckets = getClassicBuckets(data);
250-
long cumulativeCount = 0;
251-
for (int i = 0; i < buckets.size(); i++) {
252-
if (i > 0) {
253-
writer.write(',');
254-
}
255-
cumulativeCount += buckets.getCount(i);
256-
writeDouble(writer, buckets.getUpperBound(i));
257-
writer.write(':');
258-
writeLong(writer, cumulativeCount);
257+
writeClassicBucketsField(writer, data);
258+
writer.write('}');
259+
if (data.hasScrapeTimestamp()) {
260+
writer.write(' ');
261+
writeOpenMetricsTimestamp(writer, data.getScrapeTimestampMillis());
259262
}
260-
writer.write("]}");
263+
if (includeStartTimestamp && data.hasCreatedTimestamp()) {
264+
writer.write(" st@");
265+
writeOpenMetricsTimestamp(writer, data.getCreatedTimestampMillis());
266+
}
267+
writeExemplars(writer, data.getExemplars(), scheme);
268+
writer.write('\n');
269+
}
270+
271+
private void writeNativeHistogramDataPoint(
272+
Writer writer,
273+
String name,
274+
String countKey,
275+
String sumKey,
276+
HistogramSnapshot.HistogramDataPointSnapshot data,
277+
EscapingScheme scheme,
278+
boolean includeStartTimestamp)
279+
throws IOException {
280+
writeNameAndLabels(writer, name, null, data.getLabels(), scheme);
281+
writer.write('{');
282+
writer.write(countKey);
283+
writer.write(':');
284+
writeLong(writer, data.getCount());
285+
writer.write(',');
286+
writer.write(sumKey);
287+
writer.write(':');
288+
writeDouble(writer, data.getSum());
289+
writer.write(",schema:");
290+
writer.write(Integer.toString(data.getNativeSchema()));
291+
writer.write(",zero_threshold:");
292+
writeDouble(writer, data.getNativeZeroThreshold());
293+
writer.write(",zero_count:");
294+
writeLong(writer, data.getNativeZeroCount());
295+
writeNativeBucketFields(writer, "negative", data.getNativeBucketsForNegativeValues());
296+
writeNativeBucketFields(writer, "positive", data.getNativeBucketsForPositiveValues());
297+
if (data.hasClassicHistogramData()) {
298+
writeClassicBucketsField(writer, data);
299+
}
300+
writer.write('}');
261301
if (data.hasScrapeTimestamp()) {
262302
writer.write(' ');
263303
writeOpenMetricsTimestamp(writer, data.getScrapeTimestampMillis());
264304
}
265-
if (data.hasCreatedTimestamp()) {
305+
if (includeStartTimestamp && data.hasCreatedTimestamp()) {
266306
writer.write(" st@");
267307
writeOpenMetricsTimestamp(writer, data.getCreatedTimestampMillis());
268308
}
269-
writeExemplar(writer, data.getExemplars().getLatest(), scheme);
309+
writeExemplars(writer, data.getExemplars(), scheme);
270310
writer.write('\n');
271311
}
272312

@@ -280,6 +320,75 @@ private ClassicHistogramBuckets getClassicBuckets(
280320
}
281321
}
282322

323+
private void writeClassicBucketsField(
324+
Writer writer, HistogramSnapshot.HistogramDataPointSnapshot data) throws IOException {
325+
writer.write(",bucket:[");
326+
ClassicHistogramBuckets buckets = getClassicBuckets(data);
327+
long cumulativeCount = 0;
328+
for (int i = 0; i < buckets.size(); i++) {
329+
if (i > 0) {
330+
writer.write(',');
331+
}
332+
cumulativeCount += buckets.getCount(i);
333+
writeDouble(writer, buckets.getUpperBound(i));
334+
writer.write(':');
335+
writeLong(writer, cumulativeCount);
336+
}
337+
writer.write(']');
338+
}
339+
340+
private void writeNativeBucketFields(Writer writer, String prefix, NativeHistogramBuckets buckets)
341+
throws IOException {
342+
if (buckets.size() == 0) {
343+
return;
344+
}
345+
writer.write(',');
346+
writer.write(prefix);
347+
writer.write("_spans:[");
348+
writeNativeBucketSpans(writer, buckets);
349+
writer.write("],");
350+
writer.write(prefix);
351+
writer.write("_buckets:[");
352+
for (int i = 0; i < buckets.size(); i++) {
353+
if (i > 0) {
354+
writer.write(',');
355+
}
356+
writeLong(writer, buckets.getCount(i));
357+
}
358+
writer.write(']');
359+
}
360+
361+
private void writeNativeBucketSpans(Writer writer, NativeHistogramBuckets buckets)
362+
throws IOException {
363+
int spanOffset = buckets.getBucketIndex(0);
364+
int spanLength = 1;
365+
int previousIndex = buckets.getBucketIndex(0);
366+
boolean firstSpan = true;
367+
for (int i = 1; i < buckets.size(); i++) {
368+
int bucketIndex = buckets.getBucketIndex(i);
369+
if (bucketIndex == previousIndex + 1) {
370+
spanLength++;
371+
} else {
372+
firstSpan = writeNativeBucketSpan(writer, spanOffset, spanLength, firstSpan);
373+
spanOffset = bucketIndex - previousIndex - 1;
374+
spanLength = 1;
375+
}
376+
previousIndex = bucketIndex;
377+
}
378+
writeNativeBucketSpan(writer, spanOffset, spanLength, firstSpan);
379+
}
380+
381+
private boolean writeNativeBucketSpan(Writer writer, int offset, int length, boolean firstSpan)
382+
throws IOException {
383+
if (!firstSpan) {
384+
writer.write(',');
385+
}
386+
writer.write(Integer.toString(offset));
387+
writer.write(':');
388+
writer.write(Integer.toString(length));
389+
return false;
390+
}
391+
283392
private void writeSummary(Writer writer, SummarySnapshot snapshot, EscapingScheme scheme)
284393
throws IOException {
285394
if (!openMetrics2Properties.getCompositeValues()

prometheus-metrics-exposition-textformats/src/test/java/io/prometheus/metrics/expositionformats/ExpositionFormatsTest.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,47 @@ void testOM2EnabledWithFeatureFlags() {
160160
assertThat(writer).isInstanceOf(OpenMetrics2TextFormatWriter.class);
161161
}
162162

163+
@Test
164+
void testOM2ContentNegotiationWithNativeHistogramOutput() throws IOException {
165+
PrometheusProperties props =
166+
PrometheusProperties.builder()
167+
.openMetrics2Properties(
168+
OpenMetrics2Properties.builder()
169+
.enabled(true)
170+
.contentNegotiation(true)
171+
.nativeHistograms(true)
172+
.build())
173+
.build();
174+
ExpositionFormats formats = ExpositionFormats.init(props);
175+
ExpositionFormatWriter writer =
176+
formats.findWriter("application/openmetrics-text; version=2.0.0");
177+
178+
ByteArrayOutputStream out = new ByteArrayOutputStream();
179+
writer.write(
180+
out,
181+
MetricSnapshots.of(
182+
HistogramSnapshot.builder()
183+
.name("latency_seconds")
184+
.dataPoint(
185+
HistogramSnapshot.HistogramDataPointSnapshot.builder()
186+
.sum(1.5)
187+
.nativeSchema(5)
188+
.nativeZeroCount(1)
189+
.nativeBucketsForPositiveValues(
190+
NativeHistogramBuckets.builder().bucket(2, 3).build())
191+
.build())
192+
.build()),
193+
EscapingScheme.ALLOW_UTF8);
194+
195+
assertThat(writer).isInstanceOf(OpenMetrics2TextFormatWriter.class);
196+
assertThat(out.toString(UTF_8))
197+
.isEqualTo(
198+
"# TYPE latency_seconds histogram\n"
199+
+ "latency_seconds {count:4,sum:1.5,schema:5,zero_threshold:0.0,zero_count:1,"
200+
+ "positive_spans:[2:1],positive_buckets:[3]}\n"
201+
+ "# EOF\n");
202+
}
203+
163204
@Test
164205
void testProtobufWriterTakesPrecedence() {
165206
PrometheusProperties props =

0 commit comments

Comments
 (0)