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