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
14use std::sync::OnceLock;
15
16use tracing_subscriber::layer::SubscriberExt;
17use tracing_subscriber::util::SubscriberInitExt;
18use tracing_subscriber::{EnvFilter, Layer, Registry, reload};
19
20/// What the reload slot holds: nothing, or the installed OTLP layer.
21type OtelSlot = Option<leviath_telemetry::LogLayer>;
22
23/// The handle [`install_otel_layer`] reloads through, parked by [`init`].
24static OTEL_HANDLE: OnceLock<reload::Handle<OtelSlot, Registry>> = OnceLock::new();
25
26/// Install the process-wide subscriber: fmt → stderr at `info` (`debug` when
27/// verbose), plus the empty reloadable OTLP slot.
28///
29/// Callable any number of times without panicking; the first global
30/// subscriber and the first parked handle win. `main` calls it exactly once,
31/// so in the real process the two are the same subscriber - the losing-race
32/// cases exist only inside the test binary, where other tests own the global
33/// slot.
34pub fn init(verbose: bool) {
35    let level = if verbose { "debug" } else { "info" };
36    let (otel_layer, handle) = reload::Layer::new(None as OtelSlot);
37    let subscriber = tracing_subscriber::registry().with(otel_layer).with(
38        tracing_subscriber::fmt::layer()
39            .with_writer(std::io::stderr)
40            .with_filter(EnvFilter::new(level)),
41    );
42    let _ = subscriber.try_init();
43    let _ = OTEL_HANDLE.set(handle);
44}
45
46/// Fill the reload slot with the daemon's OTLP log-export layer. Returns
47/// whether the layer was installed - `false` when [`init`] hasn't run (a
48/// library consumer with its own subscriber) or the slot is gone.
49pub fn install_otel_layer(layer: leviath_telemetry::LogLayer) -> bool {
50    match OTEL_HANDLE.get() {
51        Some(handle) => handle.reload(Some(layer)).is_ok(),
52        None => false,
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59    use opentelemetry_sdk::logs::{InMemoryLogExporter, SdkLoggerProvider};
60
61    /// An OTLP bridge layer wired to an in-memory exporter the test can read.
62    fn bridge_with_exporter() -> (leviath_telemetry::LogLayer, InMemoryLogExporter) {
63        let exporter = InMemoryLogExporter::default();
64        let provider = SdkLoggerProvider::builder()
65            .with_simple_exporter(exporter.clone())
66            .build();
67        let sink = leviath_telemetry::OtelSink::new(
68            opentelemetry_sdk::trace::SdkTracerProvider::builder().build(),
69            opentelemetry_sdk::metrics::SdkMeterProvider::builder().build(),
70            provider,
71        );
72        (sink.tracing_log_layer(), exporter)
73    }
74
75    /// One test drives the whole lifecycle: the `OnceLock` handle is
76    /// process-wide, so ordering between separate tests would race under the
77    /// parallel test runner. The forwarding assertions run against a
78    /// thread-scoped subscriber wired to the handle this test parks itself -
79    /// the *global* subscriber slot belongs to whichever test wins it
80    /// (testkit's `AlwaysOnSubscriber` usually does in a full run).
81    #[test]
82    fn init_parks_the_handle_and_install_forwards_events() {
83        // Before any handle is parked: nothing to install into.
84        let (layer, _exporter) = bridge_with_exporter();
85        assert!(!install_otel_layer(layer));
86
87        // Park a handle whose subscriber this thread controls.
88        let (otel_layer, handle) = reload::Layer::new(None as OtelSlot);
89        assert!(
90            OTEL_HANDLE.set(handle).is_ok(),
91            "this test parks the handle first"
92        );
93        let subscriber = tracing_subscriber::registry().with(otel_layer);
94        let _guard = tracing::subscriber::set_default(subscriber);
95
96        let (layer, exporter) = bridge_with_exporter();
97        assert!(install_otel_layer(layer));
98        tracing::info!(target: "leviath::logging::test", "forwarded line");
99        let emitted = exporter.get_emitted_logs().unwrap();
100        assert!(
101            emitted
102                .iter()
103                .any(|log| format!("{:?}", log.record.body()).contains("forwarded line")),
104            "{emitted:?}"
105        );
106        // The OTel stack's own targets are filtered out of the bridge.
107        tracing::info!(target: "opentelemetry_sdk", "feedback line");
108        let emitted = exporter.get_emitted_logs().unwrap();
109        assert!(
110            !emitted
111                .iter()
112                .any(|log| format!("{:?}", log.record.body()).contains("feedback line"))
113        );
114
115        // The real init path: never panics, keeps the parked handle, and the
116        // slot stays reloadable afterwards.
117        init(false);
118        init(true);
119        let (layer, _exporter) = bridge_with_exporter();
120        assert!(install_otel_layer(layer));
121    }
122}