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     * According to OpenMetrics {@code _count} and {@code _sum} (and {@code _gcount}, {@code _gsum}) should also be
025     * reserved metric name suffixes. However, popular instrumentation libraries have Gauges with names
026     * ending in {@code _count}.
027     * Examples:
028     * <ul>
029     * <li>Micrometer: {@code jvm_buffer_count}</li>
030     * <li>OpenTelemetry: {@code process_runtime_jvm_buffer_count}</li>
031     * </ul>
032     * We do not treat {@code _count} and {@code _sum} as reserved suffixes here for compatibility with these libraries.
033     * However, there is a risk of name conflict if someone creates a gauge named {@code my_data_count} and a
034     * histogram or summary named {@code my_data}, because the histogram or summary will implicitly have a sample
035     * named {@code my_data_count}.
036     */
037    private static final String[] RESERVED_METRIC_NAME_SUFFIXES = {
038            "_total", "_created", "_bucket", "_info",
039            ".total", ".created", ".bucket", ".info"
040    };
041
042    /**
043     * Test if a metric name is valid. Rules:
044     * <ul>
045     * <li>The name must match {@link #METRIC_NAME_PATTERN}.</li>
046     * <li>The name MUST NOT end with one of the {@link #RESERVED_METRIC_NAME_SUFFIXES}.</li>
047     * </ul>
048     * If a metric has a {@link Unit}, the metric name SHOULD end with the unit as a suffix.
049     * Note that <a href="https://openmetrics.io/">OpenMetrics</a> requires metric names to have their unit as suffix,
050     * and we implement this in {@code prometheus-metrics-core}. However, {@code prometheus-metrics-model}
051     * does not enforce Unit suffixes.
052     * <p>
053     * Example: If you create a Counter for a processing time with Unit {@link Unit#SECONDS SECONDS},
054     * the name should be {@code processing_time_seconds}. When exposed in OpenMetrics Text format,
055     * this will be represented as two values: {@code processing_time_seconds_total} for the counter value,
056     * and the optional {@code processing_time_seconds_created} timestamp.
057     * <p>
058     * Use {@link #sanitizeMetricName(String)} to convert arbitrary Strings to valid metric names.
059     */
060    public static boolean isValidMetricName(String name) {
061        return validateMetricName(name) == null;
062    }
063
064    /**
065     * Same as {@link #isValidMetricName(String)}, but produces an error message.
066     * <p>
067     * The name is valid if the error message is {@code null}.
068     */
069    static String validateMetricName(String name) {
070        for (String reservedSuffix : RESERVED_METRIC_NAME_SUFFIXES) {
071            if (name.endsWith(reservedSuffix)) {
072                return "The metric name must not include the '" + reservedSuffix + "' suffix.";
073            }
074        }
075        if (!METRIC_NAME_PATTERN.matcher(name).matches()) {
076            return "The metric name contains unsupported characters";
077        }
078        return null;
079    }
080
081    public static boolean isValidLabelName(String name) {
082        return LABEL_NAME_PATTERN.matcher(name).matches() &&
083                !(name.startsWith("__") || name.startsWith("._") || name.startsWith("..") || name.startsWith("_."));
084    }
085
086    /**
087     * Get the metric or label name that is used in Prometheus exposition format.
088     *
089     * @param name must be a valid metric or label name,
090     *             i.e. {@link #isValidMetricName(String) isValidMetricName(name)}
091     *             or {@link #isValidLabelName(String) isValidLabelName(name)}  must be true.
092     * @return the name with dots replaced by underscores.
093     */
094    public static String prometheusName(String name) {
095        return name.replace(".", "_");
096    }
097
098    /**
099     * Convert an arbitrary string to a name where {@link #isValidMetricName(String) isValidMetricName(name)} is true.
100     */
101    public static String sanitizeMetricName(String metricName) {
102        if (metricName.isEmpty()) {
103            throw new IllegalArgumentException("Cannot convert an empty string to a valid metric name.");
104        }
105        String sanitizedName = replaceIllegalCharsInMetricName(metricName);
106        boolean modified = true;
107        while (modified) {
108            modified = false;
109            for (String reservedSuffix : RESERVED_METRIC_NAME_SUFFIXES) {
110                if (sanitizedName.equals(reservedSuffix)) {
111                    // This is for the corner case when you call sanitizeMetricName("_total").
112                    // In that case the result will be "total".
113                    return reservedSuffix.substring(1);
114                }
115                if (sanitizedName.endsWith(reservedSuffix)) {
116                    sanitizedName = sanitizedName.substring(0, sanitizedName.length() - reservedSuffix.length());
117                    modified = true;
118                }
119            }
120        }
121        return sanitizedName;
122    }
123
124    /**
125     * Convert an arbitrary string to a name where {@link #isValidLabelName(String) isValidLabelName(name)} is true.
126     */
127    public static String sanitizeLabelName(String labelName) {
128        if (labelName.isEmpty()) {
129            throw new IllegalArgumentException("Cannot convert an empty string to a valid label name.");
130        }
131        String sanitizedName = replaceIllegalCharsInLabelName(labelName);
132        while (sanitizedName.startsWith("__") || sanitizedName.startsWith("_.") || sanitizedName.startsWith("._") || sanitizedName.startsWith("..")) {
133            sanitizedName = sanitizedName.substring(1);
134        }
135        return sanitizedName;
136    }
137
138    /**
139     * Returns a string that matches {@link #METRIC_NAME_PATTERN}.
140     */
141    private static String replaceIllegalCharsInMetricName(String name) {
142        int length = name.length();
143        char[] sanitized = new char[length];
144        for (int i = 0; i < length; i++) {
145            char ch = name.charAt(i);
146            if (ch == ':' ||
147                    ch == '.' ||
148                    (ch >= 'a' && ch <= 'z') ||
149                    (ch >= 'A' && ch <= 'Z') ||
150                    (i > 0 && ch >= '0' && ch <= '9')) {
151                sanitized[i] = ch;
152            } else {
153                sanitized[i] = '_';
154            }
155        }
156        return new String(sanitized);
157    }
158
159    /**
160     * Returns a string that matches {@link #LABEL_NAME_PATTERN}.
161     */
162    private static String replaceIllegalCharsInLabelName(String name) {
163        int length = name.length();
164        char[] sanitized = new char[length];
165        for (int i = 0; i < length; i++) {
166            char ch = name.charAt(i);
167            if (ch == '.' ||
168                    (ch >= 'a' && ch <= 'z') ||
169                    (ch >= 'A' && ch <= 'Z') ||
170                    (i > 0 && ch >= '0' && ch <= '9')) {
171                sanitized[i] = ch;
172            } else {
173                sanitized[i] = '_';
174            }
175        }
176        return new String(sanitized);
177    }
178}