Skip to content

Commit a8d12ed

Browse files
Merge pull request #648 from DataDog/fix/monitor-slo-error-budget-remaining
fix(slos): correct monitor SLO error budget remaining in status command
2 parents 72d988d + 050b6f3 commit a8d12ed

1 file changed

Lines changed: 299 additions & 4 deletions

File tree

src/commands/slos.rs

Lines changed: 299 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
use anyhow::Result;
22
use datadog_api_client::datadogV1::api_service_level_objectives::{
3-
DeleteSLOOptionalParams, GetSLOOptionalParams, ListSLOsOptionalParams,
4-
ServiceLevelObjectivesAPI,
3+
DeleteSLOOptionalParams, GetSLOHistoryOptionalParams, GetSLOOptionalParams,
4+
ListSLOsOptionalParams, ServiceLevelObjectivesAPI,
55
};
6-
use datadog_api_client::datadogV1::model::{ServiceLevelObjective, ServiceLevelObjectiveRequest};
6+
use datadog_api_client::datadogV1::model::{
7+
SLOThreshold, SLOType, ServiceLevelObjective, ServiceLevelObjectiveRequest,
8+
};
9+
use datadog_api_client::datadogV2::model::RawErrorBudgetRemaining;
710

811
use crate::config::Config;
912
use crate::formatter;
@@ -85,7 +88,7 @@ pub async fn status(cfg: &Config, id: &str, from_ts: i64, to_ts: i64) -> Result<
8588
};
8689

8790
let api = crate::make_api!(SloV2API, cfg);
88-
let resp = api
91+
let mut resp = api
8992
.get_slo_status(
9093
id.to_string(),
9194
from_ts,
@@ -94,9 +97,104 @@ pub async fn status(cfg: &Config, id: &str, from_ts: i64, to_ts: i64) -> Result<
9497
)
9598
.await
9699
.map_err(|e| anyhow::anyhow!("failed to get SLO status: {e:?}"))?;
100+
101+
// The v2 status endpoint always reports 0 for monitor-type SLOs
102+
// (https://github.com/DataDog/pup/issues/646). Fall back to the v1
103+
// history endpoint, which computes it correctly when given a target.
104+
if let Some(budget) = monitor_error_budget_remaining(cfg, id, from_ts, to_ts).await? {
105+
resp.data.attributes.error_budget_remaining = budget.remaining_pct;
106+
resp.data.attributes.raw_error_budget_remaining = RawErrorBudgetRemaining::new(
107+
"seconds".to_string(),
108+
raw_remaining_seconds(&budget, to_ts - from_ts),
109+
);
110+
}
111+
97112
formatter::output(cfg, &resp)
98113
}
99114

