Skip to main content

pitchfork_cli/
log_parse.rs

1//! Structured log line parsing.
2//!
3//! Parses daemon stdout/stderr lines into structured fields (level, msg,
4//! logger, fields_json) based on the configured `log_format`. Supports JSON
5//! and logfmt formats.
6
7use serde_json::{Map, Value};
8
9/// Result of parsing a single log line.
10///
11/// `message` always holds the original raw line text. The structured fields
12/// are `None` when the line could not be parsed (e.g. plain text or parse
13/// failure).
14#[derive(Debug, Clone, Default)]
15pub struct ParsedLog {
16    /// The original raw line text, always preserved.
17    pub message: String,
18    /// Normalized log level: `error` | `warn` | `info` | `debug` | `trace`.
19    pub level: Option<String>,
20    /// Extracted human-readable message (from `msg`/`message`/`event`/...).
21    pub msg: Option<String>,
22    /// Logger name (from `logger`/`name`/`component`/...).
23    pub logger: Option<String>,
24    /// The full parsed JSON object as a string, for `json_extract` queries.
25    /// `None` for plain-text or logfmt lines (logfmt fields are also stored
26    /// here as a JSON object string).
27    pub fields_json: Option<String>,
28}
29
30impl ParsedLog {
31    /// Create a plain-text ParsedLog with no structured fields.
32    fn plain(message: impl Into<String>) -> Self {
33        Self {
34            message: message.into(),
35            ..Default::default()
36        }
37    }
38}
39
40/// Maximum line length for structured parsing. Lines exceeding this are
41/// stored as plain text without field extraction, protecting against
42/// pathological inputs that would cause excessive memory or CPU use.
43const MAX_PARSE_LINE_LEN: usize = 65536;
44
45/// Parse a log line according to the given format string.
46///
47/// Format values: `"json"`, `"logfmt"`, `"text"` (or any other
48/// value is treated as text). Parse failures fall back to plain text.
49pub fn parse(line: &str, format: &str) -> ParsedLog {
50    if line.len() > MAX_PARSE_LINE_LEN {
51        return ParsedLog::plain(line);
52    }
53    match format {
54        "json" => parse_json(line).unwrap_or_else(|| ParsedLog::plain(line)),
55        "logfmt" => parse_logfmt(line).unwrap_or_else(|| ParsedLog::plain(line)),
56        _ => ParsedLog::plain(line),
57    }
58}
59
60// ---------------------------------------------------------------------------
61// JSON parsing
62// ---------------------------------------------------------------------------
63
64fn parse_json(line: &str) -> Option<ParsedLog> {
65    let value: Value = serde_json::from_str(line.trim()).ok()?;
66    let obj = value.as_object()?;
67
68    let level = extract_level(obj, false);
69    let msg = extract_msg(obj);
70    let logger = extract_logger(obj);
71
72    // Re-serialize as compact JSON for storage. Using the original object
73    // (not a filtered subset) so all fields are available for json_extract.
74    let fields_json = serde_json::to_string(&value).ok()?;
75
76    Some(ParsedLog {
77        message: line.to_string(),
78        level,
79        msg,
80        logger,
81        fields_json: Some(fields_json),
82    })
83}
84
85// ---------------------------------------------------------------------------
86// logfmt parsing
87// ---------------------------------------------------------------------------
88
89fn parse_logfmt(line: &str) -> Option<ParsedLog> {
90    let pairs = parse_logfmt_pairs(line)?;
91
92    // Build a JSON object from the key-value pairs.
93    let mut obj = Map::new();
94    for (key, value) in &pairs {
95        let json_val = match value {
96            // Bare key (no '=') → boolean true.
97            LogfmtValue::Bare => Value::Bool(true),
98            // Quoting is the one way logfmt says a value is text.
99            LogfmtValue::Quoted(value) => Value::String(value.clone()),
100            LogfmtValue::Unquoted(value) => unquoted_logfmt_value(value),
101        };
102        obj.insert(key.clone(), json_val);
103    }
104
105    // Logfmt has no numbers of its own, so `level="30"` is pino's info.
106    let level = extract_level(&obj, true);
107    let msg = extract_msg(&obj);
108    let logger = extract_logger(&obj);
109    let value = Value::Object(obj);
110    let fields_json = serde_json::to_string(&value).ok()?;
111
112    Some(ParsedLog {
113        message: line.to_string(),
114        level,
115        msg,
116        logger,
117        fields_json: Some(fields_json),
118    })
119}
120
121/// Parse a logfmt line into (key, value) pairs.
122///
123/// Grammar (simplified from kr/logfmt):
124/// ```text
125/// pair = key '=' value | key '=' | key
126/// key  = ident
127/// value = ident | '"...' '"'
128/// ```
129///
130/// A logfmt value as written. Logfmt has no types; quoting is the only
131/// thing a writer can say about a value.
132enum LogfmtValue {
133    /// A key without `=`, distinct from an explicit empty value (`key=""`
134    /// or `key=`).
135    Bare,
136    Quoted(String),
137    Unquoted(String),
138}
139
140/// The JSON value for an unquoted logfmt value: a number, boolean or null
141/// when that is exactly how the text spells it, so nothing is lost
142/// converting it; otherwise the text itself. `007`, `1.10`, `TRUE` and `1e3`
143/// therefore stay strings, as they would print differently as JSON.
144fn unquoted_logfmt_value(value: &str) -> Value {
145    let typed = match value {
146        "true" => Some(Value::Bool(true)),
147        "false" => Some(Value::Bool(false)),
148        "null" => Some(Value::Null),
149        _ => value
150            .parse::<i64>()
151            .ok()
152            .map(|n| Value::Number(n.into()))
153            .or_else(|| {
154                value
155                    .parse::<f64>()
156                    .ok()
157                    .and_then(serde_json::Number::from_f64)
158                    .map(Value::Number)
159            }),
160    };
161    match typed {
162        Some(typed) if json_spelling_is(&typed, value) => typed,
163        _ => Value::String(value.to_string()),
164    }
165}
166
167/// Whether `typed` is written as JSON exactly as `text`, checked in a stack
168/// buffer: this runs for every numeric field of every logfmt line.
169fn json_spelling_is(typed: &Value, text: &str) -> bool {
170    // Longer than any number, boolean or null serde_json writes.
171    let mut buf = [0u8; 40];
172    let mut cursor = std::io::Cursor::new(&mut buf[..]);
173    if serde_json::to_writer(&mut cursor, typed).is_err() {
174        return false;
175    }
176    let len = cursor.position() as usize;
177    &buf[..len] == text.as_bytes()
178}
179
180/// Returns `None` if the line doesn't look like logfmt (no `=` found, or
181/// parsing yields zero pairs).
182fn parse_logfmt_pairs(line: &str) -> Option<Vec<(String, LogfmtValue)>> {
183    let bytes = line.as_bytes();
184    let mut pairs = Vec::new();
185    let mut bare_key_count = 0;
186    let mut i = 0;
187
188    while i < bytes.len() {
189        // Skip whitespace and garbage between pairs.
190        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
191            i += 1;
192        }
193        if i >= bytes.len() {
194            break;
195        }
196
197        // Parse key: read until '=', whitespace, or end.
198        let key_start = i;
199        while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'=' {
200            i += 1;
201        }
202        let key = &line[key_start..i];
203        if key.is_empty() {
204            // Skip stray garbage.
205            i += 1;
206            continue;
207        }
208        // Reject keys that aren't valid logfmt identifiers. Real logfmt keys
209        // are alphanumeric with '_', '.', '-', '@'. Tokens like '2026/07/23' or
210        // '22:02:39' (from Go's standard log format) are not valid keys.
211        // '@' is allowed for pino/syslog style keys like @level, @message.
212        if !key
213            .bytes()
214            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'-' || b == b'@')
215        {
216            // Skip the entire invalid token. If followed by '=value',
217            // consume that too so it isn't parsed as a separate pair.
218            while i < bytes.len() && bytes[i] == b'=' {
219                i += 1;
220                if i < bytes.len() && bytes[i] == b'"' {
221                    i += 1;
222                    while i < bytes.len() && bytes[i] != b'"' {
223                        if bytes[i] == b'\\' && i + 1 < bytes.len() {
224                            i += 2;
225                        } else {
226                            i += 1;
227                        }
228                    }
229                    if i < bytes.len() {
230                        i += 1;
231                    }
232                } else {
233                    while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
234                        i += 1;
235                    }
236                }
237            }
238            continue;
239        }
240
241        // Check for '='.
242        if i < bytes.len() && bytes[i] == b'=' {
243            i += 1; // consume '='
244
245            // Parse value.
246            if i < bytes.len() && bytes[i] == b'"' {
247                // Quoted value: read until closing '"', handling escapes.
248                i += 1; // skip opening quote
249                let val_start = i;
250                while i < bytes.len() && bytes[i] != b'"' {
251                    if bytes[i] == b'\\' && i + 1 < bytes.len() {
252                        i += 2;
253                    } else {
254                        i += 1;
255                    }
256                }
257                let value = unescape_logfmt_value(&line[val_start..i]);
258                if i < bytes.len() {
259                    i += 1; // skip closing quote
260                }
261                pairs.push((key.to_string(), LogfmtValue::Quoted(value)));
262            } else {
263                // Unquoted value: read until whitespace or end.
264                let val_start = i;
265                while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
266                    i += 1;
267                }
268                pairs.push((
269                    key.to_string(),
270                    LogfmtValue::Unquoted(line[val_start..i].to_string()),
271                ));
272            }
273        } else {
274            // Bare key (no '='): treat as boolean true.
275            pairs.push((key.to_string(), LogfmtValue::Bare));
276            bare_key_count += 1;
277        }
278    }
279
280    if pairs.is_empty() {
281        return None;
282    }
283    // Require at least one pair with '=' to distinguish from plain text.
284    if !line.contains('=') {
285        return None;
286    }
287    // Reject lines that are mostly bare keys — they are likely plain text
288    // with a trailing key=value, not real logfmt. A bare key is one without
289    // '='; explicit `key=""` or `key=` counts as a proper pair.
290    if bare_key_count * 2 > pairs.len() {
291        return None;
292    }
293    Some(pairs)
294}
295
296fn unescape_logfmt_value(s: &str) -> String {
297    let mut result = String::with_capacity(s.len());
298    let mut chars = s.chars();
299    while let Some(c) = chars.next() {
300        if c == '\\' {
301            if let Some(next) = chars.next() {
302                result.push(next);
303            }
304        } else {
305            result.push(c);
306        }
307    }
308    result
309}
310
311// ---------------------------------------------------------------------------
312// Field extraction with alias tables (inspired by pamburus/hl)
313// ---------------------------------------------------------------------------
314
315/// Try to extract a normalized level from common field names.
316///
317/// Handles both string values (case-insensitive) and integer values
318/// (pino/syslog style). See `normalize_level_value` for the mapping.
319/// `numeric_text` also reads a string of digits as an integer level, for
320/// formats like logfmt whose values are all text.
321fn extract_level(obj: &Map<String, Value>, numeric_text: bool) -> Option<String> {
322    for key in &["level", "severity", "lvl", "PRIORITY", "@level"] {
323        if let Some(val) = obj.get(*key)
324            && let Some(level) = normalize_level_value(val, numeric_text)
325        {
326            return Some(level);
327        }
328    }
329    None
330}
331
332/// Normalize a level value to one of: error, warn, info, debug, trace.
333///
334/// String matching is case-insensitive. Integer values follow pino
335/// (10-60) and syslog/RFC 5424 (0-7) conventions.
336fn normalize_level_value(val: &Value, numeric_text: bool) -> Option<String> {
337    match val {
338        Value::String(s) => normalize_level_str(s).or_else(|| {
339            numeric_text
340                .then(|| s.parse::<i64>().ok())
341                .flatten()
342                .and_then(normalize_level_number)
343        }),
344        Value::Number(n) => normalize_level_number(n.as_i64()?),
345        _ => None,
346    }
347}
348
349/// Normalize an integer level, following pino (10-60) and syslog/RFC 5424
350/// (0-7).
351fn normalize_level_number(n: i64) -> Option<String> {
352    // pino: 10=trace, 20=debug, 30=info, 40=warn, 50=error, 60=fatal
353    match n {
354        50 | 60 => Some("error".into()),
355        40 => Some("warn".into()),
356        30 => Some("info".into()),
357        20 => Some("debug".into()),
358        10 => Some("trace".into()),
359        // syslog/RFC 5424: 0=emerg..7=debug
360        0..=3 => Some("error".into()),
361        4 | 5 => Some("warn".into()),
362        6 => Some("info".into()),
363        7 => Some("debug".into()),
364        _ => None,
365    }
366}
367
368pub fn normalize_level_str(s: &str) -> Option<String> {
369    let lower = s.to_ascii_lowercase();
370    match lower.as_str() {
371        "error" | "err" | "fatal" | "critical" | "panic" | "alert" | "emerg" => {
372            Some("error".into())
373        }
374        "warn" | "warning" | "wrn" => Some("warn".into()),
375        "info" | "inf" | "information" | "notice" => Some("info".into()),
376        "debug" | "dbg" => Some("debug".into()),
377        "trace" | "trc" => Some("trace".into()),
378        _ => None,
379    }
380}
381
382fn extract_first_string(obj: &Map<String, Value>, keys: &[&str]) -> Option<String> {
383    for key in keys {
384        if let Some(Value::String(s)) = obj.get(*key) {
385            return Some(s.clone());
386        }
387    }
388    None
389}
390
391/// Try to extract the human-readable message from common field names.
392fn extract_msg(obj: &Map<String, Value>) -> Option<String> {
393    extract_first_string(obj, &["msg", "message", "event", "@message"])
394}
395
396/// Try to extract the logger name from common field names.
397fn extract_logger(obj: &Map<String, Value>) -> Option<String> {
398    extract_first_string(obj, &["logger", "name", "component", "module"])
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn test_json_parse() {
407        let line = r#"{"level":"info","msg":"server started","port":8080}"#;
408        let parsed = parse(line, "json");
409        assert_eq!(parsed.level.as_deref(), Some("info"));
410        assert_eq!(parsed.msg.as_deref(), Some("server started"));
411        assert!(parsed.fields_json.is_some());
412    }
413
414    #[test]
415    fn test_json_level_normalization() {
416        let line = r#"{"level":"FATAL","msg":"crash"}"#;
417        let parsed = parse(line, "json");
418        assert_eq!(parsed.level.as_deref(), Some("error"));
419    }
420
421    #[test]
422    fn test_json_pino_integer_level() {
423        let line = r#"{"level":50,"msg":"error occurred"}"#;
424        let parsed = parse(line, "json");
425        assert_eq!(parsed.level.as_deref(), Some("error"));
426    }
427
428    #[test]
429    fn test_json_syslog_priority() {
430        let line = r#"{"PRIORITY":3,"msg":"system error"}"#;
431        let parsed = parse(line, "json");
432        assert_eq!(parsed.level.as_deref(), Some("error"));
433    }
434
435    #[test]
436    fn test_json_msg_aliases() {
437        // structlog uses "event"
438        let line = r#"{"event":"hello","level":"info"}"#;
439        let parsed = parse(line, "json");
440        assert_eq!(parsed.msg.as_deref(), Some("hello"));
441    }
442
443    #[test]
444    fn test_logfmt_parse() {
445        let line = r#"level=info msg="server started" port=8080"#;
446        let parsed = parse(line, "logfmt");
447        assert_eq!(parsed.level.as_deref(), Some("info"));
448        assert_eq!(parsed.msg.as_deref(), Some("server started"));
449        assert!(parsed.fields_json.is_some());
450    }
451
452    fn logfmt_fields(line: &str) -> Value {
453        let parsed = parse(line, "logfmt");
454        serde_json::from_str(parsed.fields_json.as_deref().unwrap()).unwrap()
455    }
456
457    #[test]
458    fn test_logfmt_types_values_only_when_nothing_is_lost() {
459        let fields = logfmt_fields(
460            "level=info status=500 took=0.25 neg=-3 ok=true gone=null id=007 version=1.10 \
461             big=12345678901234567890 exp=1e3 flag=TRUE",
462        );
463        assert_eq!(fields["status"], serde_json::json!(500));
464        assert_eq!(fields["took"], serde_json::json!(0.25));
465        assert_eq!(fields["neg"], serde_json::json!(-3));
466        assert_eq!(fields["ok"], Value::Bool(true));
467        assert_eq!(fields["gone"], Value::Null);
468        for (key, text) in [
469            ("id", "007"),
470            ("version", "1.10"),
471            ("big", "12345678901234567890"),
472            ("exp", "1e3"),
473            ("flag", "TRUE"),
474        ] {
475            assert_eq!(fields[key], Value::String(text.into()), "{key}");
476        }
477    }
478
479    #[test]
480    fn test_logfmt_quoted_values_stay_strings() {
481        let fields = logfmt_fields(r#"level=info code="007" count="42" ok="true""#);
482        assert_eq!(fields["code"], Value::String("007".into()));
483        assert_eq!(fields["count"], Value::String("42".into()));
484        assert_eq!(fields["ok"], Value::String("true".into()));
485    }
486
487    /// A numeric level keeps its text in the fields but is still mapped to a
488    /// severity, quoted or not, as it was when it was stored as a number.
489    #[test]
490    fn test_logfmt_numeric_level_text_is_normalized() {
491        for (line, level) in [
492            (r#"level="30" msg=hi"#, "info"),
493            ("level=50 msg=hi", "error"),
494            (r#"PRIORITY="3" msg=hi"#, "error"),
495            ("level=03 msg=hi", "error"),
496        ] {
497            assert_eq!(
498                parse(line, "logfmt").level.as_deref(),
499                Some(level),
500                "{line}"
501            );
502        }
503        assert_eq!(
504            logfmt_fields(r#"level="30" msg=hi"#)["level"],
505            Value::String("30".into())
506        );
507    }
508
509    /// JSON keeps its own types, so a level written as a string is not read
510    /// as a number.
511    #[test]
512    fn test_json_numeric_level_string_is_not_normalized() {
513        assert_eq!(parse(r#"{"level":"30","msg":"hi"}"#, "json").level, None);
514    }
515
516    #[test]
517    fn test_logfmt_bare_key() {
518        let line = r#"level=debug ready msg="ok""#;
519        let parsed = parse(line, "logfmt");
520        assert_eq!(parsed.level.as_deref(), Some("debug"));
521        assert_eq!(parsed.msg.as_deref(), Some("ok"));
522        // "ready" is a bare key → true
523        let fields: Value = serde_json::from_str(parsed.fields_json.as_deref().unwrap()).unwrap();
524        assert_eq!(fields["ready"], Value::Bool(true));
525    }
526
527    #[test]
528    fn test_logfmt_quoted_value_with_spaces() {
529        let line = r#"level=error msg="connection refused: timeout""#;
530        let parsed = parse(line, "logfmt");
531        assert_eq!(parsed.msg.as_deref(), Some("connection refused: timeout"));
532    }
533
534    #[test]
535    fn test_text_format() {
536        let line = r#"{"level":"info"}"#;
537        let parsed = parse(line, "text");
538        assert!(parsed.level.is_none());
539        assert!(parsed.fields_json.is_none());
540        assert_eq!(parsed.message, line);
541    }
542
543    #[test]
544    fn test_json_parse_failure_falls_back() {
545        let line = "{not valid json";
546        let parsed = parse(line, "json");
547        assert!(parsed.level.is_none());
548        assert!(parsed.fields_json.is_none());
549        assert_eq!(parsed.message, line);
550    }
551
552    #[test]
553    fn test_logfmt_logger_extraction() {
554        let line = r#"level=info msg="hi" logger=myapp"#;
555        let parsed = parse(line, "logfmt");
556        assert_eq!(parsed.logger.as_deref(), Some("myapp"));
557    }
558
559    #[test]
560    fn test_logfmt_multiple_bare_keys_still_parsed() {
561        // 2 bare keys + 2 key=value: proper=2, 2*2=4 not < 4, so accepted.
562        let line = r#"level=debug ready enabled msg="ok""#;
563        let parsed = parse(line, "logfmt");
564        assert_eq!(parsed.level.as_deref(), Some("debug"));
565        assert_eq!(parsed.msg.as_deref(), Some("ok"));
566        let fields: Value = serde_json::from_str(parsed.fields_json.as_deref().unwrap()).unwrap();
567        assert_eq!(fields["ready"], Value::Bool(true));
568        assert_eq!(fields["enabled"], Value::Bool(true));
569    }
570
571    #[test]
572    fn test_logfmt_rejects_go_standard_log() {
573        // Go standard log format with a trailing key=value should not be
574        // misparse as logfmt.
575        let line = "2026/07/23 22:02:39 INFO acquired instance lock path=/foo/bar";
576        let parsed = parse(line, "logfmt");
577        // Should fall back to plain text — no structured fields.
578        assert!(parsed.level.is_none());
579        assert!(parsed.fields_json.is_none());
580        assert_eq!(parsed.message, line);
581    }
582
583    #[test]
584    fn test_logfmt_explicit_empty_value() {
585        // `key=""` is an explicit empty value, not a bare key.
586        // Should not be rejected by the bare-key ratio check.
587        let line = r#"level=debug msg="" extra="""#;
588        let parsed = parse(line, "logfmt");
589        assert_eq!(parsed.level.as_deref(), Some("debug"));
590        assert_eq!(parsed.msg.as_deref(), Some(""));
591        let fields: Value = serde_json::from_str(parsed.fields_json.as_deref().unwrap()).unwrap();
592        assert_eq!(fields["msg"], Value::String("".into()));
593        assert_eq!(fields["extra"], Value::String("".into()));
594    }
595
596    #[test]
597    fn test_logfmt_hyphenated_key() {
598        // Hyphens are valid in logfmt keys (e.g. request-id).
599        let line = r#"level=info msg="ok" request-id=abc123"#;
600        let parsed = parse(line, "logfmt");
601        assert_eq!(parsed.level.as_deref(), Some("info"));
602        let fields: Value = serde_json::from_str(parsed.fields_json.as_deref().unwrap()).unwrap();
603        assert_eq!(fields["request-id"], Value::String("abc123".into()));
604    }
605
606    #[test]
607    fn test_logfmt_at_prefixed_key() {
608        // pino/syslog style keys use @ prefix (e.g. @level, @message).
609        let line = r#"@level=info @message="server started" port=8080"#;
610        let parsed = parse(line, "logfmt");
611        assert_eq!(parsed.level.as_deref(), Some("info"));
612        assert_eq!(parsed.msg.as_deref(), Some("server started"));
613        let fields: Value = serde_json::from_str(parsed.fields_json.as_deref().unwrap()).unwrap();
614        assert_eq!(fields["port"], Value::Number(8080.into()));
615    }
616
617    #[test]
618    fn test_json_nested_not_extracted_as_msg() {
619        // Only top-level fields are extracted; nested objects stay in fields_json.
620        let line = r#"{"level":"info","fields":{"message":"nested"}}"#;
621        let parsed = parse(line, "json");
622        assert_eq!(parsed.level.as_deref(), Some("info"));
623        assert_eq!(parsed.msg, None); // top-level has no msg/message/event
624    }
625}