Skip to main content

faucet_cli/tui/
metrics.rs

1//! Pure Prometheus-text parsing + per-row aggregation for the live TUI.
2//!
3//! The TUI samples the in-process Prometheus recorder's rendered text every
4//! few hundred milliseconds and reduces the handful of `faucet_*` series it
5//! displays into one [`TuiModel`] per tick. Everything in this module is
6//! pure and unit-tested; the terminal loop just calls
7//! [`Sampler::observe`] with the rendered text.
8
9use std::collections::BTreeMap;
10
11/// One parsed sample line: `name{label="v",…} 1.5`.
12#[derive(Debug, Clone, PartialEq)]
13pub struct Sample {
14    pub name: String,
15    pub labels: BTreeMap<String, String>,
16    pub value: f64,
17}
18
19/// Parse the Prometheus text exposition format, skipping `# HELP` / `# TYPE`
20/// comments and lines that don't parse (robustness over strictness — the TUI
21/// must never crash on exporter output).
22pub fn parse_samples(text: &str) -> Vec<Sample> {
23    text.lines().filter_map(parse_line).collect()
24}
25
26fn parse_line(line: &str) -> Option<Sample> {
27    let line = line.trim();
28    if line.is_empty() || line.starts_with('#') {
29        return None;
30    }
31    // Split into name[{labels}] and value. The value is the last
32    // whitespace-separated token (an optional timestamp would follow it, but
33    // metrics-exporter-prometheus does not emit timestamps).
34    let (head, value_str) = match line.find('}') {
35        Some(close) => {
36            let (h, rest) = line.split_at(close + 1);
37            (h, rest.trim())
38        }
39        None => {
40            let mut parts = line.split_whitespace();
41            let h = parts.next()?;
42            (h, line[h.len()..].trim())
43        }
44    };
45    let value: f64 = value_str.split_whitespace().next()?.parse().ok()?;
46
47    let (name, labels) = match head.find('{') {
48        None => (head.trim().to_string(), BTreeMap::new()),
49        Some(open) => {
50            let name = head[..open].trim().to_string();
51            let body = head[open + 1..head.len() - 1].trim_end_matches(',');
52            (name, parse_labels(body)?)
53        }
54    };
55    if name.is_empty() {
56        return None;
57    }
58    Some(Sample {
59        name,
60        labels,
61        value,
62    })
63}
64
65/// Parse `k="v",k2="v2"` with Prometheus escaping (`\\`, `\"`, `\n`) inside
66/// label values.
67fn parse_labels(body: &str) -> Option<BTreeMap<String, String>> {
68    let mut labels = BTreeMap::new();
69    let mut chars = body.chars().peekable();
70    loop {
71        // Skip separators / whitespace.
72        while matches!(chars.peek(), Some(',') | Some(' ')) {
73            chars.next();
74        }
75        if chars.peek().is_none() {
76            return Some(labels);
77        }
78        let mut key = String::new();
79        for c in chars.by_ref() {
80            if c == '=' {
81                break;
82            }
83            key.push(c);
84        }
85        if chars.next()? != '"' {
86            return None;
87        }
88        let mut value = String::new();
89        loop {
90            match chars.next()? {
91                '\\' => match chars.next()? {
92                    'n' => value.push('\n'),
93                    '\\' => value.push('\\'),
94                    '"' => value.push('"'),
95                    other => {
96                        value.push('\\');
97                        value.push(other);
98                    }
99                },
100                '"' => break,
101                c => value.push(c),
102            }
103        }
104        labels.insert(key.trim().to_string(), value);
105    }
106}
107
108/// Live view of one matrix row / invocation.
109#[derive(Debug, Clone, Default, PartialEq)]
110pub struct RowStats {
111    /// `connector` label observed on the source-side series.
112    pub source: String,
113    /// `connector` label observed on the sink-side series.
114    pub sink: String,
115    pub records_in: u64,
116    pub records_out: u64,
117    /// Sink records/second over the last sampling window.
118    pub rate: f64,
119    pub source_errors: u64,
120    pub sink_errors: u64,
121    pub dlq_records: u64,
122    /// `faucet_pipeline_last_bookmark_unix_seconds` gauge (0 = never).
123    pub last_bookmark_unix: f64,
124    /// From `faucet_pipeline_runs_total{status}`: Some(true)=ok, Some(false)=err.
125    pub finished: Option<bool>,
126    pub in_flight: bool,
127}
128
129/// The reduced model the renderer draws.
130#[derive(Debug, Clone, Default, PartialEq)]
131pub struct TuiModel {
132    /// Row-id → stats, sorted by row id (BTreeMap keeps render order stable).
133    pub rows: BTreeMap<String, RowStats>,
134    pub total_in: u64,
135    pub total_out: u64,
136    pub total_rate: f64,
137}
138
139/// Turns successive Prometheus renders into [`TuiModel`]s, computing
140/// records/s from consecutive sink-records totals.
141#[derive(Debug, Default)]
142pub struct Sampler {
143    pipeline: String,
144    prev_out: BTreeMap<String, (u64, std::time::Duration)>,
145}
146
147impl Sampler {
148    pub fn new(pipeline: impl Into<String>) -> Self {
149        Self {
150            pipeline: pipeline.into(),
151            prev_out: BTreeMap::new(),
152        }
153    }
154
155    /// Reduce one rendered scrape into a model. `elapsed` is time since TUI
156    /// start (monotonic), used for rate windows.
157    pub fn observe(&mut self, text: &str, elapsed: std::time::Duration) -> TuiModel {
158        let mut model = TuiModel::default();
159        for s in parse_samples(text) {
160            if s.labels.get("pipeline").map(String::as_str) != Some(self.pipeline.as_str()) {
161                continue;
162            }
163            let row_id = s.labels.get("row").cloned().unwrap_or_default();
164            let row = model.rows.entry(row_id).or_default();
165            let connector = s.labels.get("connector").cloned().unwrap_or_default();
166            match s.name.as_str() {
167                "faucet_source_records_total" => {
168                    row.records_in += s.value as u64;
169                    if !connector.is_empty() {
170                        row.source = connector;
171                    }
172                }
173                "faucet_sink_records_total" => {
174                    row.records_out += s.value as u64;
175                    if !connector.is_empty() {
176                        row.sink = connector;
177                    }
178                }
179                "faucet_source_errors_total" => row.source_errors += s.value as u64,
180                "faucet_sink_errors_total" => row.sink_errors += s.value as u64,
181                "faucet_sink_dlq_records_total" => row.dlq_records += s.value as u64,
182                "faucet_pipeline_last_bookmark_unix_seconds" => {
183                    row.last_bookmark_unix = s.value;
184                }
185                "faucet_pipeline_in_flight" => {
186                    if s.value > 0.0 {
187                        row.in_flight = true;
188                    }
189                }
190                "faucet_pipeline_runs_total" => {
191                    // Fill connector names even before data flows.
192                    if row.source.is_empty()
193                        && let Some(src) = s.labels.get("source")
194                    {
195                        row.source = src.clone();
196                    }
197                    if row.sink.is_empty()
198                        && let Some(dst) = s.labels.get("sink")
199                    {
200                        row.sink = dst.clone();
201                    }
202                    if s.value > 0.0 {
203                        match s.labels.get("status").map(String::as_str) {
204                            Some("ok") => row.finished = Some(row.finished.unwrap_or(true)),
205                            Some("err") => row.finished = Some(false),
206                            _ => {}
207                        }
208                    }
209                }
210                _ => {}
211            }
212        }
213        // Rates from the previous observation of the same row.
214        let mut prev = std::mem::take(&mut self.prev_out);
215        for (row_id, row) in &mut model.rows {
216            if let Some((prev_count, prev_at)) = prev.remove(row_id) {
217                let dt = elapsed.saturating_sub(prev_at).as_secs_f64();
218                // Counter reset (shouldn't happen in-process) → rate 0.
219                if dt > 0.0 && row.records_out >= prev_count {
220                    row.rate = (row.records_out - prev_count) as f64 / dt;
221                }
222            }
223            self.prev_out
224                .insert(row_id.clone(), (row.records_out, elapsed));
225            model.total_in += row.records_in;
226            model.total_out += row.records_out;
227            model.total_rate += row.rate;
228        }
229        model
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use std::time::Duration;
237
238    #[test]
239    fn parses_bare_and_labelled_samples() {
240        let text = "\
241# HELP faucet_x helper
242# TYPE faucet_x counter
243faucet_up 1
244faucet_source_records_total{pipeline=\"p\",row=\"r1\",connector=\"csv\"} 42
245";
246        let samples = parse_samples(text);
247        assert_eq!(samples.len(), 2);
248        assert_eq!(samples[0].name, "faucet_up");
249        assert_eq!(samples[0].value, 1.0);
250        assert!(samples[0].labels.is_empty());
251        assert_eq!(samples[1].labels["connector"], "csv");
252        assert_eq!(samples[1].value, 42.0);
253    }
254
255    #[test]
256    fn parses_escaped_label_values() {
257        let text = r#"m{a="quo\"te",b="back\\slash",c="new\nline"} 7"#;
258        let s = &parse_samples(text)[0];
259        assert_eq!(s.labels["a"], "quo\"te");
260        assert_eq!(s.labels["b"], "back\\slash");
261        assert_eq!(s.labels["c"], "new\nline");
262    }
263
264    #[test]
265    fn garbage_lines_are_skipped_not_fatal() {
266        let text = "not a metric\nname_only\n{}} 3\nok_metric 5\n";
267        let samples = parse_samples(text);
268        assert_eq!(samples.len(), 1);
269        assert_eq!(samples[0].name, "ok_metric");
270    }
271
272    fn render(pipeline: &str, row: &str, out: u64) -> String {
273        format!(
274            "faucet_sink_records_total{{pipeline=\"{pipeline}\",row=\"{row}\",connector=\"jsonl\"}} {out}\n"
275        )
276    }
277
278    #[test]
279    fn sampler_filters_by_pipeline_and_computes_rates() {
280        let mut s = Sampler::new("mine");
281        let t0 = Duration::from_secs(1);
282        let m = s.observe(&(render("mine", "a", 100) + &render("other", "a", 999)), t0);
283        assert_eq!(m.rows.len(), 1);
284        assert_eq!(m.rows["a"].records_out, 100);
285        assert_eq!(m.rows["a"].rate, 0.0, "no window yet");
286
287        let m = s.observe(&render("mine", "a", 300), Duration::from_secs(3));
288        assert!((m.rows["a"].rate - 100.0).abs() < 1e-9, "200 records / 2s");
289        assert_eq!(m.total_out, 300);
290    }
291
292    #[test]
293    fn sampler_counter_reset_yields_zero_rate() {
294        let mut s = Sampler::new("p");
295        s.observe(&render("p", "a", 500), Duration::from_secs(1));
296        let m = s.observe(&render("p", "a", 10), Duration::from_secs(2));
297        assert_eq!(m.rows["a"].rate, 0.0);
298    }
299
300    #[test]
301    fn sampler_aggregates_row_fields() {
302        let text = "\
303faucet_source_records_total{pipeline=\"p\",row=\"r\",connector=\"spanner\"} 10
304faucet_sink_records_total{pipeline=\"p\",row=\"r\",connector=\"jsonl\"} 8
305faucet_source_errors_total{pipeline=\"p\",row=\"r\",connector=\"spanner\",kind=\"http\"} 1
306faucet_sink_errors_total{pipeline=\"p\",row=\"r\",connector=\"jsonl\",kind=\"io\"} 2
307faucet_sink_dlq_records_total{pipeline=\"p\",row=\"r\",connector=\"jsonl\"} 3
308faucet_pipeline_last_bookmark_unix_seconds{pipeline=\"p\",row=\"r\"} 1700000000
309faucet_pipeline_in_flight{pipeline=\"p\",row=\"r\"} 1
310";
311        let mut s = Sampler::new("p");
312        let m = s.observe(text, Duration::from_secs(1));
313        let row = &m.rows["r"];
314        assert_eq!(row.source, "spanner");
315        assert_eq!(row.sink, "jsonl");
316        assert_eq!(row.records_in, 10);
317        assert_eq!(row.records_out, 8);
318        assert_eq!(row.source_errors, 1);
319        assert_eq!(row.sink_errors, 2);
320        assert_eq!(row.dlq_records, 3);
321        assert_eq!(row.last_bookmark_unix, 1_700_000_000.0);
322        assert!(row.in_flight);
323        assert_eq!(row.finished, None);
324    }
325
326    #[test]
327    fn run_status_ok_and_err_map_to_finished() {
328        let ok = "faucet_pipeline_runs_total{pipeline=\"p\",row=\"r\",source=\"csv\",sink=\"stdout\",status=\"ok\"} 1\n";
329        let err = "faucet_pipeline_runs_total{pipeline=\"p\",row=\"r\",source=\"csv\",sink=\"stdout\",status=\"err\",kind=\"sink\"} 1\n";
330        let mut s = Sampler::new("p");
331        let m = s.observe(ok, Duration::from_secs(1));
332        assert_eq!(m.rows["r"].finished, Some(true));
333        assert_eq!(m.rows["r"].source, "csv");
334        assert_eq!(m.rows["r"].sink, "stdout");
335        let m = s.observe(&(ok.to_string() + err), Duration::from_secs(2));
336        // Any err marks the row failed even alongside an ok retry count.
337        assert_eq!(m.rows["r"].finished, Some(false));
338    }
339}