Skip to main content

etdl_core/
observation.rs

1//! Lightweight runtime evidence collection.
2//!
3//! The runtime collects **immutable observations** for later offline analysis.
4//! It does NOT run Bayesian inference, query reliability databases, run Monte
5//! Carlo, or call AI — those are analysis-time concerns (see the
6//! `etdl-reliability` crate). This keeps the runtime service-local and
7//! lightweight, per the ETDL architecture.
8
9use std::sync::Arc;
10
11/// An immutable reliability observation: what happened, when, and under what
12/// conditions. No sensitive payload data by default.
13#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
14pub struct ReliabilityObservation {
15    pub id: String,
16    pub event: String,
17    pub timestamp: String,
18    pub service: Option<String>,
19    pub operation: Option<String>,
20    pub environment: Option<String>,
21    pub deployment: Option<String>,
22    pub outcome: String,
23    pub conditions: Vec<String>,
24    pub duration_ms: Option<u64>,
25    pub trace_id: Option<String>,
26}
27
28impl ReliabilityObservation {
29    pub fn new(id: impl Into<String>, event: impl Into<String>) -> Self {
30        ReliabilityObservation {
31            id: id.into(),
32            event: event.into(),
33            timestamp: String::new(),
34            service: None,
35            operation: None,
36            environment: None,
37            deployment: None,
38            outcome: String::new(),
39            conditions: Vec::new(),
40            duration_ms: None,
41            trace_id: None,
42        }
43    }
44}
45
46/// A destination for observations. Implementations may write JSON Lines, CSV,
47/// OpenTelemetry, a database adapter, or a message stream. These are optional
48/// adapters; the runtime does not require any of them.
49pub trait ObservationSink: Send + Sync {
50    fn emit(&self, observation: &ReliabilityObservation);
51}
52
53/// A sink that drops observations (default). Enables "no telemetry configured".
54#[derive(Debug, Default, Clone)]
55pub struct NoopSink;
56
57impl ObservationSink for NoopSink {
58    fn emit(&self, _observation: &ReliabilityObservation) {}
59}
60
61/// A sink that writes observations as JSON Lines to a `Vec<String>` for tests
62/// and simple capture.
63#[derive(Debug, Default)]
64pub struct CapturingSink {
65    lines: std::sync::Mutex<Vec<String>>,
66}
67
68impl CapturingSink {
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    pub fn lines(&self) -> Vec<String> {
74        self.lines.lock().map(|g| g.clone()).unwrap_or_default()
75    }
76}
77
78impl ObservationSink for CapturingSink {
79    fn emit(&self, observation: &ReliabilityObservation) {
80        if let Ok(line) = serde_json::to_string(observation) {
81            if let Ok(mut g) = self.lines.lock() {
82                g.push(line);
83            }
84        }
85    }
86}
87
88/// Shared sink handle used by the runtime.
89pub type SharedSink = Arc<dyn ObservationSink>;
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn capturing_sink_records() {
97        let sink = CapturingSink::new();
98        let obs = ReliabilityObservation::new("obs-1", "failure.network.timeout");
99        sink.emit(&obs);
100        let lines = sink.lines();
101        assert_eq!(lines.len(), 1);
102        assert!(lines[0].contains("failure.network.timeout"));
103    }
104
105    #[test]
106    fn observation_is_plain_data() {
107        let obs = ReliabilityObservation::new("obs-1", "failure.network.timeout");
108        assert_eq!(obs.event, "failure.network.timeout");
109        assert!(obs.duration_ms.is_none());
110    }
111}