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    writeName(writer, name, nameType, isValidLegacyName(name, nameType));
227  }
228
229  /**
230   * Variant of {@link #writeName(Writer, String, NameType)} for callers that already know whether
231   * {@code name} is a valid legacy name. The metric name is constant across all data points of a
232   * metric family, so a caller can scan it once per family (via {@link #isValidLegacyName}) and
233   * hoist the check out of the per-data-point loop instead of re-scanning the same string for every
234   * series.
235   */
236  static void writeName(Writer writer, String name, NameType nameType, boolean validLegacyName)
237      throws IOException {
238    if (validLegacyName) {
239      writer.write(name);
240      return;
241    }
242    if (nameType != NameType.Metric && nameType != NameType.Label) {
243      throw new RuntimeException("Invalid name type requested: " + nameType);
244    }
245    writer.write('"');
246    writeEscapedString(writer, name);
247    writer.write('"');
248  }
249
250  static boolean isValidLegacyName(String name, NameType nameType) {
251    switch (nameType) {
252      case Metric:
253        return PrometheusNaming.isValidLegacyMetricName(name);
254      case Label:
255        return PrometheusNaming.isValidLegacyLabelName(name);
256      default:
257        throw new RuntimeException("Invalid name type requested: " + nameType);
258    }
259  }
260
261  /**
262   * Merges multiple snapshots of the same type into a single snapshot with combined data points.
263   */
264  @SuppressWarnings("unchecked")
265  private static MetricSnapshot mergeSnapshots(List<MetricSnapshot> snapshots) {
266    MetricSnapshot first = snapshots.get(0);
267
268    int totalDataPoints = 0;
269    for (MetricSnapshot snapshot : snapshots) {
270      if (snapshot.getClass() != first.getClass()) {
271        throw new IllegalArgumentException(
272            "Cannot merge snapshots of different types: "
273                + first.getClass().getName()
274                + " and "
275                + snapshot.getClass().getName());
276      }
277      if (first instanceof HistogramSnapshot) {
278        HistogramSnapshot histogramFirst = (HistogramSnapshot) first;
279        HistogramSnapshot histogramSnapshot = (HistogramSnapshot) snapshot;
280        if (histogramFirst.isGaugeHistogram() != histogramSnapshot.isGaugeHistogram()) {
281          throw new IllegalArgumentException(
282              "Cannot merge histograms: gauge histogram and classic histogram");
283        }
284      }
285      // Validate metadata consistency so we don't silently pick one help/unit when they differ.
286      if (!Objects.equals(
287          first.getMetadata().getPrometheusName(), snapshot.getMetadata().getPrometheusName())) {
288        throw new IllegalArgumentException("Cannot merge snapshots: inconsistent metric name");
289      }
290      if (!Objects.equals(first.getMetadata().getUnit(), snapshot.getMetadata().getUnit())) {
291        throw new IllegalArgumentException(
292            "Cannot merge snapshots: conflicting unit for metric "
293                + first.getMetadata().getPrometheusName());
294      }
295      totalDataPoints += snapshot.getDataPoints().size();
296    }
297
298    List<DataPointSnapshot> allDataPoints = new ArrayList<>(totalDataPoints);
299    for (MetricSnapshot snapshot : snapshots) {
300      allDataPoints.addAll(snapshot.getDataPoints());
301    }
302
303    if (first instanceof CounterSnapshot) {
304      return new CounterSnapshot(
305          first.getMetadata(),
306          (Collection<CounterSnapshot.CounterDataPointSnapshot>) (Object) allDataPoints);
307    } else if (first instanceof GaugeSnapshot) {
308      return new GaugeSnapshot(
309          first.getMetadata(),
310          (Collection<GaugeSnapshot.GaugeDataPointSnapshot>) (Object) allDataPoints);
311    } else if (first instanceof HistogramSnapshot) {
312      HistogramSnapshot histFirst = (HistogramSnapshot) first;
313      return new HistogramSnapshot(
314          histFirst.isGaugeHistogram(),
315          first.getMetadata(),
316          (Collection<HistogramSnapshot.HistogramDataPointSnapshot>) (Object) allDataPoints);
317    } else if (first instanceof SummarySnapshot) {
318      return new SummarySnapshot(
319          first.getMetadata(),
320          (Collection<SummarySnapshot.SummaryDataPointSnapshot>) (Object) allDataPoints);
321    } else if (first instanceof InfoSnapshot) {
322      return new InfoSnapshot(
323          first.getMetadata(),
324          (Collection<InfoSnapshot.InfoDataPointSnapshot>) (Object) allDataPoints);
325    } else if (first instanceof StateSetSnapshot) {
326      return new StateSetSnapshot(
327          first.getMetadata(),
328          (Collection<StateSetSnapshot.StateSetDataPointSnapshot>) (Object) allDataPoints);
329    } else if (first instanceof UnknownSnapshot) {
330      return new UnknownSnapshot(
331          first.getMetadata(),
332          (Collection<UnknownSnapshot.UnknownDataPointSnapshot>) (Object) allDataPoints);
333    } else {
334      throw new IllegalArgumentException("Unknown snapshot type: " + first.getClass().getName());
335    }
336  }
337}