Skip to main content

omni_dev/datadog/
metrics_api.rs

1//! Datadog Metrics API wrapper.
2//!
3//! Exposes a thin façade over [`DatadogClient`] for the metrics endpoints
4//! needed by the CLI. Currently covers the point-in-time timeseries query
5//! (`GET /api/v1/query`); subsequent slices will add scalar and multi-query
6//! variants.
7
8use anyhow::{Context, Result};
9use url::Url;
10
11use crate::datadog::client::DatadogClient;
12use crate::datadog::types::MetricQueryResponse;
13
14/// Metrics API façade.
15#[derive(Debug)]
16pub struct MetricsApi<'a> {
17    client: &'a DatadogClient,
18}
19
20impl<'a> MetricsApi<'a> {
21    /// Wraps an existing [`DatadogClient`] for metrics operations.
22    #[must_use]
23    pub fn new(client: &'a DatadogClient) -> Self {
24        Self { client }
25    }
26
27    /// Executes a point-in-time metrics timeseries query.
28    ///
29    /// `from` / `to` are Unix epoch **seconds** — the unit expected by
30    /// Datadog's v1 `query` parameters. The response from Datadog uses
31    /// milliseconds for its own `from_date` / `to_date` / pointlist
32    /// timestamps; we pass those through unmodified.
33    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
53/// Builds `{base_url}/api/v1/query?from=…&to=…&query=…` with proper
54/// percent-encoding for the query string.
55fn 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    // ── build_query_url ────────────────────────────────────────────
71
72    #[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        // Braces must be percent-encoded to pass through Datadog's URL parser.
85        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        // The client normalises the base URL at construction time; double-check
91        // we don't produce a duplicate slash.
92        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    // ── point_query happy path ─────────────────────────────────────
103
104    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        // `DatadogClient::new` doesn't validate its URL, so the error only
179        // surfaces when `build_query_url` tries to parse it.
180        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        // Point at a port that refuses connection; `get_json` surfaces the
191        // reqwest send failure via anyhow context.
192        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}