1use crate::error::{ForgeError, Result};
9use prometheus::{
10 Counter, CounterVec, Gauge, GaugeVec, HistogramOpts, HistogramVec, Opts, Registry,
11};
12use std::sync::Arc;
13use tracing::info;
14
15pub struct ForgeMetrics {
17 registry: Registry,
18
19 pub jobs_submitted: Counter,
21 pub jobs_running: Gauge,
23 pub jobs_completed: CounterVec,
25
26 pub scale_events: CounterVec,
28 pub current_instances: GaugeVec,
30
31 pub route_requests: CounterVec,
33 pub route_latency: HistogramVec,
35
36 pub cpu_utilization: GaugeVec,
38 pub memory_utilization: GaugeVec,
40
41 pub requests_total: CounterVec,
43 pub request_duration: HistogramVec,
45 pub active_connections: Gauge,
47}
48
49impl ForgeMetrics {
50 pub fn new() -> Result<Self> {
52 let registry = Registry::new();
53
54 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 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 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 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 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 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 pub fn registry(&self) -> &Registry {
138 &self.registry
139 }
140
141 pub fn record_job_submitted(&self) {
143 self.jobs_submitted.inc();
144 self.jobs_running.inc();
145 }
146
147 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 pub fn record_scale_event(&self, job: &str, direction: &str) {
156 self.scale_events.with_label_values(&[job, direction]).inc();
157 }
158
159 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 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 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 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 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
223pub trait MetricsHook: Send + Sync {
225 fn collect(&self, metrics: &ForgeMetrics);
227
228 fn name(&self) -> &str;
230}
231
232pub struct MetricsExporter {
234 metrics: Arc<ForgeMetrics>,
235 hooks: Vec<Box<dyn MetricsHook>>,
236}
237
238impl MetricsExporter {
239 pub fn new(metrics: Arc<ForgeMetrics>) -> Self {
241 Self {
242 metrics,
243 hooks: Vec::new(),
244 }
245 }
246
247 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 pub fn collect(&self) {
255 for hook in &self.hooks {
256 hook.collect(&self.metrics);
257 }
258 }
259
260 pub fn export(&self) -> Result<String> {
262 self.collect();
263 self.metrics.gather_text()
264 }
265
266 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
294pub struct Timer {
296 start: std::time::Instant,
297}
298
299impl Timer {
300 pub fn start() -> Self {
302 Self {
303 start: std::time::Instant::now(),
304 }
305 }
306
307 pub fn elapsed_secs(&self) -> f64 {
309 self.start.elapsed().as_secs_f64()
310 }
311
312 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}