Skip to main content

ant_core/node/daemon/forward/
parse.rs

1//! Turning a line of an ant-node log file into a forwardable event.
2//!
3//! A node writes one of two layouts depending on `--log-format`, and the daemon does not set that
4//! flag, so a user may have chosen either. Rather than force a format — which would mean adding an
5//! argument to every node's command line and restarting them all just to switch forwarding on —
6//! this module detects the layout per line:
7//!
8//! ```text
9//! text: 2026-08-19T20:50:00.123456Z  INFO ant_node::node: connected peers=3
10//! json: {"timestamp":"2026-08-19T20:50:00.123456Z","level":"INFO","target":"ant_node::node", …}
11//! ```
12//!
13//! Lines that are neither — panic messages, backtrace frames, anything a dependency writes
14//! straight to the file — are continuations of the event above them rather than events in their
15//! own right, and are appended to it. That keeps a multi-line panic intact as one document instead
16//! of scattering it across twenty timestamp-less ones.
17
18use serde::{Deserialize, Serialize};
19
20use super::config::LogLevel;
21
22/// A single parsed log event, before it is tagged with the node's identity.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct LogEvent {
25    /// RFC3339 timestamp, forwarded as `@timestamp`.
26    pub timestamp: String,
27    pub level: LogLevel,
28    /// Rust module path the event came from, when the layout carried one.
29    pub target: Option<String>,
30    /// The event text, including any continuation lines appended to it.
31    pub message: String,
32    /// Public protocol identifier, lifted opportunistically when the line happens to carry one.
33    pub peer_id: Option<String>,
34    /// Version ant-node reported for itself, present only on its startup line.
35    pub version: Option<String>,
36    /// Commit ant-node reported for itself, present only on its startup line.
37    pub commit: Option<String>,
38}
39
40impl LogEvent {
41    /// Append a continuation line to this event's message.
42    pub fn push_continuation(&mut self, line: &str) {
43        self.message.push('\n');
44        self.message.push_str(line);
45    }
46
47    /// The daily index date for this event, as Elasticsearch wants it: `YYYY.MM.DD`.
48    ///
49    /// Derived from the event's own timestamp rather than the wall clock. That is not a stylistic
50    /// choice: document `_id`s are unique per index, so a batch replayed after midnight must land
51    /// in the same index its first attempt targeted or the deduplication silently stops working.
52    #[must_use]
53    pub fn index_date(&self) -> Option<String> {
54        index_date_from_timestamp(&self.timestamp)
55    }
56}
57
58/// Extract `YYYY.MM.DD` from an RFC3339 timestamp, validating the shape rather than trusting it.
59#[must_use]
60pub fn index_date_from_timestamp(timestamp: &str) -> Option<String> {
61    let date = timestamp.get(..10)?;
62    let bytes = date.as_bytes();
63    if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
64        return None;
65    }
66    if !bytes
67        .iter()
68        .enumerate()
69        .all(|(i, b)| matches!(i, 4 | 7) || b.is_ascii_digit())
70    {
71        return None;
72    }
73    Some(format!("{}.{}.{}", &date[..4], &date[5..7], &date[8..10]))
74}
75
76/// Parse one line, returning `None` when it is a continuation of the event above it.
77#[must_use]
78pub fn parse_line(line: &str) -> Option<LogEvent> {
79    let trimmed = line.trim_end_matches(['\r', '\n']);
80    if trimmed.trim().is_empty() {
81        return None;
82    }
83    if trimmed.trim_start().starts_with('{') {
84        parse_json_line(trimmed)
85    } else {
86        parse_text_line(trimmed)
87    }
88}
89
90/// Parse the JSON layout produced by `fmt::layer().json().flatten_event(true)`.
91///
92/// `flatten_event` lifts the event's fields to the top level, so `message`, `peer_id`, `version`
93/// and `commit` all sit beside `timestamp`, `level` and `target`.
94fn parse_json_line(line: &str) -> Option<LogEvent> {
95    let value: serde_json::Value = serde_json::from_str(line).ok()?;
96    let object = value.as_object()?;
97
98    let level = LogLevel::parse(object.get("level")?.as_str()?)?;
99    let timestamp = object.get("timestamp")?.as_str()?.to_string();
100    // Held to the same standard as the text layout: an event whose timestamp cannot be read is an
101    // event with no index to go to, so it is better treated as a continuation than shipped blind.
102    index_date_from_timestamp(&timestamp)?;
103
104    let message = object
105        .get("message")
106        .and_then(|m| {
107            m.as_str()
108                .map(str::to_string)
109                .or_else(|| Some(m.to_string()))
110        })
111        .unwrap_or_default();
112
113    Some(LogEvent {
114        timestamp,
115        level,
116        target: object
117            .get("target")
118            .and_then(|t| t.as_str())
119            .map(str::to_string),
120        message,
121        peer_id: json_string_field(object, "peer_id"),
122        version: json_string_field(object, "version"),
123        commit: json_string_field(object, "commit"),
124    })
125}
126
127/// Read a field as a string whether it was logged as one or as a number/bool via `Display`.
128fn json_string_field(
129    object: &serde_json::Map<String, serde_json::Value>,
130    key: &str,
131) -> Option<String> {
132    match object.get(key)? {
133        serde_json::Value::String(s) => Some(s.clone()),
134        serde_json::Value::Null => None,
135        other => Some(other.to_string()),
136    }
137}
138
139/// Parse the default text layout: timestamp, level, optional span scope, target, then the message.
140///
141/// The span-scope-and-target prefix is delimited from the message by `": "`, but so is any message
142/// that happens to contain a colon. The prefix segments are therefore consumed only while they
143/// still look like a span or module path — no whitespace, no stray punctuation — which is what
144/// stops `connected to peer: 12D3Koo…` losing its first two words to a phantom target.
145fn parse_text_line(line: &str) -> Option<LogEvent> {
146    let mut parts = line.splitn(2, char::is_whitespace);
147    let timestamp = parts.next()?.to_string();
148    // Cheapest available proof that this really is the start of an event rather than a stray line
149    // that happens to begin with a word.
150    index_date_from_timestamp(&timestamp)?;
151
152    let rest = parts.next()?.trim_start();
153    let (level_token, rest) = rest.split_once(char::is_whitespace)?;
154    let level = LogLevel::parse(level_token)?;
155
156    let (target, message) = split_target_and_message(rest.trim_start());
157
158    Some(LogEvent {
159        timestamp,
160        level,
161        target,
162        peer_id: scan_field(line, "peer_id"),
163        version: scan_field(line, "version"),
164        commit: scan_field(line, "commit"),
165        message,
166    })
167}
168
169fn split_target_and_message(rest: &str) -> (Option<String>, String) {
170    let mut remaining = rest;
171    let mut target = None;
172
173    while let Some(index) = remaining.find(": ") {
174        let head = &remaining[..index];
175        if !looks_like_span_or_target(head) {
176            break;
177        }
178        if looks_like_target(head) {
179            target = Some(head.to_string());
180        }
181        remaining = &remaining[index + 2..];
182    }
183
184    (target, remaining.to_string())
185}
186
187/// A span scope (`upload{id=1}`) or a module path — never a sentence.
188fn looks_like_span_or_target(candidate: &str) -> bool {
189    !candidate.is_empty() && !candidate.contains(char::is_whitespace)
190}
191
192/// A bare Rust module path, which is what the target always is.
193fn looks_like_target(candidate: &str) -> bool {
194    !candidate.is_empty()
195        && candidate
196            .chars()
197            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == ':')
198}
199
200/// Find a `key=value` field in a text-format line.
201///
202/// Best-effort by design: the text layout does not delimit the message from the trailing fields, so
203/// there is no way to do this exactly, and it is not worth a real parser. A false negative costs an
204/// absent field on one document.
205fn scan_field(line: &str, key: &str) -> Option<String> {
206    let needle = format!("{key}=");
207    let mut search_from = 0;
208
209    while let Some(offset) = line[search_from..].find(&needle) {
210        let start = search_from + offset;
211        let preceded_by_boundary = start == 0
212            || line[..start]
213                .chars()
214                .next_back()
215                .is_some_and(char::is_whitespace);
216
217        if preceded_by_boundary {
218            let value = &line[start + needle.len()..];
219            let value = value
220                .split_whitespace()
221                .next()
222                .unwrap_or_default()
223                .trim_end_matches(',')
224                .trim_matches('"');
225            if !value.is_empty() {
226                return Some(value.to_string());
227            }
228        }
229        search_from = start + needle.len();
230    }
231
232    None
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    const TEXT_LINE: &str =
240        "2026-08-19T20:50:00.123456Z  INFO ant_node::node: connected to the network peers=3";
241
242    #[test]
243    fn parses_the_text_layout() {
244        let event = parse_line(TEXT_LINE).unwrap();
245        assert_eq!(event.timestamp, "2026-08-19T20:50:00.123456Z");
246        assert_eq!(event.level, LogLevel::Info);
247        assert_eq!(event.target.as_deref(), Some("ant_node::node"));
248        assert_eq!(event.message, "connected to the network peers=3");
249    }
250
251    #[test]
252    fn parses_the_json_layout() {
253        let line = r#"{"timestamp":"2026-08-19T20:50:00.123456Z","level":"WARN","target":"ant_node::node","message":"peer unreachable","peer_id":"12D3KooWabc"}"#;
254        let event = parse_line(line).unwrap();
255        assert_eq!(event.timestamp, "2026-08-19T20:50:00.123456Z");
256        assert_eq!(event.level, LogLevel::Warn);
257        assert_eq!(event.target.as_deref(), Some("ant_node::node"));
258        assert_eq!(event.message, "peer unreachable");
259        assert_eq!(event.peer_id.as_deref(), Some("12D3KooWabc"));
260    }
261
262    #[test]
263    fn every_level_round_trips_through_the_text_layout() {
264        for (token, expected) in [
265            ("TRACE", LogLevel::Trace),
266            ("DEBUG", LogLevel::Debug),
267            ("INFO", LogLevel::Info),
268            ("WARN", LogLevel::Warn),
269            ("ERROR", LogLevel::Error),
270        ] {
271            let line = format!("2026-08-19T20:50:00.123456Z {token} ant_node: hello");
272            assert_eq!(parse_line(&line).unwrap().level, expected, "{token}");
273        }
274    }
275
276    /// The text layer pads the level to five columns, so INFO and WARN arrive with two leading
277    /// spaces where ERROR and TRACE arrive with one.
278    #[test]
279    fn tolerates_the_level_column_padding() {
280        let padded = "2026-08-19T20:50:00.123456Z  INFO ant_node: hello";
281        let unpadded = "2026-08-19T20:50:00.123456Z ERROR ant_node: hello";
282        assert_eq!(parse_line(padded).unwrap().level, LogLevel::Info);
283        assert_eq!(parse_line(unpadded).unwrap().level, LogLevel::Error);
284    }
285
286    /// The case the naive "split on the first colon" approach gets wrong.
287    #[test]
288    fn a_colon_in_the_message_does_not_become_a_target() {
289        let line = "2026-08-19T20:50:00.123456Z  INFO ant_node: dialing peer: 12D3KooWabc";
290        let event = parse_line(line).unwrap();
291        assert_eq!(event.target.as_deref(), Some("ant_node"));
292        assert_eq!(event.message, "dialing peer: 12D3KooWabc");
293    }
294
295    #[test]
296    fn a_span_scope_before_the_target_is_skipped() {
297        let line = "2026-08-19T20:50:00.123456Z  INFO upload{id=1}: ant_node::store: stored chunk";
298        let event = parse_line(line).unwrap();
299        assert_eq!(event.target.as_deref(), Some("ant_node::store"));
300        assert_eq!(event.message, "stored chunk");
301    }
302
303    #[test]
304    fn a_message_with_no_target_still_parses() {
305        let line = "2026-08-19T20:50:00.123456Z  INFO started with no target at all";
306        let event = parse_line(line).unwrap();
307        assert_eq!(event.target, None);
308        assert_eq!(event.message, "started with no target at all");
309    }
310
311    #[test]
312    fn lines_without_a_timestamp_are_continuations() {
313        assert!(parse_line("  at src/node.rs:42").is_none());
314        assert!(parse_line("thread 'main' panicked").is_none());
315        assert!(parse_line("").is_none());
316        assert!(parse_line("   ").is_none());
317    }
318
319    #[test]
320    fn a_line_with_an_unknown_level_is_treated_as_a_continuation() {
321        let line = "2026-08-19T20:50:00.123456Z  NOISE ant_node: hello";
322        assert!(parse_line(line).is_none());
323    }
324
325    #[test]
326    fn malformed_json_is_treated_as_a_continuation_rather_than_guessed_at() {
327        assert!(parse_line(r#"{"level":"INFO""#).is_none());
328        assert!(parse_line(r#"{"level":"INFO","target":"x"}"#).is_none());
329    }
330
331    /// An event with an unreadable timestamp has no index to be written to, in either layout.
332    #[test]
333    fn a_json_line_with_an_unusable_timestamp_is_rejected() {
334        let line = r#"{"timestamp":"not-a-date","level":"INFO","message":"m"}"#;
335        assert!(parse_line(line).is_none());
336    }
337
338    #[test]
339    fn every_parsed_event_can_name_its_index() {
340        for line in [
341            TEXT_LINE,
342            r#"{"timestamp":"2026-08-19T20:50:00.123456Z","level":"INFO","message":"m"}"#,
343        ] {
344            assert!(parse_line(line).unwrap().index_date().is_some(), "{line}");
345        }
346    }
347
348    #[test]
349    fn continuations_are_appended_to_the_event_above_them() {
350        let mut event = parse_line(TEXT_LINE).unwrap();
351        event.push_continuation("thread 'main' panicked");
352        event.push_continuation("  at src/node.rs:42");
353        assert_eq!(
354            event.message,
355            "connected to the network peers=3\nthread 'main' panicked\n  at src/node.rs:42"
356        );
357    }
358
359    #[test]
360    fn lifts_peer_id_version_and_commit_from_a_text_line() {
361        let line = "2026-08-19T20:50:00.123456Z  INFO ant_node: starting version=0.17.2 commit=abc1234 peer_id=12D3KooWabc";
362        let event = parse_line(line).unwrap();
363        assert_eq!(event.version.as_deref(), Some("0.17.2"));
364        assert_eq!(event.commit.as_deref(), Some("abc1234"));
365        assert_eq!(event.peer_id.as_deref(), Some("12D3KooWabc"));
366    }
367
368    #[test]
369    fn field_scanning_ignores_a_key_that_is_only_a_suffix_of_another() {
370        let line = "2026-08-19T20:50:00.123456Z  INFO ant_node: hello node_version=9.9.9";
371        assert_eq!(parse_line(line).unwrap().version, None);
372    }
373
374    #[test]
375    fn field_scanning_strips_quotes_and_trailing_commas() {
376        let line = r#"2026-08-19T20:50:00.123456Z  INFO ant_node: hello peer_id="12D3KooWabc","#;
377        assert_eq!(
378            parse_line(line).unwrap().peer_id.as_deref(),
379            Some("12D3KooWabc")
380        );
381    }
382
383    #[test]
384    fn a_json_field_logged_as_a_number_still_reads_as_a_string() {
385        let line = r#"{"timestamp":"2026-08-19T20:50:00.123456Z","level":"INFO","message":"m","peer_id":42}"#;
386        assert_eq!(parse_line(line).unwrap().peer_id.as_deref(), Some("42"));
387    }
388
389    #[test]
390    fn index_date_is_derived_from_the_events_own_timestamp() {
391        let event = parse_line(TEXT_LINE).unwrap();
392        assert_eq!(event.index_date().as_deref(), Some("2026.08.19"));
393    }
394
395    #[test]
396    fn index_date_rejects_a_timestamp_it_cannot_trust() {
397        assert_eq!(index_date_from_timestamp("nonsense"), None);
398        assert_eq!(index_date_from_timestamp("2026/08/19T00:00:00Z"), None);
399        assert_eq!(index_date_from_timestamp("20xx-08-19T00:00:00Z"), None);
400        assert_eq!(index_date_from_timestamp("2026-08"), None);
401    }
402
403    #[test]
404    fn trailing_newlines_are_stripped_from_the_message() {
405        let event = parse_line(&format!("{TEXT_LINE}\r\n")).unwrap();
406        assert_eq!(event.message, "connected to the network peers=3");
407    }
408}