//! Monitoring Service Handler - Direct Prometheus scraping for training metrics //! //! Serves `GetLiveTrainingMetrics`, `StreamTrainingMetrics`, and `GetEpochHistory` //! by querying Prometheus directly, eliminating the monitoring-service backend. //! //! The merged monitoring.proto defines 16 RPCs (system health + training). //! This handler implements the 3 training RPCs with real Prometheus scraping; //! the 13 system health RPCs return UNIMPLEMENTED until real health checks are wired. use std::collections::{HashMap, VecDeque}; use std::pin::Pin; use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result}; use futures::Stream; use serde::Deserialize; use tokio::sync::RwLock; use tonic::{Request, Response, Status}; use tracing::{error, info, instrument}; use crate::monitoring::monitoring_service_server::{MonitoringService, MonitoringServiceServer}; use crate::monitoring::{ AcknowledgeAlertRequest, AcknowledgeAlertResponse, AlertEvent, ClusterPodsResponse, EpochFinancialSnapshot, GetActiveAlertsRequest, GetActiveAlertsResponse, GetEpochHistoryRequest, GetEpochHistoryResponse, GetHealthCheckRequest, GetHealthCheckResponse, GetLatencyMetricsRequest, GetLatencyMetricsResponse, GetLiveTrainingMetricsRequest, GetLiveTrainingMetricsResponse, GetMetricsRequest, GetMetricsResponse, GetSystemStatusRequest, GetSystemStatusResponse, GetThroughputMetricsRequest, GetThroughputMetricsResponse, GpuSnapshot, HealthCheck, HealthStatus, Metric, MetricType, MetricsEvent, ServiceHealth, ServiceState, ServiceStatus, StreamTrainingMetricsRequest, SubscribeClusterPodsRequest, SystemHealth, SystemMetrics, SystemStatus, SystemStatusChangeType, SystemStatusEvent, TrainingSession, }; // ============================================================================ // Prometheus client (ported from monitoring_service/src/prometheus_client.rs) // ============================================================================ /// A single Prometheus instant-query result entry #[derive(Debug, Deserialize)] struct PromResult { metric: HashMap, value: (f64, String), // (timestamp, value_string) } #[derive(Debug, Deserialize)] struct PromData { result: Vec, } #[derive(Debug, Deserialize)] struct PromResponse { status: String, data: PromData, } /// Parsed metric value with model/fold labels #[derive(Debug, Clone)] pub struct MetricSample { pub name: String, pub model: String, pub fold: String, pub value: f64, } struct PrometheusClient { http: reqwest::Client, base_url: String, } impl PrometheusClient { fn new(base_url: &str) -> Self { let http = reqwest::Client::builder() .timeout(Duration::from_secs(5)) .build() .unwrap_or_else(|_| reqwest::Client::new()); Self { http, base_url: base_url.trim_end_matches('/').to_owned(), } } /// Execute an instant query against Prometheus async fn query(&self, promql: &str) -> Result> { let url = format!("{}/api/v1/query", self.base_url); let resp = self .http .get(&url) .query(&[("query", promql)]) .send() .await .context("Prometheus HTTP request failed")?; if !resp.status().is_success() { anyhow::bail!("Prometheus returned HTTP {}", resp.status()); } let body: PromResponse = resp .json() .await .context("Failed to parse Prometheus response")?; if body.status != "success" { anyhow::bail!("Prometheus query status: {}", body.status); } Ok(body.data.result) } /// Fetch all training + hyperopt metrics async fn fetch_training_metrics(&self) -> Result> { let results = self .query(r#"{__name__=~"foxhunt_training_.*|foxhunt_hyperopt_.*"}"#) .await?; Ok(parse_samples(results)) } /// Fetch GPU metrics from DCGM exporter async fn fetch_gpu_metrics(&self) -> Result> { let results = self .query(r#"{__name__=~"dcgm_gpu_utilization|dcgm_fb_used|dcgm_fb_free|dcgm_gpu_temp|dcgm_power_usage"}"#) .await?; Ok(parse_samples(results)) } /// Fetch active training worker count (covers both K8s Jobs and CI runner pods) async fn fetch_active_jobs(&self) -> Result { let results = self.query("foxhunt_training_active_workers").await?; let count: f64 = results .iter() .filter_map(|r| r.value.1.parse::().ok()) .sum(); #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] let count = count as u32; Ok(count) } /// Query node-level CPU and memory usage from Prometheus. /// /// Returns `(cpu_usage_pct, mem_usage_pct, disk_usage_pct)`. /// Falls back to zeros on any query failure (best-effort). async fn query_system_metrics(&self) -> (f64, f64, f64) { // CPU: 1 − idle fraction across all cores, averaged over 1m let cpu = self .query( r#"100 * (1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m])))"#, ) .await .ok() .and_then(|r| r.first().and_then(|v| v.value.1.parse::().ok())) .unwrap_or(0.0); // Memory: (total − available) / total × 100 let mem = self .query( r#"100 * (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)"#, ) .await .ok() .and_then(|r| r.first().and_then(|v| v.value.1.parse::().ok())) .unwrap_or(0.0); // Disk: (total − free) / total × 100 on root mount let disk = self .query( r#"100 * (1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"})"#, ) .await .ok() .and_then(|r| r.first().and_then(|v| v.value.1.parse::().ok())) .unwrap_or(0.0); (cpu, mem, disk) } /// Query cluster-level CPU and memory from node-exporter metrics. /// /// Returns `(cpu_usage_pct, mem_used_mb, mem_total_mb)`. /// Best-effort: returns `(0.0, 0.0, 0.0)` on any query failure. #[allow(clippy::cast_possible_truncation)] async fn query_cluster_resources(&self) -> (f32, f32, f32) { let cpu_pct = self .query( r#"100 * (1 - avg(rate(node_cpu_seconds_total{mode="idle"}[1m])))"#, ) .await .ok() .and_then(|r| r.first().and_then(|v| v.value.1.parse::().ok())) .unwrap_or(0.0) as f32; let mem_used_mb = self .query( r#"sum(node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes) / 1024 / 1024"#, ) .await .ok() .and_then(|r| r.first().and_then(|v| v.value.1.parse::().ok())) .unwrap_or(0.0) as f32; let mem_total_mb = self .query(r#"sum(node_memory_MemTotal_bytes) / 1024 / 1024"#) .await .ok() .and_then(|r| r.first().and_then(|v| v.value.1.parse::().ok())) .unwrap_or(0.0) as f32; (cpu_pct, mem_used_mb, mem_total_mb) } } fn parse_samples(results: Vec) -> Vec { results .into_iter() .filter_map(|r| { let value: f64 = r.value.1.parse().ok()?; Some(MetricSample { name: r.metric.get("__name__")?.clone(), model: r.metric.get("model").cloned().unwrap_or_default(), fold: r.metric.get("fold").cloned().unwrap_or_default(), value, }) }) .collect() } // ============================================================================ // Monitoring Service Handler // ============================================================================ const MAX_EPOCH_HISTORY: usize = 50; /// Monitoring Service Handler /// /// Directly scrapes Prometheus for training metrics, GPU stats, and active jobs. /// Replaces the former proxy pattern that forwarded to monitoring-service backend. pub struct MonitoringServiceHandler { prom: Arc, default_interval: u32, epoch_histories: Arc>>>, last_epochs: Arc>>, /// Backend service URLs for health checking: (name, url) backends: Arc>, start_time: std::time::Instant, } impl MonitoringServiceHandler { pub fn new(prometheus_url: &str, default_interval_secs: u32) -> Self { let prom = PrometheusClient::new(prometheus_url); info!( "MonitoringServiceHandler: Prometheus={}, interval={}s", prometheus_url, default_interval_secs ); Self { prom: Arc::new(prom), default_interval: default_interval_secs, epoch_histories: Arc::new(RwLock::new(HashMap::new())), last_epochs: Arc::new(RwLock::new(HashMap::new())), backends: Arc::new(Vec::new()), start_time: std::time::Instant::now(), } } /// Set the list of backend service URLs for health checking pub fn with_backends(mut self, backends: Vec<(String, String)>) -> Self { self.backends = Arc::new(backends); self } /// Check health of a single backend via gRPC health check protocol. /// /// `uptime_secs` is the gateway's own uptime — used as an approximation /// for the backend service uptime (individual services don't expose this). async fn check_backend_health( name: &str, url: &str, uptime_secs: i64, ) -> ServiceStatus { let channel = match tonic::transport::Channel::from_shared(url.to_string()) { Ok(c) => c.connect_lazy(), Err(_) => { return ServiceStatus { service_name: name.to_string(), health: ServiceHealth::Unhealthy.into(), state: ServiceState::Error.into(), error_message: Some(format!("Invalid URL: {url}")), version: Some(common::build_info::version().to_owned()), ..Default::default() }; } }; let mut hc = tonic_health::pb::health_client::HealthClient::new(channel); let req = tonic_health::pb::HealthCheckRequest { service: String::new(), }; let start = std::time::Instant::now(); let (health, state, msg) = match tokio::time::timeout( Duration::from_millis(2000), hc.check(req), ) .await { Ok(Ok(_)) => ( ServiceHealth::Healthy, ServiceState::Running, None, ), Ok(Err(s)) if s.code() == tonic::Code::Unimplemented => ( ServiceHealth::Healthy, ServiceState::Running, Some("Serving (no health service)".to_string()), ), Ok(Err(e)) => ( ServiceHealth::Unhealthy, ServiceState::Error, Some(format!("gRPC error: {e}")), ), Err(_) => ( ServiceHealth::Unhealthy, ServiceState::Error, Some("Unreachable (2s timeout)".to_string()), ), }; let latency_ms = start.elapsed().as_secs_f64() * 1000.0; let now = chrono::Utc::now().timestamp(); let mut metadata = std::collections::HashMap::new(); metadata.insert("latency_ms".to_string(), format!("{latency_ms:.1}")); ServiceStatus { service_name: name.to_string(), health: health.into(), state: state.into(), error_message: msg, version: Some(common::build_info::version().to_owned()), last_health_check: now, uptime_seconds: uptime_secs, metadata, ..Default::default() } } pub fn into_server(self) -> MonitoringServiceServer { MonitoringServiceServer::new(self) } /// Build a full training metrics snapshot from Prometheus and update epoch history. async fn build_response( prom: &PrometheusClient, model_filter: &str, epoch_histories: &RwLock>>, last_epochs: &RwLock>, ) -> Result { // Fetch training/GPU/jobs (fail-fast) and cluster resources (best-effort) concurrently. let (training_result, (cpu_pct, mem_used_mb, mem_total_mb)) = tokio::join!( async { tokio::try_join!( prom.fetch_training_metrics(), prom.fetch_gpu_metrics(), prom.fetch_active_jobs(), ) }, prom.query_cluster_resources(), ); let (training, gpu, jobs) = training_result .map_err(|e| Status::internal(format!("Prometheus query failed: {e}")))?; let sessions = group_into_sessions(&training, model_filter); let gpu_snapshot = build_gpu_snapshot(&gpu); // Record epoch history snapshots for sessions with new epoch data { let mut last = last_epochs.write().await; let mut histories = epoch_histories.write().await; for session in &sessions { let key = format!("{}/{}", session.model, session.fold); let prev_epoch = last.get(&key).copied().unwrap_or_default(); if session.current_epoch > prev_epoch && session.epoch_sharpe != 0.0 { last.insert(key.clone(), session.current_epoch); let snapshot = EpochFinancialSnapshot { epoch: session.current_epoch as u32, sharpe: session.epoch_sharpe, sortino: session.epoch_sortino, win_rate: session.epoch_win_rate, max_drawdown: session.epoch_max_drawdown, profit_factor: session.epoch_profit_factor, total_return: session.epoch_total_return, avg_return: session.epoch_avg_return, total_trades: session.epoch_total_trades, loss: session.epoch_loss, val_loss: session.validation_loss, learning_rate: session.learning_rate, action_buy_pct: session.action_buy_pct, action_sell_pct: session.action_sell_pct, action_hold_pct: session.action_hold_pct, }; let history = histories .entry(key) .or_insert_with(|| VecDeque::with_capacity(MAX_EPOCH_HISTORY)); if history.len() >= MAX_EPOCH_HISTORY { history.pop_front(); } history.push_back(snapshot); } } } Ok(GetLiveTrainingMetricsResponse { sessions, gpu: Some(gpu_snapshot), active_k8s_jobs: jobs, timestamp: chrono::Utc::now().timestamp(), cpu_percent: cpu_pct, memory_used_mb: mem_used_mb, memory_total_mb: mem_total_mb, }) } } #[tonic::async_trait] impl MonitoringService for MonitoringServiceHandler { // ======================================================================== // Training metrics RPCs (implemented -- direct Prometheus scraping) // ======================================================================== type StreamTrainingMetricsStream = Pin> + Send>>; #[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)] async fn get_live_training_metrics( &self, request: Request, ) -> Result, Status> { info!("GetLiveTrainingMetrics: scraping Prometheus"); let filter = &request.into_inner().model_filter; let resp = Self::build_response(&self.prom, filter, &self.epoch_histories, &self.last_epochs) .await?; Ok(Response::new(resp)) } #[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)] async fn stream_training_metrics( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let interval_secs = if req.interval_seconds == 0 { self.default_interval } else { req.interval_seconds.clamp(1, 60) }; let filter = req.model_filter; let prom = self.prom.clone(); let epoch_histories = self.epoch_histories.clone(); let last_epochs = self.last_epochs.clone(); info!( "StreamTrainingMetrics: interval={}s, filter={:?}", interval_secs, if filter.is_empty() { "" } else { &filter } ); let stream = async_stream::stream! { let mut interval = tokio::time::interval(Duration::from_secs(u64::from(interval_secs))); loop { interval.tick().await; match Self::build_response(&prom, &filter, &epoch_histories, &last_epochs).await { Ok(resp) => yield Ok(resp), Err(e) => { error!("Stream tick failed: {}", e); yield Err(e); break; } } } }; Ok(Response::new(Box::pin(stream))) } #[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)] async fn get_epoch_history( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let key = format!("{}/{}", req.model, req.fold); let histories = self.epoch_histories.read().await; let epochs = match histories.get(&key) { Some(deque) => { let max = if req.max_epochs == 0 { MAX_EPOCH_HISTORY } else { req.max_epochs as usize }; deque.iter().rev().take(max).rev().cloned().collect() } None => vec![], }; Ok(Response::new(GetEpochHistoryResponse { model: req.model, fold: req.fold, epochs, })) } // ======================================================================== // System health RPCs (stubs -- will be implemented when real health checks are wired) // ======================================================================== type StreamSystemStatusStream = Pin> + Send>>; #[instrument(skip(self, request), err)] async fn get_system_status( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let backends = self.backends.clone(); #[allow(clippy::cast_possible_truncation)] let uptime_secs = self.start_time.elapsed().as_secs() as i64; // Fan-out health checks + system metrics concurrently let prom = self.prom.clone(); let checks: Vec<_> = backends .iter() .filter(|(name, _)| { req.service_names.is_empty() || req.service_names.contains(name) }) .map(|(name, url)| { let name = name.clone(); let url = url.clone(); async move { Self::check_backend_health(&name, &url, uptime_secs).await } }) .collect(); let (statuses, (cpu, mem, disk)) = tokio::join!( futures::future::join_all(checks), prom.query_system_metrics(), ); let healthy_count = statuses .iter() .filter(|s| s.health == i32::from(ServiceHealth::Healthy)) .count(); let total = statuses.len(); #[allow(clippy::cast_possible_truncation)] let overall_health = if healthy_count == total { SystemHealth::Healthy } else if healthy_count == 0 { SystemHealth::Critical } else if healthy_count * 2 >= total { SystemHealth::Degraded } else { SystemHealth::Unhealthy }; let critical_issues: Vec = statuses .iter() .filter(|s| s.health != i32::from(ServiceHealth::Healthy)) .map(|s| { format!( "{}: {}", s.service_name, s.error_message.as_deref().unwrap_or("unhealthy") ) }) .collect(); #[allow(clippy::cast_possible_truncation)] let response = GetSystemStatusResponse { overall_status: Some(SystemStatus { overall_health: overall_health.into(), healthy_services: healthy_count as i32, total_services: total as i32, critical_issues, system_uptime_seconds: uptime_secs, system_metrics: Some(SystemMetrics { cpu_usage_percent: cpu, memory_usage_percent: mem, disk_usage_percent: disk, ..Default::default() }), }), service_statuses: statuses, timestamp: chrono::Utc::now().timestamp(), }; Ok(Response::new(response)) } #[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)] async fn stream_system_status( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let interval_secs = req.update_frequency_seconds.unwrap_or(0); let interval_secs = if interval_secs == 0 { 5u32 } else { (interval_secs as u32).clamp(1, 60) }; let backends = self.backends.clone(); let start_time = self.start_time; let service_filter = req.service_names; info!( "StreamSystemStatus: interval={}s, filter={:?}", interval_secs, if service_filter.is_empty() { "" } else { "filtered" } ); let prom = self.prom.clone(); let stream = async_stream::stream! { let mut interval = tokio::time::interval(Duration::from_secs(u64::from(interval_secs))); loop { interval.tick().await; #[allow(clippy::cast_possible_truncation)] let uptime_secs = start_time.elapsed().as_secs() as i64; let checks: Vec<_> = backends .iter() .filter(|(name, _)| { service_filter.is_empty() || service_filter.contains(name) }) .map(|(name, url)| { let name = name.clone(); let url = url.clone(); async move { Self::check_backend_health(&name, &url, uptime_secs).await } }) .collect(); let (statuses, (cpu, mem, disk)) = tokio::join!( futures::future::join_all(checks), prom.query_system_metrics(), ); let healthy_count = statuses .iter() .filter(|s| s.health == i32::from(ServiceHealth::Healthy)) .count(); let total = statuses.len(); #[allow(clippy::cast_possible_truncation)] let overall_health = if healthy_count == total { SystemHealth::Healthy } else if healthy_count == 0 { SystemHealth::Critical } else if healthy_count * 2 >= total { SystemHealth::Degraded } else { SystemHealth::Unhealthy }; let critical_issues: Vec = statuses .iter() .filter(|s| s.health != i32::from(ServiceHealth::Healthy)) .map(|s| { format!( "{}: {}", s.service_name, s.error_message.as_deref().unwrap_or("unhealthy") ) }) .collect(); #[allow(clippy::cast_possible_truncation)] let system_status = SystemStatus { overall_health: overall_health.into(), healthy_services: healthy_count as i32, total_services: total as i32, critical_issues, system_uptime_seconds: start_time.elapsed().as_secs() as i64, system_metrics: Some(SystemMetrics { cpu_usage_percent: cpu, memory_usage_percent: mem, disk_usage_percent: disk, ..Default::default() }), }; let event = SystemStatusEvent { system_status: Some(system_status), change_type: SystemStatusChangeType::Unspecified.into(), timestamp: chrono::Utc::now().timestamp(), }; yield Ok(event); } }; Ok(Response::new(Box::pin(stream))) } #[instrument(skip(self, request), err)] async fn get_health_check( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let backends = self.backends.clone(); #[allow(clippy::cast_possible_truncation)] let uptime_secs = self.start_time.elapsed().as_secs() as i64; // Fan-out health checks let checks: Vec<_> = backends .iter() .filter(|(name, _)| { req.service_name.is_none() || req.service_name.as_deref() == Some(name.as_str()) }) .map(|(name, url)| { let name = name.clone(); let url = url.clone(); async move { let start = std::time::Instant::now(); let status = Self::check_backend_health(&name, &url, uptime_secs).await; let elapsed = start.elapsed(); let healthy = status.health == i32::from(ServiceHealth::Healthy); HealthCheck { check_name: name, status: if healthy { HealthStatus::Healthy.into() } else { HealthStatus::Unhealthy.into() }, message: status.error_message, response_time_ms: Some(elapsed.as_secs_f64() * 1000.0), last_checked: chrono::Utc::now().timestamp(), details: HashMap::new(), } } }) .collect(); let health_checks = futures::future::join_all(checks).await; let all_healthy = health_checks .iter() .all(|c| c.status == i32::from(HealthStatus::Healthy)); let response = GetHealthCheckResponse { health_status: if all_healthy { HealthStatus::Healthy.into() } else { HealthStatus::Degraded.into() }, health_checks, timestamp: chrono::Utc::now().timestamp(), }; Ok(Response::new(response)) } type StreamMetricsStream = Pin> + Send>>; #[instrument(skip(self, request), err)] async fn get_metrics( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); // If specific metric names requested, query each; otherwise return empty let mut metrics = Vec::new(); for name in &req.metric_names { match self.prom.query(name).await { Ok(results) => { for r in results { if let Ok(value) = r.value.1.parse::() { metrics.push(Metric { name: r .metric .get("__name__") .cloned() .unwrap_or_else(|| name.clone()), metric_type: MetricType::Gauge.into(), value, unit: String::new(), labels: r.metric, timestamp: chrono::Utc::now().timestamp(), statistics: None, }); } } } Err(e) => { error!("Prometheus query for '{}' failed: {}", name, e); } } } Ok(Response::new(GetMetricsResponse { metrics, timestamp: chrono::Utc::now().timestamp(), })) } #[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)] async fn stream_metrics( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let interval_secs = req.update_frequency_seconds.unwrap_or(0); let interval_secs = if interval_secs == 0 { 5u32 } else { (interval_secs as u32).clamp(1, 60) }; let metric_names: Vec = if req.metric_names.is_empty() { vec![ "cpu_usage".to_owned(), "memory_usage".to_owned(), "gpu_utilization".to_owned(), ] } else { req.metric_names }; let prom = self.prom.clone(); info!( "StreamMetrics: interval={}s, metrics={:?}", interval_secs, metric_names ); let stream = async_stream::stream! { let mut interval = tokio::time::interval(Duration::from_secs(u64::from(interval_secs))); loop { interval.tick().await; let mut metrics = Vec::new(); for name in &metric_names { match prom.query(name).await { Ok(results) => { for r in results { if let Ok(value) = r.value.1.parse::() { metrics.push(Metric { name: r .metric .get("__name__") .cloned() .unwrap_or_else(|| name.clone()), metric_type: MetricType::Gauge.into(), value, unit: String::new(), labels: r.metric, timestamp: chrono::Utc::now().timestamp(), statistics: None, }); } } } Err(e) => { error!("StreamMetrics: Prometheus query for '{}' failed: {}", name, e); } } } let event = MetricsEvent { metrics, timestamp: chrono::Utc::now().timestamp(), }; yield Ok(event); } }; Ok(Response::new(Box::pin(stream))) } async fn get_latency_metrics( &self, _request: Request, ) -> Result, Status> { Err(Status::unimplemented( "GetLatencyMetrics not yet implemented", )) } async fn get_throughput_metrics( &self, _request: Request, ) -> Result, Status> { Err(Status::unimplemented( "GetThroughputMetrics not yet implemented", )) } type StreamAlertsStream = Pin> + Send>>; #[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)] async fn stream_alerts( &self, request: Request, ) -> Result, Status> { let _req = request.into_inner(); Err(Status::unimplemented( "StreamAlerts will be wired to Prometheus Alertmanager", )) } async fn acknowledge_alert( &self, _request: Request, ) -> Result, Status> { Err(Status::unimplemented( "AcknowledgeAlert not yet implemented", )) } async fn get_active_alerts( &self, _request: Request, ) -> Result, Status> { Err(Status::unimplemented( "GetActiveAlerts not yet implemented", )) } // ======================================================================== // Cluster pods RPC (real -- queries Kubernetes API via pods_handler) // ======================================================================== type SubscribeClusterPodsStream = Pin> + Send>>; async fn subscribe_cluster_pods( &self, request: Request, ) -> Result, Status> { let req = request.into_inner(); let interval_secs = if req.interval_seconds == 0 { 5 } else { req.interval_seconds.clamp(1, 60) }; info!( "SubscribeClusterPods: interval={}s, namespace=foxhunt", interval_secs ); let stream = async_stream::stream! { let mut interval = tokio::time::interval(Duration::from_secs(u64::from(interval_secs))); loop { interval.tick().await; match super::pods_handler::list_pods("foxhunt").await { Ok(pods) => { yield Ok(ClusterPodsResponse { pods, timestamp: chrono::Utc::now().timestamp(), }); } Err(e) => { tracing::warn!("Pod listing failed: {e}"); yield Ok(ClusterPodsResponse { pods: vec![], timestamp: chrono::Utc::now().timestamp(), }); } } } }; Ok(Response::new(Box::pin(stream))) } } // ============================================================================ // Helper functions (ported from monitoring_service/src/service.rs) // ============================================================================ /// Group flat metric samples into TrainingSession structs keyed by (model, fold) fn group_into_sessions(samples: &[MetricSample], model_filter: &str) -> Vec { let mut map: HashMap<(String, String), TrainingSession> = HashMap::new(); for s in samples { // Skip metrics without a model label (e.g. foxhunt_training_active_workers) if s.model.is_empty() { continue; } if !model_filter.is_empty() && s.model != model_filter { continue; } let key = (s.model.clone(), s.fold.clone()); let session = map.entry(key).or_insert_with(|| TrainingSession { model: s.model.clone(), fold: s.fold.clone(), ..Default::default() }); #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] match s.name.as_str() { "foxhunt_training_current_epoch" => session.current_epoch = s.value as f32, "foxhunt_training_epoch_loss" => session.epoch_loss = s.value as f32, "foxhunt_training_validation_loss" => session.validation_loss = s.value as f32, "foxhunt_training_batches_per_second" => session.batches_per_second = s.value as f32, "foxhunt_training_batches_processed" => session.batches_processed = s.value as f32, "foxhunt_training_iteration_seconds" => session.iteration_seconds = s.value as f32, "foxhunt_training_eval_accuracy" => session.eval_accuracy = s.value as f32, "foxhunt_training_eval_precision" => session.eval_precision = s.value as f32, "foxhunt_training_eval_recall" => session.eval_recall = s.value as f32, "foxhunt_training_eval_f1" => session.eval_f1 = s.value as f32, "foxhunt_training_checkpoint_size_bytes" => { session.checkpoint_size_bytes = s.value as f32; } "foxhunt_training_checkpoint_saves_total" => { session.checkpoint_saves = s.value as u32; } "foxhunt_training_checkpoint_failures_total" => { session.checkpoint_failures = s.value as u32; } "foxhunt_training_nan_detected_total" => session.nan_detected = s.value as u32, "foxhunt_training_gradient_explosion_total" => { session.gradient_explosions = s.value as u32; } "foxhunt_training_feature_errors_total" => session.feature_errors = s.value as u32, "foxhunt_hyperopt_trial_current" => { session.hyperopt_trial_current = s.value as u32; session.is_hyperopt = true; } "foxhunt_hyperopt_trial_total" => session.hyperopt_trial_total = s.value as u32, "foxhunt_hyperopt_best_objective" => { session.hyperopt_best_objective = s.value as f32; } "foxhunt_hyperopt_trials_failed_total" => { session.hyperopt_trials_failed = s.value as u32; } "foxhunt_hyperopt_mode" => { if s.value > 0.5 { session.is_hyperopt = true; } } // RL diagnostics "foxhunt_training_q_value_mean" => session.q_value_mean = s.value as f32, "foxhunt_training_q_value_max" => session.q_value_max = s.value as f32, "foxhunt_training_policy_entropy" => session.policy_entropy = s.value as f32, "foxhunt_training_kl_divergence" => session.kl_divergence = s.value as f32, "foxhunt_training_advantage_mean" => session.advantage_mean = s.value as f32, "foxhunt_training_replay_buffer_size" => { session.replay_buffer_size = s.value as u32; } // Gradient & training health "foxhunt_training_gradient_norm" => session.gradient_norm = s.value as f32, "foxhunt_training_learning_rate" => session.learning_rate = s.value as f32, "foxhunt_training_epoch_duration_seconds" => { session.epoch_duration_seconds = s.value as f32; } // Hyperopt intra-trial "foxhunt_hyperopt_trial_epoch" => session.hyperopt_trial_epoch = s.value as u32, "foxhunt_hyperopt_trial_best_loss" => { session.hyperopt_trial_best_loss = s.value as f32; } "foxhunt_hyperopt_elapsed_seconds" => { session.hyperopt_elapsed_seconds = s.value as f32; } // Epoch-level financial metrics "foxhunt_training_epoch_sharpe" => session.epoch_sharpe = s.value as f32, "foxhunt_training_epoch_sortino" => session.epoch_sortino = s.value as f32, "foxhunt_training_epoch_win_rate" => session.epoch_win_rate = s.value as f32, "foxhunt_training_epoch_max_drawdown" => { session.epoch_max_drawdown = s.value as f32; } "foxhunt_training_epoch_profit_factor" => { session.epoch_profit_factor = s.value as f32; } "foxhunt_training_epoch_total_return" => { session.epoch_total_return = s.value as f32; } "foxhunt_training_epoch_avg_return" => { session.epoch_avg_return = s.value as f32; } "foxhunt_training_epoch_total_trades" => { session.epoch_total_trades = s.value as u32; } // Action distribution "foxhunt_training_epoch_action_buy_pct" => session.action_buy_pct = s.value as f32, "foxhunt_training_epoch_action_sell_pct" => session.action_sell_pct = s.value as f32, "foxhunt_training_epoch_action_hold_pct" => session.action_hold_pct = s.value as f32, _ => {} } } let mut sessions: Vec<_> = map.into_values().collect(); sessions.sort_by(|a, b| (&a.model, &a.fold).cmp(&(&b.model, &b.fold))); sessions } fn build_gpu_snapshot(samples: &[MetricSample]) -> GpuSnapshot { let mut snap = GpuSnapshot::default(); let mut fb_free: f32 = 0.0; #[allow(clippy::cast_possible_truncation)] for s in samples { match s.name.as_str() { "dcgm_gpu_utilization" => snap.utilization_percent = s.value as f32, "dcgm_fb_used" => snap.memory_used_mb = s.value as f32, "dcgm_fb_free" => fb_free = s.value as f32, "dcgm_gpu_temp" => snap.temperature_celsius = s.value as f32, "dcgm_power_usage" => snap.power_watts = s.value as f32, _ => {} } } // dcgm_fb_free + dcgm_fb_used = total snap.memory_total_mb = snap.memory_used_mb + fb_free; snap } // ============================================================================ // Tests (ported from monitoring_service/src/service.rs) // ============================================================================ #[cfg(test)] mod tests { use super::*; #[test] fn test_handler_creation() { let handler = MonitoringServiceHandler::new("http://localhost:9090", 3); assert_eq!(handler.default_interval, 3); } #[test] fn test_group_empty_samples() { let sessions = group_into_sessions(&[], ""); assert!(sessions.is_empty()); } #[test] fn test_group_with_filter() { let samples = vec![ MetricSample { name: "foxhunt_training_current_epoch".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 5.0, }, MetricSample { name: "foxhunt_training_current_epoch".to_owned(), model: "ppo".to_owned(), fold: "0".to_owned(), value: 3.0, }, ]; let sessions = group_into_sessions(&samples, "dqn"); assert_eq!(sessions.len(), 1); assert_eq!(sessions.first().map(|s| s.model.as_str()), Some("dqn")); assert_eq!(sessions.first().map(|s| s.current_epoch), Some(5.0)); } #[test] fn test_group_sets_hyperopt_flag() { let samples = vec![MetricSample { name: "foxhunt_hyperopt_mode".to_owned(), model: "dqn".to_owned(), fold: "".to_owned(), value: 1.0, }]; let sessions = group_into_sessions(&samples, ""); assert_eq!(sessions.len(), 1); assert!(sessions.first().map(|s| s.is_hyperopt).unwrap_or(false)); } #[test] fn test_group_new_diagnostic_metrics() { let samples = vec![ MetricSample { name: "foxhunt_training_current_epoch".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 25.0, }, MetricSample { name: "foxhunt_training_q_value_mean".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 12.3, }, MetricSample { name: "foxhunt_training_q_value_max".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 45.6, }, MetricSample { name: "foxhunt_training_gradient_norm".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 1.23, }, MetricSample { name: "foxhunt_training_learning_rate".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 0.001, }, MetricSample { name: "foxhunt_training_epoch_duration_seconds".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 12.5, }, MetricSample { name: "foxhunt_training_replay_buffer_size".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 50000.0, }, ]; let sessions = group_into_sessions(&samples, ""); assert_eq!(sessions.len(), 1); let s = &sessions[0]; assert_eq!(s.current_epoch, 25.0); assert!((s.q_value_mean - 12.3).abs() < 0.01); assert!((s.q_value_max - 45.6).abs() < 0.01); assert!((s.gradient_norm - 1.23).abs() < 0.01); assert!((s.learning_rate - 0.001).abs() < 0.0001); assert!((s.epoch_duration_seconds - 12.5).abs() < 0.01); assert_eq!(s.replay_buffer_size, 50000); } #[test] fn test_group_ppo_diagnostics() { let samples = vec![ MetricSample { name: "foxhunt_training_policy_entropy".to_owned(), model: "ppo".to_owned(), fold: "0".to_owned(), value: 1.23, }, MetricSample { name: "foxhunt_training_kl_divergence".to_owned(), model: "ppo".to_owned(), fold: "0".to_owned(), value: 0.008, }, MetricSample { name: "foxhunt_training_advantage_mean".to_owned(), model: "ppo".to_owned(), fold: "0".to_owned(), value: 0.001, }, ]; let sessions = group_into_sessions(&samples, ""); assert_eq!(sessions.len(), 1); let s = &sessions[0]; assert!((s.policy_entropy - 1.23).abs() < 0.01); assert!((s.kl_divergence - 0.008).abs() < 0.001); assert!((s.advantage_mean - 0.001).abs() < 0.001); } #[test] fn test_group_hyperopt_intra_trial() { let samples = vec![ MetricSample { name: "foxhunt_hyperopt_mode".to_owned(), model: "dqn".to_owned(), fold: "".to_owned(), value: 1.0, }, MetricSample { name: "foxhunt_hyperopt_trial_epoch".to_owned(), model: "dqn".to_owned(), fold: "".to_owned(), value: 12.0, }, MetricSample { name: "foxhunt_hyperopt_trial_best_loss".to_owned(), model: "dqn".to_owned(), fold: "".to_owned(), value: 0.042, }, MetricSample { name: "foxhunt_hyperopt_elapsed_seconds".to_owned(), model: "dqn".to_owned(), fold: "".to_owned(), value: 123.4, }, ]; let sessions = group_into_sessions(&samples, ""); assert_eq!(sessions.len(), 1); let s = &sessions[0]; assert!(s.is_hyperopt); assert_eq!(s.hyperopt_trial_epoch, 12); assert!((s.hyperopt_trial_best_loss - 0.042).abs() < 0.001); assert!((s.hyperopt_elapsed_seconds - 123.4).abs() < 0.1); } #[test] fn test_group_financial_metrics() { let samples = vec![ MetricSample { name: "foxhunt_training_epoch_sharpe".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 2.31, }, MetricSample { name: "foxhunt_training_epoch_win_rate".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 0.552, }, MetricSample { name: "foxhunt_training_epoch_max_drawdown".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 0.081, }, MetricSample { name: "foxhunt_training_epoch_action_buy_pct".to_owned(), model: "dqn".to_owned(), fold: "0".to_owned(), value: 0.35, }, ]; let sessions = group_into_sessions(&samples, ""); assert_eq!(sessions.len(), 1); let s = &sessions[0]; assert!((s.epoch_sharpe - 2.31).abs() < 0.01); assert!((s.epoch_win_rate - 0.552).abs() < 0.001); assert!((s.epoch_max_drawdown - 0.081).abs() < 0.001); assert!((s.action_buy_pct - 0.35).abs() < 0.01); } #[test] fn test_build_gpu_snapshot() { let samples = vec![ MetricSample { name: "dcgm_gpu_utilization".to_owned(), model: String::new(), fold: String::new(), value: 87.0, }, MetricSample { name: "dcgm_fb_used".to_owned(), model: String::new(), fold: String::new(), value: 38200.0, }, MetricSample { name: "dcgm_fb_free".to_owned(), model: String::new(), fold: String::new(), value: 9800.0, }, MetricSample { name: "dcgm_gpu_temp".to_owned(), model: String::new(), fold: String::new(), value: 62.0, }, MetricSample { name: "dcgm_power_usage".to_owned(), model: String::new(), fold: String::new(), value: 245.0, }, ]; let snap = build_gpu_snapshot(&samples); assert_eq!(snap.utilization_percent, 87.0); assert_eq!(snap.memory_used_mb, 38200.0); assert_eq!(snap.memory_total_mb, 48000.0); assert_eq!(snap.temperature_celsius, 62.0); assert_eq!(snap.power_watts, 245.0); } }