use std::sync::Arc;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct ReliabilityObservation {
pub id: String,
pub event: String,
pub timestamp: String,
pub service: Option<String>,
pub operation: Option<String>,
pub environment: Option<String>,
pub deployment: Option<String>,
pub service_version: Option<String>,
pub build_ref: Option<String>,
pub outcome: String,
pub conditions: Vec<String>,
pub duration_ms: Option<u64>,
pub trace_id: Option<String>,
}
impl ReliabilityObservation {
pub fn new(id: impl Into<String>, event: impl Into<String>) -> Self {
ReliabilityObservation {
id: id.into(),
event: event.into(),
timestamp: String::new(),
service: None,
operation: None,
environment: None,
deployment: None,
service_version: None,
build_ref: None,
outcome: String::new(),
conditions: Vec::new(),
duration_ms: None,
trace_id: None,
}
}
}
pub fn generate_observation_id() -> String {
let mut bytes = [0u8; 12];
if getrandom::getrandom(&mut bytes).is_err() {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let mut seed = nanos ^ counter.wrapping_mul(0x9E3779B97F4A7C15);
for slot in bytes.iter_mut() {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
*slot = seed as u8;
}
}
let mut s = String::with_capacity(3 + bytes.len() * 2);
s.push_str("obs");
for b in bytes {
s.push_str(&format!("{:02x}", b));
}
s
}
pub fn now_rfc3339() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
civil_from_unix(secs)
}
fn civil_from_unix(unix_secs: u64) -> String {
let days = (unix_secs / 86_400) as i64;
let rem = unix_secs % 86_400;
let (hh, mm, ss) = (rem / 3600, (rem % 3600) / 60, rem % 60);
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, m, d, hh, mm, ss)
}
pub trait ObservationSink: Send + Sync {
fn emit(&self, observation: &ReliabilityObservation);
}
#[derive(Debug, Default, Clone)]
pub struct NoopSink;
impl ObservationSink for NoopSink {
fn emit(&self, _observation: &ReliabilityObservation) {}
}
#[derive(Debug, Default)]
pub struct CapturingSink {
lines: std::sync::Mutex<Vec<String>>,
}
impl CapturingSink {
pub fn new() -> Self {
Self::default()
}
pub fn lines(&self) -> Vec<String> {
self.lines.lock().map(|g| g.clone()).unwrap_or_default()
}
}
impl ObservationSink for CapturingSink {
fn emit(&self, observation: &ReliabilityObservation) {
if let Ok(line) = serde_json::to_string(observation) {
if let Ok(mut g) = self.lines.lock() {
g.push(line);
}
}
}
}
pub struct JsonlSink {
file: std::sync::Mutex<std::fs::File>,
}
impl JsonlSink {
pub fn open(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
Ok(JsonlSink {
file: std::sync::Mutex::new(file),
})
}
}
impl ObservationSink for JsonlSink {
fn emit(&self, observation: &ReliabilityObservation) {
use std::io::Write;
let Ok(mut line) = serde_json::to_string(observation) else {
return;
};
line.push('\n');
if let Ok(mut f) = self.file.lock() {
let _ = f.write_all(line.as_bytes());
let _ = f.flush();
}
}
}
pub type SharedSink = Arc<dyn ObservationSink>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn capturing_sink_records() {
let sink = CapturingSink::new();
let obs = ReliabilityObservation::new("obs-1", "failure.network.timeout");
sink.emit(&obs);
let lines = sink.lines();
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("failure.network.timeout"));
}
#[test]
fn observation_is_plain_data() {
let obs = ReliabilityObservation::new("obs-1", "failure.network.timeout");
assert_eq!(obs.event, "failure.network.timeout");
assert!(obs.duration_ms.is_none());
}
#[test]
fn generated_ids_are_unique_and_stable_prefix() {
let a = generate_observation_id();
let b = generate_observation_id();
assert_ne!(a, b);
assert!(a.starts_with("obs"));
assert_eq!(a.len(), 3 + 24); }
#[test]
fn rfc3339_timestamp_is_well_formed() {
assert_eq!(civil_from_unix(1_755_475_200), "2025-08-18T00:00:00Z");
assert_eq!(civil_from_unix(0), "1970-01-01T00:00:00Z");
let now = now_rfc3339();
assert_eq!(now.len(), 20);
assert!(now.ends_with('Z'));
}
#[test]
fn jsonl_sink_appends_lines_and_survives_reopen() {
let dir = std::env::temp_dir().join(format!("etdl-jsonl-test-{}", generate_observation_id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("observations.jsonl");
{
let sink = JsonlSink::open(&path).unwrap();
sink.emit(&ReliabilityObservation::new("obs-1", "failure.a"));
}
{
let sink = JsonlSink::open(&path).unwrap();
sink.emit(&ReliabilityObservation::new("obs-2", "failure.b"));
}
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("failure.a"));
assert!(lines[1].contains("failure.b"));
std::fs::remove_dir_all(&dir).ok();
}
}