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    Map<String, List<MetricSnapshot>> grouped = new LinkedHashMap<>();
045
046    for (MetricSnapshot snapshot : metricSnapshots) {
047      String prometheusName = snapshot.getMetadata().getPrometheusName();
048      List<MetricSnapshot> list = grouped.get(prometheusName);
049      if (list == null) {
050        list = new ArrayList<>();
051        grouped.put(prometheusName, list);
052      }
053      list.add(snapshot);
054    }
055
056    MetricSnapshots.Builder builder = MetricSnapshots.builder();
057    for (List<MetricSnapshot> group : grouped.values()) {
058      if (group.size() == 1) {
059        builder.metricSnapshot(group.get(0));
060      } else {
061        MetricSnapshot merged = mergeSnapshots(group);
062        builder.metricSnapshot(merged);
063      }
064    }
065
066    return builder.build();
067  }
068
069  static void writeLong(Writer writer, long value) throws IOException {
070    if (value == Long.MIN_VALUE) {
071      writer.write("-9223372036854775808");
072      return;
073    }
074    char[] buf = new char[20];
075    int pos = 20;
076    boolean negative = value < 0;
077    long v = negative ? -value : value;
078    do {
079      buf[--pos] = (char) ('0' + (v % 10));
080      v /= 10;
081    } while (v > 0);
082    if (negative) {
083      buf[--pos] = '-';
084    }
085    writer.write(buf, pos, 20 - pos);
086  }
087
088  static void writeDouble(Writer writer, double d) throws IOException {
089    if (d == Double.POSITIVE_INFINITY) {
090      writer.write("+Inf");
091    } else if (d == Double.NEGATIVE_INFINITY) {
092      writer.write("-Inf");
093    } else {
094      writer.write(Double.toString(d));
095    }
096  }
097
098  static void writePrometheusTimestamp(Writer writer, long timestampMs, boolean timestampsInMs)
099      throws IOException {
100    if (timestampsInMs) {
101      // correct for prometheus exposition format
102      // https://prometheus.io/docs/instrumenting/exposition_formats/#text-format-details
103      writeLong(writer, timestampMs);
104    } else {
105      // incorrect for prometheus exposition format -
106      // but we need to support it for backwards compatibility
107      writeOpenMetricsTimestamp(writer, timestampMs);
108    }
109  }
110
111  static void writeOpenMetricsTimestamp(Writer writer, long timestampMs) throws IOException {
112    writeLong(writer, timestampMs / 1000L);
113    writer.write(".");
114    long ms = timestampMs % 1000;
115    if (ms < 100) {
116      writer.write("0");
117    }
118    if (ms < 10) {
119      writer.write("0");
120    }
121    writeLong(writer, ms);
122  }
123
124  static void writeEscapedString(Writer writer, String s) throws IOException {
125    // optimize for the common case where no escaping is needed
126    int start = 0;
127    // #indexOf is a vectorized intrinsic
128    int backslashIndex = s.indexOf('\\', start);
129    int quoteIndex = s.indexOf('\"', start);
130    int newlineIndex = s.indexOf('\n', start);
131
132    int allEscapesIndex = backslashIndex & quoteIndex & newlineIndex;
133    while (allEscapesIndex != -1) {
134      int escapeStart = Integer.MAX_VALUE;
135      if (backslashIndex != -1) {
136        escapeStart = backslashIndex;
137      }
138      if (quoteIndex != -1) {
139        escapeStart = Math.min(escapeStart, quoteIndex);
140      }
141      if (newlineIndex != -1) {
142        escapeStart = Math.min(escapeStart, newlineIndex);
143      }
144
145      // bulk write up to the first character that needs to be escaped
146      if (escapeStart > start) {
147        writer.write(s, start, escapeStart - start);
148      }
149      char c = s.charAt(escapeStart);
150      start = escapeStart + 1;
151      switch (c) {
152        case '\\':
153          writer.write("\\\\");
154          backslashIndex = s.indexOf('\\', start);
155          break;
156        case '\"':
157          writer.write("\\\"");
158          quoteIndex = s.indexOf('\"', start);
159          break;
160        case '\n':
161          writer.write("\\n");
162          newlineIndex = s.indexOf('\n', start);
163          break;
164      }
165
166      allEscapesIndex = backslashIndex & quoteIndex & newlineIndex;
167    }
168    // up until the end nothing needs to be escaped anymore
169    int remaining = s.length() - start;
170    if (remaining > 0) {
171      writer.write(s, start, remaining);
172    }
173  }
174
175  static void writeLabels(
176      Writer writer,
177      Labels labels,
178      @Nullable String additionalLabelName,
179      double additionalLabelValue,
180      boolean metricInsideBraces,
181      EscapingScheme scheme)
182      throws IOException {
183    if (!metricInsideBraces) {
184      writer.write('{');
185    }
186    for (int i = 0; i < labels.size(); i++) {
187      if (i > 0 || metricInsideBraces) {
188        writer.write(",");
189      }
190      writeName(writer, SnapshotEscaper.getSnapshotLabelName(labels, i, scheme), NameType.Label);
191      writer.write("=\"");
192      writeEscapedString(writer, labels.getValue(i));
193      writer.write("\"");
194    }
195    if (additionalLabelName != null) {
196      if (!labels.isEmpty() || metricInsideBraces) {
197        writer.write(",");
198      }
199      writer.write(additionalLabelName);
200      writer.write("=\"");
201      writeDouble(writer, additionalLabelValue);
202      writer.write("\"");
203    }
204    writer.write('}');
205  }
206
207  static void writeName(Writer writer, String name, NameType nameType) throws IOException {
208    switch (nameType) {
209      case Metric:
210        if (PrometheusNaming.isValidLegacyMetricName(name)) {
211          writer.write(name);
212          return;
213        }
214        break;
215      case Label:
216        if (PrometheusNaming.isValidLegacyLabelName(name)) {
217          writer.write(name);
218          return;
219        }
220        break;
221      default:
222        throw new RuntimeException("Invalid name type requested: " + nameType);
223    }
224    writer.write('"');
225    writeEscapedString(writer, name);
226    writer.write('"');
227  }
228
229  /**
230   * Merges multiple snapshots of the same type into a single snapshot with combined data points.
231   */
232  @SuppressWarnings("unchecked")
233  private static MetricSnapshot mergeSnapshots(List<MetricSnapshot> snapshots) {
234    MetricSnapshot first = snapshots.get(0);
235
236    int totalDataPoints = 0;
237    for (MetricSnapshot snapshot : snapshots) {
238      if (snapshot.getClass() != first.getClass()) {
239        throw new IllegalArgumentException(
240            "Cannot merge snapshots of different types: "
241                + first.getClass().getName()
242                + " and "
243                + snapshot.getClass().getName());
244      }
245      if (first instanceof HistogramSnapshot) {
246        HistogramSnapshot histogramFirst = (HistogramSnapshot) first;
247        HistogramSnapshot histogramSnapshot = (HistogramSnapshot) snapshot;
248        if (histogramFirst.isGaugeHistogram() != histogramSnapshot.isGaugeHistogram()) {
249          throw new IllegalArgumentException(
250              "Cannot merge histograms: gauge histogram and classic histogram");
251        }
252      }
253      // Validate metadata consistency so we don't silently pick one help/unit when they differ.
254      if (!Objects.equals(
255          first.getMetadata().getPrometheusName(), snapshot.getMetadata().getPrometheusName())) {
256        throw new IllegalArgumentException("Cannot merge snapshots: inconsistent metric name");
257      }
258      if (!Objects.equals(first.getMetadata().getUnit(), snapshot.getMetadata().getUnit())) {
259        throw new IllegalArgumentException(
260            "Cannot merge snapshots: conflicting unit for metric "
261                + first.getMetadata().getPrometheusName());
262      }
263      totalDataPoints += snapshot.getDataPoints().size();
264    }
265
266    List<DataPointSnapshot> allDataPoints = new ArrayList<>(totalDataPoints);
267    for (MetricSnapshot snapshot : snapshots) {
268      allDataPoints.addAll(snapshot.getDataPoints());
269    }
270
271    if (first instanceof CounterSnapshot) {
272      return new CounterSnapshot(
273          first.getMetadata(),
274          (Collection<CounterSnapshot.CounterDataPointSnapshot>) (Object) allDataPoints);
275    } else if (first instanceof GaugeSnapshot) {
276      return new GaugeSnapshot(
277          first.getMetadata(),
278          (Collection<GaugeSnapshot.GaugeDataPointSnapshot>) (Object) allDataPoints);
279    } else if (first instanceof HistogramSnapshot) {
280      HistogramSnapshot histFirst = (HistogramSnapshot) first;
281      return new HistogramSnapshot(
282          histFirst.isGaugeHistogram(),
283          first.getMetadata(),
284          (Collection<HistogramSnapshot.HistogramDataPointSnapshot>) (Object) allDataPoints);
285    } else if (first instanceof SummarySnapshot) {
286      return new SummarySnapshot(
287          first.getMetadata(),
288          (Collection<SummarySnapshot.SummaryDataPointSnapshot>) (Object) allDataPoints);
289    } else if (first instanceof InfoSnapshot) {
290      return new InfoSnapshot(
291          first.getMetadata(),
292          (Collection<InfoSnapshot.InfoDataPointSnapshot>) (Object) allDataPoints);
293    } else if (first instanceof StateSetSnapshot) {
294      return new StateSetSnapshot(
295          first.getMetadata(),
296          (Collection<StateSetSnapshot.StateSetDataPointSnapshot>) (Object) allDataPoints);
297    } else if (first instanceof UnknownSnapshot) {
298      return new UnknownSnapshot(
299          first.getMetadata(),
300          (Collection<UnknownSnapshot.UnknownDataPointSnapshot>) (Object) allDataPoints);
301    } else {
302      throw new IllegalArgumentException("Unknown snapshot type: " + first.getClass().getName());
303    }
304  }
305}