Skip to main content

etdl_core/
monitor.rs

1use crate::chaos::ChaosController;
2use crate::observation::{
3    generate_observation_id, now_rfc3339, NoopSink, ReliabilityObservation, SharedSink,
4};
5use crate::sla::SlaTracker;
6use std::sync::Arc;
7use std::sync::Mutex;
8
9pub struct BranchMonitor {
10    node_id: String,
11    sla_tracker: Arc<Mutex<SlaTracker>>,
12    chaos: Arc<Mutex<ChaosController>>,
13    observation_sink: SharedSink,
14    service: Option<String>,
15    service_version: Option<String>,
16    deployment: Option<String>,
17    build_ref: Option<String>,
18}
19
20impl BranchMonitor {
21    pub fn new(node_id: &str) -> Self {
22        BranchMonitor {
23            node_id: node_id.to_string(),
24            sla_tracker: Arc::new(Mutex::new(SlaTracker::new())),
25            chaos: Arc::new(Mutex::new(ChaosController::new())),
26            observation_sink: Arc::new(NoopSink),
27            service: None,
28            service_version: None,
29            deployment: None,
30            build_ref: None,
31        }
32    }
33
34    /// Attach an observation sink (JSON Lines, OTel, database adapter, ...).
35    pub fn with_sink(mut self, sink: SharedSink) -> Self {
36        self.observation_sink = sink;
37        self
38    }
39
40    /// Identify the service/component this monitor runs in, for the
41    /// observations it emits.
42    pub fn with_service(mut self, service: impl Into<String>) -> Self {
43        self.service = Some(service.into());
44        self
45    }
46
47    /// Identify the software version generating observations, distinct from
48    /// the deployment slot. Lets an analyst compare "predictions from build
49    /// v1" against "observations generated by v1".
50    pub fn with_service_version(mut self, version: impl Into<String>) -> Self {
51        self.service_version = Some(version.into());
52        self
53    }
54
55    /// Identify the deployment/environment (e.g. `prod-us-east-1`).
56    pub fn with_deployment(mut self, deployment: impl Into<String>) -> Self {
57        self.deployment = Some(deployment.into());
58        self
59    }
60
61    /// A stable reference to the compiled reliability artifact/build that
62    /// produced this service, so observations can be traced back to the
63    /// model that predicted them without embedding the whole artifact.
64    pub fn with_build_ref(mut self, build_ref: impl Into<String>) -> Self {
65        self.build_ref = Some(build_ref.into());
66        self
67    }
68
69    /// Build a runtime observation stamped with this monitor's identity.
70    fn observation(&self, outcome: &str) -> ReliabilityObservation {
71        ReliabilityObservation {
72            id: generate_observation_id(),
73            event: self.node_id.clone(),
74            timestamp: now_rfc3339(),
75            service: self.service.clone(),
76            operation: None,
77            environment: None,
78            deployment: self.deployment.clone(),
79            service_version: self.service_version.clone(),
80            build_ref: self.build_ref.clone(),
81            outcome: outcome.to_string(),
82            conditions: Vec::new(),
83            duration_ms: None,
84            trace_id: None,
85        }
86    }
87
88    /// Record an immutable failure observation for offline analysis.
89    /// Lightweight: does not run statistics, query databases, or call AI.
90    pub fn record_failure_observation(&self, observation: ReliabilityObservation) {
91        self.observation_sink.emit(&observation);
92    }
93
94    pub fn with_sla(node_id: &str, sla_tracker: Arc<Mutex<SlaTracker>>) -> Self {
95        BranchMonitor {
96            node_id: node_id.to_string(),
97            sla_tracker,
98            chaos: Arc::new(Mutex::new(ChaosController::new())),
99            observation_sink: Arc::new(NoopSink),
100            service: None,
101            service_version: None,
102            deployment: None,
103            build_ref: None,
104        }
105    }
106
107    pub fn with_chaos(node_id: &str, chaos: Arc<Mutex<ChaosController>>) -> Self {
108        BranchMonitor {
109            node_id: node_id.to_string(),
110            sla_tracker: Arc::new(Mutex::new(SlaTracker::new())),
111            chaos,
112            observation_sink: Arc::new(NoopSink),
113            service: None,
114            service_version: None,
115            deployment: None,
116            build_ref: None,
117        }
118    }
119
120    pub fn record_branch(&mut self, outcome: &str, declared_probability: f64) {
121        let should_chaos = {
122            let mut chaos = self.chaos.lock().unwrap();
123            chaos.should_inject_chaos(&self.node_id)
124        };
125        if should_chaos {
126            return;
127        }
128
129        let mut sla = self.sla_tracker.lock().unwrap();
130        let anomaly = sla.record(&self.node_id, outcome, declared_probability, true);
131
132        if anomaly {
133            crate::telemetry::emit_anomaly_event(
134                &self.node_id,
135                outcome,
136                declared_probability,
137                sla.observed_frequency(&self.node_id, outcome),
138            );
139        }
140        drop(sla);
141
142        crate::telemetry::attach_node_span_attribute(&self.node_id);
143
144        // Record what happened. The declared probability is the prediction;
145        // it lives in the compiled artifact/build manifest, not on every
146        // observation, so it is not duplicated here (it would go stale on
147        // recalibration). The runtime only records data; interpretation
148        // (predicted vs observed) happens offline, in etdl-reliability.
149        self.observation_sink.emit(&self.observation(outcome));
150    }
151
152    pub fn record_failure(
153        &mut self,
154        operation_id: &str,
155        error: &dyn std::error::Error,
156        declared_probability: Option<f64>,
157    ) {
158        let key = format!("{}.failure", operation_id);
159        let outcome = "FAILURE";
160
161        if let Some(prob) = declared_probability {
162            let mut sla = self.sla_tracker.lock().unwrap();
163            let anomaly = sla.record(&key, outcome, prob, true);
164
165            if anomaly {
166                crate::telemetry::emit_anomaly_event(
167                    &key,
168                    outcome,
169                    prob,
170                    sla.observed_frequency(&key, outcome),
171                );
172            }
173        }
174
175        crate::telemetry::attach_node_span_attribute(&key);
176        eprintln!("[etdl] operation '{}' failed: {}", operation_id, error);
177
178        let mut o = self.observation("failed");
179        o.event = key;
180        o.operation = Some(operation_id.to_string());
181        self.observation_sink.emit(&o);
182    }
183
184    /// Record that `operation_id` completed *without* failing. Must be
185    /// called on the same `"{operation_id}.failure"` SLA key
186    /// [`record_failure`] uses, with `occurred = false`, or that key's
187    /// rolling window only ever sees failures (every entry `record_failure`
188    /// ever pushes) and its observed frequency is permanently `1.0` —
189    /// which made [`record_failure`]'s anomaly check fire unconditionally
190    /// for any operation that failed at least a handful of times over its
191    /// lifetime (`sla::MIN_OBSERVATIONS`), regardless of its actual overall
192    /// failure rate. Generated code calls this from
193    /// the operation's `Ok` arm whenever it calls `record_failure` from the
194    /// matching `Err` arm (i.e. whenever `onFailureProbabilitySource` is
195    /// declared and resolves) — see `codegen/rust.rs`'s `Ok(_result) =>`
196    /// arm.
197    pub fn record_success(&mut self, operation_id: &str, declared_probability: Option<f64>) {
198        let key = format!("{}.failure", operation_id);
199        let outcome = "FAILURE";
200
201        if let Some(prob) = declared_probability {
202            let mut sla = self.sla_tracker.lock().unwrap();
203            let anomaly = sla.record(&key, outcome, prob, false);
204
205            if anomaly {
206                crate::telemetry::emit_anomaly_event(
207                    &key,
208                    outcome,
209                    prob,
210                    sla.observed_frequency(&key, outcome),
211                );
212            }
213        }
214    }
215
216    pub fn flush(&self) {
217        let sla = self.sla_tracker.lock().unwrap();
218        eprintln!(
219            "[etdl] node '{}': {} evaluations recorded",
220            self.node_id,
221            sla.total_evaluations()
222        );
223    }
224}
225
226impl Drop for BranchMonitor {
227    fn drop(&mut self) {
228        self.flush();
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn test_record_branch() {
238        let mut monitor = BranchMonitor::new("test_barrier");
239        monitor.record_branch("SUCCESS", 0.95);
240        monitor.record_branch("SUCCESS", 0.95);
241        monitor.record_branch("FAILURE", 0.05);
242    }
243
244    #[derive(Debug)]
245    struct FakeError;
246    impl std::fmt::Display for FakeError {
247        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248            write!(f, "fake error")
249        }
250    }
251    impl std::error::Error for FakeError {}
252
253    /// Regression test for the false-alarm bug: `record_failure` alone
254    /// pushes every call into a `"{op}.failure"`-only SLA window, so its
255    /// observed frequency was permanently `1.0` regardless of the
256    /// operation's actual overall failure rate — any operation with a
257    /// declared failure probability below `1.0 - threshold` would
258    /// eventually, and permanently, be flagged anomalous. `record_success`
259    /// must be called on the same key with `occurred = false` (as
260    /// generated code now does in the `Ok` arm) so the window reflects the
261    /// operation's real success/failure mix.
262    #[test]
263    fn record_success_keeps_observed_frequency_meaningful_not_permanently_one() {
264        let mut monitor = BranchMonitor::new("op");
265        // 5% declared failure probability, 20 attempts: 1 failure, 19
266        // successes — exactly matching the declared rate.
267        for _ in 0..19 {
268            monitor.record_success("checkout", Some(0.05));
269        }
270        monitor.record_failure("checkout", &FakeError, Some(0.05));
271
272        let observed = monitor
273            .sla_tracker
274            .lock()
275            .unwrap()
276            .observed_frequency("checkout.failure", "FAILURE");
277
278        // Without record_success, this would be 1.0 (every recorded entry
279        // is a failure) regardless of how rarely the operation actually
280        // fails. With it, the window reflects the true 1-in-20 rate.
281        assert!(
282            (observed - 0.05).abs() < 1e-9,
283            "expected observed frequency ~0.05 (1 failure in 20 attempts), got {observed} \
284             (1.0 would mean the false-alarm bug regressed)"
285        );
286    }
287}