001package io.prometheus.metrics.expositionformats;
002
003import io.prometheus.metrics.config.EscapingScheme;
004import io.prometheus.metrics.model.snapshots.CounterSnapshot;
005import io.prometheus.metrics.model.snapshots.DataPointSnapshot;
006import io.prometheus.metrics.model.snapshots.GaugeSnapshot;
007import io.prometheus.metrics.model.snapshots.HistogramSnapshot;
008import io.prometheus.metrics.model.snapshots.InfoSnapshot;
009import io.prometheus.metrics.model.snapshots.Labels;
010import io.prometheus.metrics.model.snapshots.MetricSnapshot;
011import io.prometheus.metrics.model.snapshots.MetricSnapshots;
012import io.prometheus.metrics.model.snapshots.PrometheusNaming;
013import io.prometheus.metrics.model.snapshots.SnapshotEscaper;
014import io.prometheus.metrics.model.snapshots.StateSetSnapshot;
015import io.prometheus.metrics.model.snapshots.SummarySnapshot;
016import io.prometheus.metrics.model.snapshots.UnknownSnapshot;
017import java.io.IOException;
018import java.io.Writer;
019import java.util.ArrayList;
020import java.util.Collection;
021import java.util.LinkedHashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.Objects;
025import javax.annotation.Nullable;
026
027/**
028 * Utility methods for writing Prometheus text exposition formats.
029 *
030 * <p>This class provides low-level formatting utilities used by both Prometheus text format and
031 * OpenMetrics format writers. It handles escaping, label formatting, timestamp conversion, and
032 * merging of duplicate metric names.
033 */
034public class TextFormatUtil {
035  /**
036   * Merges snapshots with duplicate Prometheus names by combining their data points. This ensures
037   * only one HELP/TYPE declaration per metric family.
038   */
039  public static MetricSnapshots mergeDuplicates(MetricSnapshots metricSnapshots) {
040    if (metricSnapshots.size() <= 1) {
041      return metricSnapshots;
042    }
043
044    // MetricSnapshots is sorted by prometheus name, so any duplicates are adjacent. Detect them in
045    // a single allocation-free pass; when there are none (the common case) return the input as-is
046    // rather than rebuilding it through a map, a list per group and a new MetricSnapshots.
047    boolean hasDuplicates = false;
048    for (int i = 1; i < metricSnapshots.size(); i++) {
049      if (metricSnapshots
050          .get(i)
051          .getMetadata()
052          .getPrometheusName()
053          .equals(metricSnapshots.get(i - 1).getMetadata().getPrometheusName())) {
054        hasDuplicates = true;
055        break;
056      }
057    }
058    if (!hasDuplicates) {
059      return metricSnapshots;
060    }
061
062    Map<String, List<MetricSnapshot>> grouped = new LinkedHashMap<>();
063
064    for (MetricSnapshot snapshot : metricSnapshots) {
065      String prometheusName = snapshot.getMetadata().getPrometheusName();
066      List<MetricSnapshot> list = grouped.get(prometheusName);
067      if (list == null) {
068        list = new ArrayList<>();
069        grouped.put(prometheusName, list);
070      }
071      list.add(snapshot);
072    }
073
074    MetricSnapshots.Builder builder = MetricSnapshots.builder();
075    for (List<MetricSnapshot> group : grouped.values()) {
076      if (group.size() == 1) {
077        builder.metricSnapshot(group.get(0));
078      } else {
079        MetricSnapshot merged = mergeSnapshots(group);
080        builder.metricSnapshot(merged);
081      }
082    }
083
084    return builder.build();
085  }
086
087  static void writeLong(Writer writer, long value) throws IOException {
088    if (value == Long.MIN_VALUE) {
089      writer.write("-9223372036854775808");
090      return;
091    }
092    char[] buf = new char[20];
093    int pos = 20;
094    boolean negative = value < 0;
095    long v = negative ? -value : value;
096    do {
097      buf[--pos] = (char) ('0' + (v % 10));
098      v /= 10;
099    } while (v > 0);
100    if (negative) {
101      buf[--pos] = '-';
102    }
103    writer.write(buf, pos, 20 - pos);
104  }
105
106  static void writeDouble(Writer writer, double d) throws IOException {
107    if (d == Double.POSITIVE_INFINITY) {
108      writer.write("+Inf");
109    } else if (d == Double.NEGATIVE_INFINITY) {
110      writer.write("-Inf");
111    } else {
112      writer.write(Double.toString(d));
113    }
114  }
115
116  static void writePrometheusTimestamp(Writer writer, long timestampMs, boolean timestampsInMs)
117      throws IOException {
118    if (timestampsInMs) {
119      // correct for prometheus exposition format
120      // https://prometheus.io/docs/instrumenting/exposition_formats/#text-format-details
121      writeLong(writer, timestampMs);
122    } else {
123      // incorrect for prometheus exposition format -
124      // but we need to support it for backwards compatibility
125      writeOpenMetricsTimestamp(writer, timestampMs);
126    }
127  }
128
129  static void writeOpenMetricsTimestamp(Writer writer, long timestampMs) throws IOException {
130    writeLong(writer, timestampMs / 1000L);
131    writer.write(".");
132    long ms = timestampMs % 1000;
133    if (ms < 100) {
134      writer.write("0");
135    }
136    if (ms < 10) {
137      writer.write("0");
138    }
139    writeLong(writer, ms);
140  }
141
142  static void writeEscapedString(Writer writer, String s) throws IOException {
143    // optimize for the common case where no escaping is needed
144    int start = 0;
145    // #indexOf is a vectorized intrinsic
146    int backslashIndex = s.indexOf('\\', start);
147    int quoteIndex = s.indexOf('\"', start);
148    int newlineIndex = s.indexOf('\n', start);
149
150    int allEscapesIndex = backslashIndex & quoteIndex & newlineIndex;
151    while (allEscapesIndex != -1) {
152      int escapeStart = Integer.MAX_VALUE;
153      if (backslashIndex != -1) {
154        escapeStart = backslashIndex;
155      }
156      if (quoteIndex != -1) {
157        escapeStart = Math.min(escapeStart, quoteIndex);
158      }
159      if (newlineIndex != -1) {
160        escapeStart = Math.min(escapeStart, newlineIndex);
161      }
162
163      // bulk write up to the first character that needs to be escaped
164      if (escapeStart > start) {
165        writer.write(s, start, escapeStart - start);
166      }
167      char c = s.charAt(escapeStart);
168      start = escapeStart + 1;
169      switch (c) {
170        case '\\':
171          writer.write("\\\\");
172          backslashIndex = s.indexOf('\\', start);
173          break;
174        case '\"':
175          writer.write("\\\"");
176          quoteIndex = s.indexOf('\"', start);
177          break;
178        case '\n':
179          writer.write("\\n");
180          newlineIndex = s.indexOf('\n', start);
181          break;
182      }
183
184      allEscapesIndex = backslashIndex & quoteIndex & newlineIndex;
185    }
186    // up until the end nothing needs to be escaped anymore
187    int remaining = s.length() - start;
188    if (remaining > 0) {
189      writer.write(s, start, remaining);
190    }
191  }
192
193  static void writeLabels(
194      Writer writer,
195      Labels labels,
196      @Nullable String additionalLabelName,
197      double additionalLabelValue,
198      boolean metricInsideBraces,
199      EscapingScheme scheme)
200      throws IOException {
201    if (!metricInsideBraces) {
202      writer.write('{');
203    }
204    for (int i = 0; i < labels.size(); i++) {
205      if (i > 0 || metricInsideBraces) {
206        writer.write(",");
207      }
208      writeName(writer, SnapshotEscaper.getSnapshotLabelName(labels, i, scheme), NameType.Label);
209      writer.write("=\"");
210      writeEscapedString(writer, labels.getValue(i));
211      writer.write("\"");
212    }
213    if (additionalLabelName != null) {
214      if (!labels.isEmpty() || metricInsideBraces) {
215        writer.write(",");
216      }
217      writer.write(additionalLabelName);
218      writer.write("=\"");
219      writeDouble(writer, additionalLabelValue);
220      writer.write("\"");
221    }
222    writer.write('}');
223  }
224
225  static void writeName(Writer writer, String name, NameType nameType) throws IOException {
226    switch (nameType) {
227      case Metric:
228        if (PrometheusNaming.isValidLegacyMetricName(name)) {
229          writer.write(name);
230          return;
231        }
232        break;
233      case Label:
234        if (PrometheusNaming.isValidLegacyLabelName(name)) {
235          writer.write(name);
236          return;
237        }
238        break;
239      default:
240        throw new RuntimeException("Invalid name type requested: " + nameType);
241    }
242    writer.write('"');
243    writeEscapedString(writer, name);
244    writer.write('"');
245  }
246
247  /**
248   * Merges multiple snapshots of the same type into a single snapshot with combined data points.
249   */
250  @SuppressWarnings("unchecked")
251  private static MetricSnapshot mergeSnapshots(List<MetricSnapshot> snapshots) {
252    MetricSnapshot first = snapshots.get(0);
253
254    int totalDataPoints = 0;
255    for (MetricSnapshot snapshot : snapshots) {
256      if (snapshot.getClass() != first.getClass()) {
257        throw new IllegalArgumentException(
258            "Cannot merge snapshots of different types: "
259                + first.getClass().getName()
260                + " and "
261                + snapshot.getClass().getName());
262      }
263      if (first instanceof HistogramSnapshot) {
264        HistogramSnapshot histogramFirst = (HistogramSnapshot) first;
265        HistogramSnapshot histogramSnapshot = (HistogramSnapshot) snapshot;
266        if (histogramFirst.isGaugeHistogram() != histogramSnapshot.isGaugeHistogram()) {
267          throw new IllegalArgumentException(
268              "Cannot merge histograms: gauge histogram and classic histogram");
269        }
270      }
271      // Validate metadata consistency so we don't silently pick one help/unit when they differ.
272      if (!Objects.equals(
273          first.getMetadata().getPrometheusName(), snapshot.getMetadata().getPrometheusName())) {
274        throw new IllegalArgumentException("Cannot merge snapshots: inconsistent metric name");
275      }
276      if (!Objects.equals(first.getMetadata().getUnit(), snapshot.getMetadata().getUnit())) {
277        throw new IllegalArgumentException(
278            "Cannot merge snapshots: conflicting unit for metric "
279                + first.getMetadata().getPrometheusName());
280      }
281      totalDataPoints += snapshot.getDataPoints().size();
282    }
283
284    List<DataPointSnapshot> allDataPoints = new ArrayList<>(totalDataPoints);
285    for (MetricSnapshot snapshot : snapshots) {
286      allDataPoints.addAll(snapshot.getDataPoints());
287    }
288
289    if (first instanceof CounterSnapshot) {
290      return new CounterSnapshot(
291          first.getMetadata(),
292          (Collection<CounterSnapshot.CounterDataPointSnapshot>) (Object) allDataPoints);
293    } else if (first instanceof GaugeSnapshot) {
294      return new GaugeSnapshot(
295          first.getMetadata(),
296          (Collection<GaugeSnapshot.GaugeDataPointSnapshot>) (Object) allDataPoints);
297    } else if (first instanceof HistogramSnapshot) {
298      HistogramSnapshot histFirst = (HistogramSnapshot) first;
299      return new HistogramSnapshot(
300          histFirst.isGaugeHistogram(),
301          first.getMetadata(),
302          (Collection<HistogramSnapshot.HistogramDataPointSnapshot>) (Object) allDataPoints);
303    } else if (first instanceof SummarySnapshot) {
304      return new SummarySnapshot(
305          first.getMetadata(),
306          (Collection<SummarySnapshot.SummaryDataPointSnapshot>) (Object) allDataPoints);
307    } else if (first instanceof InfoSnapshot) {
308      return new InfoSnapshot(
309          first.getMetadata(),
310          (Collection<InfoSnapshot.InfoDataPointSnapshot>) (Object) allDataPoints);
311    } else if (first instanceof StateSetSnapshot) {
312      return new StateSetSnapshot(
313          first.getMetadata(),
314          (Collection<StateSetSnapshot.StateSetDataPointSnapshot>) (Object) allDataPoints);
315    } else if (first instanceof UnknownSnapshot) {
316      return new UnknownSnapshot(
317          first.getMetadata(),
318          (Collection<UnknownSnapshot.UnknownDataPointSnapshot>) (Object) allDataPoints);
319    } else {
320      throw new IllegalArgumentException("Unknown snapshot type: " + first.getClass().getName());
321    }
322  }
323}