Skip to main content

enya_client/prometheus/
response.rs

1//! Prometheus response parsing.
2//!
3//! Converts Prometheus HTTP API JSON responses to `QueryResponse`.
4//!
5//! Note: Some structs use `std::collections::HashMap` for serde compatibility,
6//! as `FxHashMap` doesn't implement `Deserialize`. The module-level allow
7//! suppresses the disallowed_types lint for these cases.
8
9#![allow(clippy::disallowed_types)]
10
11use rustc_hash::{FxHashMap, FxHashSet};
12use serde::Deserialize;
13
14use crate::error::ClientError;
15use crate::types::{MetricsBucket, MetricsGroup, QueryResponse, ResultType};
16
17/// Prometheus API response wrapper for query endpoints.
18#[derive(Debug, Deserialize)]
19pub struct PrometheusResponse {
20    pub status: String,
21    #[serde(default)]
22    pub error: Option<String>,
23    #[serde(default)]
24    pub error_type: Option<String>,
25    pub data: Option<PrometheusData>,
26}
27
28/// Prometheus API response wrapper for label/metadata endpoints.
29#[derive(Debug, Deserialize)]
30pub struct PrometheusLabelsResponse {
31    pub status: String,
32    #[serde(default)]
33    pub error: Option<String>,
34    #[serde(default)]
35    pub error_type: Option<String>,
36    #[serde(default)]
37    pub data: Vec<String>,
38}
39
40/// Prometheus API response wrapper for series endpoint.
41#[derive(Debug, Deserialize)]
42pub struct PrometheusSeriesResponse {
43    pub status: String,
44    #[serde(default)]
45    pub error: Option<String>,
46    #[serde(default)]
47    pub error_type: Option<String>,
48    #[serde(default)]
49    pub data: Vec<std::collections::HashMap<String, String>>,
50}
51
52/// Prometheus API response wrapper for buildinfo endpoint.
53#[derive(Debug, Deserialize)]
54pub struct PrometheusBuildInfoResponse {
55    pub status: String,
56    #[serde(default)]
57    pub error: Option<String>,
58    #[serde(default)]
59    pub error_type: Option<String>,
60    pub data: Option<PrometheusBuildInfo>,
61}
62
63/// Build information from Prometheus.
64#[derive(Debug, Clone, Deserialize)]
65pub struct PrometheusBuildInfo {
66    pub version: String,
67    #[serde(default)]
68    pub revision: String,
69    #[serde(default)]
70    pub branch: String,
71    #[serde(default, rename = "buildUser")]
72    pub build_user: String,
73    #[serde(default, rename = "buildDate")]
74    pub build_date: String,
75    #[serde(default, rename = "goVersion")]
76    pub go_version: String,
77}
78
79/// Prometheus query result data.
80#[derive(Debug, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct PrometheusData {
83    pub result_type: String,
84    pub result: Vec<PrometheusResult>,
85}
86
87/// A single result entry from Prometheus.
88#[derive(Debug, Deserialize)]
89pub struct PrometheusResult {
90    /// Label set for this series (metric name + labels).
91    pub metric: std::collections::HashMap<String, String>,
92    /// Time series values as [timestamp, value] pairs.
93    /// Timestamps are Unix seconds (float), values are strings.
94    pub values: Vec<(f64, String)>,
95}
96
97/// Parse a Prometheus JSON response into a `QueryResponse`.
98///
99/// # Arguments
100///
101/// * `json` - Raw JSON bytes from Prometheus HTTP API
102/// * `metric` - The original metric name (for the response)
103/// * `query` - The original query string (for the response)
104/// * `granularity_ns` - The query step in nanoseconds
105///
106/// # Errors
107///
108/// Returns `ClientError::ParseError` if the JSON is invalid or has unexpected structure.
109/// Returns `ClientError::BackendError` if Prometheus returned an error status.
110pub fn parse_response(
111    json: &[u8],
112    metric: &str,
113    query: &str,
114    granularity_ns: u128,
115) -> Result<QueryResponse, ClientError> {
116    let response: PrometheusResponse =
117        serde_json::from_slice(json).map_err(|e| ClientError::ParseError(e.to_string()))?;
118
119    // Check for error status
120    if response.status != "success" {
121        let message = response
122            .error
123            .unwrap_or_else(|| "unknown error".to_string());
124        return Err(ClientError::BackendError {
125            status: 400,
126            message,
127        });
128    }
129
130    let data = response
131        .data
132        .ok_or_else(|| ClientError::ParseError("missing data field".to_string()))?;
133
134    // Parse result_type string to enum
135    let result_type = match data.result_type.as_str() {
136        "matrix" => ResultType::Matrix,
137        "vector" => ResultType::Vector,
138        "scalar" => ResultType::Scalar,
139        "string" => ResultType::String,
140        _ => ResultType::Matrix, // Default fallback
141    };
142
143    // Convert Prometheus results to MetricsGroups
144    let groups = data.result.into_iter().map(convert_result).collect();
145
146    Ok(QueryResponse {
147        metric: metric.to_string(),
148        query: query.to_string(),
149        parsed_agg: None,
150        parsed_filter: String::new(),
151        parsed_grouping: None,
152        parsed_time_range: None,
153        start: None,
154        end: None,
155        granularity_ns,
156        groups,
157        result_type,
158    })
159}
160
161/// Parse a Prometheus labels/values response into a Vec<String>.
162///
163/// Used for `/api/v1/labels`, `/api/v1/label/{name}/values`, etc.
164///
165/// # Errors
166///
167/// Returns `ClientError::ParseError` if the JSON is invalid.
168/// Returns `ClientError::BackendError` if Prometheus returned an error status.
169pub fn parse_labels_response(json: &[u8]) -> Result<Vec<String>, ClientError> {
170    let response: PrometheusLabelsResponse =
171        serde_json::from_slice(json).map_err(|e| ClientError::ParseError(e.to_string()))?;
172
173    if response.status != "success" {
174        let message = response
175            .error
176            .unwrap_or_else(|| "unknown error".to_string());
177        return Err(ClientError::BackendError {
178            status: 400,
179            message,
180        });
181    }
182
183    Ok(response.data)
184}
185
186/// Metric label information extracted from series data.
187///
188/// Contains label names and their possible values for a specific metric.
189#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
190pub struct MetricLabels {
191    /// Map of label name -> set of possible values
192    pub labels: std::collections::HashMap<String, Vec<String>>,
193}
194
195/// Parse a Prometheus series response into MetricLabels.
196///
197/// Used for `/api/v1/series?match[]={__name__="metric_name"}`.
198/// Extracts all label names and their unique values from the series data.
199///
200/// # Errors
201///
202/// Returns `ClientError::ParseError` if the JSON is invalid.
203/// Returns `ClientError::BackendError` if Prometheus returned an error status.
204pub fn parse_series_response(json: &[u8]) -> Result<MetricLabels, ClientError> {
205    let response: PrometheusSeriesResponse =
206        serde_json::from_slice(json).map_err(|e| ClientError::ParseError(e.to_string()))?;
207
208    if response.status != "success" {
209        let message = response
210            .error
211            .unwrap_or_else(|| "unknown error".to_string());
212        return Err(ClientError::BackendError {
213            status: 400,
214            message,
215        });
216    }
217
218    // Collect all label names and their values across all series
219    let mut labels: FxHashMap<String, FxHashSet<String>> = FxHashMap::default();
220
221    for series in response.data {
222        for (key, value) in series {
223            // Skip the __name__ label as it's the metric name itself
224            if key == "__name__" {
225                continue;
226            }
227            labels.entry(key).or_default().insert(value);
228        }
229    }
230
231    // Convert HashSet to sorted Vec for consistent ordering
232    let labels = labels
233        .into_iter()
234        .map(|(k, v)| {
235            let mut values: Vec<_> = v.into_iter().collect();
236            values.sort();
237            (k, values)
238        })
239        .collect();
240
241    Ok(MetricLabels { labels })
242}
243
244/// Parse a Prometheus buildinfo response.
245///
246/// Used for `/api/v1/status/buildinfo` to validate connectivity and get version.
247///
248/// # Errors
249///
250/// Returns `ClientError::ParseError` if the JSON is invalid.
251/// Returns `ClientError::BackendError` if Prometheus returned an error status.
252pub fn parse_buildinfo_response(json: &[u8]) -> Result<PrometheusBuildInfo, ClientError> {
253    let response: PrometheusBuildInfoResponse =
254        serde_json::from_slice(json).map_err(|e| ClientError::ParseError(e.to_string()))?;
255
256    if response.status != "success" {
257        let message = response
258            .error
259            .unwrap_or_else(|| "unknown error".to_string());
260        return Err(ClientError::BackendError {
261            status: 400,
262            message,
263        });
264    }
265
266    response
267        .data
268        .ok_or_else(|| ClientError::ParseError("missing data field".to_string()))
269}
270
271/// Convert a Prometheus result entry to a MetricsGroup.
272fn convert_result(result: PrometheusResult) -> MetricsGroup {
273    // Build group identifier from labels (excluding __name__)
274    let group = result
275        .metric
276        .iter()
277        .filter(|(k, _)| *k != "__name__")
278        .map(|(k, v)| format!("{k}:{v}"))
279        .collect::<Vec<_>>()
280        .join(",");
281
282    // Convert [timestamp, value] pairs to MetricsBuckets
283    let buckets = result
284        .values
285        .iter()
286        .filter_map(|(ts, val)| {
287            let value: f64 = val.parse().ok()?;
288            let ts_ns = (*ts * 1_000_000_000.0) as u128;
289            Some(MetricsBucket {
290                start: ts_ns,
291                end: ts_ns, // Point-in-time for Prometheus
292                value,
293                count: 1,
294            })
295        })
296        .collect();
297
298    MetricsGroup { group, buckets }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn test_parse_success_response() {
307        let json = r#"{
308            "status": "success",
309            "data": {
310                "resultType": "matrix",
311                "result": [
312                    {
313                        "metric": {"__name__": "cpu_usage", "env": "prod", "host": "server1"},
314                        "values": [[1700000000, "0.5"], [1700000060, "0.6"]]
315                    }
316                ]
317            }
318        }"#;
319
320        let response = parse_response(json.as_bytes(), "cpu_usage", "env:prod", 60_000_000_000)
321            .expect("should parse");
322
323        assert_eq!(response.metric, "cpu_usage");
324        assert_eq!(response.result_type, ResultType::Matrix);
325        assert_eq!(response.groups.len(), 1);
326
327        let group = &response.groups[0];
328        // Group should contain labels but not __name__
329        assert!(group.group.contains("env:prod"));
330        assert!(group.group.contains("host:server1"));
331        assert!(!group.group.contains("__name__"));
332
333        assert_eq!(group.buckets.len(), 2);
334        assert!((group.buckets[0].value - 0.5).abs() < f64::EPSILON);
335        assert!((group.buckets[1].value - 0.6).abs() < f64::EPSILON);
336    }
337
338    #[test]
339    fn test_parse_multiple_series() {
340        let json = r#"{
341            "status": "success",
342            "data": {
343                "resultType": "matrix",
344                "result": [
345                    {
346                        "metric": {"env": "prod"},
347                        "values": [[1700000000, "10"]]
348                    },
349                    {
350                        "metric": {"env": "staging"},
351                        "values": [[1700000000, "5"]]
352                    }
353                ]
354            }
355        }"#;
356
357        let response =
358            parse_response(json.as_bytes(), "metric", "*", 60_000_000_000).expect("should parse");
359
360        assert_eq!(response.groups.len(), 2);
361    }
362
363    #[test]
364    fn test_parse_error_response() {
365        let json = r#"{
366            "status": "error",
367            "errorType": "bad_data",
368            "error": "invalid query syntax"
369        }"#;
370
371        let result = parse_response(json.as_bytes(), "metric", "bad", 60_000_000_000);
372        assert!(result.is_err());
373
374        match result.unwrap_err() {
375            ClientError::BackendError { message, .. } => {
376                assert!(message.contains("invalid query syntax"));
377            }
378            _ => panic!("expected BackendError"),
379        }
380    }
381
382    #[test]
383    fn test_parse_empty_result() {
384        let json = r#"{
385            "status": "success",
386            "data": {
387                "resultType": "matrix",
388                "result": []
389            }
390        }"#;
391
392        let response =
393            parse_response(json.as_bytes(), "metric", "*", 60_000_000_000).expect("should parse");
394
395        assert!(response.groups.is_empty());
396    }
397
398    #[test]
399    fn test_parse_invalid_json() {
400        let json = b"not json";
401        let result = parse_response(json, "metric", "*", 60_000_000_000);
402        assert!(matches!(result, Err(ClientError::ParseError(_))));
403    }
404
405    // === Labels response tests ===
406
407    #[test]
408    fn test_parse_labels_response_success() {
409        let json = r#"{
410            "status": "success",
411            "data": ["env", "host", "service", "region"]
412        }"#;
413
414        let labels = parse_labels_response(json.as_bytes()).expect("should parse");
415        assert_eq!(labels, vec!["env", "host", "service", "region"]);
416    }
417
418    #[test]
419    fn test_parse_labels_response_empty() {
420        let json = r#"{
421            "status": "success",
422            "data": []
423        }"#;
424
425        let labels = parse_labels_response(json.as_bytes()).expect("should parse");
426        assert!(labels.is_empty());
427    }
428
429    #[test]
430    fn test_parse_labels_response_error() {
431        let json = r#"{
432            "status": "error",
433            "errorType": "bad_data",
434            "error": "invalid label name"
435        }"#;
436
437        let result = parse_labels_response(json.as_bytes());
438        assert!(result.is_err());
439
440        match result.unwrap_err() {
441            ClientError::BackendError { message, .. } => {
442                assert!(message.contains("invalid label name"));
443            }
444            _ => panic!("expected BackendError"),
445        }
446    }
447
448    #[test]
449    fn test_parse_labels_response_invalid_json() {
450        let json = b"not json";
451        let result = parse_labels_response(json);
452        assert!(matches!(result, Err(ClientError::ParseError(_))));
453    }
454
455    #[test]
456    fn test_parse_label_values_response() {
457        // This is the same format as labels, just with different data
458        let json = r#"{
459            "status": "success",
460            "data": ["prod", "staging", "dev"]
461        }"#;
462
463        let values = parse_labels_response(json.as_bytes()).expect("should parse");
464        assert_eq!(values, vec!["prod", "staging", "dev"]);
465    }
466
467    #[test]
468    fn test_parse_metric_names_response() {
469        // Metric names come from __name__ label values
470        let json = r#"{
471            "status": "success",
472            "data": ["cpu_usage", "memory_usage", "http_requests_total"]
473        }"#;
474
475        let metrics = parse_labels_response(json.as_bytes()).expect("should parse");
476        assert_eq!(
477            metrics,
478            vec!["cpu_usage", "memory_usage", "http_requests_total"]
479        );
480    }
481
482    // === Series response tests ===
483
484    #[test]
485    fn test_parse_series_response_success() {
486        let json = r#"{
487            "status": "success",
488            "data": [
489                {"__name__": "cpu_usage", "cpu": "0", "mode": "idle"},
490                {"__name__": "cpu_usage", "cpu": "0", "mode": "system"},
491                {"__name__": "cpu_usage", "cpu": "1", "mode": "idle"},
492                {"__name__": "cpu_usage", "cpu": "1", "mode": "user"}
493            ]
494        }"#;
495
496        let result = parse_series_response(json.as_bytes()).expect("should parse");
497
498        // Should have 2 labels: cpu and mode (not __name__)
499        assert_eq!(result.labels.len(), 2);
500
501        // cpu should have values "0" and "1"
502        let cpu_values = result.labels.get("cpu").expect("should have cpu label");
503        assert_eq!(cpu_values, &vec!["0".to_string(), "1".to_string()]);
504
505        // mode should have values "idle", "system", "user"
506        let mode_values = result.labels.get("mode").expect("should have mode label");
507        assert_eq!(
508            mode_values,
509            &vec!["idle".to_string(), "system".to_string(), "user".to_string()]
510        );
511    }
512
513    #[test]
514    fn test_parse_series_response_empty() {
515        let json = r#"{
516            "status": "success",
517            "data": []
518        }"#;
519
520        let result = parse_series_response(json.as_bytes()).expect("should parse");
521        assert!(result.labels.is_empty());
522    }
523
524    #[test]
525    fn test_parse_series_response_error() {
526        let json = r#"{
527            "status": "error",
528            "errorType": "bad_data",
529            "error": "invalid match selector"
530        }"#;
531
532        let result = parse_series_response(json.as_bytes());
533        assert!(result.is_err());
534
535        match result.unwrap_err() {
536            ClientError::BackendError { message, .. } => {
537                assert!(message.contains("invalid match selector"));
538            }
539            _ => panic!("expected BackendError"),
540        }
541    }
542
543    // === Result type tests ===
544
545    #[test]
546    fn test_parse_result_type_vector() {
547        let json = r#"{
548            "status": "success",
549            "data": {
550                "resultType": "vector",
551                "result": [
552                    {
553                        "metric": {"env": "prod"},
554                        "values": [[1700000000, "42"]]
555                    }
556                ]
557            }
558        }"#;
559
560        let response =
561            parse_response(json.as_bytes(), "metric", "*", 60_000_000_000).expect("should parse");
562        assert_eq!(response.result_type, ResultType::Vector);
563    }
564
565    #[test]
566    fn test_parse_result_type_scalar() {
567        let json = r#"{
568            "status": "success",
569            "data": {
570                "resultType": "scalar",
571                "result": []
572            }
573        }"#;
574
575        let response =
576            parse_response(json.as_bytes(), "metric", "*", 60_000_000_000).expect("should parse");
577        assert_eq!(response.result_type, ResultType::Scalar);
578    }
579
580    #[test]
581    fn test_parse_result_type_string() {
582        let json = r#"{
583            "status": "success",
584            "data": {
585                "resultType": "string",
586                "result": []
587            }
588        }"#;
589
590        let response =
591            parse_response(json.as_bytes(), "metric", "*", 60_000_000_000).expect("should parse");
592        assert_eq!(response.result_type, ResultType::String);
593    }
594}