Skip to main content

forge_orchestration/
metrics.rs

1//! Metrics and monitoring for Forge
2//!
3//! ## Table of Contents
4//! - **MetricsExporter**: Prometheus metrics exporter (requires `metrics` feature)
5//! - **MetricsHook**: Custom metrics callback trait
6//! - **MetricsRegistry**: Central metrics registry
7
8use crate::error::{ForgeError, Result};
9use prometheus::{
10    Counter, CounterVec, Gauge, GaugeVec, HistogramOpts, HistogramVec, Opts, Registry,
11};
12use std::sync::Arc;
13use tracing::info;
14
15/// Core metrics for Forge
16pub struct ForgeMetrics {
17    registry: Registry,
18
19    /// Total number of jobs submitted.
20    pub jobs_submitted: Counter,
21    /// Currently running jobs.
22    pub jobs_running: Gauge,
23    /// Completed jobs, labeled by outcome (success/failure).
24    pub jobs_completed: CounterVec,
25
26    /// Scaling events, labeled by job and direction.
27    pub scale_events: CounterVec,
28    /// Current instance count, labeled by job/group.
29    pub current_instances: GaugeVec,
30
31    /// MoE routing requests, labeled by router and expert.
32    pub route_requests: CounterVec,
33    /// MoE routing latency distribution.
34    pub route_latency: HistogramVec,
35
36    /// CPU utilization, labeled by node/job.
37    pub cpu_utilization: GaugeVec,
38    /// Memory utilization, labeled by node/job.
39    pub memory_utilization: GaugeVec,
40
41    /// Total HTTP requests served.
42    pub requests_total: CounterVec,
43    /// HTTP request duration distribution.
44    pub request_duration: HistogramVec,
45    /// Currently active network connections.
46    pub active_connections: Gauge,
47}
48
49impl ForgeMetrics {
50    /// Create a new metrics instance
51    pub fn new() -> Result<Self> {
52        let registry = Registry::new();
53
54        // Job metrics
55        let jobs_submitted = Counter::new("forge_jobs_submitted_total", "Total jobs submitted")?;
56        let jobs_running = Gauge::new("forge_jobs_running", "Currently running jobs")?;
57        let jobs_completed = CounterVec::new(
58            Opts::new("forge_jobs_completed_total", "Total jobs completed"),
59            &["status"],
60        )?;
61
62        // Scaling metrics
63        let scale_events = CounterVec::new(
64            Opts::new("forge_scale_events_total", "Total scaling events"),
65            &["job", "direction"],
66        )?;
67        let current_instances = GaugeVec::new(
68            Opts::new("forge_instances", "Current instance count"),
69            &["job", "group"],
70        )?;
71
72        // Routing metrics
73        let route_requests = CounterVec::new(
74            Opts::new("forge_route_requests_total", "Total routing requests"),
75            &["router", "expert"],
76        )?;
77        let route_latency = HistogramVec::new(
78            HistogramOpts::new("forge_route_latency_seconds", "Routing latency")
79                .buckets(vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0]),
80            &["router"],
81        )?;
82
83        // Resource metrics
84        let cpu_utilization = GaugeVec::new(
85            Opts::new("forge_cpu_utilization", "CPU utilization (0-1)"),
86            &["job", "group"],
87        )?;
88        let memory_utilization = GaugeVec::new(
89            Opts::new("forge_memory_utilization", "Memory utilization (0-1)"),
90            &["job", "group"],
91        )?;
92
93        // Network metrics
94        let requests_total = CounterVec::new(
95            Opts::new("forge_http_requests_total", "Total HTTP requests"),
96            &["method", "path", "status"],
97        )?;
98        let request_duration = HistogramVec::new(
99            HistogramOpts::new("forge_http_request_duration_seconds", "HTTP request duration")
100                .buckets(vec![0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0]),
101            &["method", "path"],
102        )?;
103        let active_connections = Gauge::new("forge_active_connections", "Active connections")?;
104
105        // Register all metrics
106        registry.register(Box::new(jobs_submitted.clone()))?;
107        registry.register(Box::new(jobs_running.clone()))?;
108        registry.register(Box::new(jobs_completed.clone()))?;
109        registry.register(Box::new(scale_events.clone()))?;
110        registry.register(Box::new(current_instances.clone()))?;
111        registry.register(Box::new(route_requests.clone()))?;
112        registry.register(Box::new(route_latency.clone()))?;
113        registry.register(Box::new(cpu_utilization.clone()))?;
114        registry.register(Box::new(memory_utilization.clone()))?;
115        registry.register(Box::new(requests_total.clone()))?;
116        registry.register(Box::new(request_duration.clone()))?;
117        registry.register(Box::new(active_connections.clone()))?;
118
119        Ok(Self {
120            registry,
121            jobs_submitted,
122            jobs_running,
123            jobs_completed,
124            scale_events,
125            current_instances,
126            route_requests,
127            route_latency,
128            cpu_utilization,
129            memory_utilization,
130            requests_total,
131            request_duration,
132            active_connections,
133        })
134    }
135
136    /// Get the Prometheus registry
137    pub fn registry(&self) -> &Registry {
138        &self.registry
139    }
140
141    /// Record a job submission
142    pub fn record_job_submitted(&self) {
143        self.jobs_submitted.inc();
144        self.jobs_running.inc();
145    }
146
147    /// Record a job completion
148    pub fn record_job_completed(&self, success: bool) {
149        self.jobs_running.dec();
150        let status = if success { "success" } else { "failed" };
151        self.jobs_completed.with_label_values(&[status]).inc();
152    }
153
154    /// Record a scaling event
155    pub fn record_scale_event(&self, job: &str, direction: &str) {
156        self.scale_events.with_label_values(&[job, direction]).inc();
157    }
158
159    /// Update instance count
160    pub fn set_instances(&self, job: &str, group: &str, count: f64) {
161        self.current_instances
162            .with_label_values(&[job, group])
163            .set(count);
164    }
165
166    /// Record a routing request
167    pub fn record_route(&self, router: &str, expert: usize, latency_secs: f64) {
168        let expert_str = expert.to_string();
169        self.route_requests
170            .with_label_values(&[router, &expert_str])
171            .inc();
172        self.route_latency
173            .with_label_values(&[router])
174            .observe(latency_secs);
175    }
176
177    /// Update resource utilization
178    pub fn set_utilization(&self, job: &str, group: &str, cpu: f64, memory: f64) {
179        self.cpu_utilization
180            .with_label_values(&[job, group])
181            .set(cpu);
182        self.memory_utilization
183            .with_label_values(&[job, group])
184            .set(memory);
185    }
186
187    /// Record an HTTP request
188    pub fn record_http_request(&self, method: &str, path: &str, status: u16, duration_secs: f64) {
189        let status_str = status.to_string();
190        self.requests_total
191            .with_label_values(&[method, path, &status_str])
192            .inc();
193        self.request_duration
194            .with_label_values(&[method, path])
195            .observe(duration_secs);
196    }
197
198    /// Gather all metrics as text
199    pub fn gather_text(&self) -> Result<String> {
200        use prometheus::Encoder;
201        let encoder = prometheus::TextEncoder::new();
202        let metric_families = self.registry.gather();
203        let mut buffer = Vec::new();
204        encoder
205            .encode(&metric_families, &mut buffer)
206            .map_err(|e| ForgeError::metrics(format!("Encode error: {}", e)))?;
207        String::from_utf8(buffer).map_err(|e| ForgeError::metrics(format!("UTF8 error: {}", e)))
208    }
209}
210
211impl Default for ForgeMetrics {
212    fn default() -> Self {
213        match Self::new() {
214            Ok(m) => m,
215            Err(e) => {
216                tracing::error!(error = %e, "Failed to create metrics, using stub");
217                panic!("ForgeMetrics::default() failed: {}", e);
218            }
219        }
220    }
221}
222
223/// Trait for custom metrics hooks
224pub trait MetricsHook: Send + Sync {
225    /// Called periodically to collect custom metrics
226    fn collect(&self, metrics: &ForgeMetrics);
227
228    /// Hook name for identification
229    fn name(&self) -> &str;
230}
231
232/// Metrics exporter for Prometheus scraping
233pub struct MetricsExporter {
234    metrics: Arc<ForgeMetrics>,
235    hooks: Vec<Box<dyn MetricsHook>>,
236}
237
238impl MetricsExporter {
239    /// Create a new exporter
240    pub fn new(metrics: Arc<ForgeMetrics>) -> Self {
241        Self {
242            metrics,
243            hooks: Vec::new(),
244        }
245    }
246
247    /// Register a custom metrics hook
248    pub fn register_hook(&mut self, hook: Box<dyn MetricsHook>) {
249        info!(hook = %hook.name(), "Registered metrics hook");
250        self.hooks.push(hook);
251    }
252
253    /// Collect all metrics
254    pub fn collect(&self) {
255        for hook in &self.hooks {
256            hook.collect(&self.metrics);
257        }
258    }
259
260    /// Get metrics as Prometheus text format
261    pub fn export(&self) -> Result<String> {
262        self.collect();
263        self.metrics.gather_text()
264    }
265
266    /// Create an axum handler for /metrics endpoint
267    pub fn handler(
268        metrics: Arc<ForgeMetrics>,
269    ) -> impl Fn() -> std::pin::Pin<
270        Box<dyn std::future::Future<Output = axum::response::Response> + Send>,
271    > + Clone {
272        move || {
273            let metrics = Arc::clone(&metrics);
274            Box::pin(async move {
275                match metrics.gather_text() {
276                    Ok(text) => axum::response::Response::builder()
277                        .header("Content-Type", "text/plain; charset=utf-8")
278                        .body(axum::body::Body::from(text))
279                        .unwrap_or_else(|_| {
280                            axum::response::Response::new(axum::body::Body::from("Internal error"))
281                        }),
282                    Err(e) => axum::response::Response::builder()
283                        .status(500)
284                        .body(axum::body::Body::from(format!("Error: {}", e)))
285                        .unwrap_or_else(|_| {
286                            axum::response::Response::new(axum::body::Body::from("Internal error"))
287                        }),
288                }
289            })
290        }
291    }
292}
293
294/// Timer for measuring operation duration
295pub struct Timer {
296    start: std::time::Instant,
297}
298
299impl Timer {
300    /// Start a new timer
301    pub fn start() -> Self {
302        Self {
303            start: std::time::Instant::now(),
304        }
305    }
306
307    /// Get elapsed time in seconds
308    pub fn elapsed_secs(&self) -> f64 {
309        self.start.elapsed().as_secs_f64()
310    }
311
312    /// Stop and return elapsed seconds
313    pub fn stop(self) -> f64 {
314        self.elapsed_secs()
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_metrics_creation() {
324        let metrics = ForgeMetrics::new().unwrap();
325        assert!(metrics.gather_text().is_ok());
326    }
327
328    #[test]
329    fn test_job_metrics() {
330        let metrics = ForgeMetrics::new().unwrap();
331
332        metrics.record_job_submitted();
333        metrics.record_job_submitted();
334        metrics.record_job_completed(true);
335
336        let text = metrics.gather_text().unwrap();
337        assert!(text.contains("forge_jobs_submitted_total 2"));
338        assert!(text.contains("forge_jobs_running 1"));
339    }
340
341    #[test]
342    fn test_scale_metrics() {
343        let metrics = ForgeMetrics::new().unwrap();
344
345        metrics.record_scale_event("my-job", "up");
346        metrics.record_scale_event("my-job", "up");
347        metrics.record_scale_event("my-job", "down");
348
349        let text = metrics.gather_text().unwrap();
350        assert!(text.contains("forge_scale_events_total"));
351    }
352
353    #[test]
354    fn test_timer() {
355        let timer = Timer::start();
356        std::thread::sleep(std::time::Duration::from_millis(10));
357        let elapsed = timer.stop();
358        assert!(elapsed >= 0.01);
359    }
360}