Platform restructured into crates/, added AI service and detector,
migrated control-center-ui to Leptos 0.8
510 lines
15 KiB
Rust
510 lines
15 KiB
Rust
//! Monitoring and metrics collection for RAG system
|
|
//!
|
|
//! Provides comprehensive metrics tracking for:
|
|
//! - Query latency (P50, P95, P99)
|
|
//! - Cache hit rates
|
|
//! - Confidence score distribution
|
|
//! - Error rates and types
|
|
//! - Volume metrics
|
|
//!
|
|
//! Metrics can be exported in Prometheus format
|
|
|
|
use std::collections::VecDeque;
|
|
use std::sync::Arc;
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use parking_lot::RwLock;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Latency histogram for percentile calculations
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LatencyHistogram {
|
|
/// All recorded latencies (milliseconds)
|
|
latencies: VecDeque<f32>,
|
|
/// Maximum number of samples to keep
|
|
max_samples: usize,
|
|
}
|
|
|
|
impl LatencyHistogram {
|
|
/// Create a new latency histogram
|
|
pub fn new(max_samples: usize) -> Self {
|
|
Self {
|
|
latencies: VecDeque::with_capacity(max_samples),
|
|
max_samples,
|
|
}
|
|
}
|
|
|
|
/// Record a latency measurement
|
|
pub fn record(&mut self, latency_ms: f32) {
|
|
if self.latencies.len() >= self.max_samples {
|
|
self.latencies.pop_front();
|
|
}
|
|
self.latencies.push_back(latency_ms);
|
|
}
|
|
|
|
/// Get P50 (median) latency
|
|
pub fn p50(&self) -> f32 {
|
|
self.percentile(50.0)
|
|
}
|
|
|
|
/// Get P95 latency
|
|
pub fn p95(&self) -> f32 {
|
|
self.percentile(95.0)
|
|
}
|
|
|
|
/// Get P99 latency
|
|
pub fn p99(&self) -> f32 {
|
|
self.percentile(99.0)
|
|
}
|
|
|
|
/// Get percentile latency
|
|
pub fn percentile(&self, p: f32) -> f32 {
|
|
if self.latencies.is_empty() {
|
|
return 0.0;
|
|
}
|
|
|
|
let mut sorted: Vec<_> = self.latencies.iter().copied().collect();
|
|
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
|
|
|
let index = ((p / 100.0) * sorted.len() as f32).ceil() as usize;
|
|
let index = index.saturating_sub(1).min(sorted.len() - 1);
|
|
|
|
sorted[index]
|
|
}
|
|
|
|
/// Get average latency
|
|
pub fn average(&self) -> f32 {
|
|
if self.latencies.is_empty() {
|
|
return 0.0;
|
|
}
|
|
|
|
let sum: f32 = self.latencies.iter().sum();
|
|
sum / self.latencies.len() as f32
|
|
}
|
|
|
|
/// Get minimum latency
|
|
pub fn min(&self) -> f32 {
|
|
self.latencies.iter().copied().fold(f32::INFINITY, f32::min)
|
|
}
|
|
|
|
/// Get maximum latency
|
|
pub fn max(&self) -> f32 {
|
|
self.latencies.iter().copied().fold(0.0, f32::max)
|
|
}
|
|
}
|
|
|
|
/// Confidence score distribution
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct ConfidenceDistribution {
|
|
/// Scores in ranges: [0.0-0.25), [0.25-0.5), [0.5-0.75), [0.75-1.0]
|
|
ranges: [u64; 4],
|
|
total: u64,
|
|
}
|
|
|
|
impl ConfidenceDistribution {
|
|
/// Create a new confidence distribution
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Record a confidence score
|
|
pub fn record(&mut self, score: f32) {
|
|
let score = score.clamp(0.0, 1.0);
|
|
let idx = (score * 4.0).min(3.0) as usize;
|
|
self.ranges[idx] += 1;
|
|
self.total += 1;
|
|
}
|
|
|
|
/// Get average confidence score
|
|
pub fn average(&self) -> f32 {
|
|
if self.total == 0 {
|
|
return 0.0;
|
|
}
|
|
|
|
let sum = (self.ranges[0] as f32 * 0.125)
|
|
+ (self.ranges[1] as f32 * 0.375)
|
|
+ (self.ranges[2] as f32 * 0.625)
|
|
+ (self.ranges[3] as f32 * 0.875);
|
|
|
|
sum / self.total as f32
|
|
}
|
|
|
|
/// Get distribution as percentages
|
|
pub fn percentages(&self) -> [f32; 4] {
|
|
if self.total == 0 {
|
|
return [0.0; 4];
|
|
}
|
|
|
|
[
|
|
(self.ranges[0] as f32 / self.total as f32) * 100.0,
|
|
(self.ranges[1] as f32 / self.total as f32) * 100.0,
|
|
(self.ranges[2] as f32 / self.total as f32) * 100.0,
|
|
(self.ranges[3] as f32 / self.total as f32) * 100.0,
|
|
]
|
|
}
|
|
}
|
|
|
|
/// RAG system metrics
|
|
#[derive(Debug)]
|
|
pub struct RagMetrics {
|
|
/// Query latency tracking
|
|
pub query_latency: Arc<RwLock<LatencyHistogram>>,
|
|
|
|
/// Retrieval latency tracking
|
|
pub retrieval_latency: Arc<RwLock<LatencyHistogram>>,
|
|
|
|
/// LLM latency tracking
|
|
pub llm_latency: Arc<RwLock<LatencyHistogram>>,
|
|
|
|
/// Confidence score distribution
|
|
pub confidence: Arc<RwLock<ConfidenceDistribution>>,
|
|
|
|
/// Total queries processed
|
|
pub queries_total: u64,
|
|
|
|
/// Total cache hits
|
|
pub cache_hits: u64,
|
|
|
|
/// Total cache misses
|
|
pub cache_misses: u64,
|
|
|
|
/// Total errors
|
|
pub errors_total: u64,
|
|
|
|
/// API errors
|
|
pub api_errors: u64,
|
|
|
|
/// Average sources per query
|
|
pub avg_sources: f32,
|
|
|
|
/// When metrics were created
|
|
pub created_at: DateTime<Utc>,
|
|
|
|
/// Last reset time
|
|
pub last_reset: DateTime<Utc>,
|
|
}
|
|
|
|
// Manual Clone implementation for Arc<RwLock<T>>
|
|
impl Clone for RagMetrics {
|
|
fn clone(&self) -> Self {
|
|
Self {
|
|
query_latency: Arc::clone(&self.query_latency),
|
|
retrieval_latency: Arc::clone(&self.retrieval_latency),
|
|
llm_latency: Arc::clone(&self.llm_latency),
|
|
confidence: Arc::clone(&self.confidence),
|
|
queries_total: self.queries_total,
|
|
cache_hits: self.cache_hits,
|
|
cache_misses: self.cache_misses,
|
|
errors_total: self.errors_total,
|
|
api_errors: self.api_errors,
|
|
avg_sources: self.avg_sources,
|
|
created_at: self.created_at,
|
|
last_reset: self.last_reset,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for RagMetrics {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl RagMetrics {
|
|
/// Create new metrics tracker
|
|
pub fn new() -> Self {
|
|
let now = Utc::now();
|
|
Self {
|
|
query_latency: Arc::new(RwLock::new(LatencyHistogram::new(1000))),
|
|
retrieval_latency: Arc::new(RwLock::new(LatencyHistogram::new(1000))),
|
|
llm_latency: Arc::new(RwLock::new(LatencyHistogram::new(1000))),
|
|
confidence: Arc::new(RwLock::new(ConfidenceDistribution::new())),
|
|
queries_total: 0,
|
|
cache_hits: 0,
|
|
cache_misses: 0,
|
|
errors_total: 0,
|
|
api_errors: 0,
|
|
avg_sources: 0.0,
|
|
created_at: now,
|
|
last_reset: now,
|
|
}
|
|
}
|
|
|
|
/// Record a query execution
|
|
pub fn record_query(
|
|
&mut self,
|
|
total_latency_ms: f32,
|
|
retrieval_latency_ms: f32,
|
|
llm_latency_ms: f32,
|
|
source_count: usize,
|
|
confidence: f32,
|
|
) {
|
|
self.queries_total += 1;
|
|
|
|
// Update latency histograms
|
|
self.query_latency.write().record(total_latency_ms);
|
|
self.retrieval_latency.write().record(retrieval_latency_ms);
|
|
self.llm_latency.write().record(llm_latency_ms);
|
|
|
|
// Update confidence distribution
|
|
self.confidence.write().record(confidence);
|
|
|
|
// Update average sources
|
|
self.avg_sources = (self.avg_sources * (self.queries_total - 1) as f32
|
|
+ source_count as f32)
|
|
/ self.queries_total as f32;
|
|
}
|
|
|
|
/// Record a cache hit
|
|
pub fn record_cache_hit(&mut self, latency_ms: f32) {
|
|
self.cache_hits += 1;
|
|
self.query_latency.write().record(latency_ms);
|
|
}
|
|
|
|
/// Record a cache miss
|
|
pub fn record_cache_miss(&mut self) {
|
|
self.cache_misses += 1;
|
|
}
|
|
|
|
/// Record an error
|
|
pub fn record_error(&mut self, error_type: ErrorType) {
|
|
self.errors_total += 1;
|
|
if let ErrorType::ApiError = error_type {
|
|
self.api_errors += 1;
|
|
}
|
|
}
|
|
|
|
/// Get cache hit rate
|
|
pub fn cache_hit_rate(&self) -> f32 {
|
|
let total = self.cache_hits + self.cache_misses;
|
|
if total == 0 {
|
|
0.0
|
|
} else {
|
|
self.cache_hits as f32 / total as f32
|
|
}
|
|
}
|
|
|
|
/// Get error rate
|
|
pub fn error_rate(&self) -> f32 {
|
|
if self.queries_total == 0 {
|
|
0.0
|
|
} else {
|
|
self.errors_total as f32 / self.queries_total as f32
|
|
}
|
|
}
|
|
|
|
/// Export metrics in Prometheus format
|
|
pub fn export_prometheus(&self) -> String {
|
|
let mut output = String::new();
|
|
|
|
// Query latency metrics
|
|
let ql = self.query_latency.read();
|
|
output.push_str("# HELP rag_query_duration_seconds Query duration in seconds\n");
|
|
output.push_str("# TYPE rag_query_duration_seconds histogram\n");
|
|
output.push_str(&format!(
|
|
"rag_query_duration_seconds{{quantile=\"0.5\"}} {}\n",
|
|
ql.p50() / 1000.0
|
|
));
|
|
output.push_str(&format!(
|
|
"rag_query_duration_seconds{{quantile=\"0.95\"}} {}\n",
|
|
ql.p95() / 1000.0
|
|
));
|
|
output.push_str(&format!(
|
|
"rag_query_duration_seconds{{quantile=\"0.99\"}} {}\n",
|
|
ql.p99() / 1000.0
|
|
));
|
|
|
|
// Retrieval latency
|
|
let rl = self.retrieval_latency.read();
|
|
output.push_str("# HELP rag_retrieval_duration_ms Retrieval duration in milliseconds\n");
|
|
output.push_str("# TYPE rag_retrieval_duration_ms histogram\n");
|
|
output.push_str(&format!(
|
|
"rag_retrieval_duration_ms{{quantile=\"0.95\"}} {}\n",
|
|
rl.p95()
|
|
));
|
|
|
|
// LLM latency
|
|
let ll = self.llm_latency.read();
|
|
output.push_str("# HELP rag_llm_duration_ms LLM duration in milliseconds\n");
|
|
output.push_str("# TYPE rag_llm_duration_ms histogram\n");
|
|
output.push_str(&format!(
|
|
"rag_llm_duration_ms{{quantile=\"0.95\"}} {}\n",
|
|
ll.p95()
|
|
));
|
|
|
|
// Cache metrics
|
|
output.push_str("# HELP rag_cache_hits Total cache hits\n");
|
|
output.push_str("# TYPE rag_cache_hits counter\n");
|
|
output.push_str(&format!("rag_cache_hits {}\n", self.cache_hits));
|
|
|
|
output.push_str("# HELP rag_cache_misses Total cache misses\n");
|
|
output.push_str("# TYPE rag_cache_misses counter\n");
|
|
output.push_str(&format!("rag_cache_misses {}\n", self.cache_misses));
|
|
|
|
output.push_str("# HELP rag_cache_hit_rate Cache hit rate (0-1)\n");
|
|
output.push_str("# TYPE rag_cache_hit_rate gauge\n");
|
|
output.push_str(&format!("rag_cache_hit_rate {}\n", self.cache_hit_rate()));
|
|
|
|
// Query metrics
|
|
output.push_str("# HELP rag_queries_total Total queries processed\n");
|
|
output.push_str("# TYPE rag_queries_total counter\n");
|
|
output.push_str(&format!("rag_queries_total {}\n", self.queries_total));
|
|
|
|
// Error metrics
|
|
output.push_str("# HELP rag_errors_total Total errors\n");
|
|
output.push_str("# TYPE rag_errors_total counter\n");
|
|
output.push_str(&format!("rag_errors_total {}\n", self.errors_total));
|
|
|
|
output.push_str("# HELP rag_error_rate Error rate (0-1)\n");
|
|
output.push_str("# TYPE rag_error_rate gauge\n");
|
|
output.push_str(&format!("rag_error_rate {}\n", self.error_rate()));
|
|
|
|
// Confidence metrics
|
|
let conf = self.confidence.read();
|
|
output.push_str("# HELP rag_confidence_avg Average confidence score\n");
|
|
output.push_str("# TYPE rag_confidence_avg gauge\n");
|
|
output.push_str(&format!("rag_confidence_avg {}\n", conf.average()));
|
|
|
|
// Sources metrics
|
|
output.push_str("# HELP rag_avg_sources Average number of sources\n");
|
|
output.push_str("# TYPE rag_avg_sources gauge\n");
|
|
output.push_str(&format!("rag_avg_sources {}\n", self.avg_sources));
|
|
|
|
output
|
|
}
|
|
|
|
/// Reset all metrics
|
|
pub fn reset(&mut self) {
|
|
self.query_latency = Arc::new(RwLock::new(LatencyHistogram::new(1000)));
|
|
self.retrieval_latency = Arc::new(RwLock::new(LatencyHistogram::new(1000)));
|
|
self.llm_latency = Arc::new(RwLock::new(LatencyHistogram::new(1000)));
|
|
self.confidence = Arc::new(RwLock::new(ConfidenceDistribution::new()));
|
|
self.queries_total = 0;
|
|
self.cache_hits = 0;
|
|
self.cache_misses = 0;
|
|
self.errors_total = 0;
|
|
self.api_errors = 0;
|
|
self.avg_sources = 0.0;
|
|
self.last_reset = Utc::now();
|
|
}
|
|
}
|
|
|
|
/// Error types for metrics
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub enum ErrorType {
|
|
/// API error (network, rate limit, etc)
|
|
ApiError,
|
|
/// Retrieval error
|
|
RetrievalError,
|
|
/// LLM error
|
|
LlmError,
|
|
/// Other error
|
|
Other,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_latency_histogram_percentiles() {
|
|
let mut hist = LatencyHistogram::new(100);
|
|
|
|
// Record some latencies
|
|
for i in 1..=100 {
|
|
hist.record(i as f32);
|
|
}
|
|
|
|
assert!(hist.p50() > 0.0);
|
|
assert!(hist.p95() > hist.p50());
|
|
assert!(hist.p99() > hist.p95());
|
|
}
|
|
|
|
#[test]
|
|
fn test_latency_histogram_min_max() {
|
|
let mut hist = LatencyHistogram::new(100);
|
|
hist.record(10.0);
|
|
hist.record(50.0);
|
|
hist.record(100.0);
|
|
|
|
assert_eq!(hist.min(), 10.0);
|
|
assert_eq!(hist.max(), 100.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_confidence_distribution() {
|
|
let mut dist = ConfidenceDistribution::new();
|
|
|
|
dist.record(0.1); // Low confidence
|
|
dist.record(0.5); // Medium confidence
|
|
dist.record(0.9); // High confidence
|
|
|
|
let pcts = dist.percentages();
|
|
assert!(pcts[0] > 0.0); // Low bucket has 1/3
|
|
assert!(pcts[2] > 0.0); // High bucket has 1/3
|
|
}
|
|
|
|
#[test]
|
|
fn test_rag_metrics_creation() {
|
|
let metrics = RagMetrics::new();
|
|
assert_eq!(metrics.queries_total, 0);
|
|
assert_eq!(metrics.cache_hits, 0);
|
|
assert_eq!(metrics.cache_hit_rate(), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rag_metrics_record_query() {
|
|
let mut metrics = RagMetrics::new();
|
|
metrics.record_query(450.0, 100.0, 300.0, 3, 0.85);
|
|
|
|
assert_eq!(metrics.queries_total, 1);
|
|
assert_eq!(metrics.avg_sources, 3.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rag_metrics_cache_hit_rate() {
|
|
let mut metrics = RagMetrics::new();
|
|
metrics.record_cache_hit(50.0);
|
|
metrics.record_cache_miss();
|
|
metrics.record_cache_hit(45.0);
|
|
|
|
assert_eq!(metrics.cache_hits, 2);
|
|
assert_eq!(metrics.cache_misses, 1);
|
|
assert!((metrics.cache_hit_rate() - 2.0 / 3.0).abs() < 0.01);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rag_metrics_prometheus_export() {
|
|
let mut metrics = RagMetrics::new();
|
|
metrics.record_query(450.0, 100.0, 300.0, 3, 0.85);
|
|
metrics.record_cache_hit(50.0);
|
|
|
|
let prometheus_output = metrics.export_prometheus();
|
|
assert!(prometheus_output.contains("rag_query_duration_seconds"));
|
|
assert!(prometheus_output.contains("rag_cache_hit_rate"));
|
|
assert!(prometheus_output.contains("rag_queries_total"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rag_metrics_reset() {
|
|
let mut metrics = RagMetrics::new();
|
|
metrics.record_query(450.0, 100.0, 300.0, 3, 0.85);
|
|
assert_eq!(metrics.queries_total, 1);
|
|
|
|
metrics.reset();
|
|
assert_eq!(metrics.queries_total, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_latency_histogram_overflow() {
|
|
let mut hist = LatencyHistogram::new(10);
|
|
|
|
// Record more than capacity
|
|
for i in 0..20 {
|
|
hist.record(i as f32);
|
|
}
|
|
|
|
// Should only have 10 items
|
|
assert!(hist.latencies.len() <= 10);
|
|
}
|
|
}
|