monitrs_core/diagnostics/
window.rs1use core::time::Duration;
26
27use crate::history::{
28 ContributorMetric, HistoricalSample, HistoryMetric, HistoryRing, HistoryView,
29};
30use crate::model::{MeasuredValue, ProcessIdentity};
31
32use super::TimeWindow;
33
34#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
36pub struct Counted {
37 pub matched: usize,
39 pub considered: usize,
41 pub unavailable: usize,
43 pub span: Duration,
45}
46
47impl Counted {
48 #[must_use]
50 pub const fn visited(&self) -> usize {
51 self.considered.saturating_add(self.unavailable)
52 }
53
54 #[must_use]
56 pub const fn window(&self) -> TimeWindow {
57 TimeWindow::new(self.span, self.considered)
58 }
59
60 #[must_use]
67 pub const fn sustained(&self, required: usize, minimum: usize) -> bool {
68 self.considered >= minimum && self.matched >= required
69 }
70}
71
72#[derive(Clone, Copy, Debug)]
74pub struct HistoryWindow<'a> {
75 ring: &'a HistoryRing,
76 view: HistoryView,
77}
78
79impl<'a> HistoryWindow<'a> {
80 #[must_use]
82 pub const fn new(ring: &'a HistoryRing, view: HistoryView) -> Self {
83 Self { ring, view }
84 }
85
86 #[must_use]
88 pub const fn live(ring: &'a HistoryRing) -> Self {
89 Self::new(ring, HistoryView::live())
90 }
91
92 #[must_use]
94 pub const fn view(&self) -> HistoryView {
95 self.view
96 }
97
98 #[must_use]
100 pub const fn ring(&self) -> &'a HistoryRing {
101 self.ring
102 }
103
104 #[must_use]
109 pub fn expected_interval(&self) -> Duration {
110 self.ring.limits().interval()
111 }
112
113 #[must_use]
115 pub fn selected(&self) -> Option<&'a HistoricalSample> {
116 self.view.selected(self.ring)
117 }
118
119 #[must_use]
121 pub fn len(&self) -> usize {
122 self.ring.len()
123 }
124
125 #[must_use]
127 pub fn is_empty(&self) -> bool {
128 self.ring.is_empty()
129 }
130
131 pub fn recent(&self, count: usize) -> impl DoubleEndedIterator<Item = &'a HistoricalSample> {
136 let ring = self.ring;
137 self.bounds(count)
138 .into_iter()
139 .flat_map(move |(first, last)| {
140 (first..=last).filter_map(move |at| ring.get_absolute(at))
141 })
142 }
143
144 #[must_use]
150 pub fn previous_sample(&self, sequence: u64) -> Option<&'a HistoricalSample> {
151 self.recent(2).rfind(|sample| sample.sequence < sequence)
152 }
153
154 #[must_use]
161 pub fn count_where(
162 &self,
163 metric: HistoryMetric,
164 count: usize,
165 predicate: impl Fn(f64) -> bool,
166 ) -> Counted {
167 let mut counted = Counted::default();
168 let mut oldest: Option<Duration> = None;
169 let mut newest = Duration::ZERO;
170
171 for sample in self.recent(count) {
172 if oldest.is_none() {
173 oldest = Some(sample.monotonic_offset);
174 }
175 newest = sample.monotonic_offset;
176 match sample.system.scalar(metric) {
177 Some(value) => {
178 counted.considered = counted.considered.saturating_add(1);
179 if predicate(value) {
180 counted.matched = counted.matched.saturating_add(1);
181 }
182 }
183 None => counted.unavailable = counted.unavailable.saturating_add(1),
184 }
185 }
186
187 counted.span = newest.saturating_sub(oldest.unwrap_or(newest));
188 counted
189 }
190
191 #[must_use]
194 pub fn count_at_least(&self, metric: HistoryMetric, count: usize, threshold: f64) -> Counted {
195 self.count_where(metric, count, |value| value >= threshold)
196 }
197
198 #[must_use]
204 pub fn trend(&self, metric: HistoryMetric, count: usize) -> Option<(f64, f64, Duration)> {
205 let mut first: Option<(f64, Duration)> = None;
206 let mut last: Option<(f64, Duration)> = None;
207 for sample in self.recent(count) {
208 if let Some(value) = sample.system.scalar(metric) {
209 if first.is_none() {
210 first = Some((value, sample.monotonic_offset));
211 }
212 last = Some((value, sample.monotonic_offset));
213 }
214 }
215 let (start, start_at) = first?;
216 let (end, end_at) = last?;
217 Some((start, end, end_at.saturating_sub(start_at)))
218 }
219
220 fn bounds(&self, count: usize) -> Option<(u64, u64)> {
223 if count == 0 {
224 return None;
225 }
226 let last = self.view.selected_absolute(self.ring)?;
227 let span = u64::try_from(count).unwrap_or(u64::MAX).saturating_sub(1);
228 let first = last.saturating_sub(span).max(self.ring.first_absolute());
229 Some((first, last))
230 }
231}
232
233#[must_use]
241pub fn contributor_value(
242 sample: &HistoricalSample,
243 metric: ContributorMetric,
244 identity: ProcessIdentity,
245) -> Option<f64> {
246 sample
247 .contributors
248 .metric(metric)
249 .entries()
250 .iter()
251 .find(|entry| entry.identity == identity)
252 .map(|entry| measured_scalar(entry.value))
253}
254
255pub(crate) fn measured_scalar(value: MeasuredValue) -> f64 {
260 match value {
261 MeasuredValue::Bytes(bytes) | MeasuredValue::Count(bytes) => bytes as f64,
262 MeasuredValue::ByteRate(rate) | MeasuredValue::EventRate(rate) => rate.per_second(),
263 MeasuredValue::Percent(percent) => f64::from(percent.value()),
264 MeasuredValue::Duration(duration) => duration.as_secs_f64(),
265 MeasuredValue::Load(load) => f64::from(load),
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use crate::diagnostics::fixtures::{Timeline, set_cpu, set_memory};
273 use crate::history::HistoryMetric;
274 use crate::model::{MetricState, UnavailableReason};
275
276 #[test]
277 fn counting_an_empty_ring_reports_nothing_considered() {
278 let timeline = Timeline::new(Duration::from_secs(1));
279 let window = timeline.window();
280 let counted = window.count_at_least(HistoryMetric::CpuBusy, 15, 80.0);
281
282 assert!(window.is_empty());
283 assert_eq!(counted, Counted::default());
284 assert!(!counted.sustained(1, 1));
285 assert_eq!(counted.window().samples, 0);
286 }
287
288 #[test]
289 fn the_expected_interval_comes_from_history_rather_than_an_assumption() {
290 for interval in [Duration::from_millis(500), Duration::from_secs(5)] {
293 let timeline = Timeline::new(interval);
294 assert_eq!(timeline.window().expected_interval(), timeline.interval());
295 assert_eq!(timeline.window().expected_interval(), interval);
296 }
297 }
298
299 #[test]
300 fn counting_is_limited_to_the_requested_window() {
301 let mut timeline = Timeline::new(Duration::from_secs(1));
302 for _ in 0..10 {
303 timeline.push(|snapshot| set_cpu(snapshot, 10.0));
304 }
305 for _ in 0..5 {
306 timeline.push(|snapshot| set_cpu(snapshot, 90.0));
307 }
308
309 let window = timeline.window();
310 let counted = window.count_at_least(HistoryMetric::CpuBusy, 5, 80.0);
311 assert_eq!(counted.matched, 5);
312 assert_eq!(counted.considered, 5);
313 assert_eq!(counted.span, Duration::from_secs(4));
314
315 let wider = window.count_at_least(HistoryMetric::CpuBusy, 15, 80.0);
316 assert_eq!(wider.matched, 5);
317 assert_eq!(wider.considered, 15);
318 }
319
320 #[test]
321 fn a_window_larger_than_the_ring_counts_only_what_exists() {
322 let mut timeline = Timeline::new(Duration::from_secs(1));
323 for _ in 0..3 {
324 timeline.push(|snapshot| set_cpu(snapshot, 99.0));
325 }
326 let counted = timeline
327 .window()
328 .count_at_least(HistoryMetric::CpuBusy, 100, 80.0);
329 assert_eq!(counted.visited(), 3);
330 assert_eq!(counted.matched, 3);
331 }
332
333 #[test]
334 fn an_unavailable_sample_is_neither_a_match_nor_a_considered_reading() {
335 let mut timeline = Timeline::new(Duration::from_secs(1));
336 for _ in 0..5 {
337 timeline.push(|snapshot| set_cpu(snapshot, 99.0));
338 }
339 for _ in 0..5 {
340 timeline.push(|snapshot| {
341 snapshot.cpu.total =
342 MetricState::TemporarilyUnavailable(UnavailableReason::CounterReset);
343 });
344 }
345
346 let counted = timeline
347 .window()
348 .count_at_least(HistoryMetric::CpuBusy, 10, 80.0);
349 assert_eq!(counted.matched, 5);
350 assert_eq!(counted.considered, 5);
351 assert_eq!(counted.unavailable, 5);
352 assert_eq!(counted.visited(), 10);
353 assert!(
354 !counted.sustained(10, 10),
355 "five readings cannot support a ten-sample claim"
356 );
357 }
358
359 #[test]
360 fn the_cursor_decides_which_window_is_counted() {
361 let mut timeline = Timeline::new(Duration::from_secs(1));
362 for _ in 0..10 {
363 timeline.push(|snapshot| set_cpu(snapshot, 95.0));
364 }
365 for _ in 0..10 {
366 timeline.push(|snapshot| set_cpu(snapshot, 1.0));
367 }
368
369 let live = timeline.window();
370 assert_eq!(
371 live.count_at_least(HistoryMetric::CpuBusy, 10, 80.0)
372 .matched,
373 0
374 );
375
376 let mut view = HistoryView::live();
377 view.step_back(timeline.ring(), 10);
378 let historical = HistoryWindow::new(timeline.ring(), view);
379 assert_eq!(
380 historical
381 .count_at_least(HistoryMetric::CpuBusy, 10, 80.0)
382 .matched,
383 10,
384 "a rule evaluated over a selected sample must see that sample's past"
385 );
386 assert_eq!(historical.view(), view);
387 }
388
389 #[test]
390 fn a_trend_needs_both_endpoints_measured() {
391 let mut timeline = Timeline::new(Duration::from_secs(1));
392 timeline.push(|snapshot| set_memory(snapshot, 1_000, 800));
393 timeline.push(|snapshot| {
394 snapshot.memory.usage = MetricState::PermissionDenied;
395 });
396 assert!(
397 timeline
398 .window()
399 .trend(HistoryMetric::MemoryUsedShare, 2)
400 .is_some(),
401 "the older endpoint is still measured, so the trend spans one sample"
402 );
403
404 let mut only_unavailable = Timeline::new(Duration::from_secs(1));
405 only_unavailable.push(|snapshot| {
406 snapshot.memory.usage = MetricState::PermissionDenied;
407 });
408 assert!(
409 only_unavailable
410 .window()
411 .trend(HistoryMetric::MemoryUsedShare, 2)
412 .is_none()
413 );
414 }
415
416 #[test]
417 fn a_trend_reports_the_real_span_between_the_endpoints() {
418 let mut timeline = Timeline::new(Duration::from_millis(500));
419 for used_share in [10u64, 20, 30] {
420 timeline.push(move |snapshot| set_memory(snapshot, 1_000, 1_000 - used_share * 10));
423 }
424 let (start, end, span) = timeline
425 .window()
426 .trend(HistoryMetric::MemoryUsedShare, 3)
427 .expect("three measured samples");
428 assert!((start - 10.0).abs() < 0.01, "{start}");
429 assert!((end - 30.0).abs() < 0.01, "{end}");
430 assert_eq!(span, Duration::from_secs(1), "two 500ms intervals");
431 }
432
433 #[test]
434 fn the_previous_sample_is_the_newest_one_older_than_the_snapshot() {
435 let mut timeline = Timeline::new(Duration::from_secs(1));
436 timeline.push(|snapshot| set_cpu(snapshot, 1.0));
437 timeline.push(|snapshot| set_cpu(snapshot, 2.0));
438 let current = timeline.push(|snapshot| set_cpu(snapshot, 3.0));
439
440 let window = timeline.window();
441 let previous = window
442 .previous_sample(current.sequence)
443 .expect("a previous sample exists");
444 assert_eq!(previous.sequence, current.sequence - 1);
445 assert!(
446 window.previous_sample(0).is_none(),
447 "nothing precedes the first sample"
448 );
449 }
450
451 #[test]
452 fn a_zero_length_window_reads_nothing_instead_of_panicking() {
453 let mut timeline = Timeline::new(Duration::from_secs(1));
454 timeline.push(|snapshot| set_cpu(snapshot, 50.0));
455 assert_eq!(timeline.window().recent(0).count(), 0);
456 assert_eq!(
457 timeline
458 .window()
459 .count_at_least(HistoryMetric::CpuBusy, 0, 1.0)
460 .visited(),
461 0
462 );
463 }
464
465 #[test]
466 fn every_measured_value_kind_has_a_comparable_scalar() {
467 use crate::units::{Percent, Rate};
468 let rate = Rate::new(1_024.0).expect("valid rate");
469 let cases = [
470 (MeasuredValue::Bytes(4_096), 4_096.0),
471 (MeasuredValue::Count(7), 7.0),
472 (MeasuredValue::ByteRate(rate), 1_024.0),
473 (MeasuredValue::EventRate(rate), 1_024.0),
474 (
475 MeasuredValue::Percent(Percent::new(37.5).expect("valid")),
476 37.5,
477 ),
478 (MeasuredValue::Duration(Duration::from_secs(2)), 2.0),
479 (MeasuredValue::Load(4.25), 4.25),
480 ];
481 for (value, expected) in cases {
482 let scalar = measured_scalar(value);
483 assert!((scalar - expected).abs() < 0.001, "{value:?} -> {scalar}");
484 }
485 }
486}