1pub fn render_metrics(f: impl FnOnce()) -> String {
11 spate_core::metrics::render_local(f)
12}
13
14#[derive(Clone, Debug, PartialEq)]
16pub struct MetricSample {
17 pub labels: Vec<(String, String)>,
19 pub value: f64,
21}
22
23impl MetricSample {
24 #[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#[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#[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#[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
88fn 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
109fn 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, "ed[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 #[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 #[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}