115+
/// A monitor-type SLO's error budget remaining, as a percentage (0-100) of
116+
/// the threshold's allowed unreliability window, along with the threshold
117+
/// target it was computed against.
118+
struct MonitorErrorBudget {
119+
remaining_pct: f64,
120+
target: f64,
121+
}
122+
123+
/// Returns the error budget remaining for a monitor-type SLO by querying the
124+
/// v1 history endpoint with the target threshold closest to the requested
125+
/// window, or `None` if the SLO isn't monitor-type or has no thresholds.
126+
async fn monitor_error_budget_remaining(
127+
cfg: &Config,
128+
id: &str,
129+
from_ts: i64,
130+
to_ts: i64,
131+
) -> Result<Option<MonitorErrorBudget>> {
132+
let api = crate::make_api!(ServiceLevelObjectivesAPI, cfg);
133+
let slo = api
134+
.get_slo(id.to_string(), GetSLOOptionalParams::default())
135+
.await
136+
.map_err(|e| anyhow::anyhow!("failed to get SLO: {e:?}"))?;
137+
138+
let data = match slo.data {
139+
Some(data) => data,
140+
None => return Ok(None),
141+
};
142+
if data.type_ != Some(SLOType::MONITOR) {
143+
return Ok(None);
144+
}
145+
let target = match data.thresholds.as_deref() {
146+
Some(thresholds) => closest_threshold_target(thresholds, to_ts - from_ts),
147+
None => None,
148+
};
149+
let target = match target {
150+
Some(target) => target,
151+
None => return Ok(None),
152+
};
153+
154+
let history = api
155+
.get_slo_history(
156+
id.to_string(),
157+
from_ts,
158+
to_ts,
159+
GetSLOHistoryOptionalParams::default().target(target),
160+
)
161+
.await
162+
.map_err(|e| anyhow::anyhow!("failed to get SLO history: {e:?}"))?;
163+
164+
Ok(history
165+
.data
166+
.and_then(|data| data.overall)
167+
.and_then(|overall| overall.error_budget_remaining)
168+
.and_then(|remaining| remaining.get("custom").copied())
169+
.map(|remaining_pct| MonitorErrorBudget {
170+
remaining_pct,
171+
target,
172+
}))
173+
}
174+
175+
/// Converts a monitor SLO's remaining error budget percentage into seconds.
176+
/// The total error budget for the window is `(1 - target) * window`; the
177+
/// remaining budget is the fraction of that still unspent.
178+
fn raw_remaining_seconds(budget: &MonitorErrorBudget, window_secs: i64) -> f64 {
179+
let total_budget_secs = (1.0 - budget.target / 100.0) * window_secs as f64;
180+
budget.remaining_pct / 100.0 * total_budget_secs
181+
}
182+
183+
/// Picks the target of the threshold whose timeframe most closely matches
184+
/// the requested window duration (falls back to the first threshold if none
185+
/// have a recognized timeframe).
186+
fn closest_threshold_target(thresholds: &[SLOThreshold], window_secs: i64) -> Option<f64> {
187+
thresholds
188+
.iter()
189+
.min_by_key(|threshold| match threshold.timeframe.to_string().as_str() {
190+
"7d" => (7 * 86_400 - window_secs).abs(),
191+
"30d" => (30 * 86_400 - window_secs).abs(),
192+
"90d" => (90 * 86_400 - window_secs).abs(),
193+
_ => i64::MAX,
194+
})
195+
.map(|threshold| threshold.target)
196+
}
197+
100198
#[cfg(test)]
101199
mod tests {
102200

@@ -303,4 +401,201 @@ mod tests {
303401
assert!(result.is_ok(), "slos delete failed: {:?}", result.err());
304402
cleanup_env();
305403
}
404+
405+
fn slo_threshold(timeframe: &str, target: f64) -> super::SLOThreshold {
406+
let json = format!(r#"{{"timeframe": "{timeframe}", "target": {target}}}"#);
407+
serde_json::from_str(&json).unwrap()
408+
}
409+
410+
#[test]
411+
fn test_raw_remaining_seconds() {
412+
// 99.5% target over a 7-day window allows (1 - 0.995) * 604800 = 3024s
413+
// of downtime; 86.111% of that budget remaining is ~2603.997s.
414+
let budget = super::MonitorErrorBudget {
415+
remaining_pct: 86.111,
416+
target: 99.5,
417+
};
418+
let remaining = super::raw_remaining_seconds(&budget, 7 * 86_400);
419+
assert!(
420+
(remaining - 2603.9966).abs() < 0.01,
421+
"expected ~2603.9966s, got {remaining}"
422+
);
423+
}
424+
425+
#[test]
426+
fn test_raw_remaining_seconds_full_budget() {
427+
let budget = super::MonitorErrorBudget {
428+
remaining_pct: 100.0,
429+
target: 99.9,
430+
};
431+
let remaining = super::raw_remaining_seconds(&budget, 30 * 86_400);
432+
assert!((remaining - 2592.0).abs() < 0.01, "got {remaining}");
433+
}
434+
435+
#[test]
436+
fn test_closest_threshold_target_exact_match() {
437+
let thresholds = vec![
438+
slo_threshold("7d", 99.5),
439+
slo_threshold("30d", 99.9),
440+
slo_threshold("90d", 99.99),
441+
];
442+
let target = super::closest_threshold_target(&thresholds, 30 * 86_400);
443+
assert_eq!(target, Some(99.9));
444+
}
445+
446+
#[test]
447+
fn test_closest_threshold_target_unknown_timeframe_falls_back() {
448+
let thresholds = vec![slo_threshold("custom", 42.0)];
449+
let target = super::closest_threshold_target(&thresholds, 60);
450+
assert_eq!(target, Some(42.0), "should fall back to the only threshold");
451+
}
452+
453+
const MONITOR_SLO_STATUS_BODY: &str = r#"{"data": {"attributes": {"error_budget_remaining": 0.0, "raw_error_budget_remaining": {"unit": "second", "value": 0.0}, "sli": 100.0, "span_precision": 3, "state": "ok"}, "id": "abc123", "type": "slo_status"}}"#;
454+
const MONITOR_SLO_GET_BODY: &str = r#"{"data": {"id": "abc123", "name": "Monitor SLO", "type": "monitor", "thresholds": [{"timeframe": "7d", "target": 99.5}]}, "errors": []}"#;
455+
const MONITOR_SLO_HISTORY_BODY: &str = r#"{"data": {"overall": {"error_budget_remaining": {"custom": 86.111}, "sli_value": 99.93}}}"#;
456+
457+
#[tokio::test]
458+
async fn test_slos_status_monitor_type_falls_back_to_history() {
459+
let _lock = lock_env().await;
460+
let mut server = mockito::Server::new_async().await;
461+
let cfg = test_config(&server.url());
462+
let from_ts = 1_700_000_000_i64;
463+
let to_ts = from_ts + 7 * 86_400;
464+
465+
let status_mock = server
466+
.mock("GET", "/api/v2/slo/abc123/status")
467+
.match_query(mockito::Matcher::Any)
468+
.with_status(200)
469+
.with_header("content-type", "application/json")
470+
.with_body(MONITOR_SLO_STATUS_BODY)
471+
.create_async()
472+
.await;
473+
let get_mock = server
474+
.mock("GET", "/api/v1/slo/abc123")
475+
.match_query(mockito::Matcher::Any)
476+
.with_status(200)
477+
.with_header("content-type", "application/json")
478+
.with_body(MONITOR_SLO_GET_BODY)
479+
.create_async()
480+
.await;
481+
let history_mock = server
482+
.mock("GET", "/api/v1/slo/abc123/history")
483+
.match_query(mockito::Matcher::UrlEncoded("target".into(), "99.5".into()))
484+
.with_status(200)
485+
.with_header("content-type", "application/json")
486+
.with_body(MONITOR_SLO_HISTORY_BODY)
487+
.create_async()
488+
.await;
489+
490+
let result = super::status(&cfg, "abc123", from_ts, to_ts).await;
491+
assert!(result.is_ok(), "slos status failed: {:?}", result.err());
492+
status_mock.assert_async().await;
493+
get_mock.assert_async().await;
494+
history_mock.assert_async().await;
495+
cleanup_env();
496+
}
497+
498+
#[tokio::test]
499+
async fn test_slos_status_metric_type_does_not_call_history() {
500+
let _lock = lock_env().await;
501+
let mut server = mockito::Server::new_async().await;
502+
let cfg = test_config(&server.url());
503+
let from_ts = 1_700_000_000_i64;
504+
let to_ts = from_ts + 7 * 86_400;
505+
506+
let status_mock = server
507+
.mock("GET", "/api/v2/slo/abc123/status")
508+
.match_query(mockito::Matcher::Any)
509+
.with_status(200)
510+
.with_header("content-type", "application/json")
511+
.with_body(MONITOR_SLO_STATUS_BODY)
512+
.create_async()
513+
.await;
514+
let get_mock = server
515+
.mock("GET", "/api/v1/slo/abc123")
516+
.match_query(mockito::Matcher::Any)
517+
.with_status(200)
518+
.with_header("content-type", "application/json")
519+
.with_body(
520+
r#"{"data": {"id": "abc123", "name": "Metric SLO", "type": "metric", "thresholds": [{"timeframe": "7d", "target": 99.5}]}, "errors": []}"#,
521+
)
522+
.create_async()
523+
.await;
524+
let history_mock = server
525+
.mock("GET", "/api/v1/slo/abc123/history")
526+
.match_query(mockito::Matcher::Any)
527+
.expect(0)
528+
.with_status(200)
529+
.with_header("content-type", "application/json")
530+
.with_body(MONITOR_SLO_HISTORY_BODY)
531+
.create_async()
532+
.await;
533+
534+
let result = super::status(&cfg, "abc123", from_ts, to_ts).await;
535+
assert!(result.is_ok(), "slos status failed: {:?}", result.err());
536+
status_mock.assert_async().await;
537+
get_mock.assert_async().await;
538+
history_mock.assert_async().await;
539+
cleanup_env();
540+
}
541+
542+
#[tokio::test]
543+
async fn test_monitor_error_budget_remaining_extracts_custom_value() {
544+
let _lock = lock_env().await;
545+
let mut server = mockito::Server::new_async().await;
546+
let cfg = test_config(&server.url());
547+
let from_ts = 1_700_000_000_i64;
548+
let to_ts = from_ts + 7 * 86_400;
549+
550+
server
551+
.mock("GET", "/api/v1/slo/abc123")
552+
.match_query(mockito::Matcher::Any)
553+
.with_status(200)
554+
.with_header("content-type", "application/json")
555+
.with_body(MONITOR_SLO_GET_BODY)
556+
.create_async()
557+
.await;
558+
server
559+
.mock("GET", "/api/v1/slo/abc123/history")
560+
.match_query(mockito::Matcher::UrlEncoded("target".into(), "99.5".into()))
561+
.with_status(200)
562+
.with_header("content-type", "application/json")
563+
.with_body(MONITOR_SLO_HISTORY_BODY)
564+
.create_async()
565+
.await;
566+
567+
let budget = super::monitor_error_budget_remaining(&cfg, "abc123", from_ts, to_ts)
568+
.await
569+
.unwrap()
570+
.expect("expected a monitor error budget");
571+
assert_eq!(budget.remaining_pct, 86.111);
572+
assert_eq!(budget.target, 99.5);
573+
cleanup_env();
574+
}
575+
576+
#[tokio::test]
577+
async fn test_monitor_error_budget_remaining_none_for_metric_type() {
578+
let _lock = lock_env().await;
579+
let mut server = mockito::Server::new_async().await;
580+
let cfg = test_config(&server.url());
581+
let from_ts = 1_700_000_000_i64;
582+
let to_ts = from_ts + 7 * 86_400;
583+
584+
server
585+
.mock("GET", "/api/v1/slo/abc123")
586+
.match_query(mockito::Matcher::Any)
587+
.with_status(200)
588+
.with_header("content-type", "application/json")
589+
.with_body(
590+
r#"{"data": {"id": "abc123", "name": "Metric SLO", "type": "metric", "thresholds": [{"timeframe": "7d", "target": 99.5}]}, "errors": []}"#,
591+
)
592+
.create_async()
593+
.await;
594+
595+
let budget = super::monitor_error_budget_remaining(&cfg, "abc123", from_ts, to_ts)
596+
.await
597+
.unwrap();
598+
assert!(budget.is_none());
599+
cleanup_env();
600+
}
306601
}

0 commit comments

Comments
 (0)