omni_dev/datadog/
metrics_api.rs1use anyhow::{Context, Result};
9use url::Url;
10
11use crate::datadog::client::DatadogClient;
12use crate::datadog::types::MetricQueryResponse;
13
14#[derive(Debug)]
16pub struct MetricsApi<'a> {
17 client: &'a DatadogClient,
18}
19
20impl<'a> MetricsApi<'a> {
21 #[must_use]
23 pub fn new(client: &'a DatadogClient) -> Self {
24 Self { client }
25 }
26
27 pub async fn point_query(
34 &self,
35 query: &str,
36 from: i64,
37 to: i64,
38 ) -> Result<MetricQueryResponse> {
39 let url = build_query_url(self.client.base_url(), query, from, to)?;
40 let response = self.client.get_json(url.as_str()).await?;
41
42 if !response.status().is_success() {
43 return Err(DatadogClient::response_to_error(response).await.into());
44 }
45
46 response
47 .json::<MetricQueryResponse>()
48 .await
49 .context("Failed to parse /api/v1/query response")
50 }
51}
52
53fn build_query_url(base_url: &str, query: &str, from: i64, to: i64) -> Result<Url> {
56 let mut url =
57 Url::parse(&format!("{base_url}/api/v1/query")).context("Invalid Datadog base URL")?;
58 url.query_pairs_mut()
59 .append_pair("from", &from.to_string())
60 .append_pair("to", &to.to_string())
61 .append_pair("query", query);
62 Ok(url)
63}
64
65#[cfg(test)]
66#[allow(clippy::unwrap_used, clippy::expect_used)]
67mod tests {
68 use super::*;
69
70 #[test]
73 fn build_query_url_encodes_special_chars() {
74 let url = build_query_url(
75 "https://api.datadoghq.com",
76 "avg:system.cpu.user{host:web-01}",
77 100,
78 200,
79 )
80 .unwrap();
81 let qs = url.query().unwrap();
82 assert!(qs.contains("from=100"));
83 assert!(qs.contains("to=200"));
84 assert!(qs.contains("query=avg%3Asystem.cpu.user%7Bhost%3Aweb-01%7D"));
86 }
87
88 #[test]
89 fn build_query_url_strips_trailing_slash_on_base() {
90 let url = build_query_url("https://api.datadoghq.com", "m", 0, 1).unwrap();
93 assert_eq!(url.path(), "/api/v1/query");
94 }
95
96 #[test]
97 fn build_query_url_rejects_invalid_base() {
98 let err = build_query_url("not a url", "m", 0, 1).unwrap_err();
99 assert!(err.to_string().contains("Invalid Datadog base URL"));
100 }
101
102 fn sample_body() -> serde_json::Value {
105 serde_json::json!({
106 "status": "ok",
107 "from_date": 1_700_000_000_000_i64,
108 "to_date": 1_700_000_030_000_i64,
109 "series": [
110 {
111 "metric": "avg:system.cpu.user{*}",
112 "display_name": "avg:system.cpu.user{*}",
113 "scope": "host:*",
114 "expression": "avg:system.cpu.user{*}",
115 "pointlist": [
116 [1_700_000_000_000_i64, 0.5_f64],
117 [1_700_000_015_000_i64, null],
118 [1_700_000_030_000_i64, 0.6_f64]
119 ]
120 }
121 ]
122 })
123 }
124
125 #[tokio::test]
126 async fn point_query_returns_parsed_response() {
127 let server = wiremock::MockServer::start().await;
128 wiremock::Mock::given(wiremock::matchers::method("GET"))
129 .and(wiremock::matchers::path("/api/v1/query"))
130 .and(wiremock::matchers::query_param("from", "100"))
131 .and(wiremock::matchers::query_param("to", "200"))
132 .and(wiremock::matchers::query_param(
133 "query",
134 "avg:system.cpu.user{*}",
135 ))
136 .and(wiremock::matchers::header("DD-API-KEY", "api"))
137 .and(wiremock::matchers::header("DD-APPLICATION-KEY", "app"))
138 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(sample_body()))
139 .expect(1)
140 .mount(&server)
141 .await;
142
143 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
144 let resp = MetricsApi::new(&client)
145 .point_query("avg:system.cpu.user{*}", 100, 200)
146 .await
147 .unwrap();
148
149 assert_eq!(resp.status, "ok");
150 assert_eq!(resp.series.len(), 1);
151 assert_eq!(resp.series[0].pointlist.len(), 3);
152 assert_eq!(resp.series[0].pointlist[1].1, None);
153 }
154
155 #[tokio::test]
156 async fn point_query_propagates_api_errors_with_body() {
157 let server = wiremock::MockServer::start().await;
158 wiremock::Mock::given(wiremock::matchers::method("GET"))
159 .and(wiremock::matchers::path("/api/v1/query"))
160 .respond_with(
161 wiremock::ResponseTemplate::new(400).set_body_string(r#"{"errors":["Bad query"]}"#),
162 )
163 .mount(&server)
164 .await;
165
166 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
167 let err = MetricsApi::new(&client)
168 .point_query("bad!!", 0, 1)
169 .await
170 .unwrap_err();
171 let msg = err.to_string();
172 assert!(msg.contains("400"));
173 assert!(msg.contains("Bad query"));
174 }
175
176 #[tokio::test]
177 async fn point_query_propagates_invalid_base_url_error() {
178 let client = DatadogClient::new("not a url", "api", "app").unwrap();
181 let err = MetricsApi::new(&client)
182 .point_query("m", 0, 1)
183 .await
184 .unwrap_err();
185 assert!(err.to_string().contains("Invalid Datadog base URL"));
186 }
187
188 #[tokio::test]
189 async fn point_query_propagates_network_errors() {
190 let client = DatadogClient::new("http://127.0.0.1:1", "api", "app").unwrap();
193 let err = MetricsApi::new(&client)
194 .point_query("m", 0, 1)
195 .await
196 .unwrap_err();
197 assert!(err.to_string().contains("Failed to send"));
198 }
199
200 #[tokio::test]
201 async fn point_query_errors_on_malformed_response() {
202 let server = wiremock::MockServer::start().await;
203 wiremock::Mock::given(wiremock::matchers::method("GET"))
204 .and(wiremock::matchers::path("/api/v1/query"))
205 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
206 .mount(&server)
207 .await;
208
209 let client = DatadogClient::new(&server.uri(), "api", "app").unwrap();
210 let err = MetricsApi::new(&client)
211 .point_query("m", 0, 1)
212 .await
213 .unwrap_err();
214 assert!(err.to_string().contains("Failed to parse"));
215 }
216}