Skip to main content

alien_core/
application_logs.rs

1use serde_json::{Map, Value};
2
3/// Extracts a readable message only from recognized structured stdout formats.
4/// The caller can keep the original line as `log.record.original` when this
5/// succeeds. Unknown JSON stays untouched rather than guessing a message key.
6pub fn parse_application_log_message(body: &str) -> Option<String> {
7    let Value::Object(record) = serde_json::from_str::<Value>(body).ok()? else {
8        return None;
9    };
10    parse_application_log_level(body)?;
11
12    if record.get("target").is_some_and(Value::is_string) {
13        return record
14            .get("fields")?
15            .as_object()?
16            .get("message")?
17            .as_str()
18            .map(ToOwned::to_owned);
19    }
20
21    let has_timestamp = record.get("time").is_some_and(|value| match value {
22        Value::String(text) => chrono::DateTime::parse_from_rfc3339(text).is_ok(),
23        Value::Number(number) => number.as_f64().is_some_and(|value| value >= 0.0),
24        _ => false,
25    });
26    if !has_timestamp {
27        return None;
28    }
29    record.get("msg")?.as_str().map(ToOwned::to_owned)
30}
31
32/// A recognized application-provided log level.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ApplicationLogLevel {
35    Trace,
36    Debug,
37    Info,
38    Warn,
39    Error,
40    Fatal,
41}
42
43/// Reads an unambiguous severity from a structured application log.
44///
45/// The supported fields follow common OpenTelemetry, ECS, Python, Pino,
46/// Bunyan, Loguru, and structured-logger conventions. Message text, HTTP
47/// status codes, embedded timestamps, and arbitrary fields are deliberately
48/// ignored. Callers retain their existing stream-based fallback when parsing
49/// returns `None`.
50pub fn parse_application_log_level(body: &str) -> Option<ApplicationLogLevel> {
51    let body = body.trim();
52    if !body.starts_with('{') || !body.ends_with('}') {
53        return None;
54    }
55
56    let Value::Object(record) = serde_json::from_str::<Value>(body).ok()? else {
57        return None;
58    };
59
60    let mut resolved = None;
61    let mut conflict = false;
62
63    for key in [
64        "level",
65        "severity",
66        "severityText",
67        "severity_text",
68        "levelname",
69        "level_name",
70        "logLevel",
71        "log_level",
72        "log.level",
73    ] {
74        add_candidate(
75            &mut resolved,
76            &mut conflict,
77            record.get(key).and_then(Value::as_str).and_then(parse_name),
78        );
79    }
80
81    add_candidate(
82        &mut resolved,
83        &mut conflict,
84        nested_value(&record, &["log", "level"])
85            .and_then(Value::as_str)
86            .and_then(parse_name),
87    );
88    add_candidate(
89        &mut resolved,
90        &mut conflict,
91        nested_value(&record, &["record", "level", "name"])
92            .and_then(Value::as_str)
93            .and_then(parse_name),
94    );
95
96    for key in ["severityNumber", "severity_number"] {
97        add_candidate(
98            &mut resolved,
99            &mut conflict,
100            record
101                .get(key)
102                .and_then(Value::as_i64)
103                .and_then(parse_otel_number),
104        );
105    }
106
107    for key in ["levelno", "level_number"] {
108        add_candidate(
109            &mut resolved,
110            &mut conflict,
111            record
112                .get(key)
113                .and_then(Value::as_i64)
114                .and_then(parse_python_number),
115        );
116    }
117
118    if is_pino_or_bunyan_record(&record) {
119        add_candidate(
120            &mut resolved,
121            &mut conflict,
122            record
123                .get("level")
124                .and_then(Value::as_i64)
125                .and_then(parse_pino_number),
126        );
127    }
128
129    (!conflict).then_some(resolved).flatten()
130}
131
132fn nested_value<'a>(record: &'a Map<String, Value>, path: &[&str]) -> Option<&'a Value> {
133    let (first, rest) = path.split_first()?;
134    let mut value = record.get(*first)?;
135    for key in rest {
136        value = value.as_object()?.get(*key)?;
137    }
138    Some(value)
139}
140
141fn add_candidate(
142    resolved: &mut Option<ApplicationLogLevel>,
143    conflict: &mut bool,
144    candidate: Option<ApplicationLogLevel>,
145) {
146    let Some(candidate) = candidate else {
147        return;
148    };
149    match resolved {
150        Some(existing) if *existing != candidate => *conflict = true,
151        Some(_) => {}
152        None => *resolved = Some(candidate),
153    }
154}
155
156fn parse_name(value: &str) -> Option<ApplicationLogLevel> {
157    match value.trim().to_ascii_lowercase().as_str() {
158        "trace" => Some(ApplicationLogLevel::Trace),
159        "debug" | "verbose" | "silly" => Some(ApplicationLogLevel::Debug),
160        "info" | "information" | "informational" | "notice" | "http" | "success" => {
161            Some(ApplicationLogLevel::Info)
162        }
163        "warn" | "warning" => Some(ApplicationLogLevel::Warn),
164        "error" | "err" | "exception" => Some(ApplicationLogLevel::Error),
165        "fatal" | "critical" | "crit" | "alert" | "emergency" | "emerg" | "panic" | "dpanic" => {
166            Some(ApplicationLogLevel::Fatal)
167        }
168        _ => None,
169    }
170}
171
172fn parse_otel_number(value: i64) -> Option<ApplicationLogLevel> {
173    match value {
174        1..=4 => Some(ApplicationLogLevel::Trace),
175        5..=8 => Some(ApplicationLogLevel::Debug),
176        9..=12 => Some(ApplicationLogLevel::Info),
177        13..=16 => Some(ApplicationLogLevel::Warn),
178        17..=20 => Some(ApplicationLogLevel::Error),
179        21..=24 => Some(ApplicationLogLevel::Fatal),
180        _ => None,
181    }
182}
183
184fn parse_python_number(value: i64) -> Option<ApplicationLogLevel> {
185    match value {
186        5 => Some(ApplicationLogLevel::Trace),
187        10 => Some(ApplicationLogLevel::Debug),
188        20 => Some(ApplicationLogLevel::Info),
189        30 => Some(ApplicationLogLevel::Warn),
190        40 => Some(ApplicationLogLevel::Error),
191        50 => Some(ApplicationLogLevel::Fatal),
192        _ => None,
193    }
194}
195
196fn parse_pino_number(value: i64) -> Option<ApplicationLogLevel> {
197    match value {
198        10 => Some(ApplicationLogLevel::Trace),
199        20 => Some(ApplicationLogLevel::Debug),
200        30 => Some(ApplicationLogLevel::Info),
201        40 => Some(ApplicationLogLevel::Warn),
202        50 => Some(ApplicationLogLevel::Error),
203        60 => Some(ApplicationLogLevel::Fatal),
204        _ => None,
205    }
206}
207
208fn is_pino_or_bunyan_record(record: &Map<String, Value>) -> bool {
209    if !record.get("msg").is_some_and(Value::is_string) {
210        return false;
211    }
212
213    let pino = record.get("pid").is_some_and(Value::is_i64)
214        && record
215            .get("time")
216            .is_some_and(|value| value.is_number() || value.is_string());
217    let bunyan = record.get("v").and_then(Value::as_i64) == Some(0)
218        && record.get("name").is_some_and(Value::is_string)
219        && record.get("hostname").is_some_and(Value::is_string);
220
221    pino || bunyan
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn extracts_recognized_structured_messages_only() {
230        assert_eq!(
231            parse_application_log_message(
232                r#"{"time":"2026-09-26T12:12:02Z","level":"INFO","msg":"service ready"}"#
233            ),
234            Some("service ready".to_string())
235        );
236        assert_eq!(
237            parse_application_log_message(
238                r#"{"timestamp":"2026-09-26T12:12:02Z","level":"INFO","target":"app","fields":{"message":"ready"}}"#
239            ),
240            Some("ready".to_string())
241        );
242        assert_eq!(
243            parse_application_log_message(r#"{"status":200,"msg":"ready"}"#),
244            None
245        );
246    }
247
248    #[test]
249    fn parses_common_string_fields_and_aliases() {
250        let cases = [
251            (r#"{"level":" TRACE "}"#, ApplicationLogLevel::Trace),
252            (r#"{"severity":"verbose"}"#, ApplicationLogLevel::Debug),
253            (r#"{"severityText":"NOTICE"}"#, ApplicationLogLevel::Info),
254            (r#"{"severity_text":"success"}"#, ApplicationLogLevel::Info),
255            (r#"{"levelname":"WARNING"}"#, ApplicationLogLevel::Warn),
256            (r#"{"level_name":"err"}"#, ApplicationLogLevel::Error),
257            (r#"{"logLevel":"exception"}"#, ApplicationLogLevel::Error),
258            (r#"{"log_level":"critical"}"#, ApplicationLogLevel::Fatal),
259            (r#"{"log.level":"panic"}"#, ApplicationLogLevel::Fatal),
260            (r#"{"log":{"level":"debug"}}"#, ApplicationLogLevel::Debug),
261            (
262                r#"{"record":{"level":{"name":"INFO"}}}"#,
263                ApplicationLogLevel::Info,
264            ),
265        ];
266
267        for (body, expected) in cases {
268            assert_eq!(parse_application_log_level(body), Some(expected), "{body}");
269        }
270    }
271
272    #[test]
273    fn parses_opentelemetry_severity_ranges() {
274        let cases = [
275            (1, ApplicationLogLevel::Trace),
276            (4, ApplicationLogLevel::Trace),
277            (5, ApplicationLogLevel::Debug),
278            (8, ApplicationLogLevel::Debug),
279            (9, ApplicationLogLevel::Info),
280            (12, ApplicationLogLevel::Info),
281            (13, ApplicationLogLevel::Warn),
282            (16, ApplicationLogLevel::Warn),
283            (17, ApplicationLogLevel::Error),
284            (20, ApplicationLogLevel::Error),
285            (21, ApplicationLogLevel::Fatal),
286            (24, ApplicationLogLevel::Fatal),
287        ];
288
289        for (number, expected) in cases {
290            let body = format!(r#"{{"severityNumber":{number}}}"#);
291            assert_eq!(parse_application_log_level(&body), Some(expected), "{body}");
292        }
293        assert_eq!(parse_application_log_level(r#"{"severityNumber":0}"#), None);
294        assert_eq!(
295            parse_application_log_level(r#"{"severity_number":25}"#),
296            None
297        );
298    }
299
300    #[test]
301    fn parses_python_logging_numbers() {
302        let cases = [
303            (5, ApplicationLogLevel::Trace),
304            (10, ApplicationLogLevel::Debug),
305            (20, ApplicationLogLevel::Info),
306            (30, ApplicationLogLevel::Warn),
307            (40, ApplicationLogLevel::Error),
308            (50, ApplicationLogLevel::Fatal),
309        ];
310
311        for (number, expected) in cases {
312            let body = format!(r#"{{"levelno":{number}}}"#);
313            assert_eq!(parse_application_log_level(&body), Some(expected), "{body}");
314        }
315        assert_eq!(parse_application_log_level(r#"{"level_number":35}"#), None);
316    }
317
318    #[test]
319    fn parses_pino_and_bunyan_numeric_levels_only_with_a_signature() {
320        let pino_cases = [
321            (10, ApplicationLogLevel::Trace),
322            (20, ApplicationLogLevel::Debug),
323            (30, ApplicationLogLevel::Info),
324            (40, ApplicationLogLevel::Warn),
325            (50, ApplicationLogLevel::Error),
326            (60, ApplicationLogLevel::Fatal),
327        ];
328        for (number, expected) in pino_cases {
329            let body = format!(
330                r#"{{"level":{number},"time":1573664685466,"pid":78742,"hostname":"host","msg":"ready"}}"#
331            );
332            assert_eq!(parse_application_log_level(&body), Some(expected), "{body}");
333        }
334
335        assert_eq!(
336            parse_application_log_level(
337                r#"{"name":"api","hostname":"host","v":0,"level":40,"msg":"slow"}"#
338            ),
339            Some(ApplicationLogLevel::Warn)
340        );
341        assert_eq!(
342            parse_application_log_level(r#"{"level":30,"msg":"ready"}"#),
343            None
344        );
345        assert_eq!(
346            parse_application_log_level(r#"{"level":30,"time":1,"msg":"ready"}"#),
347            None
348        );
349    }
350
351    #[test]
352    fn accepts_agreeing_candidates_and_rejects_conflicts() {
353        assert_eq!(
354            parse_application_log_level(r#"{"level":"warn","severity":"warning"}"#),
355            Some(ApplicationLogLevel::Warn)
356        );
357        assert_eq!(
358            parse_application_log_level(r#"{"level":"custom","severity":"error"}"#),
359            Some(ApplicationLogLevel::Error)
360        );
361        assert_eq!(
362            parse_application_log_level(r#"{"level":"error","severity":"info"}"#),
363            None
364        );
365        assert_eq!(
366            parse_application_log_level(r#"{"severityText":"warn","severityNumber":17}"#),
367            None
368        );
369    }
370
371    #[test]
372    fn ignores_unstructured_or_ambiguous_records() {
373        for body in [
374            r#"{"level":"LOUD"}"#,
375            r#"{"level":"30"}"#,
376            r#"{"status":500,"message":"request failed"}"#,
377            r#"{"exception":{"message":"boom"}}"#,
378            r#"{"fields":{"level":"ERROR"}}"#,
379            r#"[{"level":"error"}]"#,
380            r#""error""#,
381            "INFO ready",
382            "prefix {\"level\":\"error\"}",
383            r#"{"level":"error""#,
384        ] {
385            assert_eq!(parse_application_log_level(body), None, "{body}");
386        }
387    }
388}