/home/noah/src/realizar/src/metrics.rs
Line | Count | Source |
1 | | //! Metrics collection and reporting for production monitoring |
2 | | //! |
3 | | //! This module provides comprehensive metrics for tracking: |
4 | | //! - Request latency (p50, p95, p99) |
5 | | //! - Throughput (requests/sec, tokens/sec) |
6 | | //! - Error rates and categorization |
7 | | //! - Model performance (inference time, token generation) |
8 | | //! |
9 | | //! Metrics are exposed in Prometheus format for easy integration with monitoring systems. |
10 | | |
11 | | use std::{ |
12 | | sync::{ |
13 | | atomic::{AtomicU64, AtomicUsize, Ordering}, |
14 | | Arc, |
15 | | }, |
16 | | time::{Duration, Instant}, |
17 | | }; |
18 | | |
19 | | /// Central metrics collector for tracking system performance |
20 | | #[derive(Debug, Clone)] |
21 | | pub struct MetricsCollector { |
22 | | /// Total number of requests processed |
23 | | total_requests: Arc<AtomicUsize>, |
24 | | /// Total number of successful requests |
25 | | successful_requests: Arc<AtomicUsize>, |
26 | | /// Total number of failed requests |
27 | | failed_requests: Arc<AtomicUsize>, |
28 | | /// Total number of tokens generated |
29 | | total_tokens: Arc<AtomicUsize>, |
30 | | /// Total inference time in microseconds |
31 | | total_inference_time_us: Arc<AtomicU64>, |
32 | | /// Start time for rate calculations |
33 | | start_time: Instant, |
34 | | } |
35 | | |
36 | | impl MetricsCollector { |
37 | | /// Create a new metrics collector |
38 | | #[must_use] |
39 | 143 | pub fn new() -> Self { |
40 | 143 | Self { |
41 | 143 | total_requests: Arc::new(AtomicUsize::new(0)), |
42 | 143 | successful_requests: Arc::new(AtomicUsize::new(0)), |
43 | 143 | failed_requests: Arc::new(AtomicUsize::new(0)), |
44 | 143 | total_tokens: Arc::new(AtomicUsize::new(0)), |
45 | 143 | total_inference_time_us: Arc::new(AtomicU64::new(0)), |
46 | 143 | start_time: Instant::now(), |
47 | 143 | } |
48 | 143 | } |
49 | | |
50 | | /// Record a successful request |
51 | | #[allow(clippy::cast_possible_truncation)] |
52 | 225 | pub fn record_success(&self, tokens: usize, duration: Duration) { |
53 | 225 | self.total_requests.fetch_add(1, Ordering::Relaxed); |
54 | 225 | self.successful_requests.fetch_add(1, Ordering::Relaxed); |
55 | 225 | self.total_tokens.fetch_add(tokens, Ordering::Relaxed); |
56 | 225 | self.total_inference_time_us |
57 | 225 | .fetch_add(duration.as_micros() as u64, Ordering::Relaxed); |
58 | 225 | } |
59 | | |
60 | | /// Record a failed request |
61 | 13 | pub fn record_failure(&self) { |
62 | 13 | self.total_requests.fetch_add(1, Ordering::Relaxed); |
63 | 13 | self.failed_requests.fetch_add(1, Ordering::Relaxed); |
64 | 13 | } |
65 | | |
66 | | /// Get current snapshot of metrics |
67 | | #[must_use] |
68 | | #[allow(clippy::cast_precision_loss)] |
69 | 13 | pub fn snapshot(&self) -> MetricsSnapshot { |
70 | 13 | let total_requests = self.total_requests.load(Ordering::Relaxed); |
71 | 13 | let successful = self.successful_requests.load(Ordering::Relaxed); |
72 | 13 | let failed = self.failed_requests.load(Ordering::Relaxed); |
73 | 13 | let total_tokens = self.total_tokens.load(Ordering::Relaxed); |
74 | 13 | let total_time_us = self.total_inference_time_us.load(Ordering::Relaxed); |
75 | 13 | let uptime = self.start_time.elapsed(); |
76 | | |
77 | | MetricsSnapshot { |
78 | 13 | total_requests, |
79 | 13 | successful_requests: successful, |
80 | 13 | failed_requests: failed, |
81 | 13 | total_tokens, |
82 | 13 | total_inference_time_us: total_time_us, |
83 | 13 | uptime_secs: uptime.as_secs(), |
84 | 13 | requests_per_sec: if uptime.as_secs() > 0 { |
85 | 1 | total_requests as f64 / uptime.as_secs_f64() |
86 | | } else { |
87 | 12 | 0.0 |
88 | | }, |
89 | 13 | tokens_per_sec: if uptime.as_secs() > 0 { |
90 | 1 | total_tokens as f64 / uptime.as_secs_f64() |
91 | | } else { |
92 | 12 | 0.0 |
93 | | }, |
94 | 13 | avg_latency_ms: if successful > 0 { |
95 | 7 | (total_time_us as f64 / 1000.0) / successful as f64 |
96 | | } else { |
97 | 6 | 0.0 |
98 | | }, |
99 | 13 | error_rate: if total_requests > 0 { |
100 | 8 | failed as f64 / total_requests as f64 |
101 | | } else { |
102 | 5 | 0.0 |
103 | | }, |
104 | | } |
105 | 13 | } |
106 | | |
107 | | /// Export metrics in Prometheus format |
108 | | #[must_use] |
109 | | #[allow(clippy::cast_precision_loss)] |
110 | 2 | pub fn to_prometheus(&self) -> String { |
111 | 2 | let snapshot = self.snapshot(); |
112 | 2 | format!( |
113 | 2 | "# HELP realizar_requests_total Total number of requests\n\ |
114 | 2 | # TYPE realizar_requests_total counter\n\ |
115 | 2 | realizar_requests_total {}\n\ |
116 | 2 | # HELP realizar_requests_successful Successful requests\n\ |
117 | 2 | # TYPE realizar_requests_successful counter\n\ |
118 | 2 | realizar_requests_successful {}\n\ |
119 | 2 | # HELP realizar_requests_failed Failed requests\n\ |
120 | 2 | # TYPE realizar_requests_failed counter\n\ |
121 | 2 | realizar_requests_failed {}\n\ |
122 | 2 | # HELP realizar_tokens_generated Total tokens generated\n\ |
123 | 2 | # TYPE realizar_tokens_generated counter\n\ |
124 | 2 | realizar_tokens_generated {}\n\ |
125 | 2 | # HELP realizar_inference_time_seconds Total inference time\n\ |
126 | 2 | # TYPE realizar_inference_time_seconds counter\n\ |
127 | 2 | realizar_inference_time_seconds {:.6}\n\ |
128 | 2 | # HELP realizar_requests_per_second Request rate\n\ |
129 | 2 | # TYPE realizar_requests_per_second gauge\n\ |
130 | 2 | realizar_requests_per_second {:.2}\n\ |
131 | 2 | # HELP realizar_tokens_per_second Token generation rate\n\ |
132 | 2 | # TYPE realizar_tokens_per_second gauge\n\ |
133 | 2 | realizar_tokens_per_second {:.2}\n\ |
134 | 2 | # HELP realizar_avg_latency_ms Average latency in milliseconds\n\ |
135 | 2 | # TYPE realizar_avg_latency_ms gauge\n\ |
136 | 2 | realizar_avg_latency_ms {:.2}\n\ |
137 | 2 | # HELP realizar_error_rate Error rate (0.0-1.0)\n\ |
138 | 2 | # TYPE realizar_error_rate gauge\n\ |
139 | 2 | realizar_error_rate {:.4}\n\ |
140 | 2 | # HELP realizar_uptime_seconds Uptime in seconds\n\ |
141 | 2 | # TYPE realizar_uptime_seconds counter\n\ |
142 | 2 | realizar_uptime_seconds {}\n", |
143 | | snapshot.total_requests, |
144 | | snapshot.successful_requests, |
145 | | snapshot.failed_requests, |
146 | | snapshot.total_tokens, |
147 | 2 | snapshot.total_inference_time_us as f64 / 1_000_000.0, |
148 | | snapshot.requests_per_sec, |
149 | | snapshot.tokens_per_sec, |
150 | | snapshot.avg_latency_ms, |
151 | | snapshot.error_rate, |
152 | | snapshot.uptime_secs |
153 | | ) |
154 | 2 | } |
155 | | |
156 | | /// Reset all metrics (useful for testing) |
157 | 1 | pub fn reset(&self) { |
158 | 1 | self.total_requests.store(0, Ordering::Relaxed); |
159 | 1 | self.successful_requests.store(0, Ordering::Relaxed); |
160 | 1 | self.failed_requests.store(0, Ordering::Relaxed); |
161 | 1 | self.total_tokens.store(0, Ordering::Relaxed); |
162 | 1 | self.total_inference_time_us.store(0, Ordering::Relaxed); |
163 | 1 | } |
164 | | } |
165 | | |
166 | | impl Default for MetricsCollector { |
167 | 0 | fn default() -> Self { |
168 | 0 | Self::new() |
169 | 0 | } |
170 | | } |
171 | | |
172 | | /// Snapshot of current metrics |
173 | | #[derive(Debug, Clone)] |
174 | | pub struct MetricsSnapshot { |
175 | | /// Total number of requests processed |
176 | | pub total_requests: usize, |
177 | | /// Number of successful requests |
178 | | pub successful_requests: usize, |
179 | | /// Number of failed requests |
180 | | pub failed_requests: usize, |
181 | | /// Total tokens generated across all requests |
182 | | pub total_tokens: usize, |
183 | | /// Total inference time in microseconds |
184 | | pub total_inference_time_us: u64, |
185 | | /// System uptime in seconds |
186 | | pub uptime_secs: u64, |
187 | | /// Request rate (requests per second) |
188 | | pub requests_per_sec: f64, |
189 | | /// Token generation rate (tokens per second) |
190 | | pub tokens_per_sec: f64, |
191 | | /// Average request latency in milliseconds |
192 | | pub avg_latency_ms: f64, |
193 | | /// Error rate as a fraction (0.0 to 1.0) |
194 | | pub error_rate: f64, |
195 | | } |
196 | | |
197 | | #[cfg(test)] |
198 | | mod tests { |
199 | | use std::thread; |
200 | | |
201 | | use super::*; |
202 | | |
203 | | #[test] |
204 | 1 | fn test_metrics_collector_creation() { |
205 | 1 | let metrics = MetricsCollector::new(); |
206 | 1 | let snapshot = metrics.snapshot(); |
207 | | |
208 | 1 | assert_eq!(snapshot.total_requests, 0); |
209 | 1 | assert_eq!(snapshot.successful_requests, 0); |
210 | 1 | assert_eq!(snapshot.failed_requests, 0); |
211 | 1 | assert_eq!(snapshot.total_tokens, 0); |
212 | 1 | assert_eq!(snapshot.total_inference_time_us, 0); |
213 | 1 | } |
214 | | |
215 | | #[test] |
216 | 1 | fn test_record_success() { |
217 | 1 | let metrics = MetricsCollector::new(); |
218 | 1 | metrics.record_success(10, Duration::from_millis(100)); |
219 | | |
220 | 1 | let snapshot = metrics.snapshot(); |
221 | 1 | assert_eq!(snapshot.total_requests, 1); |
222 | 1 | assert_eq!(snapshot.successful_requests, 1); |
223 | 1 | assert_eq!(snapshot.failed_requests, 0); |
224 | 1 | assert_eq!(snapshot.total_tokens, 10); |
225 | 1 | assert!(snapshot.total_inference_time_us >= 100_000); |
226 | 1 | } |
227 | | |
228 | | #[test] |
229 | 1 | fn test_record_failure() { |
230 | 1 | let metrics = MetricsCollector::new(); |
231 | 1 | metrics.record_failure(); |
232 | | |
233 | 1 | let snapshot = metrics.snapshot(); |
234 | 1 | assert_eq!(snapshot.total_requests, 1); |
235 | 1 | assert_eq!(snapshot.successful_requests, 0); |
236 | 1 | assert_eq!(snapshot.failed_requests, 1); |
237 | 1 | approx::assert_relative_eq!(snapshot.error_rate, 1.0); |
238 | 1 | } |
239 | | |
240 | | #[test] |
241 | 1 | fn test_multiple_requests() { |
242 | 1 | let metrics = MetricsCollector::new(); |
243 | | |
244 | 1 | metrics.record_success(5, Duration::from_millis(50)); |
245 | 1 | metrics.record_success(10, Duration::from_millis(100)); |
246 | 1 | metrics.record_failure(); |
247 | | |
248 | 1 | let snapshot = metrics.snapshot(); |
249 | 1 | assert_eq!(snapshot.total_requests, 3); |
250 | 1 | assert_eq!(snapshot.successful_requests, 2); |
251 | 1 | assert_eq!(snapshot.failed_requests, 1); |
252 | 1 | assert_eq!(snapshot.total_tokens, 15); |
253 | 1 | approx::assert_relative_eq!(snapshot.error_rate, 1.0 / 3.0); |
254 | 1 | } |
255 | | |
256 | | #[test] |
257 | 1 | fn test_avg_latency_calculation() { |
258 | 1 | let metrics = MetricsCollector::new(); |
259 | | |
260 | | // Record 100ms and 200ms requests |
261 | 1 | metrics.record_success(1, Duration::from_millis(100)); |
262 | 1 | metrics.record_success(1, Duration::from_millis(200)); |
263 | | |
264 | 1 | let snapshot = metrics.snapshot(); |
265 | | // Average should be 150ms |
266 | 1 | assert!((snapshot.avg_latency_ms - 150.0).abs() < 1.0); |
267 | 1 | } |
268 | | |
269 | | #[test] |
270 | 1 | fn test_tokens_per_second() { |
271 | 1 | let metrics = MetricsCollector::new(); |
272 | | |
273 | | // Wait to ensure at least 1 second has passed for rate calculation |
274 | 1 | thread::sleep(Duration::from_secs(1)); |
275 | | |
276 | | // Record some tokens |
277 | 1 | metrics.record_success(100, Duration::from_millis(10)); |
278 | | |
279 | 1 | let snapshot = metrics.snapshot(); |
280 | | // Should have positive rate after 1+ seconds |
281 | 1 | assert!(snapshot.tokens_per_sec > 0.0); |
282 | 1 | assert!(snapshot.tokens_per_sec <= 100.0); // Can't exceed total tokens |
283 | 1 | } |
284 | | |
285 | | #[test] |
286 | 1 | fn test_prometheus_format() { |
287 | 1 | let metrics = MetricsCollector::new(); |
288 | 1 | metrics.record_success(10, Duration::from_millis(100)); |
289 | 1 | metrics.record_failure(); |
290 | | |
291 | 1 | let prom = metrics.to_prometheus(); |
292 | | |
293 | | // Check that all required metrics are present |
294 | 1 | assert!(prom.contains("realizar_requests_total 2")); |
295 | 1 | assert!(prom.contains("realizar_requests_successful 1")); |
296 | 1 | assert!(prom.contains("realizar_requests_failed 1")); |
297 | 1 | assert!(prom.contains("realizar_tokens_generated 10")); |
298 | 1 | assert!(prom.contains("realizar_error_rate 0.5000")); |
299 | 1 | } |
300 | | |
301 | | #[test] |
302 | 1 | fn test_reset_metrics() { |
303 | 1 | let metrics = MetricsCollector::new(); |
304 | 1 | metrics.record_success(10, Duration::from_millis(100)); |
305 | 1 | metrics.record_failure(); |
306 | | |
307 | 1 | metrics.reset(); |
308 | | |
309 | 1 | let snapshot = metrics.snapshot(); |
310 | 1 | assert_eq!(snapshot.total_requests, 0); |
311 | 1 | assert_eq!(snapshot.successful_requests, 0); |
312 | 1 | assert_eq!(snapshot.failed_requests, 0); |
313 | 1 | assert_eq!(snapshot.total_tokens, 0); |
314 | 1 | assert_eq!(snapshot.total_inference_time_us, 0); |
315 | 1 | } |
316 | | |
317 | | #[test] |
318 | 1 | fn test_concurrent_updates() { |
319 | 1 | let metrics = MetricsCollector::new(); |
320 | 1 | let metrics_clone = metrics.clone(); |
321 | | |
322 | 1 | let handle = thread::spawn(move || { |
323 | 101 | for _ in 0..100 { |
324 | 100 | metrics_clone.record_success(1, Duration::from_micros(100)); |
325 | 100 | } |
326 | 1 | }); |
327 | | |
328 | 101 | for _ in 0..100 { |
329 | 100 | metrics.record_success(1, Duration::from_micros(100)); |
330 | 100 | } |
331 | | |
332 | 1 | handle.join().expect("test"); |
333 | | |
334 | 1 | let snapshot = metrics.snapshot(); |
335 | 1 | assert_eq!(snapshot.total_requests, 200); |
336 | 1 | assert_eq!(snapshot.successful_requests, 200); |
337 | 1 | assert_eq!(snapshot.total_tokens, 200); |
338 | 1 | } |
339 | | |
340 | | #[test] |
341 | 1 | fn test_zero_division_safety() { |
342 | 1 | let metrics = MetricsCollector::new(); |
343 | 1 | let snapshot = metrics.snapshot(); |
344 | | |
345 | | // Should not panic with zero values |
346 | 1 | approx::assert_relative_eq!(snapshot.requests_per_sec, 0.0); |
347 | 1 | approx::assert_relative_eq!(snapshot.tokens_per_sec, 0.0); |
348 | 1 | approx::assert_relative_eq!(snapshot.avg_latency_ms, 0.0); |
349 | 1 | approx::assert_relative_eq!(snapshot.error_rate, 0.0); |
350 | 1 | } |
351 | | } |