use crate::slo::Slo;
use crate::window::Window;
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct BurnRate(f64);
impl BurnRate {
pub fn new(value: f64) -> Self {
Self(value)
}
pub fn from_error_ratio(observed_error_ratio: f64, slo: &Slo) -> Self {
let budget = slo.error_budget_ratio();
if budget <= 0.0 {
return Self(f64::INFINITY);
}
Self(observed_error_ratio / budget)
}
pub fn value(&self) -> f64 {
self.0
}
pub fn budget_consumed_over(&self, window: Window, period: Window) -> f64 {
self.0 * (window.as_secs_f64() / period.as_secs_f64())
}
pub fn time_to_exhaustion(
&self,
remaining_budget_ratio: f64,
period: Window,
) -> Option<std::time::Duration> {
if self.0 <= 0.0 || !self.0.is_finite() {
return None;
}
let secs = (period.as_secs_f64() * remaining_budget_ratio / self.0).max(0.0);
Some(std::time::Duration::from_secs_f64(secs))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Page,
Ticket,
}
impl Severity {
pub fn label(&self) -> &'static str {
match self {
Severity::Page => "page",
Severity::Ticket => "ticket",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AlertWindow {
pub severity: Severity,
pub long: Window,
pub short: Window,
pub factor: f64,
}
impl AlertWindow {
pub fn threshold(&self, slo: &Slo) -> f64 {
self.factor * slo.error_budget_ratio()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct MwmbrConfig {
pub windows: Vec<AlertWindow>,
}
impl MwmbrConfig {
pub fn sre_default() -> Self {
Self {
windows: vec![
AlertWindow {
severity: Severity::Page,
long: Window::hours(1),
short: Window::minutes(5),
factor: 14.4,
},
AlertWindow {
severity: Severity::Page,
long: Window::hours(6),
short: Window::minutes(30),
factor: 6.0,
},
AlertWindow {
severity: Severity::Ticket,
long: Window::days(1),
short: Window::hours(2),
factor: 3.0,
},
AlertWindow {
severity: Severity::Ticket,
long: Window::days(3),
short: Window::hours(6),
factor: 1.0,
},
],
}
}
pub fn sre_default_for_period(period: Window) -> Self {
Self::sre_default().scaled(Window::days(30), period)
}
pub fn scaled(&self, from: Window, to: Window) -> Self {
if from == to {
return self.clone();
}
let ratio = to.as_secs_f64() / from.as_secs_f64();
let scale = |w: Window| {
let minutes = (w.as_secs_f64() * ratio / 60.0).round().max(1.0);
Window::minutes(minutes as u64)
};
Self {
windows: self
.windows
.iter()
.map(|w| AlertWindow {
severity: w.severity,
long: scale(w.long),
short: scale(w.short),
factor: w.factor,
})
.collect(),
}
}
pub fn for_severity(&self, severity: Severity) -> impl Iterator<Item = &AlertWindow> {
self.windows.iter().filter(move |w| w.severity == severity)
}
pub fn lookback_windows(&self) -> Vec<Window> {
let mut windows: Vec<Window> = self
.windows
.iter()
.flat_map(|w| [w.short, w.long])
.collect();
windows.sort();
windows.dedup();
windows
}
}
impl Default for MwmbrConfig {
fn default() -> Self {
Self::sre_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::slo::Objective;
fn slo_999() -> Slo {
Slo::new(Objective::percent(99.9).unwrap(), Window::days(30))
}
#[test]
fn budget_consumed_matches_workbook_constants() {
let period = Window::days(30);
let page_fast = BurnRate::new(14.4);
assert!((page_fast.budget_consumed_over(Window::hours(1), period) - 0.02).abs() < 1e-9);
let page_slow = BurnRate::new(6.0);
assert!((page_slow.budget_consumed_over(Window::hours(6), period) - 0.05).abs() < 1e-9);
let ticket_fast = BurnRate::new(3.0);
assert!((ticket_fast.budget_consumed_over(Window::days(1), period) - 0.10).abs() < 1e-9);
let ticket_slow = BurnRate::new(1.0);
assert!((ticket_slow.budget_consumed_over(Window::days(3), period) - 0.10).abs() < 1e-9);
}
#[test]
fn from_error_ratio_inverts_budget() {
let slo = slo_999();
let br = BurnRate::from_error_ratio(0.001, &slo);
assert!((br.value() - 1.0).abs() < 1e-9);
let br = BurnRate::from_error_ratio(0.01, &slo);
assert!((br.value() - 10.0).abs() < 1e-9);
}
#[test]
fn thresholds_are_factor_times_budget() {
let slo = slo_999();
let cfg = MwmbrConfig::sre_default();
let first = cfg.windows[0];
assert_eq!(first.factor, 14.4);
assert!((first.threshold(&slo) - 0.0144).abs() < 1e-9);
}
#[test]
fn default_lookback_windows_are_the_six_sloth_windows() {
let cfg = MwmbrConfig::sre_default();
assert_eq!(
cfg.lookback_windows(),
vec![
Window::minutes(5),
Window::minutes(30),
Window::hours(1),
Window::hours(2),
Window::hours(6),
Window::days(1),
Window::days(3),
]
);
}
#[test]
fn severity_filter_splits_page_and_ticket() {
let cfg = MwmbrConfig::sre_default();
assert_eq!(cfg.for_severity(Severity::Page).count(), 2);
assert_eq!(cfg.for_severity(Severity::Ticket).count(), 2);
}
#[test]
fn scaling_to_the_same_period_is_identity() {
let cfg = MwmbrConfig::sre_default();
assert_eq!(cfg.scaled(Window::days(30), Window::days(30)), cfg);
assert_eq!(MwmbrConfig::sre_default_for_period(Window::days(30)), cfg);
}
#[test]
fn scaling_to_90d_triples_every_window() {
let cfg = MwmbrConfig::sre_default_for_period(Window::days(90));
let expected = [
(Window::hours(3), Window::minutes(15), 14.4),
(Window::hours(18), Window::minutes(90), 6.0),
(Window::days(3), Window::hours(6), 3.0),
(Window::days(9), Window::hours(18), 1.0),
];
for (w, (long, short, factor)) in cfg.windows.iter().zip(expected) {
assert_eq!(w.long, long);
assert_eq!(w.short, short);
assert_eq!(w.factor, factor);
}
}
#[test]
fn scaling_preserves_budget_consumed_per_condition() {
let period = Window::days(7);
let cfg = MwmbrConfig::sre_default_for_period(period);
let base = MwmbrConfig::sre_default();
for (scaled, orig) in cfg.windows.iter().zip(&base.windows) {
let consumed = BurnRate::new(scaled.factor).budget_consumed_over(scaled.long, period);
let orig_consumed =
BurnRate::new(orig.factor).budget_consumed_over(orig.long, Window::days(30));
assert!(
(consumed - orig_consumed).abs() / orig_consumed < 0.05,
"budget consumed drifted: {consumed} vs {orig_consumed}"
);
}
}
#[test]
fn scaling_rounds_to_minutes_and_never_hits_zero() {
let cfg = MwmbrConfig::sre_default_for_period(Window::days(1));
assert!(cfg.windows.iter().all(|w| w.short >= Window::minutes(1)));
assert!(cfg
.windows
.iter()
.flat_map(|w| [w.long, w.short])
.all(|w| w.as_secs() % 60 == 0));
}
}