Skip to main content

netget/logging/
emit.rs

1//! Single dual-sink logging facade.
2//!
3//! Historically every call site that wanted a line in both the file log and the
4//! TUI wrote the two independently: a `tracing` macro for the file, and a
5//! hand-typed `status_tx.send(format!("[LEVEL] …"))` for the TUI. The `[LEVEL]`
6//! bracket was a *string*, unrelated to the `tracing` level next to it, so the
7//! two channels routinely disagreed on the level of the same event (see the
8//! logging revamp design). There was also no level filtering on the TUI stream
9//! at all — a `[TRACE]` payload reached the TUI even in a release build whose
10//! file log was INFO-filtered.
11//!
12//! [`Log`] fixes the drift structurally: one [`Level`] value drives **both** the
13//! `tracing` macro and the TUI bracket prefix, so `[INFO]` on the TUI can never
14//! disagree with `info!` in the file. It also encodes the CLAUDE.md level
15//! convention as routing defaults (see [`default_sink`]): payloads and summaries
16//! (`TRACE`/`DEBUG`) stay file-only and off the unbounded status channel unless a
17//! caller explicitly opts them in.
18//!
19//! This is the path the per-protocol logging sweep is meant to adopt everywhere;
20//! for now it is used only on the LLM request/response path
21//! (`llm::conversation`, `llm::ollama_client`).
22//!
23//! Note: because the `tracing` macro fires inside this module, file-log lines
24//! carry `netget::logging::emit` as their target rather than the originating
25//! module. Level-based `EnvFilter` (`netget=…`) still applies; only the
26//! module-path breadcrumb changes.
27
28use std::fmt::Display;
29use tokio::sync::mpsc::UnboundedSender;
30
31/// Severity of a log line.
32///
33/// The variant name is *simultaneously* the `tracing` level and the TUI bracket
34/// prefix, which is the whole point: the two cannot drift because they are read
35/// off the same value.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Level {
38    Error,
39    Warn,
40    Info,
41    Debug,
42    Trace,
43}
44
45impl Level {
46    /// Uppercase name, identical to the corresponding [`tracing::Level`] name.
47    pub fn name(self) -> &'static str {
48        match self {
49            Level::Error => "ERROR",
50            Level::Warn => "WARN",
51            Level::Info => "INFO",
52            Level::Debug => "DEBUG",
53            Level::Trace => "TRACE",
54        }
55    }
56
57    /// The TUI bracket prefix (e.g. `"[INFO]"`), derived from [`Self::name`] so
58    /// it can never disagree with the level used for the file log.
59    pub fn tui_prefix(self) -> String {
60        format!("[{}]", self.name())
61    }
62}
63
64/// Where a line is delivered.
65///
66/// The file log (via `tracing`) *always* receives the line; the TUI status
67/// stream is opt-in per line via [`Sink::Both`].
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum Sink {
70    /// File log only. The default for `DEBUG`/`TRACE`.
71    FileOnly,
72    /// File log and the TUI status stream.
73    Both,
74}
75
76impl Sink {
77    /// Whether this routing reaches the TUI status stream.
78    pub fn reaches_tui(self) -> bool {
79        matches!(self, Sink::Both)
80    }
81}
82
83/// Default routing for a level, encoding the CLAUDE.md convention:
84///
85/// - `ERROR` / `WARN` / `INFO` — lifecycle and problems: [`Sink::Both`].
86/// - `DEBUG` / `TRACE` — summaries and payloads: [`Sink::FileOnly`], to keep
87///   high-volume detail off the unbounded status channel.
88pub fn default_sink(level: Level) -> Sink {
89    match level {
90        Level::Error | Level::Warn | Level::Info => Sink::Both,
91        Level::Debug | Level::Trace => Sink::FileOnly,
92    }
93}
94
95/// A dual-sink emitter bound to an optional TUI status channel.
96///
97/// Construct one per call site (it borrows the channel, so it is a zero-cost
98/// wrapper) and emit through it. Every method routes to the file log via
99/// `tracing` and, when the [`Sink`] permits and a channel is present, to the
100/// TUI with a bracket prefix that matches the level exactly.
101pub struct Log<'a> {
102    status_tx: Option<&'a UnboundedSender<String>>,
103    payloads: bool,
104}
105
106impl<'a> Log<'a> {
107    /// Bind the facade to an optional TUI status channel. `None` makes every
108    /// emission file-only regardless of [`Sink`].
109    pub fn new(status_tx: Option<&'a UnboundedSender<String>>) -> Self {
110        Self {
111            status_tx,
112            payloads: true,
113        }
114    }
115
116    /// Suppress DEBUG/TRACE previews and bodies for one credential-bearing request.
117    /// This is local to the emitter; it does not change shared logging settings.
118    pub fn with_payloads(mut self, enabled: bool) -> Self {
119        self.payloads = enabled;
120        self
121    }
122
123    /// Core fan-out: log `msg` at `level`, delivered per `sink`.
124    ///
125    /// The file log always receives it (at the matching `tracing` level); the
126    /// TUI receives `"[LEVEL] msg"` only when `sink` is [`Sink::Both`] and a
127    /// channel is bound.
128    pub fn emit(&self, level: Level, sink: Sink, msg: impl Display) {
129        if !self.payloads && matches!(level, Level::Debug | Level::Trace) {
130            return;
131        }
132        // Peer-derived text arrives here from every protocol. `netget.log` is read with
133        // `tail -f` and the TUI paints the `[LEVEL]` copy, so a control character in it is
134        // an escape sequence on a real terminal; `multiline` keeps newline and tab only.
135        let text = crate::utils::sanitize::multiline(&msg.to_string());
136
137        // File log — always, at the level that matches the TUI prefix.
138        match level {
139            Level::Error => tracing::error!("{}", text),
140            Level::Warn => tracing::warn!("{}", text),
141            Level::Info => tracing::info!("{}", text),
142            Level::Debug => tracing::debug!("{}", text),
143            Level::Trace => tracing::trace!("{}", text),
144        }
145
146        // TUI — opt-in; prefix derived from the SAME level.
147        if sink.reaches_tui() {
148            if let Some(tx) = self.status_tx {
149                let _ = tx.send(format!("{} {}", level.tui_prefix(), text));
150            }
151        }
152    }
153
154    /// `ERROR`: something cannot proceed. Always file + TUI.
155    pub fn error(&self, msg: impl Display) {
156        self.emit(Level::Error, Sink::Both, msg);
157    }
158
159    /// `WARN`: degraded, retried or fell back but recovered. Always file + TUI.
160    pub fn warn(&self, msg: impl Display) {
161        self.emit(Level::Warn, Sink::Both, msg);
162    }
163
164    /// `INFO`: one lifecycle line. File + TUI by default; pass a [`Sink`] via
165    /// [`Self::info_to`] to keep it file-only.
166    pub fn info(&self, msg: impl Display) {
167        self.emit(Level::Info, default_sink(Level::Info), msg);
168    }
169
170    /// `INFO` with explicit routing.
171    pub fn info_to(&self, sink: Sink, msg: impl Display) {
172        self.emit(Level::Info, sink, msg);
173    }
174
175    /// `DEBUG`: a summary/count/timing. File-only by default.
176    pub fn debug(&self, msg: impl Display) {
177        self.emit(Level::Debug, default_sink(Level::Debug), msg);
178    }
179
180    /// `DEBUG` with explicit routing (e.g. surface a summary on the TUI).
181    pub fn debug_to(&self, sink: Sink, msg: impl Display) {
182        self.emit(Level::Debug, sink, msg);
183    }
184
185    /// `TRACE`: a full payload. File-only by default and rarely anything else —
186    /// full payloads must not be streamed to the unbounded TUI channel.
187    pub fn trace(&self, msg: impl Display) {
188        self.emit(Level::Trace, default_sink(Level::Trace), msg);
189    }
190
191    /// `TRACE` with explicit routing.
192    pub fn trace_to(&self, sink: Sink, msg: impl Display) {
193        self.emit(Level::Trace, sink, msg);
194    }
195
196    /// Log a labelled, char-safe, truncated payload preview at `TRACE`,
197    /// file-only. Reuses [`crate::utils::truncate_for_log`] so a multi-byte cut
198    /// point can never panic.
199    pub fn payload(&self, label: &str, body: &str, max_bytes: usize) {
200        self.emit(
201            Level::Trace,
202            Sink::FileOnly,
203            format!(
204                "{}: {}",
205                label,
206                crate::utils::truncate_for_log(body, max_bytes)
207            ),
208        );
209    }
210}