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