1use core::time::Duration;
8
9use crate::model::MetricState;
10use crate::units::Percent;
11
12#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
14#[cfg_attr(feature = "serde", derive(serde::Serialize))]
15#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
16pub enum Tier {
17 Fast,
19 Medium,
21 Slow,
23 OnDemand,
25}
26
27impl Tier {
28 #[must_use]
30 pub const fn label(self) -> &'static str {
31 match self {
32 Self::Fast => "fast",
33 Self::Medium => "medium",
34 Self::Slow => "slow",
35 Self::OnDemand => "on demand",
36 }
37 }
38
39 pub const ALL: [Self; 4] = [Self::Fast, Self::Medium, Self::Slow, Self::OnDemand];
41}
42
43#[derive(Clone, Copy, Debug, Default, PartialEq)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize))]
46pub struct TierHealth {
47 pub last_duration: Duration,
49 pub max_duration: Duration,
51 pub p95_duration: Duration,
53 pub completed: u64,
55 pub failed: u64,
57 pub since_last: Option<Duration>,
59}
60
61impl TierHealth {
62 #[must_use]
64 pub const fn has_sampled(&self) -> bool {
65 self.completed > 0
66 }
67}
68
69#[derive(Clone, Debug, PartialEq)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize))]
75pub struct CollectorIssue {
76 pub source: Box<str>,
78 pub message: Box<str>,
80 pub occurrences: u32,
82 pub last_seen: Option<Duration>,
84}
85
86#[derive(Clone, Copy, Debug, PartialEq)]
88#[cfg_attr(feature = "serde", derive(serde::Serialize))]
89pub struct SelfOverhead {
90 pub cpu: Percent,
92 pub rss_bytes: u64,
94 pub history_bytes: u64,
96 pub open_files: MetricState<u32>,
98}
99
100pub const MAX_RETAINED_ISSUES: usize = 16;
105
106#[derive(Clone, Debug, Default, PartialEq)]
108#[cfg_attr(feature = "serde", derive(serde::Serialize))]
109pub struct CollectorHealth {
110 pub fast: TierHealth,
112 pub medium: TierHealth,
114 pub slow: TierHealth,
116 pub on_demand: TierHealth,
118 pub dropped_samples: u64,
120 pub coalesced_samples: u64,
122 pub lag: Duration,
127 pub issues: Vec<CollectorIssue>,
129 pub self_overhead: Option<SelfOverhead>,
131}
132
133impl CollectorHealth {
134 #[must_use]
136 pub const fn tier(&self, tier: Tier) -> &TierHealth {
137 match tier {
138 Tier::Fast => &self.fast,
139 Tier::Medium => &self.medium,
140 Tier::Slow => &self.slow,
141 Tier::OnDemand => &self.on_demand,
142 }
143 }
144
145 pub fn record_issue(&mut self, source: &str, message: &str, since_start: Duration) {
151 if let Some(existing) = self
152 .issues
153 .iter_mut()
154 .find(|issue| &*issue.source == source && &*issue.message == message)
155 {
156 existing.occurrences = existing.occurrences.saturating_add(1);
157 existing.last_seen = Some(since_start);
158 return;
159 }
160 if self.issues.len() >= MAX_RETAINED_ISSUES {
161 return;
162 }
163 self.issues.push(CollectorIssue {
164 source: source.into(),
165 message: message.into(),
166 occurrences: 1,
167 last_seen: Some(since_start),
168 });
169 }
170
171 #[must_use]
173 pub fn is_behind(&self, interval: Duration) -> bool {
174 self.lag > interval
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn repeated_issues_are_aggregated_rather_than_duplicated() {
184 let mut health = CollectorHealth::default();
185 for _ in 0..1_000 {
186 health.record_issue("/proc/diskstats", "read failed", Duration::from_secs(1));
187 }
188 assert_eq!(health.issues.len(), 1);
189 assert_eq!(health.issues.first().map(|i| i.occurrences), Some(1_000));
190 }
191
192 #[test]
193 fn distinct_issues_are_kept_separate() {
194 let mut health = CollectorHealth::default();
195 health.record_issue("/proc/diskstats", "read failed", Duration::ZERO);
196 health.record_issue("/proc/net/dev", "read failed", Duration::ZERO);
197 health.record_issue("/proc/diskstats", "parse failed", Duration::ZERO);
198 assert_eq!(health.issues.len(), 3);
199 }
200
201 #[test]
202 fn the_issue_list_is_bounded_and_keeps_the_earliest_distinct_failures() {
203 let mut health = CollectorHealth::default();
204 for index in 0..(MAX_RETAINED_ISSUES * 4) {
205 health.record_issue("source", &format!("failure {index}"), Duration::ZERO);
206 }
207 assert_eq!(health.issues.len(), MAX_RETAINED_ISSUES);
208 assert_eq!(
209 health.issues.first().map(|i| &*i.message),
210 Some("failure 0"),
211 "a later flood must not evict the root cause"
212 );
213 }
214
215 #[test]
216 fn lag_is_reported_only_beyond_one_interval() {
217 let mut health = CollectorHealth {
218 lag: Duration::from_millis(900),
219 ..CollectorHealth::default()
220 };
221 assert!(!health.is_behind(Duration::from_secs(1)));
222 health.lag = Duration::from_millis(1_100);
223 assert!(health.is_behind(Duration::from_secs(1)));
224 }
225
226 #[test]
227 fn a_fresh_health_record_has_not_sampled_any_tier() {
228 let health = CollectorHealth::default();
229 for tier in Tier::ALL {
230 assert!(!health.tier(tier).has_sampled(), "{tier:?}");
231 }
232 assert_eq!(health.dropped_samples, 0);
233 assert_eq!(health.coalesced_samples, 0);
234 }
235}