001package io.prometheus.metrics.model.snapshots;
002
003import java.util.regex.Pattern;
004
005/**
006 * Utility for Prometheus Metric and Label naming.
007 *
008 * <p>Note that this library allows dots in metric and label names. Dots will automatically be
009 * replaced with underscores in Prometheus exposition formats. However, if metrics are exposed in
010 * OpenTelemetry format the dots are retained.
011 */
012public class PrometheusNaming {
013
014  /** Legal characters for metric names, including dot. */
015  private static final Pattern METRIC_NAME_PATTERN =
016      Pattern.compile("^[a-zA-Z_.:][a-zA-Z0-9_.:]*$");
017
018  /** Legal characters for label names, including dot. */
019  private static final Pattern LABEL_NAME_PATTERN = Pattern.compile("^[a-zA-Z_.][a-zA-Z0-9_.]*$");
020
021  /** Legal characters for unit names, including dot. */
022  private static final Pattern UNIT_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_.:]+$");
023
024  /**
025   * According to OpenMetrics {@code _count} and {@code _sum} (and {@code _gcount}, {@code _gsum})
026   * should also be reserved metric name suffixes. However, popular instrumentation libraries have
027   * Gauges with names ending in {@code _count}. Examples:
028   *
029   * <ul>
030   *   <li>Micrometer: {@code jvm_buffer_count}
031   *   <li>OpenTelemetry: {@code process_runtime_jvm_buffer_count}
032   * </ul>
033   *
034   * We do not treat {@code _count} and {@code _sum} as reserved suffixes here for compatibility
035   * with these libraries. However, there is a risk of name conflict if someone creates a gauge
036   * named {@code my_data_count} and a histogram or summary named {@code my_data}, because the
037   * histogram or summary will implicitly have a sample named {@code my_data_count}.
038   */
039  private static final String[] RESERVED_METRIC_NAME_SUFFIXES = {
040    "_total", "_created", "_bucket", "_info",
041    ".total", ".created", ".bucket", ".info"
042  };
043
044  /**
045   * Test if a metric name is valid. Rules:
046   *
047   * <ul>
048   *   <li>The name must match {@link #METRIC_NAME_PATTERN}.
049   *   <li>The name MUST NOT end with one of the {@link #RESERVED_METRIC_NAME_SUFFIXES}.
050   * </ul>
051   *
052   * If a metric has a {@link Unit}, the metric name SHOULD end with the unit as a suffix. Note that
053   * <a href="https://openmetrics.io/">OpenMetrics</a> requires metric names to have their unit as
054   * suffix, and we implement this in {@code prometheus-metrics-core}. However, {@code
055   * prometheus-metrics-model} does not enforce Unit suffixes.
056   *
057   * <p>Example: If you create a Counter for a processing time with Unit {@link Unit#SECONDS
058   * SECONDS}, the name should be {@code processing_time_seconds}. When exposed in OpenMetrics Text
059   * format, this will be represented as two values: {@code processing_time_seconds_total} for the
060   * counter value, and the optional {@code processing_time_seconds_created} timestamp.
061   *
062   * <p>Use {@link #sanitizeMetricName(String)} to convert arbitrary Strings to valid metric names.
063   */
064  public static boolean isValidMetricName(String name) {
065    return validateMetricName(name) == null;
066  }
067
068  /**
069   * Same as {@link #isValidMetricName(String)}, but produces an error message.
070   *
071   * <p>The name is valid if the error message is {@code null}.
072   */
073  public static String validateMetricName(String name) {
074    for (String reservedSuffix : RESERVED_METRIC_NAME_SUFFIXES) {
075      if (name.endsWith(reservedSuffix)) {
076        return "The metric name must not include the '" + reservedSuffix + "' suffix.";
077      }
078    }
079    if (!METRIC_NAME_PATTERN.matcher(name).matches()) {
080      return "The metric name contains unsupported characters";
081    }
082    return null;
083  }
084
085  public static boolean isValidLabelName(String name) {
086    return LABEL_NAME_PATTERN.matcher(name).matches()
087        && !(name.startsWith("__")
088            || name.startsWith("._")
089            || name.startsWith("..")
090            || name.startsWith("_."));
091  }
092
093  /**
094   * Units may not have illegal characters, and they may not end with a reserved suffix like
095   * 'total'.
096   */
097  public static boolean isValidUnitName(String name) {
098    return validateUnitName(name) == null;
099  }
100
101  /** Same as {@link #isValidUnitName(String)} but returns an error message. */
102  public static String validateUnitName(String name) {
103    if (name.isEmpty()) {
104      return "The unit name must not be empty.";
105    }
106    for (String reservedSuffix : RESERVED_METRIC_NAME_SUFFIXES) {
107      String suffixName = reservedSuffix.substring(1);
108      if (name.endsWith(suffixName)) {
109        return suffixName + " is a reserved suffix in Prometheus";
110      }
111    }
112    if (!UNIT_NAME_PATTERN.matcher(name).matches()) {
113      return "The unit name contains unsupported characters";
114    }
115    return null;
116  }
117
118  /**
119   * Get the metric or label name that is used in Prometheus exposition format.
120   *
121   * @param name must be a valid metric or label name, i.e. {@link #isValidMetricName(String)
122   *     isValidMetricName(name)} or {@link #isValidLabelName(String) isValidLabelName(name)} must
123   *     be true.
124   * @return the name with dots replaced by underscores.
125   */
126  public static String prometheusName(String name) {
127    return name.replace(".", "_");
128  }
129
130  /**
131   * Convert an arbitrary string to a name where {@link #isValidMetricName(String)
132   * isValidMetricName(name)} is true.
133   */
134  public static String sanitizeMetricName(String metricName) {
135    if (metricName.isEmpty()) {
136      throw new IllegalArgumentException("Cannot convert an empty string to a valid metric name.");
137    }
138    String sanitizedName = replaceIllegalCharsInMetricName(metricName);
139    boolean modified = true;
140    while (modified) {
141      modified = false;
142      for (String reservedSuffix : RESERVED_METRIC_NAME_SUFFIXES) {
143        if (sanitizedName.equals(reservedSuffix)) {
144          // This is for the corner case when you call sanitizeMetricName("_total").
145          // In that case the result will be "total".
146          return reservedSuffix.substring(1);
147        }
148        if (sanitizedName.endsWith(reservedSuffix)) {
149          sanitizedName =
150              sanitizedName.substring(0, sanitizedName.length() - reservedSuffix.length());
151          modified = true;
152        }
153      }
154    }
155    return sanitizedName;
156  }
157
158  /**
159   * Like {@link #sanitizeMetricName(String)}, but also makes sure that the unit is appended as a
160   * suffix if the unit is not {@code null}.
161   */
162  public static String sanitizeMetricName(String metricName, Unit unit) {
163    String result = sanitizeMetricName(metricName);
164    if (unit != null) {
165      if (!result.endsWith("_" + unit) && !result.endsWith("." + unit)) {
166        result += "_" + unit;
167      }
168    }
169    return result;
170  }
171
172  /**
173   * Convert an arbitrary string to a name where {@link #isValidLabelName(String)
174   * isValidLabelName(name)} is true.
175   */
176  public static String sanitizeLabelName(String labelName) {
177    if (labelName.isEmpty()) {
178      throw new IllegalArgumentException("Cannot convert an empty string to a valid label name.");
179    }
180    String sanitizedName = replaceIllegalCharsInLabelName(labelName);
181    while (sanitizedName.startsWith("__")
182        || sanitizedName.startsWith("_.")
183        || sanitizedName.startsWith("._")
184        || sanitizedName.startsWith("..")) {
185      sanitizedName = sanitizedName.substring(1);
186    }
187    return sanitizedName;
188  }
189
190  /**
191   * Convert an arbitrary string to a name where {@link #isValidUnitName(String)
192   * isValidUnitName(name)} is true.
193   *
194   * @throws IllegalArgumentException if the {@code unitName} cannot be converted, for example if
195   *     you call {@code sanitizeUnitName("total")} or {@code sanitizeUnitName("")}.
196   * @throws NullPointerException if {@code unitName} is null.
197   */
198  public static String sanitizeUnitName(String unitName) {
199    if (unitName.isEmpty()) {
200      throw new IllegalArgumentException("Cannot convert an empty string to a valid unit name.");
201    }
202    String sanitizedName = replaceIllegalCharsInUnitName(unitName);
203    boolean modified = true;
204    while (modified) {
205      modified = false;
206      while (sanitizedName.startsWith("_") || sanitizedName.startsWith(".")) {
207        sanitizedName = sanitizedName.substring(1);
208        modified = true;
209      }
210      while (sanitizedName.endsWith(".") || sanitizedName.endsWith("_")) {
211        sanitizedName = sanitizedName.substring(0, sanitizedName.length() - 1);
212        modified = true;
213      }
214      for (String reservedSuffix : RESERVED_METRIC_NAME_SUFFIXES) {
215        String suffixName = reservedSuffix.substring(1);
216        if (sanitizedName.endsWith(suffixName)) {
217          sanitizedName = sanitizedName.substring(0, sanitizedName.length() - suffixName.length());
218          modified = true;
219        }
220      }
221    }
222    if (sanitizedName.isEmpty()) {
223      throw new IllegalArgumentException(
224          "Cannot convert '" + unitName + "' into a valid unit name.");
225    }
226    return sanitizedName;
227  }
228
229  /** Returns a string that matches {@link #METRIC_NAME_PATTERN}. */
230  private static String replaceIllegalCharsInMetricName(String name) {
231    int length = name.length();
232    char[] sanitized = new char[length];
233    for (int i = 0; i < length; i++) {
234      char ch = name.charAt(i);
235      if (ch == '.'
236          || (ch >= 'a' && ch <= 'z')
237          || (ch >= 'A' && ch <= 'Z')
238          || (i > 0 && ch >= '0' && ch <= '9')) {
239        sanitized[i] = ch;
240      } else {
241        sanitized[i] = '_';
242      }
243    }
244    return new String(sanitized);
245  }
246
247  /** Returns a string that matches {@link #LABEL_NAME_PATTERN}. */
248  private static String replaceIllegalCharsInLabelName(String name) {
249    int length = name.length();
250    char[] sanitized = new char[length];
251    for (int i = 0; i < length; i++) {
252      char ch = name.charAt(i);
253      if (ch == '.'
254          || (ch >= 'a' && ch <= 'z')
255          || (ch >= 'A' && ch <= 'Z')
256          || (i > 0 && ch >= '0' && ch <= '9')) {
257        sanitized[i] = ch;
258      } else {
259        sanitized[i] = '_';
260      }
261    }
262    return new String(sanitized);
263  }
264
265  /** Returns a string that matches {@link #UNIT_NAME_PATTERN}. */
266  private static String replaceIllegalCharsInUnitName(String name) {
267    int length = name.length();
268    char[] sanitized = new char[length];
269    for (int i = 0; i < length; i++) {
270      char ch = name.charAt(i);
271      if (ch == ':'
272          || ch == '.'
273          || (ch >= 'a' && ch <= 'z')
274          || (ch >= 'A' && ch <= 'Z')
275          || (ch >= '0' && ch <= '9')) {
276        sanitized[i] = ch;
277      } else {
278        sanitized[i] = '_';
279      }
280    }
281    return new String(sanitized);
282  }
283}