1pub mod metrics;
20pub mod view;
21
22use crate::error::{CliError, CliResult};
23use faucet_core::CancellationToken;
24use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
25use std::collections::VecDeque;
26use std::io::IsTerminal;
27use std::sync::{Arc, Mutex, OnceLock};
28
29#[derive(Clone, Default)]
32pub struct LogBuffer {
33 inner: Arc<Mutex<VecDeque<String>>>,
34}
35
36const LOG_BUFFER_CAP: usize = 200;
37
38impl LogBuffer {
39 pub fn push_line(&self, line: &str) {
41 let line = crate::secrets::registry::redact(line).into_owned();
42 let mut q = self.inner.lock().unwrap_or_else(|p| p.into_inner());
43 if q.len() == LOG_BUFFER_CAP {
44 q.pop_front();
45 }
46 q.push_back(line);
47 }
48
49 pub fn snapshot(&self) -> Vec<String> {
51 self.inner
52 .lock()
53 .unwrap_or_else(|p| p.into_inner())
54 .iter()
55 .cloned()
56 .collect()
57 }
58}
59
60pub struct LogBufferWriter {
62 buffer: LogBuffer,
63 partial: String,
64}
65
66impl std::io::Write for LogBufferWriter {
67 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
68 self.partial.push_str(&String::from_utf8_lossy(buf));
69 while let Some(at) = self.partial.find('\n') {
70 let line: String = self.partial.drain(..=at).collect();
71 let line = line.trim_end();
72 if !line.is_empty() {
73 self.buffer.push_line(line);
74 }
75 }
76 Ok(buf.len())
77 }
78 fn flush(&mut self) -> std::io::Result<()> {
79 Ok(())
80 }
81}
82
83impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogBuffer {
84 type Writer = LogBufferWriter;
85 fn make_writer(&'a self) -> Self::Writer {
86 LogBufferWriter {
87 buffer: self.clone(),
88 partial: String::new(),
89 }
90 }
91}
92
93static TUI_LOGS: OnceLock<LogBuffer> = OnceLock::new();
96
97pub fn is_tui_session(tui_flag: bool) -> bool {
100 tui_flag && std::io::stdout().is_terminal()
101}
102
103pub fn install_tui_tracing(level: &str) -> LogBuffer {
107 use tracing_subscriber::EnvFilter;
108 let buffer = TUI_LOGS.get_or_init(LogBuffer::default).clone();
109 let filter = EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info"));
110 let _ = tracing_subscriber::fmt()
111 .with_env_filter(filter)
112 .with_ansi(false)
113 .with_writer(buffer.clone())
114 .try_init();
115 buffer
116}
117
118pub fn log_buffer() -> Option<LogBuffer> {
120 TUI_LOGS.get().cloned()
121}
122
123const DEFAULT_BUCKETS: &[f64] = &[
126 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 10.0, 30.0, 60.0, 300.0,
127];
128
129static RECORDER_HANDLE: OnceLock<PrometheusHandle> = OnceLock::new();
133
134pub fn install_metrics_recorder(
141 prom: Option<&faucet_core::PrometheusConfig>,
142) -> CliResult<PrometheusHandle> {
143 let listen: Option<std::net::SocketAddr> = match prom {
146 Some(p) => Some(
147 p.listen
148 .parse()
149 .map_err(|e| CliError::Observability(format!("prometheus listen: {e}")))?,
150 ),
151 None => None,
152 };
153 if let Some(handle) = RECORDER_HANDLE.get() {
154 return Ok(handle.clone());
155 }
156 let handle = match (prom, listen) {
157 (Some(p), Some(listen)) => {
158 let (recorder, exporter) = PrometheusBuilder::new()
159 .with_http_listener(listen)
160 .set_buckets(p.buckets.as_deref().unwrap_or(DEFAULT_BUCKETS))
161 .map_err(|e| CliError::Observability(e.to_string()))?
162 .build()
163 .map_err(|e| CliError::Observability(e.to_string()))?;
164 let handle = recorder.handle();
165 if ::metrics::set_global_recorder(recorder).is_ok() {
166 tokio::spawn(exporter);
167 tracing::info!("Prometheus /metrics listening on {}", p.listen);
168 } else {
169 warn_recorder_occupied();
170 }
171 handle
172 }
173 _ => {
174 let recorder = PrometheusBuilder::new()
175 .set_buckets(DEFAULT_BUCKETS)
176 .map_err(|e| CliError::Observability(e.to_string()))?
177 .build_recorder();
178 let handle = recorder.handle();
179 if ::metrics::set_global_recorder(recorder).is_err() {
180 warn_recorder_occupied();
181 }
182 handle
183 }
184 };
185 Ok(RECORDER_HANDLE.get_or_init(|| handle).clone())
186}
187
188fn warn_recorder_occupied() {
189 tracing::warn!(
190 "metrics recorder already installed; the TUI may show no data (was a recorder installed before `faucet run --tui`?)"
191 );
192}
193
194pub fn setup_observability(cfg: &crate::config::PipelineConfig) -> CliResult<PrometheusHandle> {
200 let mut obs_cfg = crate::obs::build_observability_config(cfg);
201 let prom = obs_cfg.prometheus.take();
202 let handle = install_metrics_recorder(prom.as_ref())?;
203 faucet_core::install_observability(&obs_cfg)?;
204 Ok(handle)
205}
206
207pub trait CancelEvents {
210 fn cancel_requested(&mut self) -> bool;
213}
214
215struct CrosstermEvents;
217
218impl CancelEvents for CrosstermEvents {
219 fn cancel_requested(&mut self) -> bool {
220 use ratatui::crossterm::event::{Event, KeyCode, KeyModifiers};
221 let mut requested = false;
222 while ratatui::crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) {
223 match ratatui::crossterm::event::read() {
224 Ok(Event::Key(key)) => {
225 let ctrl_c = key.code == KeyCode::Char('c')
226 && key.modifiers.contains(KeyModifiers::CONTROL);
227 if key.code == KeyCode::Char('q') || ctrl_c {
228 requested = true;
229 }
230 }
231 Ok(_) => {}
232 Err(_) => break,
233 }
234 }
235 requested
236 }
237}
238
239pub async fn drive<T>(
243 run: impl Future<Output = T>,
244 pipeline: &str,
245 handle: PrometheusHandle,
246 cancel: CancellationToken,
247) -> T {
248 let mut terminal = ratatui::init();
251 let result = drive_loop(
252 &mut terminal,
253 CrosstermEvents,
254 run,
255 pipeline,
256 handle,
257 cancel,
258 std::time::Duration::from_millis(250),
259 )
260 .await;
261 ratatui::restore();
262 result
263}
264
265pub async fn drive_loop<B, E, T>(
268 terminal: &mut ratatui::Terminal<B>,
269 mut events: E,
270 run: impl Future<Output = T>,
271 pipeline: &str,
272 handle: PrometheusHandle,
273 cancel: CancellationToken,
274 tick: std::time::Duration,
275) -> T
276where
277 B: ratatui::backend::Backend,
278 E: CancelEvents,
279{
280 let started = std::time::Instant::now();
281 let mut sampler = metrics::Sampler::new(pipeline);
282 let logs = log_buffer().unwrap_or_default();
283 let mut interval = tokio::time::interval(tick);
284 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
285
286 tokio::pin!(run);
287 loop {
288 tokio::select! {
289 biased;
290 result = &mut run => break result,
291 _ = interval.tick() => {
292 if events.cancel_requested() {
293 cancel.cancel();
294 }
295 handle.run_upkeep();
296 let model = sampler.observe(&handle.render(), started.elapsed());
297 let log_lines = logs.snapshot();
298 let cancelling = cancel.is_cancelled();
299 let _ = terminal.draw(|frame| {
300 view::draw(frame, pipeline, &model, started.elapsed(), &log_lines, cancelling);
301 });
302 }
303 }
304 }
305}
306
307pub fn flush_logs_to_stderr(max_lines: usize) {
311 if let Some(buffer) = log_buffer() {
312 let lines = buffer.snapshot();
313 let start = lines.len().saturating_sub(max_lines);
314 for line in &lines[start..] {
315 eprintln!("{line}");
316 }
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use std::io::Write;
324
325 #[test]
326 fn log_buffer_caps_and_orders_lines() {
327 let buffer = LogBuffer::default();
328 for i in 0..(LOG_BUFFER_CAP + 10) {
329 buffer.push_line(&format!("line {i}"));
330 }
331 let snap = buffer.snapshot();
332 assert_eq!(snap.len(), LOG_BUFFER_CAP);
333 assert_eq!(snap.first().unwrap(), "line 10");
334 assert_eq!(
335 snap.last().unwrap(),
336 &format!("line {}", LOG_BUFFER_CAP + 9)
337 );
338 }
339
340 #[test]
341 fn writer_splits_lines_and_keeps_partials() {
342 let buffer = LogBuffer::default();
343 let mut writer = LogBufferWriter {
344 buffer: buffer.clone(),
345 partial: String::new(),
346 };
347 writer.write_all(b"first line\nsecond ").unwrap();
348 writer.write_all(b"half\ntrailing").unwrap();
349 let snap = buffer.snapshot();
350 assert_eq!(
351 snap,
352 vec!["first line".to_string(), "second half".to_string()]
353 );
354 }
355
356 #[test]
357 fn log_lines_are_redacted_at_capture() {
358 crate::secrets::registry::register("tui-secret-value");
359 let buffer = LogBuffer::default();
360 buffer.push_line("token is tui-secret-value here");
361 let snap = buffer.snapshot();
362 assert!(!snap[0].contains("tui-secret-value"), "got: {}", snap[0]);
363 }
364
365 #[test]
366 fn non_tty_is_not_a_tui_session() {
367 assert!(!is_tui_session(true) || std::io::stdout().is_terminal());
370 assert!(!is_tui_session(false));
371 }
372
373 struct ScriptedEvents {
375 cancel_on_call: usize,
376 calls: usize,
377 }
378
379 impl CancelEvents for ScriptedEvents {
380 fn cancel_requested(&mut self) -> bool {
381 self.calls += 1;
382 self.calls == self.cancel_on_call
383 }
384 }
385
386 struct NoEvents;
388 impl CancelEvents for NoEvents {
389 fn cancel_requested(&mut self) -> bool {
390 false
391 }
392 }
393
394 fn test_terminal() -> ratatui::Terminal<ratatui::backend::TestBackend> {
395 ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 24)).expect("terminal")
396 }
397
398 fn recorder_handle() -> PrometheusHandle {
399 install_metrics_recorder(None).expect("recorder")
403 }
404
405 #[tokio::test(start_paused = true)]
406 async fn drive_loop_ticks_render_and_exit_on_run_completion() {
407 let mut terminal = test_terminal();
408 let cancel = CancellationToken::new();
409 let result = drive_loop(
410 &mut terminal,
411 NoEvents,
412 async {
413 tokio::time::sleep(std::time::Duration::from_millis(320)).await;
414 42
415 },
416 "loop-pipeline",
417 recorder_handle(),
418 cancel.clone(),
419 std::time::Duration::from_millis(100),
420 )
421 .await;
422 assert_eq!(result, 42);
423 assert!(!cancel.is_cancelled());
424 let text: String = terminal
426 .backend()
427 .buffer()
428 .content()
429 .iter()
430 .map(|cell| cell.symbol())
431 .collect();
432 assert!(text.contains("faucet run · loop-pipeline"), "{text}");
433 }
434
435 #[tokio::test(start_paused = true)]
436 async fn drive_loop_fires_the_cancel_token_on_user_request() {
437 let mut terminal = test_terminal();
438 let cancel = CancellationToken::new();
439 let run_cancel = cancel.clone();
440 let result = drive_loop(
441 &mut terminal,
442 ScriptedEvents {
443 cancel_on_call: 2,
444 calls: 0,
445 },
446 async move {
447 run_cancel.cancelled().await;
449 "cancelled"
450 },
451 "loop-pipeline",
452 recorder_handle(),
453 cancel.clone(),
454 std::time::Duration::from_millis(50),
455 )
456 .await;
457 assert_eq!(result, "cancelled");
458 assert!(cancel.is_cancelled());
459 let text: String = terminal
461 .backend()
462 .buffer()
463 .content()
464 .iter()
465 .map(|cell| cell.symbol())
466 .collect();
467 assert!(text.contains("cancelling…"), "{text}");
468 }
469
470 #[test]
471 fn install_metrics_recorder_is_idempotent() {
472 let first = install_metrics_recorder(None).expect("first install");
473 let second = install_metrics_recorder(None).expect("second install");
474 let _ = first.render();
476 let _ = second.render();
477 }
478
479 #[test]
480 fn install_metrics_recorder_rejects_a_bad_listen_address() {
481 let prom = faucet_core::PrometheusConfig {
482 listen: "not-an-address".into(),
483 buckets: None,
484 };
485 let err = install_metrics_recorder(Some(&prom)).expect_err("bad listen");
486 assert!(matches!(err, CliError::Observability(_)), "{err:?}");
487 }
488
489 #[tokio::test]
490 async fn install_metrics_recorder_accepts_a_listener_config() {
491 let prom = faucet_core::PrometheusConfig {
494 listen: "127.0.0.1:0".into(),
495 buckets: None,
496 };
497 let handle = install_metrics_recorder(Some(&prom)).expect("listener install");
498 let _ = handle.render();
499 }
500
501 #[test]
502 fn flush_logs_to_stderr_replays_the_tail() {
503 let buffer = install_tui_tracing("info");
504 buffer.push_line("tail line A");
505 buffer.push_line("tail line B");
506 flush_logs_to_stderr(1);
508 flush_logs_to_stderr(1000);
509 }
510}