Files
foxhunt/services/api/src/grpc/monitoring_handler.rs
jgrusewski e50ea55064 feat: create services/api/ — unified gRPC gateway with tonic-web
Copied from api_gateway, removed REST handlers (port 8080),
added tonic-web + CORS for grpc-web browser access.
Binary renamed: api-gateway → api

Changes:
- Package name: api-gateway → api
- Deleted src/handlers/ (REST ML endpoints on port 8080)
- Added tonic-web 0.13 + tower-http CORS layer
- Server::builder().accept_http1(true) for grpc-web
- CORS_ORIGINS env var (default http://localhost:5173)
- Metrics server on port 9091 (axum) preserved
- All 95 lib tests pass, 0 clippy warnings
- Added services/api to workspace members

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:32:46 +01:00

1387 lines
52 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String, String>,
value: (f64, String), // (timestamp, value_string)
}
#[derive(Debug, Deserialize)]
struct PromData {
result: Vec<PromResult>,
}
#[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<Vec<PromResult>> {
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<Vec<MetricSample>> {
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<Vec<MetricSample>> {
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<u32> {
let results = self.query("foxhunt_training_active_workers").await?;
let count: f64 = results
.iter()
.filter_map(|r| r.value.1.parse::<f64>().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::<f64>().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::<f64>().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::<f64>().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::<f64>().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::<f64>().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::<f64>().ok()))
.unwrap_or(0.0) as f32;
(cpu_pct, mem_used_mb, mem_total_mb)
}
}
fn parse_samples(results: Vec<PromResult>) -> Vec<MetricSample> {
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<PrometheusClient>,
default_interval: u32,
epoch_histories: Arc<RwLock<HashMap<String, VecDeque<EpochFinancialSnapshot>>>>,
last_epochs: Arc<RwLock<HashMap<String, f32>>>,
/// Backend service URLs for health checking: (name, url)
backends: Arc<Vec<(String, String)>>,
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<MonitoringServiceHandler> {
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<HashMap<String, VecDeque<EpochFinancialSnapshot>>>,
last_epochs: &RwLock<HashMap<String, f32>>,
) -> Result<GetLiveTrainingMetricsResponse, Status> {
// 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<Box<dyn Stream<Item = Result<GetLiveTrainingMetricsResponse, Status>> + Send>>;
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn get_live_training_metrics(
&self,
request: Request<GetLiveTrainingMetricsRequest>,
) -> Result<Response<GetLiveTrainingMetricsResponse>, 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<StreamTrainingMetricsRequest>,
) -> Result<Response<Self::StreamTrainingMetricsStream>, 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() {
"<all>"
} 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<GetEpochHistoryRequest>,
) -> Result<Response<GetEpochHistoryResponse>, 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<Box<dyn Stream<Item = Result<SystemStatusEvent, Status>> + Send>>;
#[instrument(skip(self, request), err)]
async fn get_system_status(
&self,
request: Request<GetSystemStatusRequest>,
) -> Result<Response<GetSystemStatusResponse>, 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<String> = 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<crate::monitoring::StreamSystemStatusRequest>,
) -> Result<Response<Self::StreamSystemStatusStream>, 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() {
"<all>"
} 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<String> = 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<GetHealthCheckRequest>,
) -> Result<Response<GetHealthCheckResponse>, 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<Box<dyn Stream<Item = Result<MetricsEvent, Status>> + Send>>;
#[instrument(skip(self, request), err)]
async fn get_metrics(
&self,
request: Request<GetMetricsRequest>,
) -> Result<Response<GetMetricsResponse>, 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::<f64>() {
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<crate::monitoring::StreamMetricsRequest>,
) -> Result<Response<Self::StreamMetricsStream>, 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<String> = 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::<f64>() {
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<GetLatencyMetricsRequest>,
) -> Result<Response<GetLatencyMetricsResponse>, Status> {
Err(Status::unimplemented(
"GetLatencyMetrics not yet implemented",
))
}
async fn get_throughput_metrics(
&self,
_request: Request<GetThroughputMetricsRequest>,
) -> Result<Response<GetThroughputMetricsResponse>, Status> {
Err(Status::unimplemented(
"GetThroughputMetrics not yet implemented",
))
}
type StreamAlertsStream =
Pin<Box<dyn Stream<Item = Result<AlertEvent, Status>> + Send>>;
#[instrument(skip(self, request), fields(request_id = %uuid::Uuid::new_v4()), err)]
async fn stream_alerts(
&self,
request: Request<crate::monitoring::StreamAlertsRequest>,
) -> Result<Response<Self::StreamAlertsStream>, Status> {
let _req = request.into_inner();
Err(Status::unimplemented(
"StreamAlerts will be wired to Prometheus Alertmanager",
))
}
async fn acknowledge_alert(
&self,
_request: Request<AcknowledgeAlertRequest>,
) -> Result<Response<AcknowledgeAlertResponse>, Status> {
Err(Status::unimplemented(
"AcknowledgeAlert not yet implemented",
))
}
async fn get_active_alerts(
&self,
_request: Request<GetActiveAlertsRequest>,
) -> Result<Response<GetActiveAlertsResponse>, Status> {
Err(Status::unimplemented(
"GetActiveAlerts not yet implemented",
))
}
// ========================================================================
// Cluster pods RPC (real -- queries Kubernetes API via pods_handler)
// ========================================================================
type SubscribeClusterPodsStream =
Pin<Box<dyn Stream<Item = Result<ClusterPodsResponse, Status>> + Send>>;
async fn subscribe_cluster_pods(
&self,
request: Request<SubscribeClusterPodsRequest>,
) -> Result<Response<Self::SubscribeClusterPodsStream>, 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<TrainingSession> {
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);
}
}