Skip to main content

sonda_core/model/
log.rs

1//! Log event model.
2//!
3//! Defines [`LogEvent`] and [`Severity`] — the canonical in-memory representation
4//! of a structured log entry. Format-agnostic: encoding to JSON Lines or Syslog is
5//! the encoder's concern, not this module's.
6
7use std::collections::BTreeMap;
8use std::time::SystemTime;
9
10use serde::Serialize;
11
12use crate::model::metric::Labels;
13
14/// The severity level of a log event.
15///
16/// Variants map to the conventional log severity ladder. Serializes to and from
17/// lowercase strings (e.g., `"info"`, `"error"`) for YAML and JSON compatibility.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[cfg_attr(feature = "config", derive(serde::Deserialize))]
20// `rename_all` is not behind cfg_attr because it applies to both Serialize
21// (unconditional — used by the JSON encoder) and Deserialize (config-gated).
22// Splitting it would require duplicating the attribute under two cfg_attr guards.
23#[serde(rename_all = "lowercase")]
24pub enum Severity {
25    /// Extremely detailed diagnostic information.
26    Trace,
27    /// Diagnostic information useful during development.
28    Debug,
29    /// General informational messages.
30    Info,
31    /// Potentially harmful situations that warrant attention.
32    Warn,
33    /// Error events that may allow the application to continue.
34    Error,
35    /// Severe error events that will likely cause the application to abort.
36    Fatal,
37}
38
39impl Severity {
40    /// Returns a numeric rank for this severity level.
41    ///
42    /// Lower ranks represent less-severe levels. This defines the canonical
43    /// ordering independently of enum variant declaration order, so reordering
44    /// the variants in source code never silently breaks `Ord`.
45    const fn rank(self) -> u8 {
46        match self {
47            Severity::Trace => 0,
48            Severity::Debug => 1,
49            Severity::Info => 2,
50            Severity::Warn => 3,
51            Severity::Error => 4,
52            Severity::Fatal => 5,
53        }
54    }
55}
56
57impl Ord for Severity {
58    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
59        self.rank().cmp(&other.rank())
60    }
61}
62
63impl PartialOrd for Severity {
64    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
65        Some(self.cmp(other))
66    }
67}
68
69/// A structured log entry with a timestamp, severity, message, labels, and arbitrary fields.
70///
71/// Labels are scenario-level key-value pairs (injected by the log runner).
72/// Fields are event-level key-value metadata (produced by the generator).
73/// Both are stored in sorted containers for deterministic serialization.
74#[derive(Debug, Clone)]
75pub struct LogEvent {
76    /// The time at which the event was generated.
77    pub timestamp: SystemTime,
78    /// The severity level of the event.
79    pub severity: Severity,
80    /// The human-readable log message.
81    pub message: String,
82    /// Scenario-level static labels attached to every event in this scenario.
83    pub labels: Labels,
84    /// Arbitrary key-value metadata attached to the event.
85    pub fields: BTreeMap<String, String>,
86}
87
88impl LogEvent {
89    /// Create a new [`LogEvent`] with the current system time as its timestamp.
90    ///
91    /// # Arguments
92    ///
93    /// * `severity` — The severity level.
94    /// * `message` — The human-readable message.
95    /// * `labels` — Scenario-level static labels.
96    /// * `fields` — Arbitrary key-value metadata.
97    pub fn new(
98        severity: Severity,
99        message: String,
100        labels: Labels,
101        fields: BTreeMap<String, String>,
102    ) -> Self {
103        Self {
104            timestamp: SystemTime::now(),
105            severity,
106            message,
107            labels,
108            fields,
109        }
110    }
111
112    /// Create a [`LogEvent`] with an explicit timestamp.
113    ///
114    /// Useful for deterministic testing and log replay scenarios where the original
115    /// timestamp must be preserved.
116    ///
117    /// # Arguments
118    ///
119    /// * `timestamp` — The exact timestamp to record.
120    /// * `severity` — The severity level.
121    /// * `message` — The human-readable message.
122    /// * `labels` — Scenario-level static labels.
123    /// * `fields` — Arbitrary key-value metadata.
124    pub fn with_timestamp(
125        timestamp: SystemTime,
126        severity: Severity,
127        message: String,
128        labels: Labels,
129        fields: BTreeMap<String, String>,
130    ) -> Self {
131        Self {
132            timestamp,
133            severity,
134            message,
135            labels,
136            fields,
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use std::time::{Duration, UNIX_EPOCH};
144
145    use super::*;
146
147    // -----------------------------------------------------------------------
148    // LogEvent::new — creates event with current timestamp
149    // -----------------------------------------------------------------------
150
151    #[test]
152    fn new_uses_current_timestamp() {
153        let before = SystemTime::now();
154        let event = LogEvent::new(
155            Severity::Info,
156            "hello".to_string(),
157            Labels::default(),
158            BTreeMap::new(),
159        );
160        let after = SystemTime::now();
161
162        assert!(
163            event.timestamp >= before,
164            "timestamp should not precede the call"
165        );
166        assert!(
167            event.timestamp <= after,
168            "timestamp should not exceed the call"
169        );
170    }
171
172    #[test]
173    fn new_stores_severity_message_and_fields() {
174        let mut fields = BTreeMap::new();
175        fields.insert("host".to_string(), "web-01".to_string());
176
177        let event = LogEvent::new(
178            Severity::Error,
179            "connection failed".to_string(),
180            Labels::default(),
181            fields,
182        );
183
184        assert_eq!(event.severity, Severity::Error);
185        assert_eq!(event.message, "connection failed");
186        assert_eq!(event.fields.get("host").map(String::as_str), Some("web-01"));
187    }
188
189    #[test]
190    fn new_with_empty_fields_succeeds() {
191        let event = LogEvent::new(
192            Severity::Debug,
193            "empty".to_string(),
194            Labels::default(),
195            BTreeMap::new(),
196        );
197        assert!(event.fields.is_empty());
198    }
199
200    // -----------------------------------------------------------------------
201    // LogEvent::with_timestamp — uses exact provided timestamp
202    // -----------------------------------------------------------------------
203
204    #[test]
205    fn with_timestamp_uses_exact_provided_timestamp() {
206        let ts = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
207        let event = LogEvent::with_timestamp(
208            ts,
209            Severity::Warn,
210            "test message".to_string(),
211            Labels::default(),
212            BTreeMap::new(),
213        );
214
215        assert_eq!(
216            event.timestamp, ts,
217            "timestamp must be exactly the one provided"
218        );
219    }
220
221    #[test]
222    fn with_timestamp_stores_all_fields_correctly() {
223        let ts = UNIX_EPOCH + Duration::from_secs(42);
224        let mut fields = BTreeMap::new();
225        fields.insert("service".to_string(), "api".to_string());
226        fields.insert("region".to_string(), "us-east-1".to_string());
227
228        let event = LogEvent::with_timestamp(
229            ts,
230            Severity::Fatal,
231            "system crash".to_string(),
232            Labels::default(),
233            fields,
234        );
235
236        assert_eq!(event.timestamp, ts);
237        assert_eq!(event.severity, Severity::Fatal);
238        assert_eq!(event.message, "system crash");
239        assert_eq!(event.fields.get("service").map(String::as_str), Some("api"));
240        assert_eq!(
241            event.fields.get("region").map(String::as_str),
242            Some("us-east-1")
243        );
244    }
245
246    #[test]
247    fn with_timestamp_at_unix_epoch_is_valid() {
248        let event = LogEvent::with_timestamp(
249            UNIX_EPOCH,
250            Severity::Trace,
251            "epoch".to_string(),
252            Labels::default(),
253            BTreeMap::new(),
254        );
255        assert_eq!(event.timestamp, UNIX_EPOCH);
256    }
257
258    // -----------------------------------------------------------------------
259    // LogEvent: fields use BTreeMap (sorted key order)
260    // -----------------------------------------------------------------------
261
262    #[test]
263    fn fields_are_sorted_by_key() {
264        let mut fields = BTreeMap::new();
265        fields.insert("zebra".to_string(), "z".to_string());
266        fields.insert("alpha".to_string(), "a".to_string());
267        fields.insert("mango".to_string(), "m".to_string());
268
269        let event = LogEvent::new(
270            Severity::Info,
271            "sorted".to_string(),
272            Labels::default(),
273            fields,
274        );
275
276        let keys: Vec<&str> = event.fields.keys().map(String::as_str).collect();
277        assert_eq!(keys, vec!["alpha", "mango", "zebra"]);
278    }
279
280    // -----------------------------------------------------------------------
281    // Severity: serializes to lowercase JSON
282    // -----------------------------------------------------------------------
283
284    #[test]
285    fn severity_trace_serializes_to_lowercase_json() {
286        let s = serde_json::to_string(&Severity::Trace).unwrap();
287        assert_eq!(s, r#""trace""#);
288    }
289
290    #[test]
291    fn severity_debug_serializes_to_lowercase_json() {
292        let s = serde_json::to_string(&Severity::Debug).unwrap();
293        assert_eq!(s, r#""debug""#);
294    }
295
296    #[test]
297    fn severity_info_serializes_to_lowercase_json() {
298        let s = serde_json::to_string(&Severity::Info).unwrap();
299        assert_eq!(s, r#""info""#);
300    }
301
302    #[test]
303    fn severity_warn_serializes_to_lowercase_json() {
304        let s = serde_json::to_string(&Severity::Warn).unwrap();
305        assert_eq!(s, r#""warn""#);
306    }
307
308    #[test]
309    fn severity_error_serializes_to_lowercase_json() {
310        let s = serde_json::to_string(&Severity::Error).unwrap();
311        assert_eq!(s, r#""error""#);
312    }
313
314    #[test]
315    fn severity_fatal_serializes_to_lowercase_json() {
316        let s = serde_json::to_string(&Severity::Fatal).unwrap();
317        assert_eq!(s, r#""fatal""#);
318    }
319
320    // -----------------------------------------------------------------------
321    // Severity: deserializes from lowercase JSON
322    // These tests require the `config` feature (Deserialize impl).
323    // -----------------------------------------------------------------------
324
325    #[cfg(feature = "config")]
326    #[test]
327    fn severity_deserializes_from_lowercase_trace() {
328        let s: Severity = serde_json::from_str(r#""trace""#).unwrap();
329        assert_eq!(s, Severity::Trace);
330    }
331
332    #[cfg(feature = "config")]
333    #[test]
334    fn severity_deserializes_from_lowercase_debug() {
335        let s: Severity = serde_json::from_str(r#""debug""#).unwrap();
336        assert_eq!(s, Severity::Debug);
337    }
338
339    #[cfg(feature = "config")]
340    #[test]
341    fn severity_deserializes_from_lowercase_info() {
342        let s: Severity = serde_json::from_str(r#""info""#).unwrap();
343        assert_eq!(s, Severity::Info);
344    }
345
346    #[cfg(feature = "config")]
347    #[test]
348    fn severity_deserializes_from_lowercase_warn() {
349        let s: Severity = serde_json::from_str(r#""warn""#).unwrap();
350        assert_eq!(s, Severity::Warn);
351    }
352
353    #[cfg(feature = "config")]
354    #[test]
355    fn severity_deserializes_from_lowercase_error() {
356        let s: Severity = serde_json::from_str(r#""error""#).unwrap();
357        assert_eq!(s, Severity::Error);
358    }
359
360    #[cfg(feature = "config")]
361    #[test]
362    fn severity_deserializes_from_lowercase_fatal() {
363        let s: Severity = serde_json::from_str(r#""fatal""#).unwrap();
364        assert_eq!(s, Severity::Fatal);
365    }
366
367    #[cfg(feature = "config")]
368    #[test]
369    fn severity_rejects_uppercase_deserialization() {
370        let result: Result<Severity, _> = serde_json::from_str(r#""INFO""#);
371        assert!(
372            result.is_err(),
373            "uppercase severity string must be rejected"
374        );
375    }
376
377    #[cfg(feature = "config")]
378    #[test]
379    fn severity_rejects_unknown_variant() {
380        let result: Result<Severity, _> = serde_json::from_str(r#""critical""#);
381        assert!(result.is_err(), "unknown severity variant must be rejected");
382    }
383
384    // -----------------------------------------------------------------------
385    // Severity: serializes to lowercase YAML
386    // -----------------------------------------------------------------------
387
388    #[cfg(feature = "config")]
389    #[test]
390    fn severity_info_serializes_to_lowercase_yaml() {
391        let s = serde_yaml_ng::to_string(&Severity::Info).unwrap();
392        assert!(s.trim() == "info", "expected 'info', got: {s}");
393    }
394
395    #[cfg(feature = "config")]
396    #[test]
397    fn severity_error_serializes_to_lowercase_yaml() {
398        let s = serde_yaml_ng::to_string(&Severity::Error).unwrap();
399        assert!(s.trim() == "error", "expected 'error', got: {s}");
400    }
401
402    // -----------------------------------------------------------------------
403    // Severity: Send + Sync contract
404    // -----------------------------------------------------------------------
405
406    #[test]
407    fn severity_is_send_and_sync() {
408        fn assert_send_sync<T: Send + Sync>() {}
409        assert_send_sync::<Severity>();
410    }
411
412    // -----------------------------------------------------------------------
413    // Severity: explicit ordering tests
414    // -----------------------------------------------------------------------
415
416    #[test]
417    fn severity_ordering_follows_severity_ladder() {
418        assert!(Severity::Trace < Severity::Debug);
419        assert!(Severity::Debug < Severity::Info);
420        assert!(Severity::Info < Severity::Warn);
421        assert!(Severity::Warn < Severity::Error);
422        assert!(Severity::Error < Severity::Fatal);
423    }
424
425    #[test]
426    fn severity_equal_variants_compare_as_equal() {
427        assert_eq!(
428            Severity::Info.cmp(&Severity::Info),
429            std::cmp::Ordering::Equal
430        );
431        assert_eq!(
432            Severity::Fatal.cmp(&Severity::Fatal),
433            std::cmp::Ordering::Equal
434        );
435    }
436
437    #[test]
438    fn severity_partial_ord_consistent_with_ord() {
439        assert_eq!(
440            Severity::Trace.partial_cmp(&Severity::Fatal),
441            Some(std::cmp::Ordering::Less)
442        );
443        assert_eq!(
444            Severity::Fatal.partial_cmp(&Severity::Trace),
445            Some(std::cmp::Ordering::Greater)
446        );
447    }
448
449    #[test]
450    fn severity_sort_produces_ascending_order() {
451        let mut levels = vec![
452            Severity::Fatal,
453            Severity::Trace,
454            Severity::Warn,
455            Severity::Debug,
456            Severity::Error,
457            Severity::Info,
458        ];
459        levels.sort();
460        assert_eq!(
461            levels,
462            vec![
463                Severity::Trace,
464                Severity::Debug,
465                Severity::Info,
466                Severity::Warn,
467                Severity::Error,
468                Severity::Fatal,
469            ]
470        );
471    }
472
473    // -----------------------------------------------------------------------
474    // LogEvent: Send + Sync contract
475    // -----------------------------------------------------------------------
476
477    #[test]
478    fn log_event_is_send_and_sync() {
479        fn assert_send_sync<T: Send + Sync>() {}
480        assert_send_sync::<LogEvent>();
481    }
482
483    // -----------------------------------------------------------------------
484    // LogEvent: Clone produces independent copies
485    // -----------------------------------------------------------------------
486
487    #[test]
488    fn log_event_clone_is_independent() {
489        let ts = UNIX_EPOCH + Duration::from_secs(1000);
490        let mut fields = BTreeMap::new();
491        fields.insert("k".to_string(), "v".to_string());
492
493        let original = LogEvent::with_timestamp(
494            ts,
495            Severity::Info,
496            "msg".to_string(),
497            Labels::default(),
498            fields,
499        );
500        let mut cloned = original.clone();
501
502        cloned.message = "different".to_string();
503        cloned.fields.insert("k".to_string(), "changed".to_string());
504
505        assert_eq!(original.message, "msg");
506        assert_eq!(original.fields.get("k").map(String::as_str), Some("v"));
507    }
508
509    // -----------------------------------------------------------------------
510    // LogEvent: labels field — stores scenario-level static labels
511    // -----------------------------------------------------------------------
512
513    #[test]
514    fn new_stores_labels_correctly() {
515        let labels = Labels::from_pairs(&[("device", "wlan0"), ("hostname", "router-01")]).unwrap();
516        let event = LogEvent::new(Severity::Info, "test".to_string(), labels, BTreeMap::new());
517
518        assert_eq!(event.labels.len(), 2);
519        let label_pairs: Vec<(&str, &str)> = event.labels.iter().collect();
520        assert_eq!(label_pairs[0].0, "device");
521        assert_eq!(label_pairs[0].1, "wlan0");
522        assert_eq!(label_pairs[1].0, "hostname");
523        assert_eq!(label_pairs[1].1, "router-01");
524    }
525
526    #[test]
527    fn with_timestamp_stores_labels_correctly() {
528        let ts = UNIX_EPOCH + Duration::from_secs(1_700_000_000);
529        let labels = Labels::from_pairs(&[("env", "staging"), ("region", "us_west")]).unwrap();
530        let event = LogEvent::with_timestamp(
531            ts,
532            Severity::Warn,
533            "warning event".to_string(),
534            labels,
535            BTreeMap::new(),
536        );
537
538        assert_eq!(event.labels.len(), 2);
539        let label_pairs: Vec<(&str, &str)> = event.labels.iter().collect();
540        assert_eq!(label_pairs[0].0, "env");
541        assert_eq!(label_pairs[0].1, "staging");
542        assert_eq!(label_pairs[1].0, "region");
543        assert_eq!(label_pairs[1].1, "us_west");
544    }
545
546    #[test]
547    fn log_event_clone_preserves_labels() {
548        let ts = UNIX_EPOCH + Duration::from_secs(1000);
549        let labels = Labels::from_pairs(&[("service", "api"), ("zone", "eu1")]).unwrap();
550        let original = LogEvent::with_timestamp(
551            ts,
552            Severity::Error,
553            "cloned".to_string(),
554            labels,
555            BTreeMap::new(),
556        );
557
558        let cloned = original.clone();
559
560        assert_eq!(cloned.labels.len(), 2);
561        let original_pairs: Vec<(&str, &str)> = original.labels.iter().collect();
562        let cloned_pairs: Vec<(&str, &str)> = cloned.labels.iter().collect();
563        assert_eq!(original_pairs, cloned_pairs);
564    }
565
566    #[test]
567    fn new_with_empty_labels_has_no_labels() {
568        let event = LogEvent::new(
569            Severity::Info,
570            "no labels".to_string(),
571            Labels::default(),
572            BTreeMap::new(),
573        );
574        assert!(event.labels.is_empty());
575        assert_eq!(event.labels.len(), 0);
576    }
577}