Skip to main content

leviath_cli/
logging.rs

1//! Process-wide logging: the subscriber `main` installs, with a reloadable
2//! slot for the OTLP log-export layer.
3//!
4//! The subscriber must exist before any subcommand logs, but the
5//! `[observability]` config that decides whether daemon logs also export over
6//! OTLP is only read later (by the daemon, after `Config::load`). Bridging
7//! that gap is what the reload slot is for: [`init`] installs the fmt layer
8//! plus an empty slot and parks the reload handle in a static;
9//! [`install_otel_layer`] fills the slot once the daemon has built its
10//! exporter. Everything stays on **stderr** - `lev agent-client` uses stdout
11//! as its JSON-RPC channel, and a stray log line there would corrupt the
12//! stream a host is parsing.
13//!
14//! stderr is not safe either while a full-screen TUI is up, which is what
15//! [`hold_for_tui`] exists for. `lev setup` and `lev dash` own the alternate
16//! screen on stdout, but stderr is the same terminal, so a log line lands
17//! inside the frame. Raw mode makes it worse than untidy: `OPOST` is off, so
18//! the newline is a bare line feed with no carriage return and each line
19//! starts where the last one ended, staircasing across the screen. And
20//! ratatui only redraws cells it believes changed, so nothing ever paints
21//! over the mess. A verification call at `debug` was enough to fill the
22//! wizard with what looked like garbage.
23//!
24//! So while a TUI holds the terminal, log lines are buffered instead of
25//! written, and flushed to stderr when it lets go. Nothing is lost, and
26//! nothing lands on the screen while somebody is looking at it.
27
28use std::io::Write;
29use std::sync::atomic::{AtomicBool, Ordering};
30use std::sync::{Mutex, OnceLock, PoisonError};
31
32use tracing_subscriber::layer::SubscriberExt;
33use tracing_subscriber::util::SubscriberInitExt;
34use tracing_subscriber::{EnvFilter, Layer, Registry, reload};
35
36/// What the reload slot holds: nothing, or the installed OTLP layer.
37type OtelSlot = Option<leviath_telemetry::LogLayer>;
38
39/// The handle [`install_otel_layer`] reloads through, parked by [`init`].
40static OTEL_HANDLE: OnceLock<reload::Handle<OtelSlot, Registry>> = OnceLock::new();
41
42/// Whether a TUI currently owns the terminal.
43static TUI_HOLDS_TERMINAL: AtomicBool = AtomicBool::new(false);
44
45/// Lines written while the terminal was held, waiting to be flushed.
46static PARKED: Mutex<Vec<u8>> = Mutex::new(Vec::new());
47
48/// Where a log line goes: straight to stderr, or into [`PARKED`] until the
49/// terminal is free.
50///
51/// One writer rather than a runtime swap of the subscriber, because the
52/// subscriber is installed once, process-wide, before any subcommand knows
53/// whether it will draw.
54struct TerminalAwareWriter;
55
56impl Write for TerminalAwareWriter {
57    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
58        if TUI_HOLDS_TERMINAL.load(Ordering::Relaxed) {
59            // A poisoned lock means a thread panicked while parking a line. The
60            // buffer is still a valid buffer, so it is taken back rather than
61            // handled: a logging call is the worst place to raise a second
62            // panic, and there is nothing here that a poisoned flag protects.
63            PARKED
64                .lock()
65                .unwrap_or_else(PoisonError::into_inner)
66                .extend_from_slice(buf);
67            return Ok(buf.len());
68        }
69        std::io::stderr().write(buf)
70    }
71
72    fn flush(&mut self) -> std::io::Result<()> {
73        if TUI_HOLDS_TERMINAL.load(Ordering::Relaxed) {
74            return Ok(());
75        }
76        std::io::stderr().flush()
77    }
78}
79
80/// The fmt layer's writer factory.
81///
82/// A named function rather than a closure at the call site, so the one region
83/// this indirection costs is something a test can execute. A closure inside
84/// [`init`] only ever runs when this process owns the global subscriber, which
85/// under a parallel test runner is whichever test won the slot.
86fn writer() -> TerminalAwareWriter {
87    TerminalAwareWriter
88}
89
90/// Park log output for as long as a TUI owns the terminal.
91///
92/// Call from the terminal setup that enters the alternate screen, and pair it
93/// with [`release_from_tui`] on every exit path including the panic hook.
94pub fn hold_for_tui() {
95    TUI_HOLDS_TERMINAL.store(true, Ordering::Relaxed);
96}
97
98/// Hand the terminal back and flush whatever was logged meanwhile.
99///
100/// Safe to call when nothing was held: there is simply nothing parked.
101pub fn release_from_tui() {
102    TUI_HOLDS_TERMINAL.store(false, Ordering::Relaxed);
103    let parked = std::mem::take(&mut *PARKED.lock().unwrap_or_else(PoisonError::into_inner));
104    if parked.is_empty() {
105        return;
106    }
107    let _ = std::io::stderr().write_all(&parked);
108    let _ = std::io::stderr().flush();
109}
110
111/// Install the process-wide subscriber: fmt → stderr at `info` (`debug` when
112/// verbose), plus the empty reloadable OTLP slot.
113///
114/// Callable any number of times without panicking; the first global
115/// subscriber and the first parked handle win. `main` calls it exactly once,
116/// so in the real process the two are the same subscriber - the losing-race
117/// cases exist only inside the test binary, where other tests own the global
118/// slot.
119pub fn init(verbose: bool) {
120    let level = if verbose { "debug" } else { "info" };
121    let (otel_layer, handle) = reload::Layer::new(None as OtelSlot);
122    let subscriber = tracing_subscriber::registry().with(otel_layer).with(
123        tracing_subscriber::fmt::layer()
124            .with_writer(writer)
125            .with_filter(EnvFilter::new(level)),
126    );
127    let _ = subscriber.try_init();
128    let _ = OTEL_HANDLE.set(handle);
129}
130
131/// Fill the reload slot with the daemon's OTLP log-export layer. Returns
132/// whether the layer was installed - `false` when [`init`] hasn't run (a
133/// library consumer with its own subscriber) or the slot is gone.
134pub fn install_otel_layer(layer: leviath_telemetry::LogLayer) -> bool {
135    match OTEL_HANDLE.get() {
136        Some(handle) => handle.reload(Some(layer)).is_ok(),
137        None => false,
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use opentelemetry_sdk::logs::{InMemoryLogExporter, SdkLoggerProvider};
145
146    /// An OTLP bridge layer wired to an in-memory exporter the test can read.
147    fn bridge_with_exporter() -> (leviath_telemetry::LogLayer, InMemoryLogExporter) {
148        let exporter = InMemoryLogExporter::default();
149        let provider = SdkLoggerProvider::builder()
150            .with_simple_exporter(exporter.clone())
151            .build();
152        let sink = leviath_telemetry::OtelSink::new(
153            opentelemetry_sdk::trace::SdkTracerProvider::builder().build(),
154            opentelemetry_sdk::metrics::SdkMeterProvider::builder().build(),
155            provider,
156        );
157        (sink.tracing_log_layer(), exporter)
158    }
159
160    /// One test drives the whole lifecycle: the `OnceLock` handle is
161    /// process-wide, so ordering between separate tests would race under the
162    /// parallel test runner. The forwarding assertions run against a
163    /// thread-scoped subscriber wired to the handle this test parks itself -
164    /// the *global* subscriber slot belongs to whichever test wins it
165    /// (testkit's `AlwaysOnSubscriber` usually does in a full run).
166    #[test]
167    fn init_parks_the_handle_and_install_forwards_events() {
168        // Before any handle is parked: nothing to install into.
169        let (layer, _exporter) = bridge_with_exporter();
170        assert!(!install_otel_layer(layer));
171
172        // Park a handle whose subscriber this thread controls.
173        let (otel_layer, handle) = reload::Layer::new(None as OtelSlot);
174        assert!(
175            OTEL_HANDLE.set(handle).is_ok(),
176            "this test parks the handle first"
177        );
178        let subscriber = tracing_subscriber::registry().with(otel_layer);
179        let _guard = tracing::subscriber::set_default(subscriber);
180
181        let (layer, exporter) = bridge_with_exporter();
182        assert!(install_otel_layer(layer));
183        tracing::info!(target: "leviath::logging::test", "forwarded line");
184        let emitted = exporter.get_emitted_logs().unwrap();
185        assert!(
186            emitted
187                .iter()
188                .any(|log| format!("{:?}", log.record.body()).contains("forwarded line")),
189            "{emitted:?}"
190        );
191        // The OTel stack's own targets are filtered out of the bridge.
192        tracing::info!(target: "opentelemetry_sdk", "feedback line");
193        let emitted = exporter.get_emitted_logs().unwrap();
194        assert!(
195            !emitted
196                .iter()
197                .any(|log| format!("{:?}", log.record.body()).contains("feedback line"))
198        );
199
200        // The real init path: never panics, keeps the parked handle, and the
201        // slot stays reloadable afterwards.
202        init(false);
203        init(true);
204        let (layer, _exporter) = bridge_with_exporter();
205        assert!(install_otel_layer(layer));
206    }
207
208    /// The hold is what keeps a log line out of a wizard someone is reading,
209    /// and the release is what keeps it from being lost instead. Both halves
210    /// live in one test because the flag is process-wide: a second test
211    /// toggling it in parallel would park the first one's writes.
212    #[test]
213    fn holding_the_terminal_parks_output_until_it_is_released() {
214        // Released is the resting state, so a write goes straight out.
215        release_from_tui();
216        assert!(!TUI_HOLDS_TERMINAL.load(Ordering::Relaxed));
217        writer().write_all(b"").expect("stderr accepts a write");
218        writer().flush().expect("stderr accepts a flush");
219
220        hold_for_tui();
221        writer()
222            .write_all(b"parked line\n")
223            .expect("a held write is buffered, never refused");
224        // A flush while held must not reach the terminal either, or the point
225        // of buffering is lost on the very next `tracing` call.
226        writer().flush().expect("a held flush is a no-op");
227        assert_eq!(
228            PARKED.lock().expect("uncontended").as_slice(),
229            b"parked line\n"
230        );
231
232        release_from_tui();
233        assert!(
234            PARKED.lock().expect("uncontended").is_empty(),
235            "release hands the buffer to stderr and empties it"
236        );
237        // Releasing twice is what the panic hook plus `Drop` actually does, and
238        // with nothing parked it must stay quiet rather than write an empty
239        // line.
240        release_from_tui();
241    }
242}