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 dashmap::DashMap;
17use std::collections::VecDeque;
18use std::sync::Arc;
19use std::sync::Mutex;
20use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
21use std::time::Duration;
22use tokio::sync::broadcast;
23
24/// Per-run ring-buffer capacity (lines). Past this the oldest line is evicted and
25/// late `/logs` subscribers see a `truncated` event.
26pub const RING_CAPACITY: usize = 10_000;
27
28/// Live-tail broadcast channel depth. A `/logs` reader that falls this far behind
29/// gets a `truncated` event rather than blocking producers.
30pub const BROADCAST_CAPACITY: usize = 1024;
31
32/// How long a run's log buffer survives after the run reaches a terminal state,
33/// so a late `/logs` fetcher can still replay it. Independent of run-record
34/// retention (spec §12).
35pub const LOG_DRAIN: Duration = Duration::from_secs(60);
36
37/// A single captured log line, tagged with a monotonic sequence number so a late
38/// `/logs` subscriber can de-duplicate ring backfill against the live tail.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct LogLine {
41    pub seq: u64,
42    pub line: String,
43}
44
45/// A message on a run's live-tail broadcast channel.
46#[derive(Clone, Debug)]
47pub enum LogMsg {
48    /// A newly captured log line.
49    Line(LogLine),
50    /// The run reached a terminal state; no further lines will arrive.
51    End,
52}
53
54/// One run's bounded log buffer: a ring for backfill + a broadcast for live tail.
55struct RunBuffer {
56    ring: Mutex<VecDeque<LogLine>>,
57    seq: AtomicU64,
58    tx: broadcast::Sender<LogMsg>,
59    ended: AtomicBool,
60}
61
62impl RunBuffer {
63    fn new() -> Self {
64        let (tx, _rx) = broadcast::channel(BROADCAST_CAPACITY);
65        Self {
66            ring: Mutex::new(VecDeque::with_capacity(64)),
67            seq: AtomicU64::new(0),
68            tx,
69            ended: AtomicBool::new(false),
70        }
71    }
72
73    /// Append a line: assign a sequence, push to the ring (evicting the oldest
74    /// past the cap), and best-effort broadcast to live subscribers.
75    fn push(&self, line: String) {
76        let seq = self.seq.fetch_add(1, Ordering::Relaxed);
77        let entry = LogLine { seq, line };
78        {
79            let mut ring = self.ring.lock().expect("log ring poisoned");
80            if ring.len() == RING_CAPACITY {
81                ring.pop_front();
82            }
83            ring.push_back(entry.clone());
84        }
85        // No live subscribers → Err; the ring still holds the line for backfill.
86        let _ = self.tx.send(LogMsg::Line(entry));
87    }
88
89    fn snapshot(&self) -> Vec<LogLine> {
90        self.ring
91            .lock()
92            .expect("log ring poisoned")
93            .iter()
94            .cloned()
95            .collect()
96    }
97
98    /// Mark the run terminal and notify live subscribers. `ended` is stored
99    /// **before** the broadcast so a reader that misses the `End` message always
100    /// observes `is_ended() == true` (see [`LogHub::reader`]).
101    fn finish(&self) {
102        self.ended.store(true, Ordering::SeqCst);
103        let _ = self.tx.send(LogMsg::End);
104    }
105}
106
107/// Shared, cheaply-cloneable registry of per-run log buffers. One instance is
108/// created at subscriber install and shared between [`RunLogLayer`] and the
109/// `/logs` handler via `ServerState`.
110#[derive(Clone, Default)]
111pub struct LogHub {
112    inner: Arc<DashMap<String, Arc<RunBuffer>>>,
113}
114
115impl LogHub {
116    pub fn new() -> Self {
117        Self::default()
118    }
119
120    /// Get-or-create the buffer for a run (lazily, on first captured event).
121    fn buffer(&self, run_id: &str) -> Arc<RunBuffer> {
122        if let Some(b) = self.inner.get(run_id) {
123            return Arc::clone(b.value());
124        }
125        Arc::clone(
126            self.inner
127                .entry(run_id.to_string())
128                .or_insert_with(|| Arc::new(RunBuffer::new()))
129                .value(),
130        )
131    }
132
133    /// Append a captured line for a run (called by [`RunLogLayer::on_event`]).
134    pub fn append(&self, run_id: &str, line: String) {
135        self.buffer(run_id).push(line);
136    }
137
138    /// Open a `/logs` reader: returns the ring snapshot, a live-tail receiver, and
139    /// whether the run has already ended. `None` means no buffer exists for the
140    /// run (never logged, or already dropped after the drain window).
141    ///
142    /// Subscribe-then-snapshot-then-load-`ended` ordering is deliberate: a reader
143    /// that misses the `End` broadcast still observes `ended == true`, so the
144    /// stream can close without hanging. Backfill/live duplicates are removed by
145    /// sequence number in [`log_events`].
146    pub fn reader(
147        &self,
148        run_id: &str,
149    ) -> Option<(Vec<LogLine>, broadcast::Receiver<LogMsg>, bool)> {
150        let buf = Arc::clone(self.inner.get(run_id)?.value());
151        let rx = buf.tx.subscribe();
152        let snapshot = buf.snapshot();
153        let ended = buf.ended.load(Ordering::SeqCst);
154        Some((snapshot, rx, ended))
155    }
156
157    /// Mark a run terminal: broadcast `End` so live readers can close.
158    pub fn finish(&self, run_id: &str) {
159        if let Some(buf) = self.inner.get(run_id) {
160            buf.finish();
161        }
162    }
163
164    /// Drop a run's buffer, freeing its ring (called after the drain window).
165    pub fn drop_run(&self, run_id: &str) {
166        self.inner.remove(run_id);
167    }
168}
169
170/// An SSE-bound log event, decoupled from `axum`'s `Event` so the streaming logic
171/// (ring replay → live tail, de-dup, lag handling) is unit-testable.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub enum LogEvent {
174    /// A log line.
175    Log(String),
176    /// `n` live-tail lines were dropped because the reader fell behind.
177    Truncated(u64),
178    /// Terminal: the run finished and the stream should close.
179    End,
180}
181
182/// Build the ordered event stream for a `/logs` request: replay the ring
183/// snapshot, then forward the live tail, de-duplicating against the snapshot by
184/// sequence number and mapping `broadcast` lag to [`LogEvent::Truncated`].
185pub fn log_events(
186    snapshot: Vec<LogLine>,
187    mut rx: broadcast::Receiver<LogMsg>,
188    ended: bool,
189) -> impl futures::Stream<Item = LogEvent> {
190    use tokio::sync::broadcast::error::RecvError;
191    async_stream::stream! {
192        let mut last_seq: Option<u64> = None;
193        for entry in snapshot {
194            last_seq = Some(entry.seq);
195            yield LogEvent::Log(entry.line);
196        }
197        // The run already ended before we subscribed: the ring is the whole story.
198        if ended {
199            yield LogEvent::End;
200            return;
201        }
202        loop {
203            match rx.recv().await {
204                Ok(LogMsg::Line(entry)) => {
205                    if last_seq.is_none_or(|s| entry.seq > s) {
206                        last_seq = Some(entry.seq);
207                        yield LogEvent::Log(entry.line);
208                    }
209                }
210                Ok(LogMsg::End) => {
211                    yield LogEvent::End;
212                    break;
213                }
214                Err(RecvError::Lagged(n)) => {
215                    yield LogEvent::Truncated(n);
216                }
217                Err(RecvError::Closed) => {
218                    yield LogEvent::End;
219                    break;
220                }
221            }
222        }
223    }
224}
225
226// ── tracing layer ───────────────────────────────────────────────────────────
227
228use tracing::field::{Field, Visit};
229use tracing::span::{Attributes, Id};
230use tracing::{Event, Subscriber};
231use tracing_subscriber::layer::{Context, Layer};
232use tracing_subscriber::registry::LookupSpan;
233
234/// Span extension marking a span (and, via scope walking, its descendants) as
235/// belonging to a serve run.
236#[derive(Clone)]
237struct RunIdExt(String);
238
239/// Extracts a `serve_run_id` field value from span attributes.
240#[derive(Default)]
241struct RunIdVisitor(Option<String>);
242
243impl Visit for RunIdVisitor {
244    fn record_str(&mut self, field: &Field, value: &str) {
245        if field.name() == "serve_run_id" {
246            self.0 = Some(value.to_string());
247        }
248    }
249    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
250        // `%run_id` records via Display → Debug-of-DisplayValue, i.e. unquoted.
251        if field.name() == "serve_run_id" && self.0.is_none() {
252            self.0 = Some(format!("{value:?}"));
253        }
254    }
255}
256
257/// Formats an event's fields into a single log line: the `message` field, then
258/// any remaining `key=value` fields.
259#[derive(Default)]
260struct EventLineVisitor {
261    message: String,
262    fields: String,
263}
264
265impl Visit for EventLineVisitor {
266    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
267        use std::fmt::Write;
268        if field.name() == "message" {
269            let _ = write!(self.message, "{value:?}");
270        } else {
271            let _ = write!(self.fields, " {}={:?}", field.name(), value);
272        }
273    }
274}
275
276impl EventLineVisitor {
277    fn finish(self) -> String {
278        if self.fields.is_empty() {
279            self.message
280        } else {
281            format!("{}{}", self.message, self.fields)
282        }
283    }
284}
285
286/// Tracing layer that captures events tagged with a `serve_run_id` into the
287/// [`LogHub`] for SSE streaming. Added to serve's global subscriber alongside the
288/// redacting fmt layer (`observability.rs`).
289pub struct RunLogLayer {
290    hub: LogHub,
291}
292
293impl RunLogLayer {
294    pub fn new(hub: LogHub) -> Self {
295        Self { hub }
296    }
297}
298
299impl<S> Layer<S> for RunLogLayer
300where
301    S: Subscriber + for<'a> LookupSpan<'a>,
302{
303    fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
304        let mut visitor = RunIdVisitor::default();
305        attrs.record(&mut visitor);
306        if let Some(run_id) = visitor.0
307            && let Some(span) = ctx.span(id)
308        {
309            span.extensions_mut().insert(RunIdExt(run_id));
310        }
311    }
312
313    fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
314        let Some(run_id) = ctx.event_scope(event).and_then(|scope| {
315            scope
316                .from_root()
317                .find_map(|span| span.extensions().get::<RunIdExt>().map(|ext| ext.0.clone()))
318        }) else {
319            return;
320        };
321        let mut visitor = EventLineVisitor::default();
322        event.record(&mut visitor);
323        let meta = event.metadata();
324        let line = format!("{} {}: {}", meta.level(), meta.target(), visitor.finish());
325        let line = crate::secrets::registry::redact(&line).into_owned();
326        self.hub.append(&run_id, line);
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use futures::StreamExt;
334
335    #[test]
336    fn ring_caps_and_orders_by_seq() {
337        let hub = LogHub::new();
338        for i in 0..(RING_CAPACITY + 5) {
339            hub.append("r", format!("line-{i}"));
340        }
341        let (snapshot, _rx, _ended) = hub.reader("r").unwrap();
342        assert_eq!(snapshot.len(), RING_CAPACITY, "ring must cap at capacity");
343        // The five oldest lines were evicted; line-5 is now the front.
344        assert_eq!(snapshot.first().unwrap().line, "line-5");
345        assert!(
346            snapshot.windows(2).all(|w| w[0].seq < w[1].seq),
347            "sequence numbers must be strictly increasing"
348        );
349    }
350
351    #[test]
352    fn reader_none_for_unknown_run() {
353        let hub = LogHub::new();
354        assert!(hub.reader("nope").is_none());
355    }
356
357    #[test]
358    fn finish_sets_ended_flag() {
359        let hub = LogHub::new();
360        hub.append("r", "x".into());
361        hub.finish("r");
362        let (snapshot, _rx, ended) = hub.reader("r").unwrap();
363        assert!(ended, "reader must observe ended after finish");
364        assert_eq!(snapshot.len(), 1);
365    }
366
367    #[test]
368    fn drop_run_frees_buffer() {
369        let hub = LogHub::new();
370        hub.append("r", "x".into());
371        assert!(hub.reader("r").is_some());
372        hub.drop_run("r");
373        assert!(hub.reader("r").is_none());
374    }
375
376    #[tokio::test]
377    async fn ended_buffer_streams_snapshot_then_end() {
378        let hub = LogHub::new();
379        hub.append("r", "a".into());
380        hub.append("r", "b".into());
381        hub.finish("r");
382        let (snapshot, rx, ended) = hub.reader("r").unwrap();
383        let events: Vec<LogEvent> = log_events(snapshot, rx, ended).collect().await;
384        assert_eq!(
385            events,
386            vec![
387                LogEvent::Log("a".into()),
388                LogEvent::Log("b".into()),
389                LogEvent::End
390            ]
391        );
392    }
393
394    #[tokio::test]
395    async fn snapshot_then_live_dedups_by_seq() {
396        let (tx, rx) = broadcast::channel(8);
397        // seq 0 lands in BOTH the snapshot and the broadcast — must not duplicate.
398        let _ = tx.send(LogMsg::Line(LogLine {
399            seq: 0,
400            line: "a".into(),
401        }));
402        let _ = tx.send(LogMsg::Line(LogLine {
403            seq: 1,
404            line: "b".into(),
405        }));
406        let _ = tx.send(LogMsg::End);
407        let snapshot = vec![LogLine {
408            seq: 0,
409            line: "a".into(),
410        }];
411        let events: Vec<LogEvent> = log_events(snapshot, rx, false).collect().await;
412        assert_eq!(
413            events,
414            vec![
415                LogEvent::Log("a".into()),
416                LogEvent::Log("b".into()),
417                LogEvent::End
418            ]
419        );
420    }
421
422    #[tokio::test]
423    async fn truncated_emitted_on_broadcast_lag() {
424        // Fill the channel past capacity before the receiver reads → Lagged.
425        let (tx, rx) = broadcast::channel(2);
426        for i in 0..5u64 {
427            let _ = tx.send(LogMsg::Line(LogLine {
428                seq: i,
429                line: format!("l{i}"),
430            }));
431        }
432        let _ = tx.send(LogMsg::End);
433        let events: Vec<LogEvent> = log_events(vec![], rx, false).collect().await;
434        assert!(
435            events.iter().any(|e| matches!(e, LogEvent::Truncated(_))),
436            "a lagging reader must get a Truncated event: {events:?}"
437        );
438        assert_eq!(events.last(), Some(&LogEvent::End));
439    }
440
441    #[test]
442    fn layer_captures_events_in_run_span_only() {
443        use tracing_subscriber::layer::SubscriberExt;
444        let hub = LogHub::new();
445        let subscriber = tracing_subscriber::registry().with(RunLogLayer::new(hub.clone()));
446        tracing::subscriber::with_default(subscriber, || {
447            // An event outside any serve-run span is ignored.
448            tracing::info!("orphan event");
449            let span = tracing::info_span!("faucet.serve.run", serve_run_id = "run-xyz");
450            let _g = span.enter();
451            tracing::info!("hello from the run");
452        });
453        let (snapshot, _rx, _ended) = hub.reader("run-xyz").expect("buffer for the run exists");
454        assert!(
455            snapshot
456                .iter()
457                .any(|l| l.line.contains("hello from the run")),
458            "in-span event must be captured: {snapshot:?}"
459        );
460        assert!(
461            snapshot.iter().all(|l| !l.line.contains("orphan")),
462            "events outside the run span must not be captured"
463        );
464        // No buffer is created for events that never had a serve_run_id span.
465        assert!(hub.reader("nonexistent").is_none());
466    }
467}