Skip to main content

bee_tui/
bee_log.rs

1//! Parser for Bee's logfmt-with-quoted-keys log format. Each Bee
2//! log line looks like:
3//!
4//! ```text
5//! "time"="2026-05-07 22:14:31.211867" "level"="debug" "logger"="node/batchservice" "msg"="block height updated" "new_block"=10809557
6//! ```
7//!
8//! Always-quoted keys, mixed quoted-string / unquoted-scalar values,
9//! single space separator. The format is non-standard enough that
10//! reaching for a generic logfmt crate would either over-parse or
11//! under-parse — a small purpose-built scanner does the right thing
12//! and stays testable.
13//!
14//! The output of [`parse_line`] is a [`BeeLogEntry`] with the four
15//! "structural" fields (time / level / logger / msg) lifted out and
16//! everything else captured as `(key, value)` pairs preserving order.
17//!
18//! ## What this module is not
19//!
20//! Not a tailer — that's `bee_log_tailer.rs`. Not a renderer — the
21//! `LogPane` consumes [`BeeLogLine`]s built from these entries.
22//! Pure parsing only.
23
24use crate::components::log_pane::{BeeLogLine, LogTab};
25
26/// One parsed Bee log line. Owned strings — the input is consumed
27/// once per call and we don't try to be zero-copy.
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub struct BeeLogEntry {
30    /// `time` field. Bee writes UTC with microsecond precision:
31    /// `"2026-05-07 22:14:31.211867"`. We keep it verbatim so the
32    /// renderer doesn't have to re-parse / re-format.
33    pub time: String,
34    /// `level` field, lower-cased. `error`, `warning`, `info`,
35    /// `debug`, occasionally `none` / `trace`. Empty if the line
36    /// didn't have a `level` field.
37    pub level: String,
38    /// `logger` field. Usually `node/<subsystem>` — `node/pseudosettle`,
39    /// `node/kademlia`, `node/api`. Empty if absent.
40    pub logger: String,
41    /// `msg` field. Empty if absent (rare — Bee always logs a msg).
42    pub msg: String,
43    /// All other fields, in the order they appeared. Preserved so
44    /// the renderer can show contextual data (peer addresses,
45    /// amounts, error reasons) verbatim.
46    pub extras: Vec<(String, String)>,
47}
48
49impl BeeLogEntry {
50    /// Lines coming from Bee's REST API server have logger names
51    /// that start with `node/api` (covers `node/api`,
52    /// `node/api/access`, and similar variants across Bee versions).
53    /// They get their own tab so served-request traffic doesn't
54    /// drown out the severity views.
55    pub fn is_bee_http(&self) -> bool {
56        self.logger.starts_with("node/api")
57    }
58
59    /// Heuristic: was this `node/api` line caused by a bee-tui
60    /// request? Checks the line's extras for any User-Agent-like
61    /// field (keys vary across Bee versions: `user_agent`,
62    /// `user-agent`, `useragent`, sometimes `ua`) whose value
63    /// contains `bee-tui`. The cockpit sends a stable UA on every
64    /// request so the filter pivots cleanly.
65    ///
66    /// Returns `false` when no UA field is present — operators
67    /// running an older Bee build that doesn't log UA will see
68    /// bee-tui's own traffic in the Bee HTTP tab. The fallback is
69    /// the bee::http tab (the trust anchor) which always reflects
70    /// bee-tui's perspective regardless of Bee's logging.
71    pub fn is_bee_tui_request(&self) -> bool {
72        for (k, v) in &self.extras {
73            let key = k.to_ascii_lowercase();
74            if matches!(
75                key.as_str(),
76                "user_agent" | "user-agent" | "useragent" | "ua"
77            ) && v.contains("bee-tui")
78            {
79                return true;
80            }
81        }
82        false
83    }
84
85    /// Map the `level` + logger combination to the cockpit tab it
86    /// belongs on. The Bee-HTTP check wins over severity routing —
87    /// an `error`-level line from `node/api` shows up on Bee HTTP,
88    /// not on Errors. (Reason: an operator looking at Errors wants
89    /// to see *infrastructure* errors, not "client sent a malformed
90    /// request" lines that flood under load testing.) Returns
91    /// `None` for unrecognised levels so a future Bee build with a
92    /// new severity isn't silently misfiled.
93    pub fn tab(&self) -> Option<LogTab> {
94        if self.is_bee_http() {
95            return Some(LogTab::BeeHttp);
96        }
97        match self.level.as_str() {
98            "error" | "err" | "fatal" => Some(LogTab::Errors),
99            "warning" | "warn" => Some(LogTab::Warning),
100            "info" => Some(LogTab::Info),
101            "debug" | "trace" => Some(LogTab::Debug),
102            _ => None,
103        }
104    }
105
106    /// Build the renderable form for [`LogPane::push_bee`]. Combines
107    /// the structural fields and extras into the three-column shape
108    /// the tab renderer expects (timestamp / logger / message).
109    pub fn to_log_line(&self) -> BeeLogLine {
110        let mut message = self.msg.clone();
111        for (k, v) in &self.extras {
112            // Compact `key=value` format mirroring Bee's own logfmt
113            // shape, minus the redundant outer quotes around keys.
114            // Operators reading the tail recognise the layout.
115            if !message.is_empty() {
116                message.push(' ');
117            }
118            message.push_str(k);
119            message.push('=');
120            // Re-quote values that contain spaces; bare otherwise.
121            if v.chars().any(|c| c == ' ' || c == '"') || v.is_empty() {
122                message.push('"');
123                message.push_str(v);
124                message.push('"');
125            } else {
126                message.push_str(v);
127            }
128        }
129        BeeLogLine {
130            timestamp: self.time.clone(),
131            logger: self.logger.clone(),
132            message,
133        }
134    }
135}
136
137/// Parse a single Bee log line. Returns `None` for empty / unparseable
138/// input — these are dropped silently in the live tail so a single
139/// malformed line doesn't break the stream.
140pub fn parse_line(line: &str) -> Option<BeeLogEntry> {
141    let line = line.trim();
142    if line.is_empty() {
143        return None;
144    }
145
146    let mut entry = BeeLogEntry::default();
147    let mut cursor = line;
148
149    while !cursor.is_empty() {
150        cursor = cursor.trim_start();
151        if cursor.is_empty() {
152            break;
153        }
154        // Each pair is "key"=value. Key is always quoted in Bee's
155        // format; we still tolerate a bare key in case the format
156        // shifts in a future version.
157        let (key, rest) = take_key(cursor)?;
158        let after_eq = rest.strip_prefix('=')?;
159        let (value, rest) = take_value(after_eq)?;
160        match key.as_str() {
161            "time" => entry.time = value,
162            "level" => entry.level = value.to_ascii_lowercase(),
163            "logger" => entry.logger = value,
164            "msg" => entry.msg = value,
165            _ => entry.extras.push((key, value)),
166        }
167        cursor = rest;
168    }
169
170    // A line without any of the structural fields isn't a Bee log
171    // entry — could be a stray banner or panic line. Preserving these
172    // would muddy the severity tabs, so reject them.
173    if entry.time.is_empty() && entry.level.is_empty() && entry.logger.is_empty() {
174        return None;
175    }
176    Some(entry)
177}
178
179/// Pull a key off the front of `s`. Bee always quotes keys; we also
180/// accept bare identifiers `[a-zA-Z_][a-zA-Z0-9_-]*` for resilience.
181/// Returns `(key, remainder)` or `None` if the input doesn't start
182/// with a valid key.
183fn take_key(s: &str) -> Option<(String, &str)> {
184    if let Some(rest) = s.strip_prefix('"') {
185        let end = rest.find('"')?;
186        let key = rest[..end].to_string();
187        Some((key, &rest[end + 1..]))
188    } else {
189        // Bare key — read until '=' or whitespace. Empty bare key is
190        // treated as a parse failure.
191        let end = s
192            .find(|c: char| c == '=' || c.is_whitespace())
193            .unwrap_or(s.len());
194        if end == 0 {
195            return None;
196        }
197        Some((s[..end].to_string(), &s[end..]))
198    }
199}
200
201/// Pull a value off the front of `s`. Quoted values (the common
202/// case) consume up to the closing quote; unquoted values (numbers,
203/// booleans) read until whitespace or end of input.
204fn take_value(s: &str) -> Option<(String, &str)> {
205    if let Some(rest) = s.strip_prefix('"') {
206        let end = rest.find('"')?;
207        let value = rest[..end].to_string();
208        Some((value, &rest[end + 1..]))
209    } else {
210        let end = s.find(char::is_whitespace).unwrap_or(s.len());
211        Some((s[..end].to_string(), &s[end..]))
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn parses_pseudosettle_debug_line() {
221        // Sourced verbatim from the operator's live testnet log.
222        let line = r#""time"="2026-05-07 22:14:19.605485" "level"="debug" "logger"="node/pseudosettle" "v"=1 "msg"="pseudosettle sending payment message to peer" "peer_address"="097b3be6af660b6d9569c47f1f077ed419e5326f6ab4930c587b2f6a1cdada55" "amount"="48870000""#;
223        let e = parse_line(line).expect("must parse");
224        assert_eq!(e.time, "2026-05-07 22:14:19.605485");
225        assert_eq!(e.level, "debug");
226        assert_eq!(e.logger, "node/pseudosettle");
227        assert_eq!(e.msg, "pseudosettle sending payment message to peer");
228        // Extras preserve order.
229        assert_eq!(e.extras[0], ("v".into(), "1".into()));
230        assert_eq!(
231            e.extras[1],
232            (
233                "peer_address".into(),
234                "097b3be6af660b6d9569c47f1f077ed419e5326f6ab4930c587b2f6a1cdada55".into()
235            )
236        );
237        assert_eq!(e.extras[2], ("amount".into(), "48870000".into()));
238        assert_eq!(e.tab(), Some(LogTab::Debug));
239    }
240
241    #[test]
242    fn parses_unquoted_numeric_value() {
243        // Bee writes integers without quotes: "v"=1, "new_block"=10809557.
244        let line = r#""time"="t" "level"="debug" "logger"="node/batchservice" "msg"="block height updated" "new_block"=10809557"#;
245        let e = parse_line(line).expect("must parse");
246        assert_eq!(e.extras, vec![("new_block".into(), "10809557".into())]);
247    }
248
249    #[test]
250    fn parses_unquoted_bool_value() {
251        let line = r#""time"="t" "level"="debug" "logger"="node" "msg"="sync status check" "synced"=false "reserveSize"=2582243"#;
252        let e = parse_line(line).expect("must parse");
253        assert_eq!(e.extras[0], ("synced".into(), "false".into()));
254        assert_eq!(e.extras[1], ("reserveSize".into(), "2582243".into()));
255    }
256
257    #[test]
258    fn parses_unquoted_float_value() {
259        // Bee mixes integer + float numerics in the same line.
260        let line = r#""time"="t" "level"="debug" "logger"="node" "msg"="sync status check" "syncRate"=0.0989528913580248"#;
261        let e = parse_line(line).expect("must parse");
262        assert_eq!(
263            e.extras[0],
264            ("syncRate".into(), "0.0989528913580248".into())
265        );
266    }
267
268    #[test]
269    fn parses_long_error_message() {
270        // The libp2p stream-reset error is the longest single value
271        // we've seen in the wild — make sure we don't truncate.
272        let line = r#""time"="t" "level"="debug" "logger"="node/libp2p" "msg"="handle protocol failed" "protocol"="swap" "version"="1.0.0" "stream"="swap" "peer"="54b5..." "error"="read request from peer 54b5...: stream reset (remote): code: 0x0: transport error: stream reset by remote, error code: 0""#;
273        let e = parse_line(line).expect("must parse");
274        let err_pair = e.extras.iter().find(|(k, _)| k == "error").unwrap();
275        assert!(err_pair.1.contains("stream reset by remote"));
276    }
277
278    #[test]
279    fn level_routing_covers_known_severities() {
280        for (lvl, tab) in [
281            ("error", LogTab::Errors),
282            ("err", LogTab::Errors),
283            ("fatal", LogTab::Errors),
284            ("warning", LogTab::Warning),
285            ("warn", LogTab::Warning),
286            ("info", LogTab::Info),
287            ("debug", LogTab::Debug),
288            ("trace", LogTab::Debug),
289        ] {
290            let e = BeeLogEntry {
291                level: lvl.into(),
292                ..Default::default()
293            };
294            assert_eq!(e.tab(), Some(tab), "level {lvl} should route to {tab:?}");
295        }
296    }
297
298    #[test]
299    fn level_routing_unknown_returns_none() {
300        // Defensive — a future Bee build that adds a new severity
301        // shouldn't get silently slotted into the wrong tab.
302        let e = BeeLogEntry {
303            level: "panic".into(),
304            ..Default::default()
305        };
306        assert_eq!(e.tab(), None);
307        let e = BeeLogEntry::default();
308        assert_eq!(e.tab(), None);
309    }
310
311    #[test]
312    fn node_api_logger_routes_to_bee_http() {
313        // Bee's REST API server uses `node/api` as the logger name.
314        // Should land on the Bee HTTP tab regardless of severity.
315        for logger in ["node/api", "node/api/access", "node/api/handler"] {
316            let e = BeeLogEntry {
317                logger: logger.into(),
318                level: "debug".into(),
319                ..Default::default()
320            };
321            assert_eq!(e.tab(), Some(LogTab::BeeHttp), "logger {logger}");
322        }
323    }
324
325    #[test]
326    fn bee_http_wins_over_severity_routing() {
327        // An error-level line from node/api goes to BeeHttp, not
328        // Errors — the spec says "errors" is for infrastructure
329        // problems, not 4xx replies to clients.
330        let e = BeeLogEntry {
331            logger: "node/api".into(),
332            level: "error".into(),
333            ..Default::default()
334        };
335        assert_eq!(e.tab(), Some(LogTab::BeeHttp));
336    }
337
338    #[test]
339    fn non_api_logger_falls_through_to_severity() {
340        // Sanity check the regression: the logger filter should
341        // ONLY catch `node/api*`, not anything that happens to
342        // contain `api`.
343        let e = BeeLogEntry {
344            logger: "node/batchapi".into(),
345            level: "error".into(),
346            ..Default::default()
347        };
348        assert_eq!(e.tab(), Some(LogTab::Errors));
349    }
350
351    #[test]
352    fn is_bee_tui_request_detects_user_agent() {
353        // Bee logs vary by version: user_agent (snake_case),
354        // user-agent (kebab), useragent (squashed), ua (short).
355        // All four should match.
356        for key in ["user_agent", "user-agent", "useragent", "ua"] {
357            let e = BeeLogEntry {
358                extras: vec![(key.into(), "bee-tui/1.0.0".into())],
359                ..Default::default()
360            };
361            assert!(e.is_bee_tui_request(), "key {key:?} should match");
362        }
363    }
364
365    #[test]
366    fn is_bee_tui_request_is_case_insensitive_on_keys() {
367        // Defensive — Bee might capitalize differently across
368        // versions. Match regardless of case on the key.
369        let e = BeeLogEntry {
370            extras: vec![("User-Agent".into(), "bee-tui/1.0.0 extra-suffix".into())],
371            ..Default::default()
372        };
373        assert!(e.is_bee_tui_request());
374    }
375
376    #[test]
377    fn is_bee_tui_request_rejects_other_clients() {
378        let e = BeeLogEntry {
379            extras: vec![("user_agent".into(), "curl/8.0.1".into())],
380            ..Default::default()
381        };
382        assert!(!e.is_bee_tui_request());
383        // Empty extras → not a bee-tui request.
384        let e = BeeLogEntry::default();
385        assert!(!e.is_bee_tui_request());
386    }
387
388    #[test]
389    fn level_is_lowercased_during_parse() {
390        let line = r#""time"="t" "level"="ERROR" "logger"="node" "msg"="oops""#;
391        let e = parse_line(line).expect("must parse");
392        assert_eq!(e.level, "error");
393        assert_eq!(e.tab(), Some(LogTab::Errors));
394    }
395
396    #[test]
397    fn empty_input_returns_none() {
398        assert!(parse_line("").is_none());
399        assert!(parse_line("   ").is_none());
400        assert!(parse_line("\n").is_none());
401    }
402
403    #[test]
404    fn line_without_structural_fields_returns_none() {
405        // Stray banner or panic line that doesn't fit the format
406        // shouldn't pollute the severity tabs. parse_line drops it.
407        assert!(parse_line(r#""foo"="bar" "baz"=42"#).is_none());
408    }
409
410    #[test]
411    fn malformed_line_returns_none() {
412        // No `=` after the key — total parse failure, drop.
413        assert!(parse_line(r#""time" "level"="debug""#).is_none());
414        // Unterminated quoted value — parse failure, drop.
415        assert!(parse_line(r#""time"="2026" "level"="debug"#).is_none());
416    }
417
418    #[test]
419    fn to_log_line_compacts_extras_into_message() {
420        let e = BeeLogEntry {
421            time: "t1".into(),
422            logger: "node/foo".into(),
423            msg: "did a thing".into(),
424            extras: vec![("count".into(), "42".into()), ("peer".into(), "abc".into())],
425            ..Default::default()
426        };
427        let line = e.to_log_line();
428        assert_eq!(line.timestamp, "t1");
429        assert_eq!(line.logger, "node/foo");
430        assert_eq!(line.message, "did a thing count=42 peer=abc");
431    }
432
433    #[test]
434    fn to_log_line_quotes_values_with_spaces() {
435        let e = BeeLogEntry {
436            msg: "x".into(),
437            extras: vec![("error".into(), "stream reset by remote".into())],
438            ..Default::default()
439        };
440        let line = e.to_log_line();
441        assert!(line.message.contains(r#"error="stream reset by remote""#));
442    }
443
444    #[test]
445    fn to_log_line_quotes_empty_values() {
446        // Without quoting, an empty value would render as `key=`
447        // which is ambiguous (key alone vs. key with empty value).
448        let e = BeeLogEntry {
449            msg: "x".into(),
450            extras: vec![("nullable".into(), "".into())],
451            ..Default::default()
452        };
453        let line = e.to_log_line();
454        assert!(line.message.contains(r#"nullable="""#));
455    }
456}