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", snapshot.getDataPoints(), scheme);
183    } else {
184      writeMetadata(writer, "histogram", metadata, scheme);
185      writeClassicHistogramBuckets(
186          writer, metadata, "_count", "_sum", snapshot.getDataPoints(), scheme);
187    }
188  }
189
190  private void writeClassicHistogramBuckets(
191      Writer writer,
192      MetricMetadata metadata,
193      String countSuffix,
194      String sumSuffix,
195      List<HistogramSnapshot.HistogramDataPointSnapshot> dataList,
196      EscapingScheme scheme)
197      throws IOException {
198    String name = getMetadataName(metadata, scheme);
199    String bucketName = name + "_bucket";
200    String countName = name + countSuffix;
201    String sumName = name + sumSuffix;
202    boolean nameValidLegacy = PrometheusNaming.isValidLegacyMetricName(name);
203    for (HistogramSnapshot.HistogramDataPointSnapshot data : dataList) {
204      ClassicHistogramBuckets buckets = getClassicBuckets(data);
205      Exemplars exemplars = data.getExemplars();
206      long cumulativeCount = 0;
207      for (int i = 0; i < buckets.size(); i++) {
208        cumulativeCount += buckets.getCount(i);
209        writeNameAndLabels(
210            writer,
211            bucketName,
212            null,
213            data.getLabels(),
214            scheme,
215            "le",
216            buckets.getUpperBound(i),
217            nameValidLegacy);
218        writeLong(writer, cumulativeCount);
219        Exemplar exemplar;
220        if (i == 0) {
221          exemplar = exemplars.get(Double.NEGATIVE_INFINITY, buckets.getUpperBound(i));
222        } else {
223          exemplar = exemplars.get(buckets.getUpperBound(i - 1), buckets.getUpperBound(i));
224        }
225        writeScrapeTimestampAndExemplar(writer, data, exemplar, scheme);
226      }
227      // In OpenMetrics format, histogram _count and _sum are either both present or both absent.
228      if (data.hasCount() && data.hasSum()) {
229        writeCountAndSum(writer, countName, sumName, data, exemplars, scheme, nameValidLegacy);
230      }
231      writeCreated(writer, name, data, scheme, nameValidLegacy);
232    }
233  }
234
235  private ClassicHistogramBuckets getClassicBuckets(
236      HistogramSnapshot.HistogramDataPointSnapshot data) {
237    if (data.getClassicBuckets().isEmpty()) {
238      return ClassicHistogramBuckets.of(
239          new double[] {Double.POSITIVE_INFINITY}, new long[] {data.getCount()});
240    } else {
241      return data.getClassicBuckets();
242    }
243  }
244
245  void writeSummary(Writer writer, SummarySnapshot snapshot, EscapingScheme scheme)
246      throws IOException {
247    writeSummary(writer, snapshot, scheme, getMetadataName(snapshot.getMetadata(), scheme));
248  }
249
250  void writeSummary(Writer writer, SummarySnapshot snapshot, EscapingScheme scheme, String name)
251      throws IOException {
252    boolean metadataWritten = false;
253    MetricMetadata metadata = snapshot.getMetadata();
254    String countName = name + "_count";
255    String sumName = name + "_sum";
256    boolean nameValidLegacy = PrometheusNaming.isValidLegacyMetricName(name);
257    for (SummarySnapshot.SummaryDataPointSnapshot data : snapshot.getDataPoints()) {
258      if (data.getQuantiles().size() == 0 && !data.hasCount() && !data.hasSum()) {
259        continue;
260      }
261      if (!metadataWritten) {
262        writeMetadataWithName(writer, name, "summary", metadata);
263        metadataWritten = true;
264      }
265      Exemplars exemplars = data.getExemplars();
266      // Exemplars for summaries are new, and there's no best practice yet which Exemplars to choose
267      // for which
268      // time series. We select exemplars[0] for _count, exemplars[1] for _sum, and exemplars[2...]
269      // for the
270      // quantiles, all indexes modulo exemplars.length.
271      int exemplarIndex = 1;
272      for (Quantile quantile : data.getQuantiles()) {
273        writeNameAndLabels(
274            writer,
275            name,
276            null,
277            data.getLabels(),
278            scheme,
279            "quantile",
280            quantile.getQuantile(),
281            nameValidLegacy);
282        writeDouble(writer, quantile.getValue());
283        if (exemplars.size() > 0 && exemplarsOnAllMetricTypesEnabled) {
284          exemplarIndex = (exemplarIndex + 1) % exemplars.size();
285          writeScrapeTimestampAndExemplar(writer, data, exemplars.get(exemplarIndex), scheme);
286        } else {
287          writeScrapeTimestampAndExemplar(writer, data, null, scheme);
288        }
289      }
290      // Unlike histograms, summaries can have only a count or only a sum according to OpenMetrics.
291      writeCountAndSum(writer, countName, sumName, data, exemplars, scheme, nameValidLegacy);
292      writeCreated(writer, name, data, scheme, nameValidLegacy);
293    }
294  }
295
296  private void writeInfo(Writer writer, InfoSnapshot snapshot, EscapingScheme scheme)
297      throws IOException {
298    MetricMetadata metadata = snapshot.getMetadata();
299    String infoName = resolveExpositionName(metadata, "_info", scheme);
300    String baseName = resolveBaseName(infoName, "_info");
301    boolean nameValidLegacy = PrometheusNaming.isValidLegacyMetricName(infoName);
302    writeMetadataWithName(writer, baseName, "info", metadata);
303    for (InfoSnapshot.InfoDataPointSnapshot data : snapshot.getDataPoints()) {
304      writeNameAndLabels(writer, infoName, null, data.getLabels(), scheme, nameValidLegacy);
305      writer.write("1");
306      writeScrapeTimestampAndExemplar(writer, data, null, scheme);
307    }
308  }
309
310  private void writeStateSet(Writer writer, StateSetSnapshot snapshot, EscapingScheme scheme)
311      throws IOException {
312    MetricMetadata metadata = snapshot.getMetadata();
313    writeMetadata(writer, "stateset", metadata, scheme);
314    String name = getMetadataName(metadata, scheme);
315    for (StateSetSnapshot.StateSetDataPointSnapshot data : snapshot.getDataPoints()) {
316      for (int i = 0; i < data.size(); i++) {
317        writer.write(name);
318        writer.write('{');
319        Labels labels = data.getLabels();
320        for (int j = 0; j < labels.size(); j++) {
321          if (j > 0) {
322            writer.write(",");
323          }
324          writer.write(getSnapshotLabelName(labels, j, scheme));
325          writer.write("=\"");
326          writeEscapedString(writer, labels.getValue(j));
327          writer.write("\"");
328        }
329        if (!labels.isEmpty()) {
330          writer.write(",");
331        }
332        writer.write(name);
333        writer.write("=\"");
334        writeEscapedString(writer, data.getName(i));
335        writer.write("\"} ");
336        if (data.isTrue(i)) {
337          writer.write("1");
338        } else {
339          writer.write("0");
340        }
341        writeScrapeTimestampAndExemplar(writer, data, null, scheme);
342      }
343    }
344  }
345
346  private void writeUnknown(Writer writer, UnknownSnapshot snapshot, EscapingScheme scheme)
347      throws IOException {
348    MetricMetadata metadata = snapshot.getMetadata();
349    writeMetadata(writer, "unknown", metadata, scheme);
350    String name = getMetadataName(metadata, scheme);
351    boolean nameValidLegacy = PrometheusNaming.isValidLegacyMetricName(name);
352    for (UnknownSnapshot.UnknownDataPointSnapshot data : snapshot.getDataPoints()) {
353      writeNameAndLabels(writer, name, null, data.getLabels(), scheme, nameValidLegacy);
354      writeDouble(writer, data.getValue());
355      if (exemplarsOnAllMetricTypesEnabled) {
356        writeScrapeTimestampAndExemplar(writer, data, data.getExemplar(), scheme);
357      } else {
358        writeScrapeTimestampAndExemplar(writer, data, null, scheme);
359      }
360    }
361  }
362
363  private void writeCountAndSum(
364      Writer writer,
365      String countName,
366      String sumName,
367      DistributionDataPointSnapshot data,
368      Exemplars exemplars,
369      EscapingScheme scheme,
370      boolean nameValidLegacy)
371      throws IOException {
372    if (data.hasCount()) {
373      writeNameAndLabels(writer, countName, null, data.getLabels(), scheme, nameValidLegacy);
374      writeLong(writer, data.getCount());
375      if (exemplarsOnAllMetricTypesEnabled) {
376        writeScrapeTimestampAndExemplar(writer, data, exemplars.getLatest(), scheme);
377      } else {
378        writeScrapeTimestampAndExemplar(writer, data, null, scheme);
379      }
380    }
381    if (data.hasSum()) {
382      writeNameAndLabels(writer, sumName, null, data.getLabels(), scheme, nameValidLegacy);
383      writeDouble(writer, data.getSum());
384      writeScrapeTimestampAndExemplar(writer, data, null, scheme);
385    }
386  }
387
388  private void writeCreated(
389      Writer writer,
390      String baseName,
391      DataPointSnapshot data,
392      EscapingScheme scheme,
393      boolean nameValidLegacy)
394      throws IOException {
395    if (createdTimestampsEnabled && data.hasCreatedTimestamp()) {
396      writeNameAndLabels(writer, baseName, "_created", data.getLabels(), scheme, nameValidLegacy);
397      writeOpenMetricsTimestamp(writer, data.getCreatedTimestampMillis());
398      if (data.hasScrapeTimestamp()) {
399        writer.write(' ');
400        writeOpenMetricsTimestamp(writer, data.getScrapeTimestampMillis());
401      }
402      writer.write('\n');
403    }
404  }
405
406  private void writeNameAndLabels(
407      Writer writer,
408      String name,
409      @Nullable String suffix,
410      Labels labels,
411      EscapingScheme escapingScheme,
412      boolean nameValidLegacy)
413      throws IOException {
414    writeNameAndLabels(writer, name, suffix, labels, escapingScheme, null, 0.0, nameValidLegacy);
415  }
416
417  private void writeNameAndLabels(
418      Writer writer,
419      String name,
420      @Nullable String suffix,
421      Labels labels,
422      EscapingScheme escapingScheme,
423      @Nullable String additionalLabelName,
424      double additionalLabelValue,
425      boolean nameValidLegacy)
426      throws IOException {
427    boolean metricInsideBraces = false;
428    // If the name does not pass the legacy validity check, we must put the metric name inside the
429    // braces. Validity is scanned once per family by the caller (the metric name is constant across
430    // all data points), not re-scanned for every series. A suffix built from legacy characters
431    // (_total, _bucket, ...) does not change legacy validity, so the base-name check applies here.
432    if (!nameValidLegacy) {
433      metricInsideBraces = true;
434      writer.write('{');
435    }
436    writeName(writer, suffix != null ? name + suffix : name, NameType.Metric, nameValidLegacy);
437    if (!labels.isEmpty() || additionalLabelName != null) {
438      writeLabels(
439          writer,
440          labels,
441          additionalLabelName,
442          additionalLabelValue,
443          metricInsideBraces,
444          escapingScheme);
445    } else if (metricInsideBraces) {
446      writer.write('}');
447    }
448    writer.write(' ');
449  }
450
451  void writeScrapeTimestampAndExemplar(
452      Writer writer, DataPointSnapshot data, @Nullable Exemplar exemplar, EscapingScheme scheme)
453      throws IOException {
454    if (data.hasScrapeTimestamp()) {
455      writer.write(' ');
456      writeOpenMetricsTimestamp(writer, data.getScrapeTimestampMillis());
457    }
458    if (exemplar != null) {
459      writeExemplar(writer, exemplar, scheme);
460    }
461    writer.write('\n');
462  }
463
464  void writeExemplar(Writer writer, Exemplar exemplar, EscapingScheme scheme) throws IOException {
465    writer.write(" # ");
466    writeLabels(writer, exemplar.getLabels(), null, 0, false, scheme);
467    writer.write(' ');
468    writeDouble(writer, exemplar.getValue());
469    if (exemplar.hasTimestamp()) {
470      writer.write(' ');
471      writeOpenMetricsTimestamp(writer, exemplar.getTimestampMillis());
472    }
473  }
474
475  /**
476   * Returns the full exposition name for a metric. If the original name already ends with the given
477   * suffix (e.g. "_total" for counters), uses the original name directly. Otherwise, appends the
478   * suffix to the base name.
479   */
480  private static String resolveExpositionName(
481      MetricMetadata metadata, String suffix, EscapingScheme scheme) {
482    String expositionBaseName = getExpositionBaseMetadataName(metadata, scheme);
483    if (expositionBaseName.endsWith(suffix)) {
484      return expositionBaseName;
485    }
486    return getMetadataName(metadata, scheme) + suffix;
487  }
488
489  private void writeMetadata(
490      Writer writer, String typeName, MetricMetadata metadata, EscapingScheme scheme)
491      throws IOException {
492    writeMetadataWithName(writer, getMetadataName(metadata, scheme), typeName, metadata);
493  }
494
495  private void writeMetadataWithName(
496      Writer writer, String name, String typeName, MetricMetadata metadata) throws IOException {
497    writer.write("# TYPE ");
498    writeName(writer, name, NameType.Metric);
499    writer.write(' ');
500    writer.write(typeName);
501    writer.write('\n');
502    if (metadata.getUnit() != null) {
503      writer.write("# UNIT ");
504      writeName(writer, name, NameType.Metric);
505      writer.write(' ');
506      writeEscapedString(writer, metadata.getUnit().toString());
507      writer.write('\n');
508    }
509    if (metadata.getHelp() != null && !metadata.getHelp().isEmpty()) {
510      writer.write("# HELP ");
511      writeName(writer, name, NameType.Metric);
512      writer.write(' ');
513      writeEscapedString(writer, metadata.getHelp());
514      writer.write('\n');
515    }
516  }
517
518  private static String resolveBaseName(String fullName, String suffix) {
519    if (fullName.endsWith(suffix)) {
520      return fullName.substring(0, fullName.length() - suffix.length());
521    }
522    return fullName;
523  }
524}