Skip to main content

faucet_cli/serve/
logs.rs

1//! Per-run log capture for SSE streaming (`GET /v1/runs/{id}/logs`, spec §12).
2//!
3//! [`RunLogLayer`] is a `tracing` `Layer` added to serve's global subscriber. It
4//! tags every span that carries a `serve_run_id` field (the
5//! `faucet.serve.run` span each run executes inside — see `runner.rs`) and, for
6//! every event in such a span's scope, formats a redacted line and pushes it into
7//! that run's per-run buffer: a bounded ring (for backfill) plus a `broadcast`
8//! channel (for the live tail). The `/logs` handler replays the ring, then
9//! streams the live tail via [`log_events`].
10//!
11//! **Ephemeral lifecycle.** Buffers live while a run is active plus a short drain
12//! window ([`LOG_DRAIN`]) for late fetchers, then are dropped regardless of
13//! `--retain-terminal-runs-secs` — only `RunRecord` metadata honours that
14//! retention. Bulk/historic logs belong in the centralized tracing sink.
15
16use crate::serve::history::{RUN_LOG_TRUNCATED_SEQ, RunHistory, RunLogLine};
17use dashmap::DashMap;
18use std::collections::{HashMap, VecDeque};
19use std::sync::Arc;
20use std::sync::Mutex;
21use std::sync::OnceLock;
22use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
23use std::time::Duration;
24use tokio::sync::{broadcast, mpsc};
25
26/// Per-run ring-buffer capacity (lines). Past this the oldest line is evicted and
27/// late `/logs` subscribers see a `truncated` event.
28pub const RING_CAPACITY: usize = 10_000;
29
30/// Live-tail broadcast channel depth. A `/logs` reader that falls this far behind
31/// gets a `truncated` event rather than blocking producers.
32pub const BROADCAST_CAPACITY: usize = 1024;
33
34/// How long a run's log buffer survives after the run reaches a terminal state,
35/// so a late `/logs` fetcher can still replay it. Independent of run-record
36/// retention (spec §12).
37pub const LOG_DRAIN: Duration = Duration::from_secs(60);
38
39/// Bound on the persistence channel between capture and the writer task (#529).
40/// A log storm past this drops lines (recorded via `faucet_serve_run_logs_dropped_total`)
41/// rather than blocking the pipeline.
42const PERSIST_CHANNEL_CAPACITY: usize = 16_384;
43
44/// Flush a run's pending persisted lines once its buffer reaches this size (the
45/// rest flush at run end).
46const PERSIST_BATCH: usize = 256;
47
48/// A message on the persistence channel (#529): a captured line, or a run-end
49/// signal telling the writer to flush that run's remaining buffer.
50enum PersistMsg {
51    Line {
52        run_id: String,
53        seq: u64,
54        ts: String,
55        level: String,
56        line: String,
57    },
58    End {
59        run_id: String,
60    },
61}
62
63/// A single captured log line, tagged with a monotonic sequence number so a late
64/// `/logs` subscriber can de-duplicate ring backfill against the live tail.
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct LogLine {
67    pub seq: u64,
68    pub line: String,
69}
70
71/// A message on a run's live-tail broadcast channel.
72#[derive(Clone, Debug)]
73pub enum LogMsg {
74    /// A newly captured log line.
75    Line(LogLine),
76    /// The run reached a terminal state; no further lines will arrive.
77    End,
78}
79
80/// One run's bounded log buffer: a ring for backfill + a broadcast for live tail.
81struct RunBuffer {
82    ring: Mutex<VecDeque<LogLine>>,
83    seq: AtomicU64,
84    tx: broadcast::Sender<LogMsg>,
85    ended: AtomicBool,
86}
87
88impl RunBuffer {
89    fn new() -> Self {
90        let (tx, _rx) = broadcast::channel(BROADCAST_CAPACITY);
91        Self {
92            ring: Mutex::new(VecDeque::with_capacity(64)),
93            seq: AtomicU64::new(0),
94            tx,
95            ended: AtomicBool::new(false),
96        }
97    }
98
99    /// Append a line: assign a sequence, push to the ring (evicting the oldest
100    /// past the cap), and best-effort broadcast to live subscribers. Returns the
101    /// assigned sequence (used as the durable-log ordering key, #529).
102    fn push(&self, line: String) -> u64 {
103        let seq = self.seq.fetch_add(1, Ordering::Relaxed);
104        let entry = LogLine { seq, line };
105        {
106            let mut ring = self.ring.lock().expect("log ring poisoned");
107            if ring.len() == RING_CAPACITY {
108                ring.pop_front();
109            }
110            ring.push_back(entry.clone());
111        }
112        // No live subscribers → Err; the ring still holds the line for backfill.
113        let _ = self.tx.send(LogMsg::Line(entry));
114        seq
115    }
116
117    fn snapshot(&self) -> Vec<LogLine> {
118        self.ring
119            .lock()
120            .expect("log ring poisoned")
121            .iter()
122            .cloned()
123            .collect()
124    }
125
126    /// Mark the run terminal and notify live subscribers. `ended` is stored
127    /// **before** the broadcast so a reader that misses the `End` message always
128    /// observes `is_ended() == true` (see [`LogHub::reader`]).
129    fn finish(&self) {
130        self.ended.store(true, Ordering::SeqCst);
131        let _ = self.tx.send(LogMsg::End);
132    }
133}
134
135/// Shared, cheaply-cloneable registry of per-run log buffers. One instance is
136/// created at subscriber install and shared between [`RunLogLayer`] and the
137/// `/logs` handler via `ServerState`.
138#[derive(Clone, Default)]
139pub struct LogHub {
140    inner: Arc<DashMap<String, Arc<RunBuffer>>>,
141    /// Set once (via [`enable_persistence`](LogHub::enable_persistence)) when a
142    /// durable history backend is configured (#529). `None` → ephemeral-only
143    /// behavior, unchanged.
144    persist: Arc<OnceLock<mpsc::Sender<PersistMsg>>>,
145}
146
147impl LogHub {
148    pub fn new() -> Self {
149        Self::default()
150    }
151
152    /// Get-or-create the buffer for a run (lazily, on first captured event).
153    fn buffer(&self, run_id: &str) -> Arc<RunBuffer> {
154        if let Some(b) = self.inner.get(run_id) {
155            return Arc::clone(b.value());
156        }
157        Arc::clone(
158            self.inner
159                .entry(run_id.to_string())
160                .or_insert_with(|| Arc::new(RunBuffer::new()))
161                .value(),
162        )
163    }
164
165    /// Turn on durable persistence of captured logs (#529): spawn a background
166    /// writer task that batches lines into `history`, and route captured lines to
167    /// it. `max_lines_per_run` caps how many lines are persisted per run (past it,
168    /// a truncation marker is recorded). Idempotent — a second call is a no-op.
169    pub fn enable_persistence(&self, history: Arc<dyn RunHistory>, max_lines_per_run: usize) {
170        let (tx, rx) = mpsc::channel(PERSIST_CHANNEL_CAPACITY);
171        if self.persist.set(tx).is_err() {
172            return; // already enabled
173        }
174        tokio::spawn(persist_writer(rx, history, max_lines_per_run.max(1)));
175    }
176
177    /// Capture a line for a run: push to the ephemeral ring (SSE) and, when
178    /// persistence is enabled, enqueue it for durable storage (#529). Called by
179    /// [`RunLogLayer::on_event`] with the pre-redacted line.
180    pub fn capture(&self, run_id: &str, level: &str, ts: String, line: String) {
181        let seq = self.buffer(run_id).push(line.clone());
182        if let Some(tx) = self.persist.get()
183            && tx
184                .try_send(PersistMsg::Line {
185                    run_id: run_id.to_string(),
186                    seq,
187                    ts,
188                    level: level.to_string(),
189                    line,
190                })
191                .is_err()
192        {
193            metrics::counter!("faucet_serve_run_logs_dropped_total", "reason" => "queue_full")
194                .increment(1);
195        }
196    }
197
198    /// Append a captured line without level/timestamp metadata (ephemeral-only;
199    /// used by tests and any caller that doesn't persist).
200    pub fn append(&self, run_id: &str, line: String) {
201        self.buffer(run_id).push(line);
202    }
203
204    /// Open a `/logs` reader: returns the ring snapshot, a live-tail receiver, and
205    /// whether the run has already ended. `None` means no buffer exists for the
206    /// run (never logged, or already dropped after the drain window).
207    ///
208    /// Subscribe-then-snapshot-then-load-`ended` ordering is deliberate: a reader
209    /// that misses the `End` broadcast still observes `ended == true`, so the
210    /// stream can close without hanging. Backfill/live duplicates are removed by
211    /// sequence number in [`log_events`].
212    pub fn reader(
213        &self,
214        run_id: &str,
215    ) -> Option<(Vec<LogLine>, broadcast::Receiver<LogMsg>, bool)> {
216        let buf = Arc::clone(self.inner.get(run_id)?.value());
217        let rx = buf.tx.subscribe();
218        let snapshot = buf.snapshot();
219        let ended = buf.ended.load(Ordering::SeqCst);
220        Some((snapshot, rx, ended))
221    }
222
223    /// Mark a run terminal: broadcast `End` so live readers can close, and flush
224    /// the run's durable-log buffer (#529).
225    pub fn finish(&self, run_id: &str) {
226        if let Some(buf) = self.inner.get(run_id) {
227            buf.finish();
228        }
229        if let Some(tx) = self.persist.get() {
230            let _ = tx.try_send(PersistMsg::End {
231                run_id: run_id.to_string(),
232            });
233        }
234    }
235
236    /// Drop a run's buffer, freeing its ring (called after the drain window).
237    pub fn drop_run(&self, run_id: &str) {
238        self.inner.remove(run_id);
239    }
240}
241
242/// Per-run persistence state held by the writer task.
243#[derive(Default)]
244struct RunPersistState {
245    /// Lines buffered for the next batch insert.
246    pending: Vec<RunLogLine>,
247    /// Total lines persisted for this run so far (against the per-run cap).
248    persisted: u64,
249    /// Whether the per-run cap has been hit (→ a truncation marker at End).
250    truncated: bool,
251}
252
253/// Background task draining the persistence channel (#529): batches captured
254/// lines per run into `history`, enforces the per-run cap, and flushes at run
255/// end. All failures are logged, never fatal — persistence must never affect a
256/// run.
257async fn persist_writer(
258    mut rx: mpsc::Receiver<PersistMsg>,
259    history: Arc<dyn RunHistory>,
260    max_lines_per_run: usize,
261) {
262    let cap = max_lines_per_run as u64;
263    let mut runs: HashMap<String, RunPersistState> = HashMap::new();
264
265    async fn flush(history: &Arc<dyn RunHistory>, run_id: &str, st: &mut RunPersistState) {
266        if st.pending.is_empty() {
267            return;
268        }
269        let batch = std::mem::take(&mut st.pending);
270        let n = batch.len() as u64;
271        if let Err(e) = history.record_run_logs(run_id, &batch).await {
272            tracing::warn!(run_id, error = %e, "persisting run logs failed");
273            metrics::counter!("faucet_serve_run_logs_dropped_total", "reason" => "backend_error")
274                .increment(n);
275        } else {
276            metrics::counter!("faucet_serve_run_log_lines_total").increment(n);
277        }
278    }
279
280    while let Some(msg) = rx.recv().await {
281        match msg {
282            PersistMsg::Line {
283                run_id,
284                seq,
285                ts,
286                level,
287                line,
288            } => {
289                let st = runs.entry(run_id.clone()).or_default();
290                if st.persisted >= cap {
291                    if !st.truncated {
292                        st.truncated = true;
293                        metrics::counter!(
294                            "faucet_serve_run_logs_dropped_total", "reason" => "per_run_cap"
295                        )
296                        .increment(1);
297                    }
298                    continue;
299                }
300                st.persisted += 1;
301                st.pending.push(RunLogLine {
302                    seq,
303                    ts,
304                    level,
305                    line,
306                });
307                if st.pending.len() >= PERSIST_BATCH {
308                    flush(&history, &run_id, st).await;
309                }
310            }
311            PersistMsg::End { run_id } => {
312                if let Some(mut st) = runs.remove(&run_id) {
313                    flush(&history, &run_id, &mut st).await;
314                    if st.truncated {
315                        // Record a single sentinel so `list_run_logs` reports the gap.
316                        let marker = [RunLogLine {
317                            seq: RUN_LOG_TRUNCATED_SEQ,
318                            ts: String::new(),
319                            level: "WARN".to_string(),
320                            line: "log truncated: per-run cap reached".to_string(),
321                        }];
322                        if let Err(e) = history.record_run_logs(&run_id, &marker).await {
323                            tracing::warn!(run_id, error = %e, "persisting run-log truncation marker failed");
324                        }
325                    }
326                }
327            }
328        }
329    }
330}
331
332/// An SSE-bound log event, decoupled from `axum`'s `Event` so the streaming logic
333/// (ring replay → live tail, de-dup, lag handling) is unit-testable.
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub enum LogEvent {
336    /// A log line.
337    Log(String),
338    /// `n` live-tail lines were dropped because the reader fell behind.
339    Truncated(u64),
340    /// Terminal: the run finished and the stream should close.
341    End,
342}
343
344/// Build the ordered event stream for a `/logs` request: replay the ring
345/// snapshot, then forward the live tail, de-duplicating against the snapshot by
346/// sequence number and mapping `broadcast` lag to [`LogEvent::Truncated`].
347pub fn log_events(
348    snapshot: Vec<LogLine>,
349    mut rx: broadcast::Receiver<LogMsg>,
350    ended: bool,
351) -> impl futures::Stream<Item = LogEvent> {
352    use tokio::sync::broadcast::error::RecvError;
353    async_stream::stream! {
354        let mut last_seq: Option<u64> = None;
355        for entry in snapshot {
356            last_seq = Some(entry.seq);
357            yield LogEvent::Log(entry.line);
358        }
359        // The run already ended before we subscribed: the ring is the whole story.
360        if ended {
361            yield LogEvent::End;
362            return;
363        }
364        loop {
365            match rx.recv().await {
366                Ok(LogMsg::Line(entry)) => {
367                    if last_seq.is_none_or(|s| entry.seq > s) {
368                        last_seq = Some(entry.seq);
369                        yield LogEvent::Log(entry.line);
370                    }
371                }
372                Ok(LogMsg::End) => {
373                    yield LogEvent::End;
374                    break;
375                }
376                Err(RecvError::Lagged(n)) => {
377                    yield LogEvent::Truncated(n);
378                }
379                Err(RecvError::Closed) => {
380                    yield LogEvent::End;
381                    break;
382                }
383            }
384        }
385    }
386}
387
388// ── tracing layer ───────────────────────────────────────────────────────────
389
390use tracing::field::{Field, Visit};
391use tracing::span::{Attributes, Id};
392use tracing::{Event, Subscriber};
393use tracing_subscriber::layer::{Context, Layer};
394use tracing_subscriber::registry::LookupSpan;
395
396/// Span extension marking a span (and, via scope walking, its descendants) as
397/// belonging to a serve run.
398#[derive(Clone)]
399struct RunIdExt(String);
400
401/// Extracts a `serve_run_id` field value from span attributes.
402#[derive(Default)]
403struct RunIdVisitor(Option<String>);
404
405impl Visit for RunIdVisitor {
406    fn record_str(&mut self, field: &Field, value: &str) {
407        if field.name() == "serve_run_id" {
408            self.0 = Some(value.to_string());
409        }
410    }
411    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
412        // `%run_id` records via Display → Debug-of-DisplayValue, i.e. unquoted.
413        if field.name() == "serve_run_id" && self.0.is_none() {
414            self.0 = Some(format!("{value:?}"));
415        }
416    }
417}
418
419/// Formats an event's fields into a single log line: the `message` field, then
420/// any remaining `key=value` fields.
421#[derive(Default)]
422struct EventLineVisitor {
423    message: String,
424    fields: String,
425}
426
427impl Visit for EventLineVisitor {
428    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
429        use std::fmt::Write;
430        if field.name() == "message" {
431            let _ = write!(self.message, "{value:?}");
432        } else {
433            let _ = write!(self.fields, " {}={:?}", field.name(), value);
434        }
435    }
436}
437
438impl EventLineVisitor {
439    fn finish(self) -> String {
440        if self.fields.is_empty() {
441            self.message
442        } else {
443            format!("{}{}", self.message, self.fields)
444        }
445    }
446}
447
448/// Tracing layer that captures events tagged with a `serve_run_id` into the
449/// [`LogHub`] for SSE streaming. Added to serve's global subscriber alongside the
450/// redacting fmt layer (`observability.rs`).
451pub struct RunLogLayer {
452    hub: LogHub,
453}
454
455impl RunLogLayer {
456    pub fn new(hub: LogHub) -> Self {
457        Self { hub }
458    }
459}
460
461impl<S> Layer<S> for RunLogLayer
462where
463    S: Subscriber + for<'a> LookupSpan<'a>,
464{
465    fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
466        let mut visitor = RunIdVisitor::default();
467        attrs.record(&mut visitor);
468        if let Some(run_id) = visitor.0
469            && let Some(span) = ctx.span(id)
470        {
471            span.extensions_mut().insert(RunIdExt(run_id));
472        }
473    }
474
475    fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
476        let Some(run_id) = ctx.event_scope(event).and_then(|scope| {
477            scope
478                .from_root()
479                .find_map(|span| span.extensions().get::<RunIdExt>().map(|ext| ext.0.clone()))
480        }) else {
481            return;
482        };
483        let mut visitor = EventLineVisitor::default();
484        event.record(&mut visitor);
485        let meta = event.metadata();
486        let line = format!("{} {}: {}", meta.level(), meta.target(), visitor.finish());
487        let line = crate::secrets::registry::redact(&line).into_owned();
488        let ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
489        self.hub.capture(&run_id, meta.level().as_str(), ts, line);
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use futures::StreamExt;
497
498    #[test]
499    fn ring_caps_and_orders_by_seq() {
500        let hub = LogHub::new();
501        for i in 0..(RING_CAPACITY + 5) {
502            hub.append("r", format!("line-{i}"));
503        }
504        let (snapshot, _rx, _ended) = hub.reader("r").unwrap();
505        assert_eq!(snapshot.len(), RING_CAPACITY, "ring must cap at capacity");
506        // The five oldest lines were evicted; line-5 is now the front.
507        assert_eq!(snapshot.first().unwrap().line, "line-5");
508        assert!(
509            snapshot.windows(2).all(|w| w[0].seq < w[1].seq),
510            "sequence numbers must be strictly increasing"
511        );
512    }
513
514    #[test]
515    fn reader_none_for_unknown_run() {
516        let hub = LogHub::new();
517        assert!(hub.reader("nope").is_none());
518    }
519
520    #[test]
521    fn finish_sets_ended_flag() {
522        let hub = LogHub::new();
523        hub.append("r", "x".into());
524        hub.finish("r");
525        let (snapshot, _rx, ended) = hub.reader("r").unwrap();
526        assert!(ended, "reader must observe ended after finish");
527        assert_eq!(snapshot.len(), 1);
528    }
529
530    #[test]
531    fn drop_run_frees_buffer() {
532        let hub = LogHub::new();
533        hub.append("r", "x".into());
534        assert!(hub.reader("r").is_some());
535        hub.drop_run("r");
536        assert!(hub.reader("r").is_none());
537    }
538
539    #[tokio::test]
540    async fn ended_buffer_streams_snapshot_then_end() {
541        let hub = LogHub::new();
542        hub.append("r", "a".into());
543        hub.append("r", "b".into());
544        hub.finish("r");
545        let (snapshot, rx, ended) = hub.reader("r").unwrap();
546        let events: Vec<LogEvent> = log_events(snapshot, rx, ended).collect().await;
547        assert_eq!(
548            events,
549            vec![
550                LogEvent::Log("a".into()),
551                LogEvent::Log("b".into()),
552                LogEvent::End
553            ]
554        );
555    }
556
557    #[tokio::test]
558    async fn snapshot_then_live_dedups_by_seq() {
559        let (tx, rx) = broadcast::channel(8);
560        // seq 0 lands in BOTH the snapshot and the broadcast — must not duplicate.
561        let _ = tx.send(LogMsg::Line(LogLine {
562            seq: 0,
563            line: "a".into(),
564        }));
565        let _ = tx.send(LogMsg::Line(LogLine {
566            seq: 1,
567            line: "b".into(),
568        }));
569        let _ = tx.send(LogMsg::End);
570        let snapshot = vec![LogLine {
571            seq: 0,
572            line: "a".into(),
573        }];
574        let events: Vec<LogEvent> = log_events(snapshot, rx, false).collect().await;
575        assert_eq!(
576            events,
577            vec![
578                LogEvent::Log("a".into()),
579                LogEvent::Log("b".into()),
580                LogEvent::End
581            ]
582        );
583    }
584
585    #[tokio::test]
586    async fn truncated_emitted_on_broadcast_lag() {
587        // Fill the channel past capacity before the receiver reads → Lagged.
588        let (tx, rx) = broadcast::channel(2);
589        for i in 0..5u64 {
590            let _ = tx.send(LogMsg::Line(LogLine {
591                seq: i,
592                line: format!("l{i}"),
593            }));
594        }
595        let _ = tx.send(LogMsg::End);
596        let events: Vec<LogEvent> = log_events(vec![], rx, false).collect().await;
597        assert!(
598            events.iter().any(|e| matches!(e, LogEvent::Truncated(_))),
599            "a lagging reader must get a Truncated event: {events:?}"
600        );
601        assert_eq!(events.last(), Some(&LogEvent::End));
602    }
603
604    #[test]
605    fn layer_captures_events_in_run_span_only() {
606        use tracing_subscriber::layer::SubscriberExt;
607        let hub = LogHub::new();
608        let subscriber = tracing_subscriber::registry().with(RunLogLayer::new(hub.clone()));
609        tracing::subscriber::with_default(subscriber, || {
610            // An event outside any serve-run span is ignored.
611            tracing::info!("orphan event");
612            let span = tracing::info_span!("faucet.serve.run", serve_run_id = "run-xyz");
613            let _g = span.enter();
614            tracing::info!("hello from the run");
615        });
616        let (snapshot, _rx, _ended) = hub.reader("run-xyz").expect("buffer for the run exists");
617        assert!(
618            snapshot
619                .iter()
620                .any(|l| l.line.contains("hello from the run")),
621            "in-span event must be captured: {snapshot:?}"
622        );
623        assert!(
624            snapshot.iter().all(|l| !l.line.contains("orphan")),
625            "events outside the run span must not be captured"
626        );
627        // No buffer is created for events that never had a serve_run_id span.
628        assert!(hub.reader("nonexistent").is_none());
629    }
630}