use core::time::Duration;
use crate::model::MetricState;
use crate::units::Percent;
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum Tier {
Fast,
Medium,
Slow,
OnDemand,
}
impl Tier {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Fast => "fast",
Self::Medium => "medium",
Self::Slow => "slow",
Self::OnDemand => "on demand",
}
}
pub const ALL: [Self; 4] = [Self::Fast, Self::Medium, Self::Slow, Self::OnDemand];
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct TierHealth {
pub last_duration: Duration,
pub max_duration: Duration,
pub p95_duration: Duration,
pub completed: u64,
pub failed: u64,
pub since_last: Option<Duration>,
}
impl TierHealth {
#[must_use]
pub const fn has_sampled(&self) -> bool {
self.completed > 0
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct CollectorIssue {
pub source: Box<str>,
pub message: Box<str>,
pub occurrences: u32,
pub last_seen: Option<Duration>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct SelfOverhead {
pub cpu: Percent,
pub rss_bytes: u64,
pub history_bytes: u64,
pub open_files: MetricState<u32>,
}
pub const MAX_RETAINED_ISSUES: usize = 16;
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct CollectorHealth {
pub fast: TierHealth,
pub medium: TierHealth,
pub slow: TierHealth,
pub on_demand: TierHealth,
pub dropped_samples: u64,
pub coalesced_samples: u64,
pub lag: Duration,
pub issues: Vec<CollectorIssue>,
pub self_overhead: Option<SelfOverhead>,
}
impl CollectorHealth {
#[must_use]
pub const fn tier(&self, tier: Tier) -> &TierHealth {
match tier {
Tier::Fast => &self.fast,
Tier::Medium => &self.medium,
Tier::Slow => &self.slow,
Tier::OnDemand => &self.on_demand,
}
}
pub fn record_issue(&mut self, source: &str, message: &str, since_start: Duration) {
if let Some(existing) = self
.issues
.iter_mut()
.find(|issue| &*issue.source == source && &*issue.message == message)
{
existing.occurrences = existing.occurrences.saturating_add(1);
existing.last_seen = Some(since_start);
return;
}
if self.issues.len() >= MAX_RETAINED_ISSUES {
return;
}
self.issues.push(CollectorIssue {
source: source.into(),
message: message.into(),
occurrences: 1,
last_seen: Some(since_start),
});
}
#[must_use]
pub fn is_behind(&self, interval: Duration) -> bool {
self.lag > interval
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn repeated_issues_are_aggregated_rather_than_duplicated() {
let mut health = CollectorHealth::default();
for _ in 0..1_000 {
health.record_issue("/proc/diskstats", "read failed", Duration::from_secs(1));
}
assert_eq!(health.issues.len(), 1);
assert_eq!(health.issues.first().map(|i| i.occurrences), Some(1_000));
}
#[test]
fn distinct_issues_are_kept_separate() {
let mut health = CollectorHealth::default();
health.record_issue("/proc/diskstats", "read failed", Duration::ZERO);
health.record_issue("/proc/net/dev", "read failed", Duration::ZERO);
health.record_issue("/proc/diskstats", "parse failed", Duration::ZERO);
assert_eq!(health.issues.len(), 3);
}
#[test]
fn the_issue_list_is_bounded_and_keeps_the_earliest_distinct_failures() {
let mut health = CollectorHealth::default();
for index in 0..(MAX_RETAINED_ISSUES * 4) {
health.record_issue("source", &format!("failure {index}"), Duration::ZERO);
}
assert_eq!(health.issues.len(), MAX_RETAINED_ISSUES);
assert_eq!(
health.issues.first().map(|i| &*i.message),
Some("failure 0"),
"a later flood must not evict the root cause"
);
}
#[test]
fn lag_is_reported_only_beyond_one_interval() {
let mut health = CollectorHealth {
lag: Duration::from_millis(900),
..CollectorHealth::default()
};
assert!(!health.is_behind(Duration::from_secs(1)));
health.lag = Duration::from_millis(1_100);
assert!(health.is_behind(Duration::from_secs(1)));
}
#[test]
fn a_fresh_health_record_has_not_sampled_any_tier() {
let health = CollectorHealth::default();
for tier in Tier::ALL {
assert!(!health.tier(tier).has_sampled(), "{tier:?}");
}
assert_eq!(health.dropped_samples, 0);
assert_eq!(health.coalesced_samples, 0);
}
}