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}