1use serde_json::{Map, Value};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum ApplicationLogLevel {
6 Trace,
7 Debug,
8 Info,
9 Warn,
10 Error,
11 Fatal,
12}
13
14pub 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}