Skip to main content

zentinel_proxy/
metrics.rs

1//! Prometheus metrics endpoint for Zentinel proxy.
2//!
3//! This module provides:
4//! - An HTTP endpoint for Prometheus to scrape metrics
5//! - Integration with the UnifiedMetricsAggregator
6//! - Standard proxy metrics (requests, latencies, errors)
7//! - Agent pool metrics from v2 agents
8
9use pingora_http::ResponseHeader;
10use std::collections::HashMap;
11use std::sync::Arc;
12use tokio::sync::RwLock;
13use zentinel_agent_protocol::v2::{MetricsCollector, UnifiedMetricsAggregator};
14
15/// Metrics manager for the proxy.
16///
17/// This manages all proxy metrics and provides a Prometheus-compatible
18/// export endpoint.
19pub struct MetricsManager {
20    /// The unified metrics aggregator
21    aggregator: Arc<UnifiedMetricsAggregator>,
22    /// Whether metrics are enabled
23    enabled: bool,
24    /// Path for the metrics endpoint
25    path: String,
26    /// Allowed IP addresses for metrics access (empty = all allowed)
27    allowed_ips: Vec<String>,
28    /// Pool metrics collectors from v2 agents (agent_id -> collector)
29    pool_metrics: RwLock<HashMap<String, Arc<MetricsCollector>>>,
30}
31
32impl MetricsManager {
33    /// Create a new metrics manager.
34    pub fn new(service_name: impl Into<String>, instance_id: impl Into<String>) -> Self {
35        Self {
36            aggregator: Arc::new(UnifiedMetricsAggregator::new(service_name, instance_id)),
37            enabled: true,
38            path: "/metrics".to_string(),
39            allowed_ips: Vec::new(),
40            pool_metrics: RwLock::new(HashMap::new()),
41        }
42    }
43
44    /// Create from an existing aggregator.
45    pub fn with_aggregator(aggregator: Arc<UnifiedMetricsAggregator>) -> Self {
46        Self {
47            aggregator,
48            enabled: true,
49            path: "/metrics".to_string(),
50            allowed_ips: Vec::new(),
51            pool_metrics: RwLock::new(HashMap::new()),
52        }
53    }
54
55    /// Create from metrics configuration.
56    ///
57    /// Applies `enabled` and `path` from the config. The `address` field is
58    /// consumed separately by the standalone metrics server (see
59    /// [`crate::metrics_server`]), which binds the dedicated scrape listener.
60    pub fn from_config(
61        config: &zentinel_config::MetricsConfig,
62        service_name: impl Into<String>,
63        instance_id: impl Into<String>,
64    ) -> Self {
65        let mut manager = Self::new(service_name, instance_id);
66        manager.enabled = config.enabled;
67        manager.path = config.path.clone();
68        manager
69    }
70
71    /// Set the metrics endpoint path.
72    pub fn path(mut self, path: impl Into<String>) -> Self {
73        self.path = path.into();
74        self
75    }
76
77    /// Set allowed IPs for metrics access.
78    pub fn allowed_ips(mut self, ips: Vec<String>) -> Self {
79        self.allowed_ips = ips;
80        self
81    }
82
83    /// Disable metrics collection.
84    pub fn disable(mut self) -> Self {
85        self.enabled = false;
86        self
87    }
88
89    /// Check if metrics are enabled.
90    pub fn is_enabled(&self) -> bool {
91        self.enabled
92    }
93
94    /// Get the metrics path.
95    pub fn metrics_path(&self) -> &str {
96        &self.path
97    }
98
99    /// Get a reference to the aggregator.
100    pub fn aggregator(&self) -> &UnifiedMetricsAggregator {
101        &self.aggregator
102    }
103
104    /// Get an Arc to the aggregator.
105    pub fn aggregator_arc(&self) -> Arc<UnifiedMetricsAggregator> {
106        Arc::clone(&self.aggregator)
107    }
108
109    /// Check if an IP is allowed to access metrics.
110    pub fn is_ip_allowed(&self, ip: &str) -> bool {
111        if self.allowed_ips.is_empty() {
112            return true;
113        }
114        self.allowed_ips.iter().any(|allowed| allowed == ip)
115    }
116
117    /// Register a pool metrics collector for a v2 agent.
118    ///
119    /// Pool metrics will be included in the /metrics output.
120    pub async fn register_pool_metrics(
121        &self,
122        agent_id: impl Into<String>,
123        collector: Arc<MetricsCollector>,
124    ) {
125        self.pool_metrics
126            .write()
127            .await
128            .insert(agent_id.into(), collector);
129    }
130
131    /// Unregister a pool metrics collector.
132    pub async fn unregister_pool_metrics(&self, agent_id: &str) {
133        self.pool_metrics.write().await.remove(agent_id);
134    }
135
136    /// Handle a metrics request.
137    ///
138    /// Returns the Prometheus text format metrics body, including:
139    /// - Proxy metrics from the UnifiedMetricsAggregator
140    /// - Pool metrics from all registered v2 agent pools
141    pub fn handle_metrics_request(&self) -> MetricsResponse {
142        if !self.enabled {
143            return MetricsResponse::not_found();
144        }
145
146        // Export proxy metrics
147        let mut body = self.aggregator.export_prometheus();
148
149        // Append pool metrics from all registered v2 agents
150        // Use try_read to avoid blocking - if lock is held, skip pool metrics this scrape
151        if let Ok(pool_metrics) = self.pool_metrics.try_read() {
152            for (agent_id, collector) in pool_metrics.iter() {
153                let pool_output = collector.export_prometheus();
154                if !pool_output.is_empty() {
155                    // Add a comment separator for clarity
156                    body.push_str(&format!("\n# Agent pool metrics: {}\n", agent_id));
157                    body.push_str(&pool_output);
158                }
159            }
160        }
161
162        MetricsResponse::ok(body)
163    }
164
165    // -------------------------------------------------------------------------
166    // Convenience methods for recording proxy metrics
167    // -------------------------------------------------------------------------
168
169    /// Increment total requests counter.
170    pub fn inc_requests_total(&self, method: &str, status: u16, route: &str) {
171        let mut labels = HashMap::new();
172        labels.insert("method".to_string(), method.to_string());
173        labels.insert("status".to_string(), status.to_string());
174        labels.insert("route".to_string(), route.to_string());
175
176        self.aggregator.increment_counter(
177            "zentinel_requests_total",
178            "Total HTTP requests handled by the proxy",
179            labels,
180            1,
181        );
182    }
183
184    /// Record request duration.
185    pub fn observe_request_duration(&self, method: &str, route: &str, duration_secs: f64) {
186        let mut labels = HashMap::new();
187        labels.insert("method".to_string(), method.to_string());
188        labels.insert("route".to_string(), route.to_string());
189
190        // Standard latency buckets
191        let buckets = vec![
192            0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
193        ];
194
195        self.aggregator.observe_histogram(
196            "zentinel_request_duration_seconds",
197            "HTTP request duration in seconds",
198            labels,
199            &buckets,
200            duration_secs,
201        );
202    }
203
204    /// Set active connections gauge.
205    pub fn set_active_connections(&self, count: f64) {
206        self.aggregator.set_gauge(
207            "zentinel_active_connections",
208            "Number of active client connections",
209            HashMap::new(),
210            count,
211        );
212    }
213
214    /// Set active requests gauge.
215    pub fn set_active_requests(&self, count: f64) {
216        self.aggregator.set_gauge(
217            "zentinel_active_requests",
218            "Number of requests currently being processed",
219            HashMap::new(),
220            count,
221        );
222    }
223
224    /// Increment upstream requests.
225    pub fn inc_upstream_requests(&self, upstream: &str, status: u16, success: bool) {
226        let mut labels = HashMap::new();
227        labels.insert("upstream".to_string(), upstream.to_string());
228        labels.insert("status".to_string(), status.to_string());
229        labels.insert("success".to_string(), success.to_string());
230
231        self.aggregator.increment_counter(
232            "zentinel_upstream_requests_total",
233            "Total requests to upstream servers",
234            labels,
235            1,
236        );
237    }
238
239    /// Record upstream latency.
240    pub fn observe_upstream_duration(&self, upstream: &str, duration_secs: f64) {
241        let mut labels = HashMap::new();
242        labels.insert("upstream".to_string(), upstream.to_string());
243
244        let buckets = vec![
245            0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
246        ];
247
248        self.aggregator.observe_histogram(
249            "zentinel_upstream_duration_seconds",
250            "Time spent waiting for upstream response",
251            labels,
252            &buckets,
253            duration_secs,
254        );
255    }
256
257    /// Record upstream write pending time (time waiting to send request body).
258    pub fn observe_upstream_write_pending(&self, upstream: &str, duration_secs: f64) {
259        let mut labels = HashMap::new();
260        labels.insert("upstream".to_string(), upstream.to_string());
261
262        let buckets = vec![
263            0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
264        ];
265
266        self.aggregator.observe_histogram(
267            "zentinel_upstream_write_pending_seconds",
268            "Time spent waiting to write request to upstream",
269            labels,
270            &buckets,
271            duration_secs,
272        );
273    }
274
275    /// Increment agent requests.
276    pub fn inc_agent_requests(&self, agent: &str, decision: &str) {
277        let mut labels = HashMap::new();
278        labels.insert("agent".to_string(), agent.to_string());
279        labels.insert("decision".to_string(), decision.to_string());
280
281        self.aggregator.increment_counter(
282            "zentinel_agent_requests_total",
283            "Total requests processed by agents",
284            labels,
285            1,
286        );
287    }
288
289    /// Record agent processing time.
290    pub fn observe_agent_duration(&self, agent: &str, duration_secs: f64) {
291        let mut labels = HashMap::new();
292        labels.insert("agent".to_string(), agent.to_string());
293
294        let buckets = vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0];
295
296        self.aggregator.observe_histogram(
297            "zentinel_agent_duration_seconds",
298            "Time spent processing request in agent",
299            labels,
300            &buckets,
301            duration_secs,
302        );
303    }
304
305    /// Increment circuit breaker trips.
306    pub fn inc_circuit_breaker_trips(&self, upstream: &str) {
307        let mut labels = HashMap::new();
308        labels.insert("upstream".to_string(), upstream.to_string());
309
310        self.aggregator.increment_counter(
311            "zentinel_circuit_breaker_trips_total",
312            "Number of times circuit breaker has tripped",
313            labels,
314            1,
315        );
316    }
317
318    /// Set circuit breaker state.
319    pub fn set_circuit_breaker_state(&self, upstream: &str, open: bool) {
320        let mut labels = HashMap::new();
321        labels.insert("upstream".to_string(), upstream.to_string());
322
323        self.aggregator.set_gauge(
324            "zentinel_circuit_breaker_open",
325            "Whether circuit breaker is open (1) or closed (0)",
326            labels,
327            if open { 1.0 } else { 0.0 },
328        );
329    }
330
331    /// Increment rate limited requests.
332    pub fn inc_rate_limited(&self, route: &str) {
333        let mut labels = HashMap::new();
334        labels.insert("route".to_string(), route.to_string());
335
336        self.aggregator.increment_counter(
337            "zentinel_rate_limited_total",
338            "Total requests rate limited",
339            labels,
340            1,
341        );
342    }
343
344    /// Increment cache hits/misses.
345    pub fn inc_cache_access(&self, hit: bool) {
346        let mut labels = HashMap::new();
347        labels.insert(
348            "result".to_string(),
349            if hit { "hit" } else { "miss" }.to_string(),
350        );
351
352        self.aggregator.increment_counter(
353            "zentinel_cache_accesses_total",
354            "Total cache accesses",
355            labels,
356            1,
357        );
358    }
359
360    /// Set cache size.
361    pub fn set_cache_size(&self, size_bytes: f64) {
362        self.aggregator.set_gauge(
363            "zentinel_cache_size_bytes",
364            "Current cache size in bytes",
365            HashMap::new(),
366            size_bytes,
367        );
368    }
369}
370
371/// Response for metrics requests.
372#[derive(Debug)]
373pub struct MetricsResponse {
374    /// HTTP status code
375    pub status: u16,
376    /// Content type
377    pub content_type: String,
378    /// Response body
379    pub body: String,
380}
381
382impl MetricsResponse {
383    /// Create a successful metrics response.
384    pub fn ok(body: String) -> Self {
385        Self {
386            status: 200,
387            content_type: "text/plain; version=0.0.4; charset=utf-8".to_string(),
388            body,
389        }
390    }
391
392    /// Create a 404 response.
393    pub fn not_found() -> Self {
394        Self {
395            status: 404,
396            content_type: "text/plain".to_string(),
397            body: "Metrics not found".to_string(),
398        }
399    }
400
401    /// Create a 403 response.
402    pub fn forbidden() -> Self {
403        Self {
404            status: 403,
405            content_type: "text/plain".to_string(),
406            body: "Forbidden".to_string(),
407        }
408    }
409
410    /// Convert to HTTP response header.
411    pub fn to_header(&self) -> ResponseHeader {
412        let mut header = ResponseHeader::build(self.status, Some(2)).unwrap();
413        header
414            .append_header("Content-Type", &self.content_type)
415            .ok();
416        header
417            .append_header("Content-Length", self.body.len().to_string())
418            .ok();
419        header
420    }
421}
422
423/// Standard metric names for Zentinel proxy.
424pub mod standard {
425    /// Total HTTP requests
426    pub const REQUESTS_TOTAL: &str = "zentinel_requests_total";
427    /// Request duration histogram
428    pub const REQUEST_DURATION: &str = "zentinel_request_duration_seconds";
429    /// Active connections gauge
430    pub const ACTIVE_CONNECTIONS: &str = "zentinel_active_connections";
431    /// Active requests gauge
432    pub const ACTIVE_REQUESTS: &str = "zentinel_active_requests";
433    /// Upstream requests total
434    pub const UPSTREAM_REQUESTS: &str = "zentinel_upstream_requests_total";
435    /// Upstream duration histogram
436    pub const UPSTREAM_DURATION: &str = "zentinel_upstream_duration_seconds";
437    /// Agent requests total
438    pub const AGENT_REQUESTS: &str = "zentinel_agent_requests_total";
439    /// Agent duration histogram
440    pub const AGENT_DURATION: &str = "zentinel_agent_duration_seconds";
441    /// Circuit breaker trips
442    pub const CIRCUIT_BREAKER_TRIPS: &str = "zentinel_circuit_breaker_trips_total";
443    /// Circuit breaker state
444    pub const CIRCUIT_BREAKER_OPEN: &str = "zentinel_circuit_breaker_open";
445    /// Rate limited requests
446    pub const RATE_LIMITED: &str = "zentinel_rate_limited_total";
447    /// Cache accesses
448    pub const CACHE_ACCESSES: &str = "zentinel_cache_accesses_total";
449    /// Cache size
450    pub const CACHE_SIZE: &str = "zentinel_cache_size_bytes";
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn test_metrics_manager_creation() {
459        let manager = MetricsManager::new("test-service", "node-1");
460        assert!(manager.is_enabled());
461        assert_eq!(manager.metrics_path(), "/metrics");
462    }
463
464    #[test]
465    fn test_metrics_manager_disabled() {
466        let manager = MetricsManager::new("test", "1").disable();
467        assert!(!manager.is_enabled());
468
469        let response = manager.handle_metrics_request();
470        assert_eq!(response.status, 404);
471    }
472
473    #[test]
474    fn test_metrics_manager_ip_filtering() {
475        let manager = MetricsManager::new("test", "1")
476            .allowed_ips(vec!["127.0.0.1".to_string(), "10.0.0.1".to_string()]);
477
478        assert!(manager.is_ip_allowed("127.0.0.1"));
479        assert!(manager.is_ip_allowed("10.0.0.1"));
480        assert!(!manager.is_ip_allowed("192.168.1.1"));
481    }
482
483    #[test]
484    fn test_metrics_manager_all_ips_allowed() {
485        let manager = MetricsManager::new("test", "1");
486
487        // Empty allowed_ips means all IPs are allowed
488        assert!(manager.is_ip_allowed("127.0.0.1"));
489        assert!(manager.is_ip_allowed("192.168.1.1"));
490        assert!(manager.is_ip_allowed("any-ip"));
491    }
492
493    #[test]
494    fn test_metrics_response() {
495        let manager = MetricsManager::new("test", "node-1");
496
497        // Record some metrics
498        manager.inc_requests_total("GET", 200, "/api/users");
499        manager.set_active_connections(42.0);
500
501        let response = manager.handle_metrics_request();
502        assert_eq!(response.status, 200);
503        assert!(response.content_type.contains("text/plain"));
504        assert!(response.body.contains("zentinel_requests_total"));
505        assert!(response.body.contains("zentinel_active_connections"));
506        assert!(response.body.contains("zentinel_info"));
507    }
508
509    #[test]
510    fn test_request_duration_histogram() {
511        let manager = MetricsManager::new("test", "1");
512
513        manager.observe_request_duration("GET", "/api", 0.05);
514        manager.observe_request_duration("GET", "/api", 0.15);
515        manager.observe_request_duration("GET", "/api", 0.5);
516
517        let response = manager.handle_metrics_request();
518        assert!(response
519            .body
520            .contains("zentinel_request_duration_seconds_bucket"));
521        assert!(response
522            .body
523            .contains("zentinel_request_duration_seconds_sum"));
524        assert!(response
525            .body
526            .contains("zentinel_request_duration_seconds_count"));
527        // Verify count is 3 (with labels, the format is {labels} 3)
528        assert!(response.body.contains("} 3\n") || response.body.contains(" 3\n"));
529    }
530
531    #[test]
532    fn test_custom_path() {
533        let manager = MetricsManager::new("test", "1").path("/internal/metrics");
534        assert_eq!(manager.metrics_path(), "/internal/metrics");
535    }
536
537    #[test]
538    fn test_upstream_metrics() {
539        let manager = MetricsManager::new("test", "1");
540
541        manager.inc_upstream_requests("backend-1", 200, true);
542        manager.observe_upstream_duration("backend-1", 0.1);
543
544        let response = manager.handle_metrics_request();
545        assert!(response.body.contains("zentinel_upstream_requests_total"));
546        assert!(response.body.contains("zentinel_upstream_duration_seconds"));
547    }
548
549    #[test]
550    fn test_agent_metrics() {
551        let manager = MetricsManager::new("test", "1");
552
553        manager.inc_agent_requests("waf", "allow");
554        manager.observe_agent_duration("waf", 0.005);
555
556        let response = manager.handle_metrics_request();
557        assert!(response.body.contains("zentinel_agent_requests_total"));
558        assert!(response.body.contains("zentinel_agent_duration_seconds"));
559    }
560}