001package io.prometheus.metrics.expositionformats;
002
003import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeDouble;
004import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeEscapedString;
005import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeLabels;
006import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeLong;
007import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeName;
008import static io.prometheus.metrics.expositionformats.TextFormatUtil.writeOpenMetricsTimestamp;
009import static io.prometheus.metrics.model.snapshots.SnapshotEscaper.getExpositionBaseMetadataName;
010import static io.prometheus.metrics.model.snapshots.SnapshotEscaper.getMetadataName;
011import static io.prometheus.metrics.model.snapshots.SnapshotEscaper.getSnapshotLabelName;
012
013import io.prometheus.metrics.annotations.StableApi;
014import io.prometheus.metrics.config.EscapingScheme;
015import io.prometheus.metrics.model.snapshots.ClassicHistogramBuckets;
016import io.prometheus.metrics.model.snapshots.CounterSnapshot;
017import io.prometheus.metrics.model.snapshots.DataPointSnapshot;
018import io.prometheus.metrics.model.snapshots.DistributionDataPointSnapshot;
019import io.prometheus.metrics.model.snapshots.Exemplar;
020import io.prometheus.metrics.model.snapshots.Exemplars;
021import io.prometheus.metrics.model.snapshots.GaugeSnapshot;
022import io.prometheus.metrics.model.snapshots.HistogramSnapshot;
023import io.prometheus.metrics.model.snapshots.InfoSnapshot;
024import io.prometheus.metrics.model.snapshots.Labels;
025import io.prometheus.metrics.model.snapshots.MetricMetadata;
026import io.prometheus.metrics.model.snapshots.MetricSnapshot;
027import io.prometheus.metrics.model.snapshots.MetricSnapshots;
028import io.prometheus.metrics.model.snapshots.PrometheusNaming;
029import io.prometheus.metrics.model.snapshots.Quantile;
030import io.prometheus.metrics.model.snapshots.SnapshotEscaper;
031import io.prometheus.metrics.model.snapshots.StateSetSnapshot;
032import io.prometheus.metrics.model.snapshots.SummarySnapshot;
033import io.prometheus.metrics.model.snapshots.UnknownSnapshot;
034import java.io.BufferedWriter;
035import java.io.IOException;
036import java.io.OutputStream;
037import java.io.OutputStreamWriter;
038import java.io.Writer;
039import java.nio.charset.StandardCharsets;
040import java.util.List;
041import javax.annotation.Nullable;
042
043/**
044 * Write the OpenMetrics text format as defined on <a
045 * href="https://openmetrics.io/">https://openmetrics.io</a>.
046 */
047@StableApi
048public class OpenMetricsTextFormatWriter implements ExpositionFormatWriter {
049
050  public static class Builder {
051    boolean createdTimestampsEnabled;
052    boolean exemplarsOnAllMetricTypesEnabled;
053
054    private Builder() {}
055
056    /**
057     * @param createdTimestampsEnabled whether to include the _created timestamp in the output
058     */
059    public Builder setCreatedTimestampsEnabled(boolean createdTimestampsEnabled) {
060      this.createdTimestampsEnabled = createdTimestampsEnabled;
061      return this;
062    }
063
064    /**
065     * @param exemplarsOnAllMetricTypesEnabled whether to include exemplars in the output for all
066     *     metric types
067     */
068    public Builder setExemplarsOnAllMetricTypesEnabled(boolean exemplarsOnAllMetricTypesEnabled) {
069      this.exemplarsOnAllMetricTypesEnabled = exemplarsOnAllMetricTypesEnabled;
070      return this;
071    }
072
073    public OpenMetricsTextFormatWriter build() {
074      return new OpenMetricsTextFormatWriter(
075          createdTimestampsEnabled, exemplarsOnAllMetricTypesEnabled);
076    }
077  }
078
079  public static final String CONTENT_TYPE =
080      "application/openmetrics-text; version=1.0.0; charset=utf-8";
081  private final boolean createdTimestampsEnabled;
082  private final boolean exemplarsOnAllMetricTypesEnabled;
083
084  /**
085   * @param createdTimestampsEnabled whether to include the _created timestamp in the output - This
086   *     will produce an invalid OpenMetrics output, but is kept for backwards compatibility.
087   */
088  public OpenMetricsTextFormatWriter(
089      boolean createdTimestampsEnabled, boolean exemplarsOnAllMetricTypesEnabled) {
090    this.createdTimestampsEnabled = createdTimestampsEnabled;
091    this.exemplarsOnAllMetricTypesEnabled = exemplarsOnAllMetricTypesEnabled;
092  }
093
094  public static Builder builder() {
095    return new Builder();
096  }
097
098  public static OpenMetricsTextFormatWriter create() {
099    return builder().build();
100  }
101
102  @Override
103  public boolean accepts(@Nullable String acceptHeader) {
104    if (acceptHeader == null) {
105      return false;
106    }
107    return acceptHeader.contains("application/openmetrics-text");
108  }
109
110  @Override
111  public String getContentType() {
112    return CONTENT_TYPE;
113  }
114
115  @Override
116  public void write(OutputStream out, MetricSnapshots metricSnapshots, EscapingScheme scheme)
117      throws IOException {
118    Writer writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
119    MetricSnapshots merged = TextFormatUtil.mergeDuplicates(metricSnapshots);
120    for (MetricSnapshot s : merged) {
121      MetricSnapshot snapshot = SnapshotEscaper.escapeMetricSnapshot(s, scheme);
122      if (!snapshot.getDataPoints().isEmpty()) {
123        if (snapshot instanceof CounterSnapshot) {
124          writeCounter(writer, (CounterSnapshot) snapshot, scheme);
125        } else if (snapshot instanceof GaugeSnapshot) {
126          writeGauge(writer, (GaugeSnapshot) snapshot, scheme);
127        } else if (snapshot instanceof HistogramSnapshot) {
128          writeHistogram(writer, (HistogramSnapshot) snapshot, scheme);
129        } else if (snapshot instanceof SummarySnapshot) {
130          writeSummary(writer, (SummarySnapshot) snapshot, scheme);
131        } else if (snapshot instanceof InfoSnapshot) {
132          writeInfo(writer, (InfoSnapshot) snapshot, scheme);
133        } else if (snapshot instanceof StateSetSnapshot) {
134          writeStateSet(writer, (StateSetSnapshot) snapshot, scheme);
135        } else if (snapshot instanceof UnknownSnapshot) {
136          writeUnknown(writer, (UnknownSnapshot) snapshot, scheme);
137        }
138      }
139    }
140    writer.write("# EOF\n");
141    writer.flush();
142  }
143
144  private void writeCounter(Writer writer, CounterSnapshot snapshot, EscapingScheme scheme)
145      throws IOException {
146    MetricMetadata metadata = snapshot.getMetadata();
147    String counterName = resolveExpositionName(metadata, "_total", scheme);
148    String baseName = resolveBaseName(counterName, "_total");
149    boolean nameValidLegacy = PrometheusNaming.isValidLegacyMetricName(counterName);
150    writeMetadataWithName(writer, baseName, "counter", metadata);
151    for (CounterSnapshot.CounterDataPointSnapshot data : snapshot.getDataPoints()) {
152      writeNameAndLabels(writer, counterName, null, data.getLabels(), scheme, nameValidLegacy);
153      writeDouble(writer, data.getValue());
154      writeScrapeTimestampAndExemplar(writer, data, data.getExemplar(), scheme);
155      writeCreated(writer, baseName, data, scheme, nameValidLegacy);
156    }
157  }
158
159  private void writeGauge(Writer writer, GaugeSnapshot snapshot, EscapingScheme scheme)
160      throws IOException {
161    MetricMetadata metadata = snapshot.getMetadata();
162    writeMetadata(writer, "gauge", metadata, scheme);
163    String name = getMetadataName(metadata, scheme);
164    boolean nameValidLegacy = PrometheusNaming.isValidLegacyMetricName(name);
165    for (GaugeSnapshot.GaugeDataPointSnapshot data : snapshot.getDataPoints()) {
166      writeNameAndLabels(writer, name, null, data.getLabels(), scheme, nameValidLegacy);
167      writeDouble(writer, data.getValue());
168      if (exemplarsOnAllMetricTypesEnabled) {
169        writeScrapeTimestampAndExemplar(writer, data, data.getExemplar(), scheme);
170      } else {
171        writeScrapeTimestampAndExemplar(writer, data, null, scheme);
172      }
173    }
174  }
175
176  void writeHistogram(Writer writer, HistogramSnapshot snapshot, EscapingScheme scheme)
177      throws IOException {
178    MetricMetadata metadata = snapshot.getMetadata();
179    if (snapshot.isGaugeHistogram()) {
180      writeMetadata(writer, "gaugehistogram", metadata, scheme);
181      writeClassicHistogramBuckets(
182          writer, metadata, "_gcount", "_gsum", true, snapshot.getDataPoints(), scheme);
183    } else {
184      writeMetadata(writer, "histogram", metadata, scheme);
185      writeClassicHistogramBuckets(
186          writer, metadata, "_count", "_sum", false, snapshot.getDataPoints(), scheme);
187    }
188  }
189
190  private void writeClassicHistogramBuckets(
191      Writer writer,
192      MetricMetadata metadata,
193      String countSuffix,
194      String sumSuffix,
195      boolean gaugeHistogram,
196      List<HistogramSnapshot.HistogramDataPointSnapshot> dataList,
197      EscapingScheme scheme)
198      throws IOException {
199    String name = getMetadataName(metadata, scheme);
200    String bucketName = name + "_bucket";
201    String countName = name + countSuffix;
202    String sumName = name + sumSuffix;
203    boolean nameValidLegacy = PrometheusNaming.isValidLegacyMetricName(name);
204    for (HistogramSnapshot.HistogramDataPointSnapshot data : dataList) {
205      ClassicHistogramBuckets buckets = getClassicBuckets(data);
206      Exemplars exemplars = data.getExemplars();
207      long cumulativeCount = 0;
208      for (int i = 0; i < buckets.size(); i++) {
209        cumulativeCount += buckets.getCount(i);
210        writeNameAndLabels(
211            writer,
212            bucketName,
213            null,
214            data.getLabels(),
215            scheme,
216            "le",
217            buckets.getUpperBound(i),
218            nameValidLegacy);
219        writeLong(writer, cumulativeCount);
220        Exemplar exemplar;
221        if (i == 0) {
222          exemplar = exemplars.get(Double.NEGATIVE_INFINITY, buckets.getUpperBound(i));
223        } else {
224          exemplar = exemplars.get(buckets.getUpperBound(i - 1), buckets.getUpperBound(i));
225        }
226        writeScrapeTimestampAndExemplar(writer, data, exemplar, scheme);
227      }
228      // In OpenMetrics format, histogram _count and _sum are either both present or both absent.
229      // Negative threshold buckets preclude a sum for histograms, but not gauge histograms.
230      boolean hasNegativeBuckets = buckets.getUpperBound(0) < 0;
231      if (data.hasCount() && data.hasSum() && (gaugeHistogram || !hasNegativeBuckets)) {
232        writeCountAndSum(writer, countName, sumName, data, exemplars, scheme, nameValidLegacy);
233      }
234      writeCreated(writer, name, data, scheme, nameValidLegacy);
235    }
236  }
237
238  private ClassicHistogramBuckets getClassicBuckets(
239      HistogramSnapshot.HistogramDataPointSnapshot data) {
240    if (data.getClassicBuckets().isEmpty()) {
241      return ClassicHistogramBuckets.of(
242          new double[] {Double.POSITIVE_INFINITY}, new long[] {data.getCount()});
243    } else {
244      return data.getClassicBuckets();
245    }
246  }
247
248  void writeSummary(Writer writer, SummarySnapshot snapshot, EscapingScheme scheme)
249      throws IOException {
250    writeSummary(writer, snapshot, scheme, getMetadataName(snapshot.getMetadata(), scheme));
251  }
252
253  void writeSummary(Writer writer, SummarySnapshot snapshot, EscapingScheme scheme, String name)
254      throws IOException {
255    boolean metadataWritten = false;
256    MetricMetadata metadata = snapshot.getMetadata();
257    String countName = name + "_count";
258    String sumName = name + "_sum";
259    boolean nameValidLegacy = PrometheusNaming.isValidLegacyMetricName(name);
260    for (SummarySnapshot.SummaryDataPointSnapshot data : snapshot.getDataPoints()) {
261      if (data.getQuantiles().size() == 0 && !data.hasCount() && !data.hasSum()) {
262        continue;
263      }
264      if (!metadataWritten) {
265        writeMetadataWithName(writer, name, "summary", metadata);
266        metadataWritten = true;
267      }
268      Exemplars exemplars = data.getExemplars();
269      // Exemplars for summaries are new, and there's no best practice yet which Exemplars to choose
270      // for which
271      // time series. We select exemplars[0] for _count, exemplars[1] for _sum, and exemplars[2...]
272      // for the
273      // quantiles, all indexes modulo exemplars.length.
274      int exemplarIndex = 1;
275      for (Quantile quantile : data.getQuantiles()) {
276        writeNameAndLabels(
277            writer,
278            name,
279            null,
280            data.getLabels(),
281            scheme,
282            "quantile",
283            quantile.getQuantile(),
284            nameValidLegacy);
285        writeDouble(writer, quantile.getValue());
286        if (exemplars.size() > 0 && exemplarsOnAllMetricTypesEnabled) {
287          exemplarIndex = (exemplarIndex + 1) % exemplars.size();
288          writeScrapeTimestampAndExemplar(writer, data, exemplars.get(exemplarIndex), scheme);
289        } else {
290          writeScrapeTimestampAndExemplar(writer, data, null, scheme);
291        }
292      }
293      // Unlike histograms, summaries can have only a count or only a sum according to OpenMetrics.
294      writeCountAndSum(writer, countName, sumName, data, exemplars, scheme, nameValidLegacy);
295      writeCreated(writer, name, data, scheme, nameValidLegacy);
296    }
297  }
298
299  private void writeInfo(Writer writer, InfoSnapshot snapshot, EscapingScheme scheme)
300      throws IOException {
301    MetricMetadata metadata = snapshot.getMetadata();
302    String infoName = resolveExpositionName(metadata, "_info", scheme);
303    String baseName = resolveBaseName(infoName, "_info");
304    boolean nameValidLegacy = PrometheusNaming.isValidLegacyMetricName(infoName);
305    writeMetadataWithName(writer, baseName, "info", metadata);
306    for (InfoSnapshot.InfoDataPointSnapshot data : snapshot.getDataPoints()) {
307      writeNameAndLabels(writer, infoName, null, data.getLabels(), scheme, nameValidLegacy);
308      writer.write("1");
309      writeScrapeTimestampAndExemplar(writer, data, null, scheme);
310    }
311  }
312
313  private void writeStateSet(Writer writer, StateSetSnapshot snapshot, EscapingScheme scheme)
314      throws IOException {
315    MetricMetadata metadata = snapshot.getMetadata();
316    writeMetadata(writer, "stateset", metadata, scheme);
317    String name = getMetadataName(metadata, scheme);
318    for (StateSetSnapshot.StateSetDataPointSnapshot data : snapshot.getDataPoints()) {
319      for (int i = 0; i < data.size(); i++) {
320        writer.write(name);
321        writer.write('{');
322        Labels labels = data.getLabels();
323        for (int j = 0; j < labels.size(); j++) {
324          if (j > 0) {
325            writer.write(",");
326          }
327          writer.write(getSnapshotLabelName(labels, j, scheme));
328          writer.write("=\"");
329          writeEscapedString(writer, labels.getValue(j));
330          writer.write("\"");
331        }
332        if (!labels.isEmpty()) {
333          writer.write(",");
334        }
335        writer.write(name);
336        writer.write("=\"");
337        writeEscapedString(writer, data.getName(i));
338        writer.write("\"} ");
339        if (data.isTrue(i)) {
340          writer.write("1");
341        } else {
342          writer.write("0");
343        }
344        writeScrapeTimestampAndExemplar(writer, data, null, scheme);
345      }
346    }
347  }
348
349  private void writeUnknown(Writer writer, UnknownSnapshot snapshot, EscapingScheme scheme)
350      throws IOException {
351    MetricMetadata metadata = snapshot.getMetadata();
352    writeMetadata(writer, "unknown", metadata, scheme);
353    String name = getMetadataName(metadata, scheme);
354    boolean nameValidLegacy = PrometheusNaming.isValidLegacyMetricName(name);
355    for (UnknownSnapshot.UnknownDataPointSnapshot data : snapshot.getDataPoints()) {
356      writeNameAndLabels(writer, name, null, data.getLabels(), scheme, nameValidLegacy);
357      writeDouble(writer, data.getValue());
358      if (exemplarsOnAllMetricTypesEnabled) {
359        writeScrapeTimestampAndExemplar(writer, data, data.getExemplar(), scheme);
360      } else {
361        writeScrapeTimestampAndExemplar(writer, data, null, scheme);
362      }
363    }
364  }
365
366  private void writeCountAndSum(
367      Writer writer,
368      String countName,
369      String sumName,
370      DistributionDataPointSnapshot data,
371      Exemplars exemplars,
372      EscapingScheme scheme,
373      boolean nameValidLegacy)
374      throws IOException {
375    if (data.hasCount()) {
376      writeNameAndLabels(writer, countName, null, data.getLabels(), scheme, nameValidLegacy);
377      writeLong(writer, data.getCount());
378      if (exemplarsOnAllMetricTypesEnabled) {
379        writeScrapeTimestampAndExemplar(writer, data, exemplars.getLatest(), scheme);
380      } else {
381        writeScrapeTimestampAndExemplar(writer, data, null, scheme);
382      }
383    }
384    if (data.hasSum()) {
385      writeNameAndLabels(writer, sumName, null, data.getLabels(), scheme, nameValidLegacy);
386      writeDouble(writer, data.getSum());
387      writeScrapeTimestampAndExemplar(writer, data, null, scheme);
388    }
389  }
390
391  private void writeCreated(
392      Writer writer,
393      String baseName,
394      DataPointSnapshot data,
395      EscapingScheme scheme,
396      boolean nameValidLegacy)
397      throws IOException {
398    if (createdTimestampsEnabled && data.hasCreatedTimestamp()) {
399      writeNameAndLabels(writer, baseName, "_created", data.getLabels(), scheme, nameValidLegacy);
400      writeOpenMetricsTimestamp(writer, data.getCreatedTimestampMillis());
401      if (data.hasScrapeTimestamp()) {
402        writer.write(' ');
403        writeOpenMetricsTimestamp(writer, data.getScrapeTimestampMillis());
404      }
405      writer.write('\n');
406    }
407  }
408
409  private void writeNameAndLabels(
410      Writer writer,
411      String name,
412      @Nullable String suffix,
413      Labels labels,
414      EscapingScheme escapingScheme,
415      boolean nameValidLegacy)
416      throws IOException {
417    writeNameAndLabels(writer, name, suffix, labels, escapingScheme, null, 0.0, nameValidLegacy);
418  }
419
420  private void writeNameAndLabels(
421      Writer writer,
422      String name,
423      @Nullable String suffix,
424      Labels labels,
425      EscapingScheme escapingScheme,
426      @Nullable String additionalLabelName,
427      double additionalLabelValue,
428      boolean nameValidLegacy)
429      throws IOException {
430    boolean metricInsideBraces = false;
431    // If the name does not pass the legacy validity check, we must put the metric name inside the
432    // braces. Validity is scanned once per family by the caller (the metric name is constant across
433    // all data points), not re-scanned for every series. A suffix built from legacy characters
434    // (_total, _bucket, ...) does not change legacy validity, so the base-name check applies here.
435    if (!nameValidLegacy) {
436      metricInsideBraces = true;
437      writer.write('{');
438    }
439    writeName(writer, suffix != null ? name + suffix : name, NameType.Metric, nameValidLegacy);
440    if (!labels.isEmpty() || additionalLabelName != null) {
441      writeLabels(
442          writer,
443          labels,
444          additionalLabelName,
445          additionalLabelValue,
446          metricInsideBraces,
447          escapingScheme);
448    } else if (metricInsideBraces) {
449      writer.write('}');
450    }
451    writer.write(' ');
452  }
453
454  void writeScrapeTimestampAndExemplar(
455      Writer writer, DataPointSnapshot data, @Nullable Exemplar exemplar, EscapingScheme scheme)
456      throws IOException {
457    if (data.hasScrapeTimestamp()) {
458      writer.write(' ');
459      writeOpenMetricsTimestamp(writer, data.getScrapeTimestampMillis());
460    }
461    if (exemplar != null) {
462      writeExemplar(writer, exemplar, scheme);
463    }
464    writer.write('\n');
465  }
466
467  void writeExemplar(Writer writer, Exemplar exemplar, EscapingScheme scheme) throws IOException {
468    writer.write(" # ");
469    writeLabels(writer, exemplar.getLabels(), null, 0, false, scheme);
470    writer.write(' ');
471    writeDouble(writer, exemplar.getValue());
472    if (exemplar.hasTimestamp()) {
473      writer.write(' ');
474      writeOpenMetricsTimestamp(writer, exemplar.getTimestampMillis());
475    }
476  }
477
478  /**
479   * Returns the full exposition name for a metric. If the original name already ends with the given
480   * suffix (e.g. "_total" for counters), uses the original name directly. Otherwise, appends the
481   * suffix to the base name.
482   */
483  private static String resolveExpositionName(
484      MetricMetadata metadata, String suffix, EscapingScheme scheme) {
485    String expositionBaseName = getExpositionBaseMetadataName(metadata, scheme);
486    if (expositionBaseName.endsWith(suffix)) {
487      return expositionBaseName;
488    }
489    return getMetadataName(metadata, scheme) + suffix;
490  }
491
492  private void writeMetadata(
493      Writer writer, String typeName, MetricMetadata metadata, EscapingScheme scheme)
494      throws IOException {
495    writeMetadataWithName(writer, getMetadataName(metadata, scheme), typeName, metadata);
496  }
497
498  private void writeMetadataWithName(
499      Writer writer, String name, String typeName, MetricMetadata metadata) throws IOException {
500    writer.write("# TYPE ");
501    writeName(writer, name, NameType.Metric);
502    writer.write(' ');
503    writer.write(typeName);
504    writer.write('\n');
505    if (metadata.getUnit() != null) {
506      writer.write("# UNIT ");
507      writeName(writer, name, NameType.Metric);
508      writer.write(' ');
509      writeEscapedString(writer, metadata.getUnit().toString());
510      writer.write('\n');
511    }
512    if (metadata.getHelp() != null && !metadata.getHelp().isEmpty()) {
513      writer.write("# HELP ");
514      writeName(writer, name, NameType.Metric);
515      writer.write(' ');
516      writeEscapedString(writer, metadata.getHelp());
517      writer.write('\n');
518    }
519  }
520
521  private static String resolveBaseName(String fullName, String suffix) {
522    if (fullName.endsWith(suffix)) {
523      return fullName.substring(0, fullName.length() - suffix.length());
524    }
525    return fullName;
526  }
527}