1use crate::error::{CliError, CliResult};
16use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
17use std::collections::BTreeMap;
18use std::sync::OnceLock;
19
20const 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
31static RECORDER_HANDLE: OnceLock<PrometheusHandle> = OnceLock::new();
35
36pub fn install_metrics_recorder(
43 prom: Option<&faucet_core::PrometheusConfig>,
44) -> CliResult<PrometheusHandle> {
45 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
96pub 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#[derive(Debug, Clone, PartialEq)]
114pub struct Sample {
115 pub name: String,
116 pub labels: BTreeMap<String, String>,
117 pub value: f64,
118}
119
120pub 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 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
166fn parse_labels(body: &str) -> Option<BTreeMap<String, String>> {
169 let mut labels = BTreeMap::new();
170 let mut chars = body.chars().peekable();
171 loop {
172 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#[derive(Debug, Clone, Default, PartialEq)]
211pub struct RowStats {
212 pub source: String,
214 pub sink: String,
216 pub records_in: u64,
217 pub records_out: u64,
218 pub pages: u64,
220 pub rate: f64,
222 pub source_errors: u64,
223 pub sink_errors: u64,
224 pub dlq_records: u64,
225 pub last_bookmark_unix: f64,
227 pub finished: Option<bool>,
229 pub in_flight: bool,
230}
231
232#[derive(Debug, Clone, Default, PartialEq)]
234pub struct TuiModel {
235 pub rows: BTreeMap<String, RowStats>,
237 pub total_in: u64,
238 pub total_out: u64,
239 pub total_rate: f64,
240}
241
242#[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 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 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 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 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 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 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 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}