Skip to main content

torrust_metrics/label/
name.rs

1use derive_more::Display;
2use serde::{Deserialize, Serialize};
3
4use crate::prometheus::PrometheusSerializable;
5
6#[derive(Debug, Display, Clone, Eq, PartialEq, Default, Deserialize, Serialize, Hash, Ord, PartialOrd)]
7pub struct LabelName(String);
8
9impl LabelName {
10    /// Creates a new `LabelName` instance.
11    ///
12    /// # Panics
13    ///
14    /// Panics if the provided name is empty.
15    #[must_use]
16    pub fn new(name: &str) -> Self {
17        assert!(!name.is_empty(), "Label name cannot be empty.");
18        Self(name.to_owned())
19    }
20}
21
22impl PrometheusSerializable for LabelName {
23    /// In Prometheus:
24    ///
25    /// - Labels may contain ASCII letters, numbers, as well as underscores.
26    ///   They must match the regex [a-zA-Z_][a-zA-Z0-9_]*.
27    /// - Label names beginning with __ (two "_") are reserved for internal
28    ///   use.
29    /// - Label values may contain any Unicode characters.
30    /// - Labels with an empty label value are considered equivalent to
31    ///   labels that do not exist.
32    ///
33    /// The label name is changed:
34    ///
35    /// - If a label name starts with, or contains, an invalid character:
36    ///   replace character with underscore.
37    /// - If th label name starts with two underscores:
38    ///   add additional underscore (three underscores total)
39    fn to_prometheus(&self) -> String {
40        // Replace invalid characters with underscore
41        let processed: String = self
42            .0
43            .chars()
44            .enumerate()
45            .map(|(i, c)| {
46                if i == 0 {
47                    if c.is_ascii_alphabetic() || c == '_' { c } else { '_' }
48                } else if c.is_ascii_alphanumeric() || c == '_' {
49                    c
50                } else {
51                    '_'
52                }
53            })
54            .collect();
55
56        // If the label name starts with two underscores, add an additional
57        if processed.starts_with("__") && !processed.starts_with("___") {
58            format!("_{processed}")
59        } else {
60            processed
61        }
62    }
63}
64
65#[macro_export]
66macro_rules! label_name {
67    ("") => {
68        compile_error!("Label name cannot be empty");
69    };
70    ($name:literal) => {
71        $crate::label::name::LabelName::new($name)
72    };
73    ($name:ident) => {
74        $crate::label::name::LabelName::new($name)
75    };
76}
77#[cfg(test)]
78mod tests {
79    mod serialization_of_label_name_to_prometheus {
80        use rstest::rstest;
81
82        use crate::label::LabelName;
83        use crate::prometheus::PrometheusSerializable;
84
85        #[rstest]
86        #[case("1 valid name", "valid_name", "valid_name")]
87        #[case("2 leading underscore", "_leading_underscore", "_leading_underscore")]
88        #[case("3 leading lowercase", "v123", "v123")]
89        #[case("4 leading uppercase", "V123", "V123")]
90        fn valid_names_in_prometheus(#[case] case: &str, #[case] input: &str, #[case] output: &str) {
91            assert_eq!(label_name!(input).to_prometheus(), output, "{case} failed: {input:?}");
92        }
93
94        #[rstest]
95        #[case("1 invalid start 1", "9invalid_start", "_invalid_start")]
96        #[case("2 invalid start 2", "@test", "_test")]
97        #[case("3 invalid dash", "invalid-char", "invalid_char")]
98        #[case("4 invalid spaces", "spaces are bad", "spaces_are_bad")]
99        #[case("5 invalid special chars", "a!b@c#d$e%f^g&h*i(j)", "a_b_c_d_e_f_g_h_i_j_")]
100        #[case("6 invalid colon", "my:metric/version", "my_metric_version")]
101        #[case("7 all invalid characters", "!@#$%^&*()", "__________")]
102        #[case("8 non_ascii_characters", "ñaca©", "_aca_")]
103        fn names_that_need_changes_in_prometheus(#[case] case: &str, #[case] input: &str, #[case] output: &str) {
104            assert_eq!(label_name!(input).to_prometheus(), output, "{case} failed: {input:?}");
105        }
106
107        #[rstest]
108        #[case("1 double underscore start", "__private", "___private")]
109        #[case("2 double underscore only", "__", "___")]
110        #[case("3 processed to double underscore", "^^name", "___name")]
111        #[case("4 processed to double underscore after first char", "0__name", "___name")]
112        fn names_starting_with_double_underscore(#[case] case: &str, #[case] input: &str, #[case] output: &str) {
113            assert_eq!(label_name!(input).to_prometheus(), output, "{case} failed: {input:?}");
114        }
115
116        #[test]
117        #[should_panic(expected = "Label name cannot be empty.")]
118        fn empty_name() {
119            let _name = LabelName::new("");
120        }
121    }
122}