Skip to main content

dcp/observability/
metrics.rs

1//! Prometheus-compatible metrics for DCP server.
2//!
3//! Provides request counters, latency histograms, and error tracking.
4
5use std::collections::HashMap;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, RwLock};
8use std::time::{Duration, Instant};
9
10use crate::security::sanitize_text;
11
12fn sanitize_metric_label(value: &str) -> String {
13    sanitize_text(value)
14}
15
16fn prometheus_label_value(value: &str) -> String {
17    sanitize_metric_label(value)
18        .replace('\\', r"\\")
19        .replace('\n', r"\n")
20        .replace('"', r#"\""#)
21}
22
23/// Metrics configuration
24#[derive(Debug, Clone)]
25pub struct MetricsConfig {
26    /// Enable metrics collection
27    pub enabled: bool,
28    /// Histogram bucket boundaries (in seconds)
29    pub histogram_buckets: Vec<f64>,
30    /// Metrics endpoint path
31    pub endpoint_path: String,
32}
33
34impl Default for MetricsConfig {
35    fn default() -> Self {
36        Self {
37            enabled: true,
38            histogram_buckets: vec![
39                0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
40            ],
41            endpoint_path: "/metrics".to_string(),
42        }
43    }
44}
45
46/// Counter metric
47#[derive(Debug, Default)]
48pub struct Counter {
49    value: AtomicU64,
50}
51
52impl Counter {
53    /// Create a new counter
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// Increment the counter
59    pub fn inc(&self) {
60        self.value.fetch_add(1, Ordering::Relaxed);
61    }
62
63    /// Add a value to the counter
64    pub fn add(&self, v: u64) {
65        self.value.fetch_add(v, Ordering::Relaxed);
66    }
67
68    /// Get the current value
69    pub fn get(&self) -> u64 {
70        self.value.load(Ordering::Relaxed)
71    }
72}
73
74/// Histogram metric for latency tracking
75#[derive(Debug)]
76pub struct Histogram {
77    /// Bucket boundaries
78    buckets: Vec<f64>,
79    /// Bucket counts
80    bucket_counts: Vec<AtomicU64>,
81    /// Sum of all observations
82    sum: AtomicU64,
83    /// Count of observations
84    count: AtomicU64,
85}
86
87impl Histogram {
88    /// Create a new histogram with the given bucket boundaries
89    pub fn new(buckets: Vec<f64>) -> Self {
90        let bucket_counts = buckets.iter().map(|_| AtomicU64::new(0)).collect();
91        Self {
92            buckets,
93            bucket_counts,
94            sum: AtomicU64::new(0),
95            count: AtomicU64::new(0),
96        }
97    }
98
99    /// Create with default latency buckets
100    pub fn with_default_buckets() -> Self {
101        Self::new(vec![
102            0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
103        ])
104    }
105
106    /// Observe a value
107    pub fn observe(&self, value: f64) {
108        // Update sum (store as microseconds for precision)
109        let micros = (value * 1_000_000.0) as u64;
110        self.sum.fetch_add(micros, Ordering::Relaxed);
111        self.count.fetch_add(1, Ordering::Relaxed);
112
113        // Update bucket counts
114        for (i, &bound) in self.buckets.iter().enumerate() {
115            if value <= bound {
116                self.bucket_counts[i].fetch_add(1, Ordering::Relaxed);
117            }
118        }
119    }
120
121    /// Observe a duration
122    pub fn observe_duration(&self, duration: Duration) {
123        self.observe(duration.as_secs_f64());
124    }
125
126    /// Get bucket counts
127    pub fn bucket_counts(&self) -> Vec<u64> {
128        self.bucket_counts
129            .iter()
130            .map(|c| c.load(Ordering::Relaxed))
131            .collect()
132    }
133
134    /// Get the sum of all observations
135    pub fn sum(&self) -> f64 {
136        self.sum.load(Ordering::Relaxed) as f64 / 1_000_000.0
137    }
138
139    /// Get the count of observations
140    pub fn count(&self) -> u64 {
141        self.count.load(Ordering::Relaxed)
142    }
143
144    /// Get bucket boundaries
145    pub fn buckets(&self) -> &[f64] {
146        &self.buckets
147    }
148}
149
150/// Labeled counter (counter with labels)
151#[derive(Debug, Default)]
152pub struct LabeledCounter {
153    counters: RwLock<HashMap<String, Arc<Counter>>>,
154}
155
156impl LabeledCounter {
157    /// Create a new labeled counter
158    pub fn new() -> Self {
159        Self::default()
160    }
161
162    /// Get or create a counter for the given label
163    pub fn with_label(&self, label: &str) -> Arc<Counter> {
164        {
165            let counters = self.counters.read().unwrap();
166            if let Some(counter) = counters.get(label) {
167                return Arc::clone(counter);
168            }
169        }
170
171        let mut counters = self.counters.write().unwrap();
172        counters
173            .entry(label.to_string())
174            .or_insert_with(|| Arc::new(Counter::new()))
175            .clone()
176    }
177
178    /// Increment counter for label
179    pub fn inc(&self, label: &str) {
180        self.with_label(label).inc();
181    }
182
183    /// Get all labels and their values
184    pub fn all(&self) -> HashMap<String, u64> {
185        let counters = self.counters.read().unwrap();
186        counters.iter().map(|(k, v)| (k.clone(), v.get())).collect()
187    }
188}
189
190/// Labeled histogram
191#[derive(Debug)]
192pub struct LabeledHistogram {
193    histograms: RwLock<HashMap<String, Arc<Histogram>>>,
194    buckets: Vec<f64>,
195}
196
197impl LabeledHistogram {
198    /// Create a new labeled histogram
199    pub fn new(buckets: Vec<f64>) -> Self {
200        Self {
201            histograms: RwLock::new(HashMap::new()),
202            buckets,
203        }
204    }
205
206    /// Create with default buckets
207    pub fn with_default_buckets() -> Self {
208        Self::new(vec![
209            0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
210        ])
211    }
212
213    /// Get or create a histogram for the given label
214    pub fn with_label(&self, label: &str) -> Arc<Histogram> {
215        {
216            let histograms = self.histograms.read().unwrap();
217            if let Some(histogram) = histograms.get(label) {
218                return Arc::clone(histogram);
219            }
220        }
221
222        let mut histograms = self.histograms.write().unwrap();
223        histograms
224            .entry(label.to_string())
225            .or_insert_with(|| Arc::new(Histogram::new(self.buckets.clone())))
226            .clone()
227    }
228
229    /// Observe a value for label
230    pub fn observe(&self, label: &str, value: f64) {
231        self.with_label(label).observe(value);
232    }
233
234    /// Get all labels
235    pub fn labels(&self) -> Vec<String> {
236        let histograms = self.histograms.read().unwrap();
237        histograms.keys().cloned().collect()
238    }
239}
240
241/// Prometheus-compatible metrics
242pub struct PrometheusMetrics {
243    /// Request counter by method
244    pub requests_total: LabeledCounter,
245    /// Request latency histogram by method
246    pub request_duration_seconds: LabeledHistogram,
247    /// Active connections gauge
248    pub active_connections: Counter,
249    /// Bytes transferred counter (by direction: "in" or "out")
250    pub bytes_total: LabeledCounter,
251    /// Error counter by type
252    pub errors_total: LabeledCounter,
253    /// Configuration
254    config: MetricsConfig,
255}
256
257impl PrometheusMetrics {
258    /// Create new Prometheus metrics
259    pub fn new(config: MetricsConfig) -> Self {
260        Self {
261            requests_total: LabeledCounter::new(),
262            request_duration_seconds: LabeledHistogram::new(config.histogram_buckets.clone()),
263            active_connections: Counter::new(),
264            bytes_total: LabeledCounter::new(),
265            errors_total: LabeledCounter::new(),
266            config,
267        }
268    }
269
270    /// Create with default configuration
271    pub fn with_defaults() -> Self {
272        Self::new(MetricsConfig::default())
273    }
274
275    /// Record a request
276    pub fn record_request(&self, method: &str, duration: Duration) {
277        let method = sanitize_metric_label(method);
278        self.requests_total.inc(&method);
279        self.request_duration_seconds
280            .observe(&method, duration.as_secs_f64());
281    }
282
283    /// Record an error
284    pub fn record_error(&self, error_type: &str) {
285        self.errors_total.inc(&sanitize_metric_label(error_type));
286    }
287
288    /// Record bytes transferred
289    pub fn record_bytes(&self, direction: &str, bytes: u64) {
290        self.bytes_total
291            .with_label(&sanitize_metric_label(direction))
292            .add(bytes);
293    }
294
295    /// Increment active connections
296    pub fn connection_opened(&self) {
297        self.active_connections.inc();
298    }
299
300    /// Decrement active connections (using a separate counter for simplicity)
301    pub fn connection_closed(&self) {
302        // In a real implementation, we'd use a gauge that can decrement
303        // For now, we track total connections opened
304    }
305
306    /// Get configuration
307    pub fn config(&self) -> &MetricsConfig {
308        &self.config
309    }
310
311    /// Format metrics in Prometheus text format
312    pub fn format_prometheus(&self) -> String {
313        let mut output = String::new();
314
315        // Requests total
316        output.push_str("# HELP dcp_requests_total Total number of requests by method\n");
317        output.push_str("# TYPE dcp_requests_total counter\n");
318        for (method, count) in self.requests_total.all() {
319            let method = prometheus_label_value(&method);
320            output.push_str(&format!(
321                "dcp_requests_total{{method=\"{}\"}} {}\n",
322                method, count
323            ));
324        }
325
326        // Request duration
327        output.push_str("\n# HELP dcp_request_duration_seconds Request latency in seconds\n");
328        output.push_str("# TYPE dcp_request_duration_seconds histogram\n");
329        for label in self.request_duration_seconds.labels() {
330            let histogram = self.request_duration_seconds.with_label(&label);
331            let buckets = histogram.buckets();
332            let counts = histogram.bucket_counts();
333            let method = prometheus_label_value(&label);
334
335            for (i, &bound) in buckets.iter().enumerate() {
336                output.push_str(&format!(
337                    "dcp_request_duration_seconds_bucket{{method=\"{}\",le=\"{}\"}} {}\n",
338                    method, bound, counts[i]
339                ));
340            }
341            output.push_str(&format!(
342                "dcp_request_duration_seconds_bucket{{method=\"{}\",le=\"+Inf\"}} {}\n",
343                method,
344                histogram.count()
345            ));
346            output.push_str(&format!(
347                "dcp_request_duration_seconds_sum{{method=\"{}\"}} {}\n",
348                method,
349                histogram.sum()
350            ));
351            output.push_str(&format!(
352                "dcp_request_duration_seconds_count{{method=\"{}\"}} {}\n",
353                method,
354                histogram.count()
355            ));
356        }
357
358        // Active connections
359        output.push_str("\n# HELP dcp_active_connections Number of active connections\n");
360        output.push_str("# TYPE dcp_active_connections gauge\n");
361        output.push_str(&format!(
362            "dcp_active_connections {}\n",
363            self.active_connections.get()
364        ));
365
366        // Bytes total
367        output.push_str("\n# HELP dcp_bytes_total Total bytes transferred\n");
368        output.push_str("# TYPE dcp_bytes_total counter\n");
369        for (direction, count) in self.bytes_total.all() {
370            let direction = prometheus_label_value(&direction);
371            output.push_str(&format!(
372                "dcp_bytes_total{{direction=\"{}\"}} {}\n",
373                direction, count
374            ));
375        }
376
377        // Errors total
378        output.push_str("\n# HELP dcp_errors_total Total number of errors by type\n");
379        output.push_str("# TYPE dcp_errors_total counter\n");
380        for (error_type, count) in self.errors_total.all() {
381            let error_type = prometheus_label_value(&error_type);
382            output.push_str(&format!(
383                "dcp_errors_total{{type=\"{}\"}} {}\n",
384                error_type, count
385            ));
386        }
387
388        output
389    }
390}
391
392impl Default for PrometheusMetrics {
393    fn default() -> Self {
394        Self::with_defaults()
395    }
396}
397
398/// Request metrics helper for timing requests
399pub struct RequestMetrics {
400    method: String,
401    start: Instant,
402    metrics: Arc<PrometheusMetrics>,
403}
404
405impl RequestMetrics {
406    /// Start timing a request
407    pub fn start(metrics: Arc<PrometheusMetrics>, method: impl Into<String>) -> Self {
408        Self {
409            method: method.into(),
410            start: Instant::now(),
411            metrics,
412        }
413    }
414
415    /// Record the request completion
416    pub fn finish(self) {
417        let duration = self.start.elapsed();
418        self.metrics.record_request(&self.method, duration);
419    }
420
421    /// Record the request with an error
422    pub fn finish_with_error(self, error_type: &str) {
423        let duration = self.start.elapsed();
424        self.metrics.record_request(&self.method, duration);
425        self.metrics.record_error(error_type);
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn test_counter() {
435        let counter = Counter::new();
436        assert_eq!(counter.get(), 0);
437
438        counter.inc();
439        assert_eq!(counter.get(), 1);
440
441        counter.add(5);
442        assert_eq!(counter.get(), 6);
443    }
444
445    #[test]
446    fn test_histogram() {
447        let histogram = Histogram::new(vec![0.1, 0.5, 1.0]);
448
449        histogram.observe(0.05);
450        histogram.observe(0.3);
451        histogram.observe(0.8);
452        histogram.observe(2.0);
453
454        assert_eq!(histogram.count(), 4);
455
456        let counts = histogram.bucket_counts();
457        assert_eq!(counts[0], 1); // <= 0.1
458        assert_eq!(counts[1], 2); // <= 0.5
459        assert_eq!(counts[2], 3); // <= 1.0
460    }
461
462    #[test]
463    fn test_labeled_counter() {
464        let counter = LabeledCounter::new();
465
466        counter.inc("method_a");
467        counter.inc("method_a");
468        counter.inc("method_b");
469
470        let all = counter.all();
471        assert_eq!(all.get("method_a"), Some(&2));
472        assert_eq!(all.get("method_b"), Some(&1));
473    }
474
475    #[test]
476    fn test_prometheus_metrics() {
477        let metrics = PrometheusMetrics::with_defaults();
478
479        metrics.record_request("tools/list", Duration::from_millis(10));
480        metrics.record_request("tools/call", Duration::from_millis(50));
481        metrics.record_error("timeout");
482        metrics.record_bytes("in", 1000);
483        metrics.record_bytes("out", 500);
484
485        let output = metrics.format_prometheus();
486        assert!(output.contains("dcp_requests_total"));
487        assert!(output.contains("dcp_errors_total"));
488        assert!(output.contains("dcp_bytes_total"));
489    }
490
491    #[test]
492    fn test_request_metrics() {
493        let metrics = Arc::new(PrometheusMetrics::with_defaults());
494
495        let request = RequestMetrics::start(Arc::clone(&metrics), "test_method");
496        std::thread::sleep(Duration::from_millis(1));
497        request.finish();
498
499        let all = metrics.requests_total.all();
500        assert_eq!(all.get("test_method"), Some(&1));
501    }
502}