Skip to main content

faucet_cli/
livemetrics.rs

1//! Shared live-metrics plumbing for `faucet run` — the in-process Prometheus
2//! recorder installer plus a pure Prometheus-text parser/aggregator.
3//!
4//! Both the full-screen TUI (`--tui`, `cli-tui`) and the lightweight inline
5//! progress line (`cli-progress`) sample the same in-process Prometheus
6//! recorder's rendered text a few times a second (read-only — zero hot-path
7//! impact) and reduce the handful of `faucet_*` series they care about into a
8//! `TuiModel` per tick. Everything below the recorder installer is pure and
9//! unit-tested; the render loops just call `Sampler::observe` with the
10//! rendered text.
11//!
12//! This module is compiled whenever *either* live-view feature is on, so the
13//! recorder + parser are shared rather than duplicated.
14
15use crate::error::{CliError, CliResult};
16use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
17use std::collections::BTreeMap;
18use std::sync::OnceLock;
19
20// ---------------------------------------------------------------------------
21// Recorder install (moved from `tui` so `cli-progress` can reuse it without
22// pulling ratatui).
23// ---------------------------------------------------------------------------
24
25/// Default histogram buckets — mirrors `faucet-core`'s installer so a
26/// CLI-owned recorder behaves identically for any `/metrics` scraper.
27const DEFAULT_BUCKETS: &[f64] = &[
28    0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
29];
30
31/// The handle of the recorder this module installed, if any — makes
32/// [`install_metrics_recorder`] idempotent (a second call returns the same
33/// handle instead of racing on the process-global recorder slot).
34static RECORDER_HANDLE: OnceLock<PrometheusHandle> = OnceLock::new();
35
36/// Install the Prometheus recorder for a live-view session and return its
37/// render handle. When the config carries an `observability.prometheus` block
38/// the `/metrics` HTTP endpoint is preserved (recorder + listener, exactly
39/// what `install_observability` would have set up); otherwise a listener-less
40/// recorder is installed purely as the live view's data source. Idempotent: a
41/// second call returns the first call's handle.
42pub fn install_metrics_recorder(
43    prom: Option<&faucet_core::PrometheusConfig>,
44) -> CliResult<PrometheusHandle> {
45    // Validate the listener address up front so a config error surfaces even
46    // when the recorder slot is already occupied.
47    let listen: Option<std::net::SocketAddr> = match prom {
48        Some(p) => Some(
49            p.listen
50                .parse()
51                .map_err(|e| CliError::Observability(format!("prometheus listen: {e}")))?,
52        ),
53        None => None,
54    };
55    if let Some(handle) = RECORDER_HANDLE.get() {
56        return Ok(handle.clone());
57    }
58    let handle = match (prom, listen) {
59        (Some(p), Some(listen)) => {
60            let (recorder, exporter) = PrometheusBuilder::new()
61                .with_http_listener(listen)
62                .set_buckets(p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS))
63                .map_err(|e| CliError::Observability(e.to_string()))?
64                .build()
65                .map_err(|e| CliError::Observability(e.to_string()))?;
66            let handle = recorder.handle();
67            if ::metrics::set_global_recorder(recorder).is_ok() {
68                tokio::spawn(exporter);
69                tracing::info!("Prometheus /metrics listening on {}", p.listen);
70            } else {
71                warn_recorder_occupied();
72            }
73            handle
74        }
75        _ => {
76            let recorder = PrometheusBuilder::new()
77                .set_buckets(DEFAULT_BUCKETS)
78                .map_err(|e| CliError::Observability(e.to_string()))?
79                .build_recorder();
80            let handle = recorder.handle();
81            if ::metrics::set_global_recorder(recorder).is_err() {
82                warn_recorder_occupied();
83            }
84            handle
85        }
86    };
87    Ok(RECORDER_HANDLE.get_or_init(|| handle).clone())
88}
89
90fn warn_recorder_occupied() {
91    tracing::warn!(
92        "metrics recorder already installed; the live view may show no data (was a recorder installed before this `faucet run`?)"
93    );
94}
95
96/// Install a live-view session's observability: the CLI owns the metrics
97/// recorder (keeping the `/metrics` endpoint when one is configured) so it can
98/// render the recorder's output; the rest of the observability config (OTLP
99/// traces) installs as usual with the prometheus block taken out.
100pub fn setup_observability(cfg: &crate::config::PipelineConfig) -> CliResult<PrometheusHandle> {
101    let mut obs_cfg = crate::obs::build_observability_config(cfg);
102    let prom = obs_cfg.prometheus.take();
103    let handle = install_metrics_recorder(prom.as_ref())?;
104    faucet_core::install_observability(&obs_cfg)?;
105    Ok(handle)
106}
107
108// ---------------------------------------------------------------------------
109// Pure Prometheus-text parsing + per-row aggregation.
110// ---------------------------------------------------------------------------
111
112/// One parsed sample line: `name{label="v",…} 1.5`.
113#[derive(Debug, Clone, PartialEq)]
114pub struct Sample {
115    pub name: String,
116    pub labels: BTreeMap<String, String>,
117    pub value: f64,
118}
119
120/// Parse the Prometheus text exposition format, skipping `# HELP` / `# TYPE`
121/// comments and lines that don't parse (robustness over strictness — a live
122/// view must never crash on exporter output).
123pub fn parse_samples(text: &str) -> Vec<Sample> {
124    text.lines().filter_map(parse_line).collect()
125}
126
127fn parse_line(line: &str) -> Option<Sample> {
128    let line = line.trim();
129    if line.is_empty() || line.starts_with('#') {
130        return None;
131    }
132    // Split into name[{labels}] and value. The value is the last
133    // whitespace-separated token (an optional timestamp would follow it, but
134    // metrics-exporter-prometheus does not emit timestamps).
135    let (head, value_str) = match line.find('}') {
136        Some(close) => {
137            let (h, rest) = line.split_at(close + 1);
138            (h, rest.trim())
139        }
140        None => {
141            let mut parts = line.split_whitespace();
142            let h = parts.next()?;
143            (h, line[h.len()..].trim())
144        }
145    };
146    let value: f64 = value_str.split_whitespace().next()?.parse().ok()?;
147
148    let (name, labels) = match head.find('{') {
149        None => (head.trim().to_string(), BTreeMap::new()),
150        Some(open) => {
151            let name = head[..open].trim().to_string();
152            let body = head[open + 1..head.len() - 1].trim_end_matches(',');
153            (name, parse_labels(body)?)
154        }
155    };
156    if name.is_empty() {
157        return None;
158    }
159    Some(Sample {
160        name,
161        labels,
162        value,
163    })
164}
165
166/// Parse `k="v",k2="v2"` with Prometheus escaping (`\\`, `\"`, `\n`) inside
167/// label values.
168fn parse_labels(body: &str) -> Option<BTreeMap<String, String>> {
169    let mut labels = BTreeMap::new();
170    let mut chars = body.chars().peekable();
171    loop {
172        // Skip separators / whitespace.
173        while matches!(chars.peek(), Some(',') | Some(' ')) {
174            chars.next();
175        }
176        if chars.peek().is_none() {
177            return Some(labels);
178        }
179        let mut key = String::new();
180        for c in chars.by_ref() {
181            if c == '=' {
182                break;
183            }
184            key.push(c);
185        }
186        if chars.next()? != '"' {
187            return None;
188        }
189        let mut value = String::new();
190        loop {
191            match chars.next()? {
192                '\\' => match chars.next()? {
193                    'n' => value.push('\n'),
194                    '\\' => value.push('\\'),
195                    '"' => value.push('"'),
196                    other => {
197                        value.push('\\');
198                        value.push(other);
199                    }
200                },
201                '"' => break,
202                c => value.push(c),
203            }
204        }
205        labels.insert(key.trim().to_string(), value);
206    }
207}
208
209/// Live view of one matrix row / invocation.
210#[derive(Debug, Clone, Default, PartialEq)]
211pub struct RowStats {
212    /// `connector` label observed on the source-side series.
213    pub source: String,
214    /// `connector` label observed on the sink-side series.
215    pub sink: String,
216    pub records_in: u64,
217    pub records_out: u64,
218    /// Source pages fetched so far (`faucet_source_pages_total`).
219    pub pages: u64,
220    /// Sink records/second over the last sampling window.
221    pub rate: f64,
222    pub source_errors: u64,
223    pub sink_errors: u64,
224    pub dlq_records: u64,
225    /// `faucet_pipeline_last_bookmark_unix_seconds` gauge (0 = never).
226    pub last_bookmark_unix: f64,
227    /// From `faucet_pipeline_runs_total{status}`: Some(true)=ok, Some(false)=err.
228    pub finished: Option<bool>,
229    pub in_flight: bool,
230}
231
232/// The reduced model the renderers draw.
233#[derive(Debug, Clone, Default, PartialEq)]
234pub struct TuiModel {
235    /// Row-id → stats, sorted by row id (BTreeMap keeps render order stable).
236    pub rows: BTreeMap<String, RowStats>,
237    pub total_in: u64,
238    pub total_out: u64,
239    pub total_rate: f64,
240}
241
242/// Turns successive Prometheus renders into `TuiModel`s, computing
243/// records/s from consecutive sink-records totals.
244#[derive(Debug, Default)]
245pub struct Sampler {
246    pipeline: String,
247    prev_out: BTreeMap<String, (u64, std::time::Duration)>,
248}
249
250impl Sampler {
251    pub fn new(pipeline: impl Into<String>) -> Self {
252        Self {
253            pipeline: pipeline.into(),
254            prev_out: BTreeMap::new(),
255        }
256    }
257
258    /// Reduce one rendered scrape into a model. `elapsed` is time since the
259    /// live view started (monotonic), used for rate windows.
260    pub fn observe(&mut self, text: &str, elapsed: std::time::Duration) -> TuiModel {
261        let mut model = TuiModel::default();
262        for s in parse_samples(text) {
263            if s.labels.get("pipeline").map(String::as_str) != Some(self.pipeline.as_str()) {
264                continue;
265            }
266            let row_id = s.labels.get("row").cloned().unwrap_or_default();
267            let row = model.rows.entry(row_id).or_default();
268            let connector = s.labels.get("connector").cloned().unwrap_or_default();
269            match s.name.as_str() {
270                "faucet_source_records_total" => {
271                    row.records_in += s.value as u64;
272                    if !connector.is_empty() {
273                        row.source = connector;
274                    }
275                }
276                "faucet_source_pages_total" => row.pages += s.value as u64,
277                "faucet_sink_records_total" => {
278                    row.records_out += s.value as u64;
279                    if !connector.is_empty() {
280                        row.sink = connector;
281                    }
282                }
283                "faucet_source_errors_total" => row.source_errors += s.value as u64,
284                "faucet_sink_errors_total" => row.sink_errors += s.value as u64,
285                "faucet_sink_dlq_records_total" => row.dlq_records += s.value as u64,
286                "faucet_pipeline_last_bookmark_unix_seconds" => {
287                    row.last_bookmark_unix = s.value;
288                }
289                "faucet_pipeline_in_flight" => {
290                    if s.value > 0.0 {
291                        row.in_flight = true;
292                    }
293                }
294                "faucet_pipeline_runs_total" => {
295                    // Fill connector names even before data flows.
296                    if row.source.is_empty()
297                        && let Some(src) = s.labels.get("source")
298                    {
299                        row.source = src.clone();
300                    }
301                    if row.sink.is_empty()
302                        && let Some(dst) = s.labels.get("sink")
303                    {
304                        row.sink = dst.clone();
305                    }
306                    if s.value > 0.0 {
307                        match s.labels.get("status").map(String::as_str) {
308                            Some("ok") => row.finished = Some(row.finished.unwrap_or(true)),
309                            Some("err") => row.finished = Some(false),
310                            _ => {}
311                        }
312                    }
313                }
314                _ => {}
315            }
316        }
317        // Rates from the previous observation of the same row.
318        let mut prev = std::mem::take(&mut self.prev_out);
319        for (row_id, row) in &mut model.rows {
320            if let Some((prev_count, prev_at)) = prev.remove(row_id) {
321                let dt = elapsed.saturating_sub(prev_at).as_secs_f64();
322                // Counter reset (shouldn't happen in-process) → rate 0.
323                if dt > 0.0 && row.records_out >= prev_count {
324                    row.rate = (row.records_out - prev_count) as f64 / dt;
325                }
326            }
327            self.prev_out
328                .insert(row_id.clone(), (row.records_out, elapsed));
329            model.total_in += row.records_in;
330            model.total_out += row.records_out;
331            model.total_rate += row.rate;
332        }
333        model
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use std::time::Duration;
341
342    #[test]
343    fn parses_bare_and_labelled_samples() {
344        let text = "\
345# HELP faucet_x helper
346# TYPE faucet_x counter
347faucet_up 1
348faucet_source_records_total{pipeline=\"p\",row=\"r1\",connector=\"csv\"} 42
349";
350        let samples = parse_samples(text);
351        assert_eq!(samples.len(), 2);
352        assert_eq!(samples[0].name, "faucet_up");
353        assert_eq!(samples[0].value, 1.0);
354        assert!(samples[0].labels.is_empty());
355        assert_eq!(samples[1].labels["connector"], "csv");
356        assert_eq!(samples[1].value, 42.0);
357    }
358
359    #[test]
360    fn parses_escaped_label_values() {
361        let text = r#"m{a="quo\"te",b="back\\slash",c="new\nline"} 7"#;
362        let s = &parse_samples(text)[0];
363        assert_eq!(s.labels["a"], "quo\"te");
364        assert_eq!(s.labels["b"], "back\\slash");
365        assert_eq!(s.labels["c"], "new\nline");
366    }
367
368    #[test]
369    fn garbage_lines_are_skipped_not_fatal() {
370        let text = "not a metric\nname_only\n{}} 3\nok_metric 5\n";
371        let samples = parse_samples(text);
372        assert_eq!(samples.len(), 1);
373        assert_eq!(samples[0].name, "ok_metric");
374    }
375
376    fn render(pipeline: &str, row: &str, out: u64) -> String {
377        format!(
378            "faucet_sink_records_total{{pipeline=\"{pipeline}\",row=\"{row}\",connector=\"jsonl\"}} {out}\n"
379        )
380    }
381
382    #[test]
383    fn sampler_filters_by_pipeline_and_computes_rates() {
384        let mut s = Sampler::new("mine");
385        let t0 = Duration::from_secs(1);
386        let m = s.observe(&(render("mine", "a", 100) + &render("other", "a", 999)), t0);
387        assert_eq!(m.rows.len(), 1);
388        assert_eq!(m.rows["a"].records_out, 100);
389        assert_eq!(m.rows["a"].rate, 0.0, "no window yet");
390
391        let m = s.observe(&render("mine", "a", 300), Duration::from_secs(3));
392        assert!((m.rows["a"].rate - 100.0).abs() < 1e-9, "200 records / 2s");
393        assert_eq!(m.total_out, 300);
394    }
395
396    #[test]
397    fn sampler_counter_reset_yields_zero_rate() {
398        let mut s = Sampler::new("p");
399        s.observe(&render("p", "a", 500), Duration::from_secs(1));
400        let m = s.observe(&render("p", "a", 10), Duration::from_secs(2));
401        assert_eq!(m.rows["a"].rate, 0.0);
402    }
403
404    #[test]
405    fn sampler_aggregates_row_fields() {
406        let text = "\
407faucet_source_records_total{pipeline=\"p\",row=\"r\",connector=\"spanner\"} 10
408faucet_source_pages_total{pipeline=\"p\",row=\"r\",connector=\"spanner\"} 4
409faucet_sink_records_total{pipeline=\"p\",row=\"r\",connector=\"jsonl\"} 8
410faucet_source_errors_total{pipeline=\"p\",row=\"r\",connector=\"spanner\",kind=\"http\"} 1
411faucet_sink_errors_total{pipeline=\"p\",row=\"r\",connector=\"jsonl\",kind=\"io\"} 2
412faucet_sink_dlq_records_total{pipeline=\"p\",row=\"r\",connector=\"jsonl\"} 3
413faucet_pipeline_last_bookmark_unix_seconds{pipeline=\"p\",row=\"r\"} 1700000000
414faucet_pipeline_in_flight{pipeline=\"p\",row=\"r\"} 1
415";
416        let mut s = Sampler::new("p");
417        let m = s.observe(text, Duration::from_secs(1));
418        let row = &m.rows["r"];
419        assert_eq!(row.source, "spanner");
420        assert_eq!(row.sink, "jsonl");
421        assert_eq!(row.records_in, 10);
422        assert_eq!(row.records_out, 8);
423        assert_eq!(row.pages, 4);
424        assert_eq!(row.source_errors, 1);
425        assert_eq!(row.sink_errors, 2);
426        assert_eq!(row.dlq_records, 3);
427        assert_eq!(row.last_bookmark_unix, 1_700_000_000.0);
428        assert!(row.in_flight);
429        assert_eq!(row.finished, None);
430    }
431
432    #[test]
433    fn run_status_ok_and_err_map_to_finished() {
434        let ok = "faucet_pipeline_runs_total{pipeline=\"p\",row=\"r\",source=\"csv\",sink=\"stdout\",status=\"ok\"} 1\n";
435        let err = "faucet_pipeline_runs_total{pipeline=\"p\",row=\"r\",source=\"csv\",sink=\"stdout\",status=\"err\",kind=\"sink\"} 1\n";
436        let mut s = Sampler::new("p");
437        let m = s.observe(ok, Duration::from_secs(1));
438        assert_eq!(m.rows["r"].finished, Some(true));
439        assert_eq!(m.rows["r"].source, "csv");
440        assert_eq!(m.rows["r"].sink, "stdout");
441        let m = s.observe(&(ok.to_string() + err), Duration::from_secs(2));
442        // Any err marks the row failed even alongside an ok retry count.
443        assert_eq!(m.rows["r"].finished, Some(false));
444    }
445
446    #[test]
447    fn install_metrics_recorder_is_idempotent() {
448        let first = install_metrics_recorder(None).expect("first install");
449        let second = install_metrics_recorder(None).expect("second install");
450        let _ = first.render();
451        let _ = second.render();
452    }
453
454    #[test]
455    fn install_metrics_recorder_rejects_a_bad_listen_address() {
456        let prom = faucet_core::PrometheusConfig {
457            listen: "not-an-address".into(),
458            buckets: None,
459        };
460        let err = install_metrics_recorder(Some(&prom)).expect_err("bad listen");
461        assert!(matches!(err, CliError::Observability(_)), "{err:?}");
462    }
463
464    #[test]
465    fn setup_observability_installs_from_a_config_without_prometheus() {
466        // No `observability.prometheus` block → listener-less recorder + the
467        // rest of observability install (both idempotent). Covers the
468        // `setup_observability` wiring end to end.
469        let yaml = "version: 1\n\
470            pipeline:\n\
471            \x20 source: { type: rest, config: { base_url: \"http://x\" } }\n\
472            \x20 sink: { type: stdout, config: {} }\n";
473        let cfg = crate::config::parse_with_extension(yaml, "yaml").expect("parse cfg");
474        let handle = setup_observability(&cfg).expect("setup observability");
475        let _ = handle.render();
476    }
477
478    #[tokio::test]
479    async fn install_metrics_recorder_accepts_a_listener_config() {
480        // Ephemeral port; whichever install wins the process-global slot, the
481        // call must succeed and hand back a handle.
482        let prom = faucet_core::PrometheusConfig {
483            listen: "127.0.0.1:0".into(),
484            buckets: None,
485        };
486        let handle = install_metrics_recorder(Some(&prom)).expect("listener install");
487        let _ = handle.render();
488    }
489}