1use std::sync::Arc;
10
11#[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 pub service_version: Option<String>,
30 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
61pub 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
91pub 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
102fn 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
123pub trait ObservationSink: Send + Sync {
127 fn emit(&self, observation: &ReliabilityObservation);
128}
129
130#[derive(Debug, Default, Clone)]
132pub struct NoopSink;
133
134impl ObservationSink for NoopSink {
135 fn emit(&self, _observation: &ReliabilityObservation) {}
136}
137
138#[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
165pub struct JsonlSink {
170 file: std::sync::Mutex<std::fs::File>,
171}
172
173impl JsonlSink {
174 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
201pub 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); }
233
234 #[test]
235 fn rfc3339_timestamp_is_well_formed() {
236 assert_eq!(civil_from_unix(1_755_475_200), "2025-08-18T00:00:00Z");
238 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 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}