1use std::sync::atomic::{AtomicU64, Ordering};
43use std::sync::Arc;
44use std::time::{Duration, Instant};
45
46use parking_lot::RwLock;
47
48#[derive(Debug, Clone)]
52pub struct SloConfig {
53 pub target_success_rate: f64,
55
56 pub long_window: Duration,
61 pub short_window: Duration,
65 pub burn_rate_threshold: f64,
69
70 pub ticket_long_window: Duration,
73 pub ticket_short_window: Duration,
75 pub ticket_burn_rate_threshold: f64,
77}
78
79impl Default for SloConfig {
80 fn default() -> Self {
81 Self {
83 target_success_rate: 0.999,
84 long_window: Duration::from_secs(3600),
86 short_window: Duration::from_secs(300),
87 burn_rate_threshold: 14.4,
88 ticket_long_window: Duration::from_secs(6 * 3600),
90 ticket_short_window: Duration::from_secs(30 * 60),
91 ticket_burn_rate_threshold: 6.0,
92 }
93 }
94}
95
96#[derive(Debug, Clone)]
98pub struct SloBurnRate {
99 pub short_success_rate: f64,
101 pub long_success_rate: f64,
103 pub short_burn_rate: f64,
105 pub long_burn_rate: f64,
107 pub error_budget_remaining: f64,
109 pub alerting: bool,
111
112 pub ticket_short_success_rate: f64,
115 pub ticket_long_success_rate: f64,
117 pub ticket_short_burn_rate: f64,
119 pub ticket_long_burn_rate: f64,
121 pub page_alerting: bool,
123 pub ticket_alerting: bool,
125}
126
127impl Default for SloBurnRate {
128 fn default() -> Self {
129 Self {
130 short_success_rate: 1.0,
131 long_success_rate: 1.0,
132 short_burn_rate: 0.0,
133 long_burn_rate: 0.0,
134 error_budget_remaining: 1.0,
135 alerting: false,
136 ticket_short_success_rate: 1.0,
137 ticket_long_success_rate: 1.0,
138 ticket_short_burn_rate: 0.0,
139 ticket_long_burn_rate: 0.0,
140 page_alerting: false,
141 ticket_alerting: false,
142 }
143 }
144}
145
146impl std::fmt::Display for SloBurnRate {
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 write!(
149 f,
150 "SloBurnRate{{page[short={:.4}/{:.2}x, long={:.4}/{:.2}x, alert={}], ticket[short={:.4}/{:.2}x, long={:.4}/{:.2}x, alert={}], budget={:.2}%}}",
151 self.short_success_rate,
152 self.short_burn_rate,
153 self.long_success_rate,
154 self.long_burn_rate,
155 self.page_alerting,
156 self.ticket_short_success_rate,
157 self.ticket_short_burn_rate,
158 self.ticket_long_success_rate,
159 self.ticket_long_burn_rate,
160 self.ticket_alerting,
161 self.error_budget_remaining * 100.0,
162 )
163 }
164}
165
166struct WindowedCounter {
168 window: Duration,
169 success: Arc<AtomicU64>,
170 failure: Arc<AtomicU64>,
171 window_start: Arc<RwLock<Instant>>,
172}
173
174impl WindowedCounter {
175 fn new(window: Duration) -> Self {
176 Self {
177 window,
178 success: Arc::new(AtomicU64::new(0)),
179 failure: Arc::new(AtomicU64::new(0)),
180 window_start: Arc::new(RwLock::new(Instant::now())),
181 }
182 }
183
184 fn record_success(&self) {
185 self.rotate_if_needed();
186 self.success.fetch_add(1, Ordering::Relaxed);
187 }
188
189 fn record_failure(&self) {
190 self.rotate_if_needed();
191 self.failure.fetch_add(1, Ordering::Relaxed);
192 }
193
194 fn rotate_if_needed(&self) {
195 let start = self.window_start.read();
196 if start.elapsed() >= self.window {
197 drop(start);
198 let mut start = self.window_start.write();
199 if start.elapsed() >= self.window {
201 self.success.store(0, Ordering::Relaxed);
203 self.failure.store(0, Ordering::Relaxed);
204 *start = Instant::now();
205 }
206 }
207 }
208
209 fn success_rate(&self) -> f64 {
210 let s = self.success.load(Ordering::Relaxed);
211 let f = self.failure.load(Ordering::Relaxed);
212 let total = s + f;
213 if total == 0 {
214 return 1.0;
215 }
216 s as f64 / total as f64
217 }
218}
219
220pub struct SloMonitor {
224 config: SloConfig,
225 page_short_counter: WindowedCounter,
227 page_long_counter: WindowedCounter,
228 ticket_short_counter: WindowedCounter,
230 ticket_long_counter: WindowedCounter,
231 total_success: AtomicU64,
233 total_failure: AtomicU64,
235}
236
237impl SloMonitor {
238 pub fn new(config: SloConfig) -> Self {
240 let page_short_counter = WindowedCounter::new(config.short_window);
241 let page_long_counter = WindowedCounter::new(config.long_window);
242 let ticket_short_counter = WindowedCounter::new(config.ticket_short_window);
243 let ticket_long_counter = WindowedCounter::new(config.ticket_long_window);
244 Self {
245 config,
246 page_short_counter,
247 page_long_counter,
248 ticket_short_counter,
249 ticket_long_counter,
250 total_success: AtomicU64::new(0),
251 total_failure: AtomicU64::new(0),
252 }
253 }
254
255 pub fn record_success(&self) {
257 self.page_short_counter.record_success();
258 self.page_long_counter.record_success();
259 self.ticket_short_counter.record_success();
260 self.ticket_long_counter.record_success();
261 self.total_success.fetch_add(1, Ordering::Relaxed);
262 }
263
264 pub fn record_failure(&self) {
266 self.page_short_counter.record_failure();
267 self.page_long_counter.record_failure();
268 self.ticket_short_counter.record_failure();
269 self.ticket_long_counter.record_failure();
270 self.total_failure.fetch_add(1, Ordering::Relaxed);
271 }
272
273 pub fn burn_rate(&self) -> SloBurnRate {
275 self.page_short_counter.rotate_if_needed();
277 self.page_long_counter.rotate_if_needed();
278 self.ticket_short_counter.rotate_if_needed();
279 self.ticket_long_counter.rotate_if_needed();
280
281 let page_short_rate = self.page_short_counter.success_rate();
283 let page_long_rate = self.page_long_counter.success_rate();
284
285 let ticket_short_rate = self.ticket_short_counter.success_rate();
287 let ticket_long_rate = self.ticket_long_counter.success_rate();
288
289 let allowed_error_rate = 1.0 - self.config.target_success_rate;
290
291 let page_short_error = 1.0 - page_short_rate;
292 let page_long_error = 1.0 - page_long_rate;
293 let ticket_short_error = 1.0 - ticket_short_rate;
294 let ticket_long_error = 1.0 - ticket_long_rate;
295
296 let page_short_burn = if allowed_error_rate > 0.0 {
297 page_short_error / allowed_error_rate
298 } else {
299 0.0
300 };
301 let page_long_burn = if allowed_error_rate > 0.0 {
302 page_long_error / allowed_error_rate
303 } else {
304 0.0
305 };
306 let ticket_short_burn = if allowed_error_rate > 0.0 {
307 ticket_short_error / allowed_error_rate
308 } else {
309 0.0
310 };
311 let ticket_long_burn = if allowed_error_rate > 0.0 {
312 ticket_long_error / allowed_error_rate
313 } else {
314 0.0
315 };
316
317 let total_success = self.total_success.load(Ordering::Relaxed);
319 let total_failure = self.total_failure.load(Ordering::Relaxed);
320 let total = total_success + total_failure;
321 let error_budget_remaining = if total == 0 {
322 1.0
323 } else {
324 let actual_error_rate = total_failure as f64 / total as f64;
325 let consumed = (actual_error_rate / allowed_error_rate).min(1.0);
326 1.0 - consumed
327 };
328
329 let page_alerting = page_short_burn > self.config.burn_rate_threshold
336 && page_long_burn > self.config.burn_rate_threshold;
337 let ticket_alerting = ticket_short_burn > self.config.ticket_burn_rate_threshold
338 && ticket_long_burn > self.config.ticket_burn_rate_threshold;
339
340 SloBurnRate {
341 short_success_rate: page_short_rate,
342 long_success_rate: page_long_rate,
343 short_burn_rate: page_short_burn,
344 long_burn_rate: page_long_burn,
345 error_budget_remaining,
346 alerting: page_alerting,
348 ticket_short_success_rate: ticket_short_rate,
349 ticket_long_success_rate: ticket_long_rate,
350 ticket_short_burn_rate: ticket_short_burn,
351 ticket_long_burn_rate: ticket_long_burn,
352 page_alerting,
353 ticket_alerting,
354 }
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use std::thread::sleep;
362
363 #[test]
364 fn test_slo_no_data() {
365 let monitor = SloMonitor::new(SloConfig::default());
366 let rate = monitor.burn_rate();
367 assert_eq!(rate.short_success_rate, 1.0);
368 assert_eq!(rate.long_success_rate, 1.0);
369 assert!(!rate.alerting);
370 assert!(!rate.page_alerting);
371 assert!(!rate.ticket_alerting);
372 }
373
374 #[test]
375 fn test_slo_all_success() {
376 let monitor = SloMonitor::new(SloConfig::default());
377 for _ in 0..1000 {
378 monitor.record_success();
379 }
380 let rate = monitor.burn_rate();
381 assert_eq!(rate.short_success_rate, 1.0);
382 assert!(!rate.alerting);
383 assert!(!rate.page_alerting);
384 assert!(!rate.ticket_alerting);
385 }
386
387 #[test]
388 fn test_slo_with_failures() {
389 let monitor = SloMonitor::new(SloConfig {
390 target_success_rate: 0.99,
391 short_window: Duration::from_millis(100),
392 long_window: Duration::from_millis(200),
393 burn_rate_threshold: 1.0,
394 ticket_short_window: Duration::from_millis(300),
395 ticket_long_window: Duration::from_millis(500),
396 ticket_burn_rate_threshold: 1.0,
397 });
398 for _ in 0..1000 {
400 monitor.record_success();
401 }
402 for _ in 0..100 {
403 monitor.record_failure();
404 }
405 let rate = monitor.burn_rate();
406 assert!(rate.short_success_rate < 0.99);
407 assert!(rate.short_burn_rate > 1.0);
408 assert!(rate.error_budget_remaining < 1.0);
409 assert!(rate.ticket_short_burn_rate > 1.0);
411 }
412
413 #[test]
414 fn test_slo_page_alerting() {
415 let monitor = SloMonitor::new(SloConfig {
416 target_success_rate: 0.999,
417 short_window: Duration::from_millis(100),
418 long_window: Duration::from_millis(200),
419 burn_rate_threshold: 1.0,
420 ticket_short_window: Duration::from_millis(300),
421 ticket_long_window: Duration::from_millis(500),
422 ticket_burn_rate_threshold: 100.0, });
424 for _ in 0..100 {
427 monitor.record_success();
428 }
429 for _ in 0..10 {
430 monitor.record_failure();
431 }
432 let rate = monitor.burn_rate();
433 assert!(rate.page_alerting, "Page should be alerting: {:?}", rate);
434 assert!(rate.alerting);
436 assert!(
438 !rate.ticket_alerting,
439 "Ticket should NOT alerting: {:?}",
440 rate
441 );
442 }
443
444 #[test]
445 fn test_slo_ticket_alerting_independent() {
446 let monitor = SloMonitor::new(SloConfig {
448 target_success_rate: 0.999,
449 short_window: Duration::from_millis(50),
451 long_window: Duration::from_millis(80),
452 burn_rate_threshold: 1.0,
453 ticket_short_window: Duration::from_millis(500),
455 ticket_long_window: Duration::from_millis(1000),
456 ticket_burn_rate_threshold: 1.0,
457 });
458 for _ in 0..100 {
459 monitor.record_success();
460 }
461 for _ in 0..10 {
462 monitor.record_failure();
463 }
464 let rate = monitor.burn_rate();
465 assert!(
467 rate.ticket_alerting,
468 "Ticket should be alerting: {:?}",
469 rate
470 );
471
472 sleep(Duration::from_millis(200));
474
475 let rate2 = monitor.burn_rate();
476 assert!(
478 !rate2.page_alerting,
479 "Page should NOT alerting after window rotation: {:?}",
480 rate2
481 );
482 assert!(
484 rate2.ticket_alerting,
485 "Ticket should still alerting: {:?}",
486 rate2
487 );
488 }
489
490 #[test]
491 fn test_window_rotation() {
492 let monitor = SloMonitor::new(SloConfig {
493 target_success_rate: 0.99,
494 short_window: Duration::from_millis(50),
495 long_window: Duration::from_millis(100),
496 burn_rate_threshold: 1.0,
497 ticket_short_window: Duration::from_millis(150),
498 ticket_long_window: Duration::from_millis(200),
499 ticket_burn_rate_threshold: 1.0,
500 });
501 for _ in 0..10 {
503 monitor.record_failure();
504 }
505 let rate1 = monitor.burn_rate();
506 assert!(rate1.short_burn_rate > 0.0);
507
508 sleep(Duration::from_millis(250));
510
511 let rate2 = monitor.burn_rate();
513 assert_eq!(rate2.short_success_rate, 1.0); assert_eq!(rate2.long_success_rate, 1.0);
515 assert_eq!(rate2.ticket_short_success_rate, 1.0);
516 assert_eq!(rate2.ticket_long_success_rate, 1.0);
517 }
518
519 #[test]
520 fn test_default_config_is_google_sre_recommended() {
521 let config = SloConfig::default();
523 assert_eq!(config.short_window, Duration::from_secs(300)); assert_eq!(config.long_window, Duration::from_secs(3600)); assert!((config.burn_rate_threshold - 14.4).abs() < 0.01);
527 assert_eq!(config.ticket_short_window, Duration::from_secs(30 * 60)); assert_eq!(config.ticket_long_window, Duration::from_secs(6 * 3600)); assert!((config.ticket_burn_rate_threshold - 6.0).abs() < 0.01);
531 }
532
533 #[test]
534 fn test_display_includes_both_alerts() {
535 let monitor = SloMonitor::new(SloConfig::default());
536 let rate = monitor.burn_rate();
537 let s = format!("{}", rate);
538 assert!(s.contains("page["), "display should include page: {}", s);
540 assert!(
541 s.contains("ticket["),
542 "display should include ticket: {}",
543 s
544 );
545 }
546}