Skip to main content

obz_core/model/
log.rs

1//! Log data models.
2//!
3//! Represents log entries normalized from all supported backend providers.
4//! All timestamps are Unix seconds. Severity is a typed enum.
5//! Attributes are flattened to `BTreeMap<String, String>`.
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11/// Normalized log severity level.
12///
13/// Standard levels follow syslog/OTel conventions. Values that cannot be
14/// mapped to a standard level are preserved as `Other(String)`.
15///
16/// Serializes as UPPERCASE (`"ERROR"`, `"WARN"`, etc.).
17/// Deserializes case-insensitively — `"error"`, `"Error"`, and `"ERROR"`
18/// all produce [`Severity::Error`].
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "UPPERCASE")]
21pub enum Severity {
22    /// Finest-grained debugging information.
23    Trace,
24    /// Debugging information.
25    Debug,
26    /// Normal informational messages.
27    Info,
28    /// Warning conditions.
29    Warn,
30    /// Error conditions.
31    Error,
32    /// System is unusable / fatal.
33    Fatal,
34    /// Unrecognized severity — preserves the original string.
35    #[serde(untagged)]
36    Other(String),
37}
38
39impl<'de> Deserialize<'de> for Severity {
40    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
41    where
42        D: serde::Deserializer<'de>,
43    {
44        let s = String::deserialize(deserializer)?;
45        Ok(parse_severity(&s))
46    }
47}
48
49/// A single log entry normalized from any backend provider.
50///
51/// Core fields (`timestamp`, `message`) are always present.
52/// Optional fields are omitted from Agent View when `None` or empty.
53/// `resource` and `extensions` are only present in Full View.
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55pub struct LogEntry {
56    /// Unix timestamp in seconds.
57    pub timestamp: i64,
58
59    /// Log message body.
60    pub message: String,
61
62    /// Normalized severity level.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub severity: Option<Severity>,
65
66    /// Source identifier (hostname, IP address).
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub source: Option<String>,
69
70    /// Service name.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub service: Option<String>,
73
74    /// Unique log entry ID (provider-specific; not all backends emit this).
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub id: Option<String>,
77
78    /// Structured attributes, flattened to string values.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub attributes: Option<BTreeMap<String, String>>,
81
82    /// Resource-level attributes (Full View only).
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub resource: Option<BTreeMap<String, String>>,
85
86    /// Correlated Trace ID.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub trace_id: Option<String>,
89
90    /// Correlated Span ID.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub span_id: Option<String>,
93
94    /// Provider-specific metadata (Full View only).
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub extensions: Option<BTreeMap<String, serde_json::Value>>,
97}
98
99/// Parse a raw severity string into a [`Severity`] enum.
100///
101/// Maps common aliases (syslog names, numeric levels, abbreviations)
102/// to standard levels. Unrecognized values are preserved as `Other`.
103pub fn parse_severity(raw: &str) -> Severity {
104    match raw.trim().to_lowercase().as_str() {
105        "trace" => Severity::Trace,
106        "debug" | "7" => Severity::Debug,
107        "info" | "informational" | "notice" | "5" | "6" => Severity::Info,
108        "warn" | "warning" | "4" => Severity::Warn,
109        "error" | "err" | "3" => Severity::Error,
110        "fatal" | "critical" | "crit" | "alert" | "emergency" | "emerg" | "0" | "1" | "2" => {
111            Severity::Fatal
112        }
113        _ => Severity::Other(raw.trim().to_string()),
114    }
115}
116
117/// Convert an OpenTelemetry `severityNumber` (1-24) into a normalized [`Severity`].
118///
119/// OpenTelemetry defines six 4-value buckets:
120/// 1-4 = TRACE, 5-8 = DEBUG, 9-12 = INFO, 13-16 = WARN,
121/// 17-20 = ERROR, and 21-24 = FATAL.
122///
123/// Returns `None` for `0` (unspecified) and values above `24` (undefined).
124pub fn severity_from_otel_number(n: u32) -> Option<Severity> {
125    match n {
126        1..=4 => Some(Severity::Trace),
127        5..=8 => Some(Severity::Debug),
128        9..=12 => Some(Severity::Info),
129        13..=16 => Some(Severity::Warn),
130        17..=20 => Some(Severity::Error),
131        21..=24 => Some(Severity::Fatal),
132        _ => None,
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn test_parse_severity_standard() {
142        assert_eq!(parse_severity("trace"), Severity::Trace);
143        assert_eq!(parse_severity("TRACE"), Severity::Trace);
144        assert_eq!(parse_severity("debug"), Severity::Debug);
145        assert_eq!(parse_severity("info"), Severity::Info);
146        assert_eq!(parse_severity("warn"), Severity::Warn);
147        assert_eq!(parse_severity("error"), Severity::Error);
148        assert_eq!(parse_severity("fatal"), Severity::Fatal);
149    }
150
151    #[test]
152    fn test_parse_severity_aliases() {
153        assert_eq!(parse_severity("warning"), Severity::Warn);
154        assert_eq!(parse_severity("err"), Severity::Error);
155        assert_eq!(parse_severity("critical"), Severity::Fatal);
156        assert_eq!(parse_severity("crit"), Severity::Fatal);
157        assert_eq!(parse_severity("emerg"), Severity::Fatal);
158        assert_eq!(parse_severity("informational"), Severity::Info);
159        assert_eq!(parse_severity("notice"), Severity::Info);
160    }
161
162    #[test]
163    fn test_parse_severity_syslog_numeric() {
164        assert_eq!(parse_severity("0"), Severity::Fatal);
165        assert_eq!(parse_severity("3"), Severity::Error);
166        assert_eq!(parse_severity("4"), Severity::Warn);
167        assert_eq!(parse_severity("6"), Severity::Info);
168        assert_eq!(parse_severity("7"), Severity::Debug);
169    }
170
171    #[test]
172    fn test_parse_severity_unknown() {
173        assert_eq!(
174            parse_severity("custom_level"),
175            Severity::Other("custom_level".to_string())
176        );
177        assert_eq!(
178            parse_severity(" verbose "),
179            Severity::Other("verbose".to_string())
180        );
181    }
182
183    #[test]
184    fn test_parse_severity_trims_whitespace_in_other() {
185        // Whitespace is trimmed even for unrecognized values
186        assert_eq!(
187            parse_severity(" custom_level "),
188            Severity::Other("custom_level".to_string())
189        );
190    }
191
192    #[test]
193    fn test_parse_severity_trims_whitespace() {
194        assert_eq!(parse_severity(" error "), Severity::Error);
195        assert_eq!(parse_severity(" WARN"), Severity::Warn);
196        assert_eq!(parse_severity("info "), Severity::Info);
197    }
198
199    #[test]
200    fn test_severity_from_otel_number_buckets() {
201        assert_eq!(severity_from_otel_number(1), Some(Severity::Trace));
202        assert_eq!(severity_from_otel_number(4), Some(Severity::Trace));
203        assert_eq!(severity_from_otel_number(5), Some(Severity::Debug));
204        assert_eq!(severity_from_otel_number(8), Some(Severity::Debug));
205        assert_eq!(severity_from_otel_number(9), Some(Severity::Info));
206        assert_eq!(severity_from_otel_number(12), Some(Severity::Info));
207        assert_eq!(severity_from_otel_number(13), Some(Severity::Warn));
208        assert_eq!(severity_from_otel_number(16), Some(Severity::Warn));
209        assert_eq!(severity_from_otel_number(17), Some(Severity::Error));
210        assert_eq!(severity_from_otel_number(20), Some(Severity::Error));
211        assert_eq!(severity_from_otel_number(21), Some(Severity::Fatal));
212        assert_eq!(severity_from_otel_number(24), Some(Severity::Fatal));
213    }
214
215    #[test]
216    fn test_severity_from_otel_number_unspecified() {
217        assert_eq!(severity_from_otel_number(0), None);
218    }
219
220    #[test]
221    fn test_severity_from_otel_number_out_of_range() {
222        assert_eq!(severity_from_otel_number(25), None);
223        assert_eq!(severity_from_otel_number(100), None);
224    }
225
226    #[test]
227    fn test_severity_json_serialization() {
228        assert_eq!(
229            serde_json::to_string(&Severity::Error).unwrap(),
230            r#""ERROR""#
231        );
232        assert_eq!(serde_json::to_string(&Severity::Warn).unwrap(), r#""WARN""#);
233        assert_eq!(
234            serde_json::to_string(&Severity::Other("verbose".into())).unwrap(),
235            r#""verbose""#
236        );
237    }
238
239    #[test]
240    fn test_severity_json_deserialization_case_insensitive() {
241        // Uppercase — canonical form
242        let s: Severity = serde_json::from_str(r#""ERROR""#).unwrap();
243        assert_eq!(s, Severity::Error);
244
245        // Lowercase — must also map to standard variant
246        let s: Severity = serde_json::from_str(r#""error""#).unwrap();
247        assert_eq!(s, Severity::Error);
248
249        // Mixed case
250        let s: Severity = serde_json::from_str(r#""Error""#).unwrap();
251        assert_eq!(s, Severity::Error);
252
253        // All standard levels (lowercase)
254        assert_eq!(
255            serde_json::from_str::<Severity>(r#""trace""#).unwrap(),
256            Severity::Trace
257        );
258        assert_eq!(
259            serde_json::from_str::<Severity>(r#""debug""#).unwrap(),
260            Severity::Debug
261        );
262        assert_eq!(
263            serde_json::from_str::<Severity>(r#""info""#).unwrap(),
264            Severity::Info
265        );
266        assert_eq!(
267            serde_json::from_str::<Severity>(r#""warn""#).unwrap(),
268            Severity::Warn
269        );
270        assert_eq!(
271            serde_json::from_str::<Severity>(r#""fatal""#).unwrap(),
272            Severity::Fatal
273        );
274
275        // Aliases via deserialization
276        assert_eq!(
277            serde_json::from_str::<Severity>(r#""warning""#).unwrap(),
278            Severity::Warn
279        );
280        assert_eq!(
281            serde_json::from_str::<Severity>(r#""critical""#).unwrap(),
282            Severity::Fatal
283        );
284
285        // Unknown → Other (preserved as-is)
286        assert_eq!(
287            serde_json::from_str::<Severity>(r#""verbose""#).unwrap(),
288            Severity::Other("verbose".to_string())
289        );
290    }
291
292    #[test]
293    fn test_severity_json_roundtrip() {
294        // Standard variants roundtrip correctly
295        for severity in [
296            Severity::Trace,
297            Severity::Debug,
298            Severity::Info,
299            Severity::Warn,
300            Severity::Error,
301            Severity::Fatal,
302        ] {
303            let json = serde_json::to_string(&severity).unwrap();
304            let back: Severity = serde_json::from_str(&json).unwrap();
305            assert_eq!(back, severity, "roundtrip failed for {json}");
306        }
307
308        // Other with non-colliding value roundtrips
309        let other = Severity::Other("verbose".to_string());
310        let json = serde_json::to_string(&other).unwrap();
311        let back: Severity = serde_json::from_str(&json).unwrap();
312        assert_eq!(back, other);
313    }
314
315    #[test]
316    fn test_log_entry_serialization() {
317        let entry = LogEntry {
318            timestamp: 1711266323,
319            message: "Connection refused".to_string(),
320            severity: Some(Severity::Error),
321            source: Some("172.16.1.100".to_string()),
322            service: Some("api-gateway".to_string()),
323            id: None,
324            attributes: None,
325            resource: None,
326            trace_id: None,
327            span_id: None,
328            extensions: None,
329        };
330
331        let json = serde_json::to_value(&entry).unwrap();
332        assert_eq!(json["severity"], "ERROR");
333        assert!(json.get("id").is_none());
334    }
335}