sentinel_common/
scoped_metrics.rs

1//! Scope-aware metrics for namespaced configurations.
2//!
3//! This module provides [`ScopedMetrics`] which extends the base [`RequestMetrics`]
4//! with namespace and service labels for multi-tenant observability.
5//!
6//! # Metric Labels
7//!
8//! All scoped metrics include these additional labels:
9//! - `namespace`: The namespace name (empty string for global scope)
10//! - `service`: The service name (empty string if not in a service scope)
11//!
12//! # Example
13//!
14//! ```ignore
15//! use sentinel_common::{ScopedMetrics, Scope};
16//!
17//! let metrics = ScopedMetrics::new()?;
18//!
19//! // Record a request with scope information
20//! let scope = Scope::Service {
21//!     namespace: "api".into(),
22//!     service: "payments".into(),
23//! };
24//! metrics.record_scoped_request("checkout", "POST", 200, duration, &scope);
25//! ```
26
27use anyhow::{Context, Result};
28use prometheus::{
29    register_histogram_vec, register_int_counter_vec, register_int_gauge_vec, HistogramVec,
30    IntCounterVec, IntGaugeVec,
31};
32use std::time::Duration;
33
34use crate::ids::Scope;
35
36/// Scope-aware metrics collector.
37///
38/// Provides metrics with namespace and service labels for hierarchical
39/// configuration visibility and multi-tenant observability.
40pub struct ScopedMetrics {
41    /// Request latency histogram by scope and route
42    request_duration: HistogramVec,
43
44    /// Request count by scope, route, and status
45    request_count: IntCounterVec,
46
47    /// Active requests gauge by scope
48    active_requests: IntGaugeVec,
49
50    /// Upstream connection attempts by scope
51    upstream_attempts: IntCounterVec,
52
53    /// Upstream failures by scope
54    upstream_failures: IntCounterVec,
55
56    /// Rate limit hits by scope
57    rate_limit_hits: IntCounterVec,
58
59    /// Circuit breaker state by scope
60    circuit_breaker_state: IntGaugeVec,
61}
62
63impl ScopedMetrics {
64    /// Create new scoped metrics collector and register with Prometheus.
65    pub fn new() -> Result<Self> {
66        // Define buckets for latency histograms (in seconds)
67        let latency_buckets = vec![
68            0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
69        ];
70
71        let request_duration = register_histogram_vec!(
72            "sentinel_scoped_request_duration_seconds",
73            "Request duration in seconds with scope labels",
74            &["namespace", "service", "route", "method"],
75            latency_buckets
76        )
77        .context("Failed to register scoped_request_duration metric")?;
78
79        let request_count = register_int_counter_vec!(
80            "sentinel_scoped_requests_total",
81            "Total number of requests with scope labels",
82            &["namespace", "service", "route", "method", "status"]
83        )
84        .context("Failed to register scoped_requests_total metric")?;
85
86        let active_requests = register_int_gauge_vec!(
87            "sentinel_scoped_active_requests",
88            "Number of currently active requests by scope",
89            &["namespace", "service"]
90        )
91        .context("Failed to register scoped_active_requests metric")?;
92
93        let upstream_attempts = register_int_counter_vec!(
94            "sentinel_scoped_upstream_attempts_total",
95            "Total upstream connection attempts with scope labels",
96            &["namespace", "service", "upstream", "route"]
97        )
98        .context("Failed to register scoped_upstream_attempts metric")?;
99
100        let upstream_failures = register_int_counter_vec!(
101            "sentinel_scoped_upstream_failures_total",
102            "Total upstream connection failures with scope labels",
103            &["namespace", "service", "upstream", "route", "reason"]
104        )
105        .context("Failed to register scoped_upstream_failures metric")?;
106
107        let rate_limit_hits = register_int_counter_vec!(
108            "sentinel_scoped_rate_limit_hits_total",
109            "Total rate limit hits with scope labels",
110            &["namespace", "service", "route", "policy"]
111        )
112        .context("Failed to register scoped_rate_limit_hits metric")?;
113
114        let circuit_breaker_state = register_int_gauge_vec!(
115            "sentinel_scoped_circuit_breaker_state",
116            "Circuit breaker state (0=closed, 1=open) with scope labels",
117            &["namespace", "service", "upstream"]
118        )
119        .context("Failed to register scoped_circuit_breaker_state metric")?;
120
121        Ok(Self {
122            request_duration,
123            request_count,
124            active_requests,
125            upstream_attempts,
126            upstream_failures,
127            rate_limit_hits,
128            circuit_breaker_state,
129        })
130    }
131
132    /// Extract namespace and service strings from a scope.
133    #[inline]
134    fn scope_labels(scope: &Scope) -> (&str, &str) {
135        match scope {
136            Scope::Global => ("", ""),
137            Scope::Namespace(ns) => (ns.as_str(), ""),
138            Scope::Service { namespace, service } => (namespace.as_str(), service.as_str()),
139        }
140    }
141
142    /// Record a completed request with scope information.
143    pub fn record_request(
144        &self,
145        route: &str,
146        method: &str,
147        status: u16,
148        duration: Duration,
149        scope: &Scope,
150    ) {
151        let (namespace, service) = Self::scope_labels(scope);
152
153        self.request_duration
154            .with_label_values(&[namespace, service, route, method])
155            .observe(duration.as_secs_f64());
156
157        self.request_count
158            .with_label_values(&[namespace, service, route, method, &status.to_string()])
159            .inc();
160    }
161
162    /// Increment active request counter for a scope.
163    pub fn inc_active_requests(&self, scope: &Scope) {
164        let (namespace, service) = Self::scope_labels(scope);
165        self.active_requests
166            .with_label_values(&[namespace, service])
167            .inc();
168    }
169
170    /// Decrement active request counter for a scope.
171    pub fn dec_active_requests(&self, scope: &Scope) {
172        let (namespace, service) = Self::scope_labels(scope);
173        self.active_requests
174            .with_label_values(&[namespace, service])
175            .dec();
176    }
177
178    /// Record an upstream attempt with scope information.
179    pub fn record_upstream_attempt(&self, upstream: &str, route: &str, scope: &Scope) {
180        let (namespace, service) = Self::scope_labels(scope);
181        self.upstream_attempts
182            .with_label_values(&[namespace, service, upstream, route])
183            .inc();
184    }
185
186    /// Record an upstream failure with scope information.
187    pub fn record_upstream_failure(
188        &self,
189        upstream: &str,
190        route: &str,
191        reason: &str,
192        scope: &Scope,
193    ) {
194        let (namespace, service) = Self::scope_labels(scope);
195        self.upstream_failures
196            .with_label_values(&[namespace, service, upstream, route, reason])
197            .inc();
198    }
199
200    /// Record a rate limit hit with scope information.
201    pub fn record_rate_limit_hit(&self, route: &str, policy: &str, scope: &Scope) {
202        let (namespace, service) = Self::scope_labels(scope);
203        self.rate_limit_hits
204            .with_label_values(&[namespace, service, route, policy])
205            .inc();
206    }
207
208    /// Update circuit breaker state with scope information.
209    pub fn set_circuit_breaker_state(&self, upstream: &str, is_open: bool, scope: &Scope) {
210        let (namespace, service) = Self::scope_labels(scope);
211        let state = if is_open { 1 } else { 0 };
212        self.circuit_breaker_state
213            .with_label_values(&[namespace, service, upstream])
214            .set(state);
215    }
216}
217
218/// Metrics labels for scoped requests.
219///
220/// Use this struct to pass scope information through the request pipeline
221/// for consistent metric labeling.
222#[derive(Debug, Clone)]
223pub struct ScopeLabels {
224    pub namespace: String,
225    pub service: String,
226}
227
228impl ScopeLabels {
229    /// Create labels for global scope.
230    pub fn global() -> Self {
231        Self {
232            namespace: String::new(),
233            service: String::new(),
234        }
235    }
236
237    /// Create labels from a scope.
238    pub fn from_scope(scope: &Scope) -> Self {
239        match scope {
240            Scope::Global => Self::global(),
241            Scope::Namespace(ns) => Self {
242                namespace: ns.clone(),
243                service: String::new(),
244            },
245            Scope::Service { namespace, service } => Self {
246                namespace: namespace.clone(),
247                service: service.clone(),
248            },
249        }
250    }
251
252    /// Get the namespace label value (empty string if global).
253    pub fn namespace(&self) -> &str {
254        &self.namespace
255    }
256
257    /// Get the service label value (empty string if not in service scope).
258    pub fn service(&self) -> &str {
259        &self.service
260    }
261
262    /// Check if this is global scope.
263    pub fn is_global(&self) -> bool {
264        self.namespace.is_empty() && self.service.is_empty()
265    }
266
267    /// Check if this is a namespace scope (not service).
268    pub fn is_namespace(&self) -> bool {
269        !self.namespace.is_empty() && self.service.is_empty()
270    }
271
272    /// Check if this is a service scope.
273    pub fn is_service(&self) -> bool {
274        !self.namespace.is_empty() && !self.service.is_empty()
275    }
276}
277
278impl Default for ScopeLabels {
279    fn default() -> Self {
280        Self::global()
281    }
282}
283
284impl From<&Scope> for ScopeLabels {
285    fn from(scope: &Scope) -> Self {
286        Self::from_scope(scope)
287    }
288}
289
290impl From<Scope> for ScopeLabels {
291    fn from(scope: Scope) -> Self {
292        Self::from_scope(&scope)
293    }
294}
295
296// ============================================================================
297// Tests
298// ============================================================================
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn test_scope_labels_from_global() {
306        let labels = ScopeLabels::from_scope(&Scope::Global);
307        assert!(labels.is_global());
308        assert!(!labels.is_namespace());
309        assert!(!labels.is_service());
310        assert_eq!(labels.namespace(), "");
311        assert_eq!(labels.service(), "");
312    }
313
314    #[test]
315    fn test_scope_labels_from_namespace() {
316        let labels = ScopeLabels::from_scope(&Scope::Namespace("api".to_string()));
317        assert!(!labels.is_global());
318        assert!(labels.is_namespace());
319        assert!(!labels.is_service());
320        assert_eq!(labels.namespace(), "api");
321        assert_eq!(labels.service(), "");
322    }
323
324    #[test]
325    fn test_scope_labels_from_service() {
326        let labels = ScopeLabels::from_scope(&Scope::Service {
327            namespace: "api".to_string(),
328            service: "payments".to_string(),
329        });
330        assert!(!labels.is_global());
331        assert!(!labels.is_namespace());
332        assert!(labels.is_service());
333        assert_eq!(labels.namespace(), "api");
334        assert_eq!(labels.service(), "payments");
335    }
336
337    #[test]
338    fn test_scope_labels_default() {
339        let labels = ScopeLabels::default();
340        assert!(labels.is_global());
341    }
342
343    // Note: ScopedMetrics::new() test requires Prometheus to not already have
344    // these metrics registered, which can conflict with other tests.
345    // In production, metrics are registered once at startup.
346}