use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum Severity {
Trace,
Debug,
Info,
Warn,
Error,
Fatal,
#[serde(untagged)]
Other(String),
}
impl<'de> Deserialize<'de> for Severity {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(parse_severity(&s))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LogEntry {
pub timestamp: i64,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<Severity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attributes: Option<BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource: Option<BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub span_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<BTreeMap<String, serde_json::Value>>,
}
pub fn parse_severity(raw: &str) -> Severity {
match raw.trim().to_lowercase().as_str() {
"trace" => Severity::Trace,
"debug" | "7" => Severity::Debug,
"info" | "informational" | "notice" | "5" | "6" => Severity::Info,
"warn" | "warning" | "4" => Severity::Warn,
"error" | "err" | "3" => Severity::Error,
"fatal" | "critical" | "crit" | "alert" | "emergency" | "emerg" | "0" | "1" | "2" => {
Severity::Fatal
}
_ => Severity::Other(raw.trim().to_string()),
}
}
pub fn severity_from_otel_number(n: u32) -> Option<Severity> {
match n {
1..=4 => Some(Severity::Trace),
5..=8 => Some(Severity::Debug),
9..=12 => Some(Severity::Info),
13..=16 => Some(Severity::Warn),
17..=20 => Some(Severity::Error),
21..=24 => Some(Severity::Fatal),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_severity_standard() {
assert_eq!(parse_severity("trace"), Severity::Trace);
assert_eq!(parse_severity("TRACE"), Severity::Trace);
assert_eq!(parse_severity("debug"), Severity::Debug);
assert_eq!(parse_severity("info"), Severity::Info);
assert_eq!(parse_severity("warn"), Severity::Warn);
assert_eq!(parse_severity("error"), Severity::Error);
assert_eq!(parse_severity("fatal"), Severity::Fatal);
}
#[test]
fn test_parse_severity_aliases() {
assert_eq!(parse_severity("warning"), Severity::Warn);
assert_eq!(parse_severity("err"), Severity::Error);
assert_eq!(parse_severity("critical"), Severity::Fatal);
assert_eq!(parse_severity("crit"), Severity::Fatal);
assert_eq!(parse_severity("emerg"), Severity::Fatal);
assert_eq!(parse_severity("informational"), Severity::Info);
assert_eq!(parse_severity("notice"), Severity::Info);
}
#[test]
fn test_parse_severity_syslog_numeric() {
assert_eq!(parse_severity("0"), Severity::Fatal);
assert_eq!(parse_severity("3"), Severity::Error);
assert_eq!(parse_severity("4"), Severity::Warn);
assert_eq!(parse_severity("6"), Severity::Info);
assert_eq!(parse_severity("7"), Severity::Debug);
}
#[test]
fn test_parse_severity_unknown() {
assert_eq!(
parse_severity("custom_level"),
Severity::Other("custom_level".to_string())
);
assert_eq!(
parse_severity(" verbose "),
Severity::Other("verbose".to_string())
);
}
#[test]
fn test_parse_severity_trims_whitespace_in_other() {
assert_eq!(
parse_severity(" custom_level "),
Severity::Other("custom_level".to_string())
);
}
#[test]
fn test_parse_severity_trims_whitespace() {
assert_eq!(parse_severity(" error "), Severity::Error);
assert_eq!(parse_severity(" WARN"), Severity::Warn);
assert_eq!(parse_severity("info "), Severity::Info);
}
#[test]
fn test_severity_from_otel_number_buckets() {
assert_eq!(severity_from_otel_number(1), Some(Severity::Trace));
assert_eq!(severity_from_otel_number(4), Some(Severity::Trace));
assert_eq!(severity_from_otel_number(5), Some(Severity::Debug));
assert_eq!(severity_from_otel_number(8), Some(Severity::Debug));
assert_eq!(severity_from_otel_number(9), Some(Severity::Info));
assert_eq!(severity_from_otel_number(12), Some(Severity::Info));
assert_eq!(severity_from_otel_number(13), Some(Severity::Warn));
assert_eq!(severity_from_otel_number(16), Some(Severity::Warn));
assert_eq!(severity_from_otel_number(17), Some(Severity::Error));
assert_eq!(severity_from_otel_number(20), Some(Severity::Error));
assert_eq!(severity_from_otel_number(21), Some(Severity::Fatal));
assert_eq!(severity_from_otel_number(24), Some(Severity::Fatal));
}
#[test]
fn test_severity_from_otel_number_unspecified() {
assert_eq!(severity_from_otel_number(0), None);
}
#[test]
fn test_severity_from_otel_number_out_of_range() {
assert_eq!(severity_from_otel_number(25), None);
assert_eq!(severity_from_otel_number(100), None);
}
#[test]
fn test_severity_json_serialization() {
assert_eq!(
serde_json::to_string(&Severity::Error).unwrap(),
r#""ERROR""#
);
assert_eq!(serde_json::to_string(&Severity::Warn).unwrap(), r#""WARN""#);
assert_eq!(
serde_json::to_string(&Severity::Other("verbose".into())).unwrap(),
r#""verbose""#
);
}
#[test]
fn test_severity_json_deserialization_case_insensitive() {
let s: Severity = serde_json::from_str(r#""ERROR""#).unwrap();
assert_eq!(s, Severity::Error);
let s: Severity = serde_json::from_str(r#""error""#).unwrap();
assert_eq!(s, Severity::Error);
let s: Severity = serde_json::from_str(r#""Error""#).unwrap();
assert_eq!(s, Severity::Error);
assert_eq!(
serde_json::from_str::<Severity>(r#""trace""#).unwrap(),
Severity::Trace
);
assert_eq!(
serde_json::from_str::<Severity>(r#""debug""#).unwrap(),
Severity::Debug
);
assert_eq!(
serde_json::from_str::<Severity>(r#""info""#).unwrap(),
Severity::Info
);
assert_eq!(
serde_json::from_str::<Severity>(r#""warn""#).unwrap(),
Severity::Warn
);
assert_eq!(
serde_json::from_str::<Severity>(r#""fatal""#).unwrap(),
Severity::Fatal
);
assert_eq!(
serde_json::from_str::<Severity>(r#""warning""#).unwrap(),
Severity::Warn
);
assert_eq!(
serde_json::from_str::<Severity>(r#""critical""#).unwrap(),
Severity::Fatal
);
assert_eq!(
serde_json::from_str::<Severity>(r#""verbose""#).unwrap(),
Severity::Other("verbose".to_string())
);
}
#[test]
fn test_severity_json_roundtrip() {
for severity in [
Severity::Trace,
Severity::Debug,
Severity::Info,
Severity::Warn,
Severity::Error,
Severity::Fatal,
] {
let json = serde_json::to_string(&severity).unwrap();
let back: Severity = serde_json::from_str(&json).unwrap();
assert_eq!(back, severity, "roundtrip failed for {json}");
}
let other = Severity::Other("verbose".to_string());
let json = serde_json::to_string(&other).unwrap();
let back: Severity = serde_json::from_str(&json).unwrap();
assert_eq!(back, other);
}
#[test]
fn test_log_entry_serialization() {
let entry = LogEntry {
timestamp: 1711266323,
message: "Connection refused".to_string(),
severity: Some(Severity::Error),
source: Some("172.16.1.100".to_string()),
service: Some("api-gateway".to_string()),
id: None,
attributes: None,
resource: None,
trace_id: None,
span_id: None,
extensions: None,
};
let json = serde_json::to_value(&entry).unwrap();
assert_eq!(json["severity"], "ERROR");
assert!(json.get("id").is_none());
}
}