1use serde_json::{Map, Value};
2
3pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum ApplicationLogLevel {
35 Trace,
36 Debug,
37 Info,
38 Warn,
39 Error,
40 Fatal,
41}
42
43pub 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}