11use anyhow:: Result ;
22use 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
811use crate :: config:: Config ;
912use 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) ]
101199mod 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