1use serde::{Deserialize, Serialize};
15
16use crate::telemetry_freshness::FreshnessAssessment;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum TelemetryUnavailabilityReason {
23 WorkerLacksTelemetry,
25 ParseFailed,
27 Pruned,
29 DaemonRestarted,
31 PollerOverloaded,
33 NeverArrived,
35}
36
37impl TelemetryUnavailabilityReason {
38 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum ProbeOutcome {
85 Ok,
87 Timeout,
89 Error,
91 NotAttempted,
93}
94
95#[derive(Debug, Clone, Default)]
97pub struct TelemetrySignals {
98 pub ever_received_sample: bool,
100 pub last_payload_parse_failed: bool,
102 pub worker_supports_telemetry: Option<bool>,
104 pub poller_behind: bool,
106 pub daemon_restarted_since_sample: bool,
108 pub sample_pruned: bool,
110}
111
112#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
136pub struct WhyUnhealthy {
137 pub worker_id: String,
138 pub healthy: bool,
140 #[serde(skip_serializing_if = "Option::is_none")]
143 pub freshness: Option<FreshnessAssessment>,
144 #[serde(skip_serializing_if = "Option::is_none")]
146 pub unavailability: Option<TelemetryUnavailabilityReason>,
147 pub last_probe_result: ProbeOutcome,
149 pub last_telemetry_result: ProbeOutcome,
151 #[serde(skip_serializing_if = "Option::is_none")]
153 pub next_probe_in_ms: Option<u64>,
154 pub observer_behind: bool,
156 pub explanation: String,
158}
159
160impl WhyUnhealthy {
161 #[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 #[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 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 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 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 assert!(v.get("freshness").is_none());
429 assert!(v.get("next_probe_in_ms").is_none());
430 }
431}