Skip to main content

spate_test/
exposition.rs

1//! Rendering metrics under a recorder of the test's own, and reading series
2//! back out of the Prometheus text exposition.
3
4/// Run `f` under a recorder local to the calling thread, and return its
5/// Prometheus text exposition with the production histogram buckets.
6///
7/// Metric handles must be built inside `f`. Gauge ownership stays
8/// process-wide (INV-10), so name each pipeline or component with
9/// [`unique_name`](crate::unique_name).
10pub fn render_metrics(f: impl FnOnce()) -> String {
11    spate_core::metrics::render_local(f)
12}
13
14/// One sample line of a text exposition.
15#[derive(Clone, Debug, PartialEq)]
16pub struct MetricSample {
17    /// Label pairs in the order the line carries them.
18    pub labels: Vec<(String, String)>,
19    /// The sample's value.
20    pub value: f64,
21}
22
23impl MetricSample {
24    /// The value of label `key`, if the sample carries it.
25    #[must_use]
26    pub fn label(&self, key: &str) -> Option<&str> {
27        self.labels
28            .iter()
29            .find(|(k, _)| k == key)
30            .map(|(_, v)| v.as_str())
31    }
32}
33
34/// Every sample named exactly `name` that carries all of `labels`, in the
35/// order they appear.
36///
37/// `name` is a sample name, so a histogram is read through `<family>_bucket`,
38/// `<family>_sum` and `<family>_count`. Label values compare unescaped, in
39/// the exporter's formatting, so a bucket bound reads `("le", "0.5")`.
40///
41/// # Panics
42///
43/// Panics on a sample line named `name` that does not parse.
44#[must_use]
45pub fn metric_series(rendered: &str, name: &str, labels: &[(&str, &str)]) -> Vec<MetricSample> {
46    rendered
47        .lines()
48        .filter_map(|line| {
49            let rest = line.strip_prefix(name)?;
50            if !(rest.starts_with('{') || rest.starts_with(' ')) {
51                return None;
52            }
53            let sample =
54                parse_sample(rest).unwrap_or_else(|| panic!("unparseable sample line: {line}"));
55            labels
56                .iter()
57                .all(|(k, v)| sample.label(k) == Some(*v))
58                .then_some(sample)
59        })
60        .collect()
61}
62
63/// The value of the one sample [`metric_series`] selects, or `None` if it
64/// selects none.
65///
66/// # Panics
67///
68/// Panics if more than one sample matches, listing them.
69#[must_use]
70pub fn metric_value(rendered: &str, name: &str, labels: &[(&str, &str)]) -> Option<f64> {
71    let mut matched = metric_series(rendered, name, labels);
72    assert!(
73        matched.len() <= 1,
74        "{} samples of {name} match {labels:?}: {matched:?}",
75        matched.len()
76    );
77    matched.pop().map(|s| s.value)
78}
79
80/// The sum of every sample [`metric_series`] selects, or `None` if it selects
81/// none.
82#[must_use]
83pub fn metric_sum(rendered: &str, name: &str, labels: &[(&str, &str)]) -> Option<f64> {
84    let matched = metric_series(rendered, name, labels);
85    (!matched.is_empty()).then(|| matched.iter().map(|s| s.value).sum())
86}
87
88/// Parse what follows the sample name: an optional label set, then the value.
89fn parse_sample(rest: &str) -> Option<MetricSample> {
90    let mut labels = Vec::new();
91    let mut rest = rest;
92    if let Some(mut body) = rest.strip_prefix('{') {
93        loop {
94            body = body.strip_prefix(',').unwrap_or(body);
95            if let Some(after) = body.strip_prefix('}') {
96                rest = after;
97                break;
98            }
99            let (key, quoted) = body.split_once("=\"")?;
100            let (value, after) = label_value(quoted)?;
101            labels.push((key.to_owned(), value));
102            body = after;
103        }
104    }
105    let value = rest.split_whitespace().next()?.parse().ok()?;
106    Some(MetricSample { labels, value })
107}
108
109/// Unescape a label value up to its closing quote, returning it and the text
110/// after the quote.
111fn label_value(quoted: &str) -> Option<(String, &str)> {
112    let mut value = String::new();
113    let mut chars = quoted.char_indices();
114    while let Some((i, c)) = chars.next() {
115        match c {
116            '"' => return Some((value, &quoted[i + 1..])),
117            '\\' => match chars.next()?.1 {
118                'n' => value.push('\n'),
119                escaped => value.push(escaped),
120            },
121            c => value.push(c),
122        }
123    }
124    None
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    const RENDERED: &str = r#"# TYPE spate_rows_total counter
132spate_rows_total{pipeline="p",component="c",partition="0"} 3
133spate_rows_total{pipeline="p",component="c",partition="1"} 4
134spate_rows_total_rate{pipeline="p",component="c"} 9
135spate_rows{pipeline="p",component="c"} 0
136spate_up 1
137spate_note{pipeline="p",reason="a \"quoted\", comma"} 2
138spate_wait_seconds_bucket{pipeline="p",le="0.5"} 1
139spate_wait_seconds_bucket{pipeline="p",le="+Inf"} 2
140spate_wait_seconds_count{pipeline="p"} 2
141"#;
142
143    /// A sample name matches exactly, so a longer name sharing its prefix is
144    /// a different series.
145    #[test]
146    fn the_name_matches_exactly() {
147        let rows = metric_series(RENDERED, "spate_rows", &[]);
148        assert_eq!(rows.len(), 1);
149        assert_eq!(rows[0].value, 0.0);
150        assert_eq!(metric_series(RENDERED, "spate_rows_total", &[]).len(), 2);
151    }
152
153    #[test]
154    fn labels_select_a_subset_in_any_order() {
155        let one = [("partition", "1"), ("pipeline", "p")];
156        assert_eq!(metric_value(RENDERED, "spate_rows_total", &one), Some(4.0));
157    }
158
159    #[test]
160    fn an_absent_series_differs_from_a_zero_one() {
161        assert_eq!(metric_value(RENDERED, "spate_rows", &[]), Some(0.0));
162        assert_eq!(metric_value(RENDERED, "spate_absent", &[]), None);
163        assert_eq!(
164            metric_value(RENDERED, "spate_rows", &[("pipeline", "q")]),
165            None
166        );
167    }
168
169    #[test]
170    fn a_sum_covers_every_match_and_none_when_absent() {
171        assert_eq!(metric_sum(RENDERED, "spate_rows_total", &[]), Some(7.0));
172        assert_eq!(metric_sum(RENDERED, "spate_rows", &[]), Some(0.0));
173        assert_eq!(metric_sum(RENDERED, "spate_absent", &[]), None);
174    }
175
176    #[test]
177    fn an_unlabeled_sample_parses() {
178        assert_eq!(metric_value(RENDERED, "spate_up", &[]), Some(1.0));
179    }
180
181    #[test]
182    fn an_escaped_label_value_is_unescaped() {
183        let note = metric_series(RENDERED, "spate_note", &[]);
184        assert_eq!(note[0].label("reason"), Some(r#"a "quoted", comma"#));
185        assert_eq!(note[0].value, 2.0);
186    }
187
188    #[test]
189    fn histogram_samples_read_by_suffix_and_le() {
190        let inf = [("le", "+Inf")];
191        assert_eq!(
192            metric_value(RENDERED, "spate_wait_seconds_bucket", &inf),
193            Some(2.0)
194        );
195        assert_eq!(
196            metric_value(RENDERED, "spate_wait_seconds_count", &[]),
197            Some(2.0)
198        );
199    }
200
201    #[test]
202    #[should_panic(expected = "2 samples of spate_rows_total")]
203    fn an_ambiguous_selector_panics() {
204        let _ = metric_value(RENDERED, "spate_rows_total", &[("pipeline", "p")]);
205    }
206
207    /// Histograms render as buckets, the way an installed exporter renders
208    /// them.
209    #[test]
210    fn render_metrics_uses_the_production_buckets() {
211        let rendered = render_metrics(|| {
212            metrics::histogram!("t_render_duration_seconds").record(0.2);
213        });
214        assert!(
215            !metric_series(&rendered, "t_render_duration_seconds_bucket", &[]).is_empty(),
216            "{rendered}"
217        );
218    }
219}