Skip to main content

slokit/
slo.rs

1//! Service Level Objectives: a target reliability over a rolling period.
2
3use crate::budget::ErrorBudget;
4use crate::error::{Result, SlokitError};
5use crate::window::Window;
6
7/// A reliability target, stored as a ratio in the open interval `(0, 1)`.
8///
9/// `99.9%` availability is `Objective::percent(99.9)` and reads back as a ratio
10/// of `0.999`.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct Objective(f64);
13
14impl Objective {
15    /// Build an objective from a percentage in the open interval `(0, 100)`.
16    pub fn percent(p: f64) -> Result<Self> {
17        if !p.is_finite() || p <= 0.0 || p >= 100.0 {
18            return Err(SlokitError::InvalidObjective(format!(
19                "{p} is not a percentage in the open interval (0, 100)"
20            )));
21        }
22        Ok(Self(p / 100.0))
23    }
24
25    /// Build an objective from a ratio in the open interval `(0, 1)`.
26    pub fn ratio(r: f64) -> Result<Self> {
27        if !r.is_finite() || r <= 0.0 || r >= 1.0 {
28            return Err(SlokitError::InvalidObjective(format!(
29                "{r} is not a ratio in the open interval (0, 1)"
30            )));
31        }
32        Ok(Self(r))
33    }
34
35    /// The objective as a ratio, e.g. `0.999`.
36    pub fn as_ratio(&self) -> f64 {
37        self.0
38    }
39
40    /// The objective as a percentage, e.g. `99.9`.
41    pub fn as_percent(&self) -> f64 {
42        self.0 * 100.0
43    }
44
45    /// The error-budget ratio, i.e. `1 - objective`.
46    pub fn error_budget_ratio(&self) -> f64 {
47        1.0 - self.0
48    }
49}
50
51/// A Service Level Objective: an [`Objective`] measured over a rolling
52/// [`Window`] (the SLO period, typically 30 days).
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct Slo {
55    /// The reliability target.
56    pub objective: Objective,
57    /// The rolling period the objective is measured over.
58    pub period: Window,
59}
60
61impl Slo {
62    /// Create an SLO from an objective and a period.
63    pub fn new(objective: Objective, period: Window) -> Self {
64        Self { objective, period }
65    }
66
67    /// The fraction of events allowed to fail, i.e. `1 - objective`.
68    pub fn error_budget_ratio(&self) -> f64 {
69        self.objective.error_budget_ratio()
70    }
71
72    /// Build a concrete [`ErrorBudget`] for a known total event count over the
73    /// period.
74    pub fn error_budget(&self, total_events: f64) -> ErrorBudget {
75        ErrorBudget::new(total_events, self.error_budget_ratio())
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    #[test]
84    fn percent_round_trips() {
85        let o = Objective::percent(99.9).unwrap();
86        assert!((o.as_ratio() - 0.999).abs() < 1e-12);
87        assert!((o.as_percent() - 99.9).abs() < 1e-9);
88        assert!((o.error_budget_ratio() - 0.001).abs() < 1e-12);
89    }
90
91    #[test]
92    fn rejects_out_of_range_objectives() {
93        assert!(Objective::percent(0.0).is_err());
94        assert!(Objective::percent(100.0).is_err());
95        assert!(Objective::percent(150.0).is_err());
96        assert!(Objective::ratio(0.0).is_err());
97        assert!(Objective::ratio(1.0).is_err());
98        assert!(Objective::ratio(f64::NAN).is_err());
99    }
100
101    #[test]
102    fn error_budget_ratio_matches_workbook() {
103        let slo = Slo::new(Objective::percent(99.95).unwrap(), Window::days(30));
104        assert!((slo.error_budget_ratio() - 0.0005).abs() < 1e-12);
105    }
106}