Skip to main content

alien_core/
application_logs.rs

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