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}