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