oxify_storage/
metrics_exporter.rs

1//! Prometheus Metrics Exporter
2//!
3//! Exports storage layer metrics in Prometheus format for monitoring and observability.
4//!
5//! ## Overview
6//!
7//! This module provides a unified interface for exporting metrics from all storage
8//! components in Prometheus-compatible format.
9//!
10//! ## Metrics Categories
11//!
12//! 1. **Connection Pool**: Active connections, idle connections, utilization
13//! 2. **Cache Performance**: Hit rates, evictions, size for all cache types
14//! 3. **Query Performance**: Query durations, slow queries, errors
15//! 4. **Database Health**: Table sizes, index bloat, replication lag
16//! 5. **Batch Operations**: Throughput, errors, duration
17//!
18//! ## Usage Example
19//!
20//! ```ignore
21//! use oxify_storage::{MetricsExporter, MetricsFormat};
22//!
23//! let exporter = MetricsExporter::new(pool, cache, vector_cache, jwks_cache);
24//!
25//! // Export in Prometheus format
26//! let metrics = exporter.export(MetricsFormat::Prometheus);
27//! println!("{}", metrics);
28//!
29//! // Or get structured metrics
30//! let structured = exporter.export_structured();
31//! for (name, value, labels) in structured {
32//!     println!("{}{{{}}}: {}", name, labels, value);
33//! }
34//! ```
35//!
36//! ## Integration
37//!
38//! ### With Axum
39//! ```ignore
40//! async fn metrics_handler(
41//!     State(exporter): State<Arc<MetricsExporter>>,
42//! ) -> impl IntoResponse {
43//!     let metrics = exporter.export(MetricsFormat::Prometheus);
44//!     (StatusCode::OK, metrics)
45//! }
46//! ```
47//!
48//! ### With Prometheus Client
49//! ```ignore
50//! // Set gauges from storage metrics
51//! for (name, value) in exporter.export_flat() {
52//!     prometheus::gauge(format!("oxify_storage_{}", name), value);
53//! }
54//! ```
55
56use crate::{Cache, CacheStats, DatabasePool, PoolHealth};
57
58// Stub types for disabled modules
59pub struct VectorCache;
60pub struct JwksCache;
61
62// Stub stats/metrics types
63#[derive(Debug, Clone, Default)]
64pub struct VectorCacheStats {
65    data: std::collections::HashMap<String, f64>,
66}
67
68impl VectorCacheStats {
69    pub fn get(&self, key: &str) -> Option<&f64> {
70        self.data.get(key)
71    }
72}
73
74#[derive(Debug, Clone, Default)]
75pub struct VectorCacheMetrics {
76    pub hits: u64,
77    pub misses: u64,
78}
79
80impl VectorCacheMetrics {
81    pub fn hit_rate(&self) -> f64 {
82        let total = self.hits + self.misses;
83        if total == 0 {
84            0.0
85        } else {
86            self.hits as f64 / total as f64
87        }
88    }
89}
90
91#[derive(Debug, Clone, Default)]
92pub struct JwksCacheStats {
93    data: std::collections::HashMap<String, f64>,
94}
95
96impl JwksCacheStats {
97    pub fn get(&self, key: &str) -> Option<&f64> {
98        self.data.get(key)
99    }
100}
101
102#[derive(Debug, Clone, Default)]
103pub struct JwksCacheMetrics {
104    pub key_hits: u64,
105    pub key_misses: u64,
106}
107
108impl JwksCacheMetrics {
109    pub fn key_hit_rate(&self) -> f64 {
110        let total = self.key_hits + self.key_misses;
111        if total == 0 {
112            0.0
113        } else {
114            self.key_hits as f64 / total as f64
115        }
116    }
117}
118
119impl VectorCache {
120    pub fn stats(&self) -> VectorCacheStats {
121        VectorCacheStats::default()
122    }
123    pub fn metrics(&self) -> VectorCacheMetrics {
124        VectorCacheMetrics::default()
125    }
126}
127
128impl JwksCache {
129    pub fn stats(&self) -> JwksCacheStats {
130        JwksCacheStats::default()
131    }
132    pub fn metrics(&self) -> JwksCacheMetrics {
133        JwksCacheMetrics::default()
134    }
135}
136use std::collections::HashMap;
137use std::fmt::Write as FmtWrite;
138use std::sync::Arc;
139
140/// Metrics export format
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum MetricsFormat {
143    /// Prometheus text format
144    Prometheus,
145    /// JSON format
146    Json,
147    /// Flat key-value pairs
148    Flat,
149}
150
151/// Metric type in Prometheus
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum MetricType {
154    Gauge,
155    Counter,
156    Histogram,
157}
158
159impl MetricType {
160    fn as_str(&self) -> &'static str {
161        match self {
162            MetricType::Gauge => "gauge",
163            MetricType::Counter => "counter",
164            MetricType::Histogram => "histogram",
165        }
166    }
167}
168
169/// A single metric with metadata
170#[derive(Debug, Clone)]
171pub struct Metric {
172    /// Metric name (e.g., "oxify_storage_pool_size")
173    pub name: String,
174    /// Metric type
175    pub metric_type: MetricType,
176    /// Help text
177    pub help: String,
178    /// Metric value
179    pub value: f64,
180    /// Labels (e.g., {"cache_type": "workflow"})
181    pub labels: HashMap<String, String>,
182}
183
184impl Metric {
185    fn new(
186        name: impl Into<String>,
187        metric_type: MetricType,
188        help: impl Into<String>,
189        value: f64,
190    ) -> Self {
191        Self {
192            name: name.into(),
193            metric_type,
194            help: help.into(),
195            value,
196            labels: HashMap::new(),
197        }
198    }
199
200    fn with_label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
201        self.labels.insert(key.into(), value.into());
202        self
203    }
204
205    /// Format as Prometheus text
206    fn to_prometheus(&self) -> String {
207        let mut output = String::new();
208
209        // Type and help
210        writeln!(&mut output, "# HELP {} {}", self.name, self.help).unwrap();
211        writeln!(
212            &mut output,
213            "# TYPE {} {}",
214            self.name,
215            self.metric_type.as_str()
216        )
217        .unwrap();
218
219        // Metric value with labels
220        if self.labels.is_empty() {
221            writeln!(&mut output, "{} {}", self.name, self.value).unwrap();
222        } else {
223            let labels: Vec<String> = self
224                .labels
225                .iter()
226                .map(|(k, v)| format!("{k}=\"{v}\""))
227                .collect();
228            writeln!(
229                &mut output,
230                "{}{{{}}} {}",
231                self.name,
232                labels.join(","),
233                self.value
234            )
235            .unwrap();
236        }
237
238        output
239    }
240}
241
242/// Prometheus metrics exporter
243pub struct MetricsExporter {
244    pool: DatabasePool,
245    cache: Option<Arc<Cache>>,
246    vector_cache: Option<Arc<VectorCache>>,
247    jwks_cache: Option<Arc<JwksCache>>,
248}
249
250impl MetricsExporter {
251    /// Create a new metrics exporter
252    pub fn new(pool: DatabasePool) -> Self {
253        Self {
254            pool,
255            cache: None,
256            vector_cache: None,
257            jwks_cache: None,
258        }
259    }
260
261    /// Add cache for metrics collection
262    pub fn with_cache(mut self, cache: Arc<Cache>) -> Self {
263        self.cache = Some(cache);
264        self
265    }
266
267    /// Add vector cache for metrics collection
268    pub fn with_vector_cache(mut self, vector_cache: Arc<VectorCache>) -> Self {
269        self.vector_cache = Some(vector_cache);
270        self
271    }
272
273    /// Add JWKS cache for metrics collection
274    pub fn with_jwks_cache(mut self, jwks_cache: Arc<JwksCache>) -> Self {
275        self.jwks_cache = Some(jwks_cache);
276        self
277    }
278
279    /// Collect all metrics
280    pub fn collect_metrics(&self) -> Vec<Metric> {
281        let mut metrics = Vec::new();
282
283        // Connection pool metrics
284        metrics.extend(self.collect_pool_metrics());
285
286        // Cache metrics
287        if let Some(ref cache) = self.cache {
288            metrics.extend(self.collect_cache_metrics(cache));
289        }
290
291        // Vector cache metrics
292        if let Some(ref vector_cache) = self.vector_cache {
293            metrics.extend(self.collect_vector_cache_metrics(vector_cache));
294        }
295
296        // JWKS cache metrics
297        if let Some(ref jwks_cache) = self.jwks_cache {
298            metrics.extend(self.collect_jwks_cache_metrics(jwks_cache));
299        }
300
301        metrics
302    }
303
304    /// Collect connection pool metrics
305    fn collect_pool_metrics(&self) -> Vec<Metric> {
306        let stats = self.pool.stats();
307        let health = self.pool.health_status();
308
309        vec![
310            Metric::new(
311                "oxify_storage_pool_size",
312                MetricType::Gauge,
313                "Current size of the connection pool",
314                f64::from(stats.size),
315            ),
316            Metric::new(
317                "oxify_storage_pool_idle",
318                MetricType::Gauge,
319                "Number of idle connections",
320                stats.num_idle as f64,
321            ),
322            Metric::new(
323                "oxify_storage_pool_active",
324                MetricType::Gauge,
325                "Number of active connections",
326                stats.active_connections() as f64,
327            ),
328            Metric::new(
329                "oxify_storage_pool_max",
330                MetricType::Gauge,
331                "Maximum connections allowed",
332                f64::from(stats.max_connections),
333            ),
334            Metric::new(
335                "oxify_storage_pool_utilization",
336                MetricType::Gauge,
337                "Pool utilization (0.0 to 1.0)",
338                stats.utilization(),
339            ),
340            Metric::new(
341                "oxify_storage_pool_health",
342                MetricType::Gauge,
343                "Pool health status (0=critical, 1=degraded, 2=healthy)",
344                match health {
345                    PoolHealth::Critical => 0.0,
346                    PoolHealth::Degraded => 1.0,
347                    PoolHealth::Healthy => 2.0,
348                },
349            ),
350        ]
351    }
352
353    /// Collect cache metrics
354    fn collect_cache_metrics(&self, cache: &Cache) -> Vec<Metric> {
355        let stats = cache.stats();
356        let metrics_data = cache.metrics();
357
358        let workflow_stats = stats.get("workflows").cloned().unwrap_or(CacheStats {
359            size: 0,
360            capacity: 0,
361            valid_entries: 0,
362            expired_entries: 0,
363            total_accesses: 0,
364        });
365
366        vec![
367            Metric::new(
368                "oxify_storage_cache_size",
369                MetricType::Gauge,
370                "Current cache size",
371                workflow_stats.size as f64,
372            )
373            .with_label("cache_type", "workflow"),
374            Metric::new(
375                "oxify_storage_cache_hits",
376                MetricType::Counter,
377                "Cache hit count",
378                metrics_data.workflow_hits as f64,
379            )
380            .with_label("cache_type", "workflow"),
381            Metric::new(
382                "oxify_storage_cache_misses",
383                MetricType::Counter,
384                "Cache miss count",
385                metrics_data.workflow_misses as f64,
386            )
387            .with_label("cache_type", "workflow"),
388            Metric::new(
389                "oxify_storage_cache_hit_rate",
390                MetricType::Gauge,
391                "Cache hit rate (0.0 to 1.0)",
392                metrics_data.overall_hit_rate(),
393            )
394            .with_label("cache_type", "workflow"),
395            Metric::new(
396                "oxify_storage_cache_evictions",
397                MetricType::Counter,
398                "Cache eviction count",
399                metrics_data.evictions as f64,
400            )
401            .with_label("cache_type", "workflow"),
402        ]
403    }
404
405    /// Collect vector cache metrics
406    fn collect_vector_cache_metrics(&self, vector_cache: &VectorCache) -> Vec<Metric> {
407        let stats = vector_cache.stats();
408        let metrics_data = vector_cache.metrics();
409
410        vec![
411            Metric::new(
412                "oxify_storage_cache_size",
413                MetricType::Gauge,
414                "Current cache size",
415                stats.get("size").copied().unwrap_or(0.0),
416            )
417            .with_label("cache_type", "vector"),
418            Metric::new(
419                "oxify_storage_cache_hits",
420                MetricType::Counter,
421                "Cache hit count",
422                metrics_data.hits as f64,
423            )
424            .with_label("cache_type", "vector"),
425            Metric::new(
426                "oxify_storage_cache_misses",
427                MetricType::Counter,
428                "Cache miss count",
429                metrics_data.misses as f64,
430            )
431            .with_label("cache_type", "vector"),
432            Metric::new(
433                "oxify_storage_cache_hit_rate",
434                MetricType::Gauge,
435                "Cache hit rate (0.0 to 1.0)",
436                metrics_data.hit_rate(),
437            )
438            .with_label("cache_type", "vector"),
439        ]
440    }
441
442    /// Collect JWKS cache metrics
443    fn collect_jwks_cache_metrics(&self, jwks_cache: &JwksCache) -> Vec<Metric> {
444        let stats = jwks_cache.stats();
445        let metrics_data = jwks_cache.metrics();
446
447        vec![
448            Metric::new(
449                "oxify_storage_cache_size",
450                MetricType::Gauge,
451                "Current cache size",
452                stats.get("issuers").copied().unwrap_or(0.0),
453            )
454            .with_label("cache_type", "jwks"),
455            Metric::new(
456                "oxify_storage_cache_hits",
457                MetricType::Counter,
458                "Cache hit count",
459                metrics_data.key_hits as f64,
460            )
461            .with_label("cache_type", "jwks"),
462            Metric::new(
463                "oxify_storage_cache_misses",
464                MetricType::Counter,
465                "Cache miss count",
466                metrics_data.key_misses as f64,
467            )
468            .with_label("cache_type", "jwks"),
469            Metric::new(
470                "oxify_storage_cache_hit_rate",
471                MetricType::Gauge,
472                "Cache hit rate (0.0 to 1.0)",
473                metrics_data.key_hit_rate(),
474            )
475            .with_label("cache_type", "jwks"),
476        ]
477    }
478
479    /// Export metrics in specified format
480    pub fn export(&self, format: MetricsFormat) -> String {
481        let metrics = self.collect_metrics();
482
483        match format {
484            MetricsFormat::Prometheus => {
485                let mut output = String::new();
486                for metric in metrics {
487                    output.push_str(&metric.to_prometheus());
488                }
489                output
490            }
491            MetricsFormat::Json => {
492                let json: Vec<serde_json::Value> = metrics
493                    .iter()
494                    .map(|m| {
495                        serde_json::json!({
496                            "name": m.name,
497                            "type": m.metric_type.as_str(),
498                            "help": m.help,
499                            "value": m.value,
500                            "labels": m.labels,
501                        })
502                    })
503                    .collect();
504                serde_json::to_string_pretty(&json).unwrap()
505            }
506            MetricsFormat::Flat => {
507                let mut output = String::new();
508                for metric in metrics {
509                    writeln!(&mut output, "{}: {}", metric.name, metric.value).unwrap();
510                }
511                output
512            }
513        }
514    }
515
516    /// Export metrics as flat key-value pairs
517    pub fn export_flat(&self) -> HashMap<String, f64> {
518        let metrics = self.collect_metrics();
519        let mut flat = HashMap::new();
520
521        for metric in metrics {
522            if metric.labels.is_empty() {
523                flat.insert(metric.name, metric.value);
524            } else {
525                let labels: Vec<String> = metric
526                    .labels
527                    .iter()
528                    .map(|(k, v)| format!("{k}_{v}"))
529                    .collect();
530                let key = format!("{}_{}", metric.name, labels.join("_"));
531                flat.insert(key, metric.value);
532            }
533        }
534
535        flat
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn test_metric_creation() {
545        let metric = Metric::new("test_metric", MetricType::Gauge, "Test metric", 42.0)
546            .with_label("env", "test")
547            .with_label("version", "1.0");
548
549        assert_eq!(metric.name, "test_metric");
550        assert_eq!(metric.value, 42.0);
551        assert_eq!(metric.labels.len(), 2);
552    }
553
554    #[test]
555    fn test_prometheus_format_no_labels() {
556        let metric = Metric::new("test_gauge", MetricType::Gauge, "A test gauge", 123.45);
557
558        let output = metric.to_prometheus();
559        assert!(output.contains("# HELP test_gauge A test gauge"));
560        assert!(output.contains("# TYPE test_gauge gauge"));
561        assert!(output.contains("test_gauge 123.45"));
562    }
563
564    #[test]
565    fn test_prometheus_format_with_labels() {
566        let metric = Metric::new("test_counter", MetricType::Counter, "A test counter", 456.0)
567            .with_label("env", "prod")
568            .with_label("region", "us-east");
569
570        let output = metric.to_prometheus();
571        assert!(output.contains("# HELP test_counter A test counter"));
572        assert!(output.contains("# TYPE test_counter counter"));
573        assert!(output.contains("test_counter{"));
574        assert!(output.contains("456"));
575    }
576
577    #[test]
578    fn test_metric_type_as_str() {
579        assert_eq!(MetricType::Gauge.as_str(), "gauge");
580        assert_eq!(MetricType::Counter.as_str(), "counter");
581        assert_eq!(MetricType::Histogram.as_str(), "histogram");
582    }
583}