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}