Skip to main content

rch_common/
telemetry_explain.rs

1//! Telemetry age explanations and "why unhealthy" status model.
2//!
3//! When a worker's telemetry age is `None` (or it is otherwise unhealthy),
4//! operators need to know *why* — and crucially whether the cause is the worker
5//! or the observer. "Telemetry unknown" with no explanation is what drives
6//! operators (and agents) to wrongly conclude a healthy worker is broken.
7//!
8//! This module classifies the unavailability cause from observable signals and
9//! packages it, alongside the adaptive [`FreshnessAssessment`], into a
10//! [`WhyUnhealthy`] record that `rch status --why-unhealthy` (and the dashboard
11//! / metrics dependents) render: last probe result, last telemetry result, next
12//! probe schedule, and observer-loop status.
13
14use serde::{Deserialize, Serialize};
15
16use crate::telemetry_freshness::FreshnessAssessment;
17
18/// Why a worker's telemetry age is unknown / unusable. Ordered by diagnostic
19/// precedence (most fundamental cause first).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum TelemetryUnavailabilityReason {
23    /// The worker binary does not provide `rch-wkr telemetry`.
24    WorkerLacksTelemetry,
25    /// The most recent telemetry payload failed to parse.
26    ParseFailed,
27    /// The last retained sample was pruned by retention before re-sampling.
28    Pruned,
29    /// The daemon restarted and lost in-memory samples; none re-collected yet.
30    DaemonRestarted,
31    /// The observer/poll loop is saturated or behind, so no fresh sample exists.
32    PollerOverloaded,
33    /// Telemetry simply has not arrived yet (first sample pending).
34    NeverArrived,
35}
36
37impl TelemetryUnavailabilityReason {
38    /// Stable snake_case identifier (matches the serialized form).
39    #[must_use]
40    pub const fn as_str(self) -> &'static str {
41        match self {
42            Self::WorkerLacksTelemetry => "worker_lacks_telemetry",
43            Self::ParseFailed => "parse_failed",
44            Self::Pruned => "pruned",
45            Self::DaemonRestarted => "daemon_restarted",
46            Self::PollerOverloaded => "poller_overloaded",
47            Self::NeverArrived => "never_arrived",
48        }
49    }
50
51    /// Operator-facing explanation.
52    #[must_use]
53    pub const fn explanation(self) -> &'static str {
54        match self {
55            Self::WorkerLacksTelemetry => {
56                "worker binary does not provide `rch-wkr telemetry` (upgrade the worker)"
57            }
58            Self::ParseFailed => "the last telemetry payload could not be parsed",
59            Self::Pruned => "the last sample was pruned by retention before re-sampling",
60            Self::DaemonRestarted => {
61                "the daemon restarted and lost in-memory samples; awaiting re-collection"
62            }
63            Self::PollerOverloaded => {
64                "the telemetry poll loop is saturated/behind; this is an observer issue, not the worker"
65            }
66            Self::NeverArrived => "telemetry has not arrived yet (first sample pending)",
67        }
68    }
69
70    /// Whether the cause is the observer (daemon/poller) rather than the worker.
71    /// Observer-side causes must NOT be read as "the worker is broken".
72    #[must_use]
73    pub const fn is_observer_side(self) -> bool {
74        matches!(
75            self,
76            Self::Pruned | Self::DaemonRestarted | Self::PollerOverloaded
77        )
78    }
79}
80
81/// Outcome of a single probe / telemetry attempt.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum ProbeOutcome {
85    /// Completed successfully.
86    Ok,
87    /// Timed out.
88    Timeout,
89    /// Failed with an error.
90    Error,
91    /// Not attempted yet (e.g. fresh start).
92    NotAttempted,
93}
94
95/// Observable signals used to classify a missing/unhealthy telemetry age.
96#[derive(Debug, Clone, Default)]
97pub struct TelemetrySignals {
98    /// Whether any sample has ever been received.
99    pub ever_received_sample: bool,
100    /// Whether the most recent payload failed to parse.
101    pub last_payload_parse_failed: bool,
102    /// Whether the worker supports `rch-wkr telemetry` (`None` = unknown).
103    pub worker_supports_telemetry: Option<bool>,
104    /// Whether the poll loop is behind/saturated.
105    pub poller_behind: bool,
106    /// Whether the daemon restarted since the last sample would have been taken.
107    pub daemon_restarted_since_sample: bool,
108    /// Whether the last sample was pruned by retention.
109    pub sample_pruned: bool,
110}
111
112/// Classify why telemetry age is unknown from `signals`, in precedence order.
113#[must_use]
114pub fn explain_unavailability(signals: &TelemetrySignals) -> TelemetryUnavailabilityReason {
115    use TelemetryUnavailabilityReason as R;
116    if signals.worker_supports_telemetry == Some(false) {
117        return R::WorkerLacksTelemetry;
118    }
119    if signals.last_payload_parse_failed {
120        return R::ParseFailed;
121    }
122    if signals.sample_pruned {
123        return R::Pruned;
124    }
125    if signals.daemon_restarted_since_sample {
126        return R::DaemonRestarted;
127    }
128    if signals.poller_behind {
129        return R::PollerOverloaded;
130    }
131    R::NeverArrived
132}
133
134/// The `rch status --why-unhealthy` record for one worker.
135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct WhyUnhealthy {
137    pub worker_id: String,
138    /// Final health verdict for the worker.
139    pub healthy: bool,
140    /// Adaptive freshness assessment when a sample age is known; `None` when age
141    /// is unknown.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub freshness: Option<FreshnessAssessment>,
144    /// Unavailability cause when age is unknown / telemetry missing.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub unavailability: Option<TelemetryUnavailabilityReason>,
147    /// Result of the last health probe.
148    pub last_probe_result: ProbeOutcome,
149    /// Result of the last telemetry fetch.
150    pub last_telemetry_result: ProbeOutcome,
151    /// Estimated time until the next probe, in milliseconds (`None` = unknown).
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub next_probe_in_ms: Option<u64>,
154    /// Whether the observer/poll loop is behind schedule.
155    pub observer_behind: bool,
156    /// Composed operator-facing explanation.
157    pub explanation: String,
158}
159
160impl WhyUnhealthy {
161    /// Build a record for a worker whose telemetry age is **known** (a
162    /// freshness assessment exists). Healthy iff the assessment is usable.
163    #[must_use]
164    pub fn from_freshness(
165        worker_id: impl Into<String>,
166        freshness: FreshnessAssessment,
167        last_probe_result: ProbeOutcome,
168        last_telemetry_result: ProbeOutcome,
169        next_probe_in_ms: Option<u64>,
170    ) -> Self {
171        let observer_behind =
172            freshness.verdict == crate::telemetry_freshness::FreshnessVerdict::SlowObserver;
173        let healthy = freshness.usable;
174        let explanation = if healthy {
175            format!("usable: {}", freshness.reason)
176        } else {
177            format!("unhealthy: {}", freshness.reason)
178        };
179        Self {
180            worker_id: worker_id.into(),
181            healthy,
182            freshness: Some(freshness),
183            unavailability: None,
184            last_probe_result,
185            last_telemetry_result,
186            next_probe_in_ms,
187            observer_behind,
188            explanation,
189        }
190    }
191
192    /// Build a record for a worker whose telemetry age is **unknown** — the
193    /// cause is classified from `signals`. Never healthy (no usable telemetry),
194    /// but the explanation makes clear whether the worker or the observer is at
195    /// fault.
196    #[must_use]
197    pub fn from_missing(
198        worker_id: impl Into<String>,
199        signals: &TelemetrySignals,
200        last_probe_result: ProbeOutcome,
201        last_telemetry_result: ProbeOutcome,
202        next_probe_in_ms: Option<u64>,
203    ) -> Self {
204        let reason = explain_unavailability(signals);
205        let explanation = format!(
206            "telemetry unknown — {} ({})",
207            reason.explanation(),
208            if reason.is_observer_side() {
209                "observer-side"
210            } else {
211                "worker-side"
212            }
213        );
214        Self {
215            worker_id: worker_id.into(),
216            healthy: false,
217            freshness: None,
218            unavailability: Some(reason),
219            last_probe_result,
220            last_telemetry_result,
221            next_probe_in_ms,
222            observer_behind: signals.poller_behind,
223            explanation,
224        }
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::telemetry_freshness::{FreshnessInputs, FreshnessVerdict, assess};
232    use std::time::Duration;
233
234    fn signals() -> TelemetrySignals {
235        TelemetrySignals::default()
236    }
237
238    #[test]
239    fn worker_lacks_telemetry_takes_precedence() {
240        let s = TelemetrySignals {
241            worker_supports_telemetry: Some(false),
242            // Even with other signals set, the missing capability dominates.
243            last_payload_parse_failed: true,
244            poller_behind: true,
245            ..signals()
246        };
247        let r = explain_unavailability(&s);
248        assert_eq!(r, TelemetryUnavailabilityReason::WorkerLacksTelemetry);
249        assert!(!r.is_observer_side());
250    }
251
252    #[test]
253    fn parse_failure_is_detected() {
254        let s = TelemetrySignals {
255            last_payload_parse_failed: true,
256            poller_behind: true,
257            ..signals()
258        };
259        assert_eq!(
260            explain_unavailability(&s),
261            TelemetryUnavailabilityReason::ParseFailed
262        );
263    }
264
265    #[test]
266    fn pruned_before_poller_overloaded() {
267        let s = TelemetrySignals {
268            sample_pruned: true,
269            daemon_restarted_since_sample: true,
270            poller_behind: true,
271            ..signals()
272        };
273        assert_eq!(
274            explain_unavailability(&s),
275            TelemetryUnavailabilityReason::Pruned
276        );
277    }
278
279    #[test]
280    fn daemon_restart_before_poller_overloaded() {
281        let s = TelemetrySignals {
282            daemon_restarted_since_sample: true,
283            poller_behind: true,
284            ..signals()
285        };
286        let r = explain_unavailability(&s);
287        assert_eq!(r, TelemetryUnavailabilityReason::DaemonRestarted);
288        assert!(r.is_observer_side());
289    }
290
291    #[test]
292    fn poller_overloaded_is_observer_side() {
293        let s = TelemetrySignals {
294            poller_behind: true,
295            ..signals()
296        };
297        let r = explain_unavailability(&s);
298        assert_eq!(r, TelemetryUnavailabilityReason::PollerOverloaded);
299        assert!(r.is_observer_side());
300    }
301
302    #[test]
303    fn never_arrived_is_the_default() {
304        let r = explain_unavailability(&signals());
305        assert_eq!(r, TelemetryUnavailabilityReason::NeverArrived);
306        assert!(!r.is_observer_side());
307    }
308
309    #[test]
310    fn all_reasons_have_distinct_stable_ids() {
311        let all = [
312            TelemetryUnavailabilityReason::WorkerLacksTelemetry,
313            TelemetryUnavailabilityReason::ParseFailed,
314            TelemetryUnavailabilityReason::Pruned,
315            TelemetryUnavailabilityReason::DaemonRestarted,
316            TelemetryUnavailabilityReason::PollerOverloaded,
317            TelemetryUnavailabilityReason::NeverArrived,
318        ];
319        let mut ids: Vec<&str> = all.iter().map(|r| r.as_str()).collect();
320        ids.sort_unstable();
321        let before = ids.len();
322        ids.dedup();
323        assert_eq!(before, ids.len());
324        // Serialized form matches as_str().
325        for r in all {
326            let json = serde_json::to_string(&r).unwrap();
327            assert_eq!(json, format!("\"{}\"", r.as_str()));
328        }
329    }
330
331    #[test]
332    fn why_unhealthy_from_missing_marks_unhealthy_and_explains() {
333        let s = TelemetrySignals {
334            poller_behind: true,
335            ..signals()
336        };
337        let w = WhyUnhealthy::from_missing(
338            "css",
339            &s,
340            ProbeOutcome::Ok,
341            ProbeOutcome::Timeout,
342            Some(15_000),
343        );
344        assert!(!w.healthy);
345        assert_eq!(
346            w.unavailability,
347            Some(TelemetryUnavailabilityReason::PollerOverloaded)
348        );
349        assert!(w.freshness.is_none());
350        assert!(w.observer_behind);
351        assert!(w.explanation.contains("observer-side"));
352        assert_eq!(w.last_telemetry_result, ProbeOutcome::Timeout);
353        assert_eq!(w.next_probe_in_ms, Some(15_000));
354    }
355
356    #[test]
357    fn why_unhealthy_from_fresh_assessment_is_healthy() {
358        let assessment = assess(&FreshnessInputs::new(
359            Duration::from_secs(30),
360            Duration::from_secs(20),
361            Duration::from_secs(10),
362        ));
363        assert_eq!(assessment.verdict, FreshnessVerdict::Fresh);
364        let w = WhyUnhealthy::from_freshness(
365            "css",
366            assessment,
367            ProbeOutcome::Ok,
368            ProbeOutcome::Ok,
369            Some(20_000),
370        );
371        assert!(w.healthy);
372        assert!(!w.observer_behind);
373        assert!(w.unavailability.is_none());
374        assert!(w.freshness.is_some());
375    }
376
377    #[test]
378    fn why_unhealthy_slow_observer_stays_healthy_but_flags_observer_behind() {
379        // A high-RTT worker is usable (SlowObserver) — healthy, observer behind.
380        let assessment = assess(&FreshnessInputs {
381            host_rtt: Some(Duration::from_secs(8)),
382            age: Some(Duration::from_secs(60)),
383            ..FreshnessInputs::new(
384                Duration::from_secs(30),
385                Duration::from_secs(20),
386                Duration::from_secs(60),
387            )
388        });
389        assert_eq!(assessment.verdict, FreshnessVerdict::SlowObserver);
390        let w = WhyUnhealthy::from_freshness(
391            "fra",
392            assessment,
393            ProbeOutcome::Ok,
394            ProbeOutcome::Ok,
395            None,
396        );
397        assert!(w.healthy, "slow-observer worker must remain healthy");
398        assert!(w.observer_behind);
399    }
400
401    #[test]
402    fn why_unhealthy_serializes_with_status_fields() {
403        let w = WhyUnhealthy::from_missing(
404            "css",
405            &TelemetrySignals {
406                worker_supports_telemetry: Some(false),
407                ..signals()
408            },
409            ProbeOutcome::Ok,
410            ProbeOutcome::NotAttempted,
411            None,
412        );
413        let v = serde_json::to_value(&w).unwrap();
414        for key in [
415            "worker_id",
416            "healthy",
417            "unavailability",
418            "last_probe_result",
419            "last_telemetry_result",
420            "observer_behind",
421            "explanation",
422        ] {
423            assert!(v.get(key).is_some(), "missing field {key}");
424        }
425        assert_eq!(v["unavailability"], "worker_lacks_telemetry");
426        assert_eq!(v["last_telemetry_result"], "not_attempted");
427        // Omitted optional fields stay absent.
428        assert!(v.get("freshness").is_none());
429        assert!(v.get("next_probe_in_ms").is_none());
430    }
431}