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///
14/// Identity is the explicit `id` field, never array position: a dataset built
15/// from these observations must remain stable under reordering.
16#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
17pub struct ReliabilityObservation {
18    pub id: String,
19    pub event: String,
20    pub timestamp: String,
21    pub service: Option<String>,
22    pub operation: Option<String>,
23    pub environment: Option<String>,
24    pub deployment: Option<String>,
25    /// The software/model version that produced this observation (e.g. a
26    /// service semver or the compiled ETDL artifact version). Distinct from
27    /// `deployment`, which identifies the deployment slot/environment, not
28    /// the code that ran in it.
29    pub service_version: Option<String>,
30    /// A stable reference to the compiled build/reliability artifact that
31    /// generated this observation (e.g. `payment-gateway@1.2.0`), so an
32    /// analyst can trace "which model predicted this" without the runtime
33    /// carrying the full artifact. See `etdl-build-manifest.json`.
34    pub build_ref: Option<String>,
35    pub outcome: String,
36    pub conditions: Vec<String>,
37    pub duration_ms: Option<u64>,
38    pub trace_id: Option<String>,
39}
40
41impl ReliabilityObservation {
42    pub fn new(id: impl Into<String>, event: impl Into<String>) -> Self {
43        ReliabilityObservation {
44            id: id.into(),
45            event: event.into(),
46            timestamp: String::new(),
47            service: None,
48            operation: None,
49            environment: None,
50            deployment: None,
51            service_version: None,
52            build_ref: None,
53            outcome: String::new(),
54            conditions: Vec::new(),
55            duration_ms: None,
56            trace_id: None,
57        }
58    }
59}
60
61/// Generate a lightweight, collision-resistant observation id. Uses OS
62/// randomness with a time+counter fallback (same strategy as
63/// [`crate::telemetry::inject_traceparent`]); no heavy dependency is added.
64pub fn generate_observation_id() -> String {
65    let mut bytes = [0u8; 12];
66    if getrandom::getrandom(&mut bytes).is_err() {
67        use std::sync::atomic::{AtomicU64, Ordering};
68        use std::time::{SystemTime, UNIX_EPOCH};
69        static COUNTER: AtomicU64 = AtomicU64::new(0);
70        let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
71        let nanos = SystemTime::now()
72            .duration_since(UNIX_EPOCH)
73            .map(|d| d.as_nanos() as u64)
74            .unwrap_or(0);
75        let mut seed = nanos ^ counter.wrapping_mul(0x9E3779B97F4A7C15);
76        for slot in bytes.iter_mut() {
77            seed ^= seed << 13;
78            seed ^= seed >> 7;
79            seed ^= seed << 17;
80            *slot = seed as u8;
81        }
82    }
83    let mut s = String::with_capacity(3 + bytes.len() * 2);
84    s.push_str("obs");
85    for b in bytes {
86        s.push_str(&format!("{:02x}", b));
87    }
88    s
89}
90
91/// The current time as an RFC 3339 / ISO-8601 UTC timestamp
92/// (`YYYY-MM-DDThh:mm:ssZ`), computed from `SystemTime` without a chrono
93/// dependency so the runtime stays lightweight.
94pub fn now_rfc3339() -> String {
95    let secs = std::time::SystemTime::now()
96        .duration_since(std::time::UNIX_EPOCH)
97        .map(|d| d.as_secs())
98        .unwrap_or(0);
99    civil_from_unix(secs)
100}
101
102/// Convert Unix seconds (UTC, no leap seconds) to an RFC 3339 timestamp using
103/// Howard Hinnant's `civil_from_days` algorithm (public domain).
104fn civil_from_unix(unix_secs: u64) -> String {
105    let days = (unix_secs / 86_400) as i64;
106    let rem = unix_secs % 86_400;
107    let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60);
108
109    let z = days + 719_468;
110    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
111    let doe = (z - era * 146_097) as u64;
112    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
113    let y = yoe as i64 + era * 400;
114    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
115    let mp = (5 * doy + 2) / 153;
116    let d = doy - (153 * mp + 2) / 5 + 1;
117    let m = if mp < 10 { mp + 3 } else { mp - 9 };
118    let y = if m <= 2 { y + 1 } else { y };
119
120    format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, m, d, hh, mm, ss)
121}
122
123/// A destination for observations. Implementations may write JSON Lines, CSV,
124/// OpenTelemetry, a database adapter, or a message stream. These are optional
125/// adapters; the runtime does not require any of them.
126pub trait ObservationSink: Send + Sync {
127    fn emit(&self, observation: &ReliabilityObservation);
128}
129
130/// A sink that drops observations (default). Enables "no telemetry configured".
131#[derive(Debug, Default, Clone)]
132pub struct NoopSink;
133
134impl ObservationSink for NoopSink {
135    fn emit(&self, _observation: &ReliabilityObservation) {}
136}
137
138/// A sink that writes observations as JSON Lines to a `Vec<String>` for tests
139/// and simple capture.
140#[derive(Debug, Default)]
141pub struct CapturingSink {
142    lines: std::sync::Mutex<Vec<String>>,
143}
144
145impl CapturingSink {
146    pub fn new() -> Self {
147        Self::default()
148    }
149
150    pub fn lines(&self) -> Vec<String> {
151        self.lines.lock().map(|g| g.clone()).unwrap_or_default()
152    }
153}
154
155impl ObservationSink for CapturingSink {
156    fn emit(&self, observation: &ReliabilityObservation) {
157        if let Ok(line) = serde_json::to_string(observation) {
158            if let Ok(mut g) = self.lines.lock() {
159                g.push(line);
160            }
161        }
162    }
163}
164
165/// A sink that appends observations as JSON Lines to a file. Each `emit` is
166/// one `write` + `flush` of a single line: no buffering that could lose
167/// observations on process termination, no statistics, no aggregation. The
168/// analysis layer (`etdl-reliability`) reads the resulting file offline.
169pub struct JsonlSink {
170    file: std::sync::Mutex<std::fs::File>,
171}
172
173impl JsonlSink {
174    /// Open (creating if absent, appending if present) a JSON Lines file for
175    /// observation capture.
176    pub fn open(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
177        let file = std::fs::OpenOptions::new()
178            .create(true)
179            .append(true)
180            .open(path)?;
181        Ok(JsonlSink {
182            file: std::sync::Mutex::new(file),
183        })
184    }
185}
186
187impl ObservationSink for JsonlSink {
188    fn emit(&self, observation: &ReliabilityObservation) {
189        use std::io::Write;
190        let Ok(mut line) = serde_json::to_string(observation) else {
191            return;
192        };
193        line.push('\n');
194        if let Ok(mut f) = self.file.lock() {
195            let _ = f.write_all(line.as_bytes());
196            let _ = f.flush();
197        }
198    }
199}
200
201/// Shared sink handle used by the runtime.
202pub type SharedSink = Arc<dyn ObservationSink>;
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn capturing_sink_records() {
210        let sink = CapturingSink::new();
211        let obs = ReliabilityObservation::new("obs-1", "failure.network.timeout");
212        sink.emit(&obs);
213        let lines = sink.lines();
214        assert_eq!(lines.len(), 1);
215        assert!(lines[0].contains("failure.network.timeout"));
216    }
217
218    #[test]
219    fn observation_is_plain_data() {
220        let obs = ReliabilityObservation::new("obs-1", "failure.network.timeout");
221        assert_eq!(obs.event, "failure.network.timeout");
222        assert!(obs.duration_ms.is_none());
223    }
224
225    #[test]
226    fn generated_ids_are_unique_and_stable_prefix() {
227        let a = generate_observation_id();
228        let b = generate_observation_id();
229        assert_ne!(a, b);
230        assert!(a.starts_with("obs"));
231        assert_eq!(a.len(), 3 + 24); // "obs" + 12 bytes hex
232    }
233
234    #[test]
235    fn rfc3339_timestamp_is_well_formed() {
236        // 2025-08-18T00:00:00Z == unix 1755475200
237        assert_eq!(civil_from_unix(1_755_475_200), "2025-08-18T00:00:00Z");
238        // 1970-01-01T00:00:00Z == unix 0 (epoch)
239        assert_eq!(civil_from_unix(0), "1970-01-01T00:00:00Z");
240        let now = now_rfc3339();
241        assert_eq!(now.len(), 20);
242        assert!(now.ends_with('Z'));
243    }
244
245    #[test]
246    fn jsonl_sink_appends_lines_and_survives_reopen() {
247        let dir = std::env::temp_dir().join(format!("etdl-jsonl-test-{}", generate_observation_id()));
248        std::fs::create_dir_all(&dir).unwrap();
249        let path = dir.join("observations.jsonl");
250
251        {
252            let sink = JsonlSink::open(&path).unwrap();
253            sink.emit(&ReliabilityObservation::new("obs-1", "failure.a"));
254        }
255        {
256            // Reopening must append, never truncate history.
257            let sink = JsonlSink::open(&path).unwrap();
258            sink.emit(&ReliabilityObservation::new("obs-2", "failure.b"));
259        }
260
261        let content = std::fs::read_to_string(&path).unwrap();
262        let lines: Vec<&str> = content.lines().collect();
263        assert_eq!(lines.len(), 2);
264        assert!(lines[0].contains("failure.a"));
265        assert!(lines[1].contains("failure.b"));
266
267        std::fs::remove_dir_all(&dir).ok();
268    }
269}