1use crate::OrchestratorError;
24use prometheus::{
25 core::Collector, Counter, CounterVec, Encoder, GaugeVec, HistogramOpts, HistogramVec,
26 IntGaugeVec, Opts, Registry, TextEncoder,
27};
28use std::collections::HashMap;
29use std::sync::OnceLock;
30use std::time::Duration;
31
32pub struct Metrics {
37 pub registry: Registry,
39 pub requests_total: CounterVec,
41 pub requests_shed: CounterVec,
43 pub requests_dropped: CounterVec,
45 pub errors_total: CounterVec,
47 pub stage_duration: HistogramVec,
49 pub queue_depth: IntGaugeVec,
51 pub inference_cost_usd: prometheus::Counter,
53 pub ttft_seconds: HistogramVec,
55 pub dedup_hits_total: Counter,
57 pub dedup_waiters_unblocked_total: Counter,
59 pub inference_timeouts_total: Counter,
61 pub requests_expired_total: Counter,
63 pub dlq_lock_poisoned_total: Counter,
65 pub rag_requests_expired_total: Counter,
67 pub circuit_breaker_state_transitions_total: CounterVec,
69 pub circuit_breaker_requests_rejected_total: Counter,
71 pub config_reload_errors_total: Counter,
73 pub worker_errors_total: CounterVec,
75 pub cache_hits_total: Counter,
77 pub cache_misses_total: Counter,
79 pub rate_limiter_tokens_remaining: GaugeVec,
81 pub config_reload_duration_seconds: HistogramVec,
83 pub session_affinity_hits_total: Counter,
85 pub session_affinity_misses_total: Counter,
87 pub dedup_hash_collisions_total: Counter,
89}
90
91static METRICS: OnceLock<Metrics> = OnceLock::new();
92
93pub fn init_metrics() -> Result<(), OrchestratorError> {
109 if METRICS.get().is_some() {
110 return Ok(());
111 }
112
113 let registry = Registry::new();
114
115 let requests_total = CounterVec::new(
116 Opts::new("orchestrator_requests_total", "Total requests processed"),
117 &["stage"],
118 )
119 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
120 registry
121 .register(Box::new(requests_total.clone()))
122 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
123
124 let requests_shed = CounterVec::new(
125 Opts::new(
126 "orchestrator_requests_shed_total",
127 "Requests dropped due to backpressure",
128 ),
129 &["stage"],
130 )
131 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
132 registry
133 .register(Box::new(requests_shed.clone()))
134 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
135
136 let requests_dropped = CounterVec::new(
137 Opts::new(
138 "requests_dropped_total",
139 "Requests dropped due to full queue per stage",
140 ),
141 &["stage"],
142 )
143 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
144 registry
145 .register(Box::new(requests_dropped.clone()))
146 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
147
148 let errors_total = CounterVec::new(
149 Opts::new("orchestrator_errors_total", "Errors by stage and type"),
150 &["stage", "err_type"],
151 )
152 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
153 registry
154 .register(Box::new(errors_total.clone()))
155 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
156
157 let stage_duration = HistogramVec::new(
158 HistogramOpts::new(
159 "orchestrator_stage_duration_seconds",
160 "Processing duration per stage",
161 ),
162 &["stage"],
163 )
164 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
165 registry
166 .register(Box::new(stage_duration.clone()))
167 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
168
169 let queue_depth = IntGaugeVec::new(
170 Opts::new("orchestrator_queue_depth", "Current queue depth per stage"),
171 &["stage"],
172 )
173 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
174 registry
175 .register(Box::new(queue_depth.clone()))
176 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
177
178 let inference_cost_usd = Counter::with_opts(Opts::new(
179 "orchestrator_inference_cost_usd_total",
180 "Cumulative USD cost of all inference calls",
181 ))
182 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
183 registry
184 .register(Box::new(inference_cost_usd.clone()))
185 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
186
187 let ttft_seconds = HistogramVec::new(
188 HistogramOpts::new(
189 "inference_time_to_first_token_seconds",
190 "Time from request start to first streaming token received",
191 )
192 .buckets(vec![0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0]),
193 &["worker", "model"],
194 )
195 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
196 registry
197 .register(Box::new(ttft_seconds.clone()))
198 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
199
200 let dedup_hits_total = Counter::with_opts(Opts::new(
201 "orchestrator_dedup_hits_total",
202 "Requests served from dedup cache (in-progress or completed) without new inference",
203 ))
204 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
205 registry
206 .register(Box::new(dedup_hits_total.clone()))
207 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
208
209 let dedup_waiters_unblocked_total = Counter::with_opts(Opts::new(
210 "orchestrator_dedup_waiters_unblocked_total",
211 "Requests that waited on an in-progress dedup entry and received its broadcast result",
212 ))
213 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
214 registry
215 .register(Box::new(dedup_waiters_unblocked_total.clone()))
216 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
217
218 let inference_timeouts_total = Counter::with_opts(Opts::new(
219 "orchestrator_inference_timeouts_total",
220 "Inference calls that exceeded the configured timeout and were cancelled",
221 ))
222 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
223 registry
224 .register(Box::new(inference_timeouts_total.clone()))
225 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
226
227 let requests_expired_total = Counter::with_opts(Opts::new(
228 "orchestrator_requests_expired_total",
229 "Requests dropped at the inference stage because their deadline had already passed",
230 ))
231 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
232 registry
233 .register(Box::new(requests_expired_total.clone()))
234 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
235
236 let dlq_lock_poisoned_total = Counter::with_opts(Opts::new(
237 "orchestrator_dlq_lock_poisoned_total",
238 "Number of times a DeadLetterQueue mutex was recovered from a poisoned state",
239 ))
240 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
241 registry
242 .register(Box::new(dlq_lock_poisoned_total.clone()))
243 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
244
245 let rag_requests_expired_total = Counter::with_opts(Opts::new(
246 "orchestrator_rag_requests_expired_total",
247 "Requests dropped at the RAG stage because their deadline had already passed",
248 ))
249 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
250 registry
251 .register(Box::new(rag_requests_expired_total.clone()))
252 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
253
254 let circuit_breaker_state_transitions_total = CounterVec::new(
255 Opts::new(
256 "orchestrator_circuit_breaker_state_transitions_total",
257 "Circuit breaker state transitions, labelled by target state",
258 ),
259 &["state"],
260 )
261 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
262 registry
263 .register(Box::new(circuit_breaker_state_transitions_total.clone()))
264 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
265
266 let circuit_breaker_requests_rejected_total = Counter::with_opts(Opts::new(
267 "orchestrator_circuit_breaker_requests_rejected_total",
268 "Requests rejected by the circuit breaker because the circuit is open",
269 ))
270 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
271 registry
272 .register(Box::new(circuit_breaker_requests_rejected_total.clone()))
273 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
274
275 let config_reload_errors_total = Counter::with_opts(Opts::new(
276 "orchestrator_config_reload_errors_total",
277 "Config hot-reload attempts that failed validation or parse",
278 ))
279 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
280 registry
281 .register(Box::new(config_reload_errors_total.clone()))
282 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
283
284 let worker_errors_total = CounterVec::new(
285 Opts::new(
286 "orchestrator_worker_errors_total",
287 "Errors returned by a named worker, labelled by worker name and error kind",
288 ),
289 &["worker", "error_kind"],
290 )
291 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
292 registry
293 .register(Box::new(worker_errors_total.clone()))
294 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
295
296 let cache_hits_total = Counter::with_opts(Opts::new(
297 "orchestrator_cache_hits_total",
298 "Response cache hits (in-memory or Redis)",
299 ))
300 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
301 registry
302 .register(Box::new(cache_hits_total.clone()))
303 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
304
305 let cache_misses_total = Counter::with_opts(Opts::new(
306 "orchestrator_cache_misses_total",
307 "Response cache misses (in-memory or Redis)",
308 ))
309 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
310 registry
311 .register(Box::new(cache_misses_total.clone()))
312 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
313
314 let rate_limiter_tokens_remaining = GaugeVec::new(
315 Opts::new(
316 "orchestrator_rate_limiter_tokens_remaining",
317 "Current token-bucket tokens remaining for a session",
318 ),
319 &["session"],
320 )
321 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
322 registry
323 .register(Box::new(rate_limiter_tokens_remaining.clone()))
324 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
325
326 let config_reload_duration_seconds = HistogramVec::new(
327 HistogramOpts::new(
328 "orchestrator_config_reload_duration_seconds",
329 "Duration of config hot-reload operations (parse + validate)",
330 )
331 .buckets(vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0]),
332 &["result"],
333 )
334 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
335 registry
336 .register(Box::new(config_reload_duration_seconds.clone()))
337 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
338
339 let session_affinity_hits_total = Counter::with_opts(Opts::new(
340 "orchestrator_session_affinity_hits_total",
341 "Requests routed to their preferred affinity shard",
342 ))
343 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
344 registry
345 .register(Box::new(session_affinity_hits_total.clone()))
346 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
347
348 let session_affinity_misses_total = Counter::with_opts(Opts::new(
349 "orchestrator_session_affinity_misses_total",
350 "Requests rerouted because preferred affinity shard was unavailable",
351 ))
352 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
353 registry
354 .register(Box::new(session_affinity_misses_total.clone()))
355 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
356
357 let dedup_hash_collisions_total = Counter::with_opts(Opts::new(
358 "orchestrator_dedup_hash_collisions_total",
359 "Number of times two different prompts produced the same dedup hash key",
360 ))
361 .map_err(|e| OrchestratorError::Other(format!("metrics init failed: {e}")))?;
362 registry
363 .register(Box::new(dedup_hash_collisions_total.clone()))
364 .map_err(|e| OrchestratorError::Other(format!("metrics registration failed: {e}")))?;
365
366 let _ = METRICS.set(Metrics {
369 registry,
370 requests_total,
371 requests_shed,
372 requests_dropped,
373 errors_total,
374 stage_duration,
375 queue_depth,
376 inference_cost_usd,
377 ttft_seconds,
378 dedup_hits_total,
379 dedup_waiters_unblocked_total,
380 inference_timeouts_total,
381 requests_expired_total,
382 dlq_lock_poisoned_total,
383 rag_requests_expired_total,
384 circuit_breaker_state_transitions_total,
385 circuit_breaker_requests_rejected_total,
386 config_reload_errors_total,
387 worker_errors_total,
388 cache_hits_total,
389 cache_misses_total,
390 rate_limiter_tokens_remaining,
391 config_reload_duration_seconds,
392 session_affinity_hits_total,
393 session_affinity_misses_total,
394 dedup_hash_collisions_total,
395 });
396
397 Ok(())
398}
399
400fn metrics() -> Option<&'static Metrics> {
403 METRICS.get()
404}
405
406pub fn record_stage_latency(stage: &str, d: Duration) {
416 if let Some(m) = metrics() {
417 if let Ok(h) = m.stage_duration.get_metric_with_label_values(&[stage]) {
418 h.observe(d.as_secs_f64());
419 }
420 }
421}
422
423pub fn inc_request(stage: &str) {
431 if let Some(m) = metrics() {
432 if let Ok(c) = m.requests_total.get_metric_with_label_values(&[stage]) {
433 c.inc();
434 }
435 }
436}
437
438pub fn inc_shed(stage: &str) {
446 if let Some(m) = metrics() {
447 if let Ok(c) = m.requests_shed.get_metric_with_label_values(&[stage]) {
448 c.inc();
449 }
450 }
451}
452
453pub fn inc_dropped(stage: &str) {
465 if let Some(m) = metrics() {
466 if let Ok(c) = m.requests_dropped.get_metric_with_label_values(&[stage]) {
467 c.inc();
468 }
469 }
470}
471
472pub fn record_ttft(worker: &str, model: &str, d: Duration) {
484 if let Some(m) = metrics() {
485 if let Ok(h) = m
486 .ttft_seconds
487 .get_metric_with_label_values(&[worker, model])
488 {
489 h.observe(d.as_secs_f64());
490 }
491 }
492}
493
494pub fn inc_error(stage: &str, err_type: &str) {
502 if let Some(m) = metrics() {
503 if let Ok(c) = m
504 .errors_total
505 .get_metric_with_label_values(&[stage, err_type])
506 {
507 c.inc();
508 }
509 }
510}
511
512pub fn set_queue_depth(stage: &str, depth: i64) {
520 if let Some(m) = metrics() {
521 if let Ok(g) = m.queue_depth.get_metric_with_label_values(&[stage]) {
522 g.set(depth);
523 }
524 }
525}
526
527pub fn record_inference_cost(usd: f64) {
535 if let Some(m) = metrics() {
536 m.inference_cost_usd.inc_by(usd);
537 }
538}
539
540pub fn total_inference_cost_usd() -> f64 {
548 metrics().map_or(0.0, |m| m.inference_cost_usd.get())
549}
550
551pub fn inc_dedup_hit() {
557 if let Some(m) = metrics() {
558 m.dedup_hits_total.inc();
559 }
560}
561
562pub fn inc_dedup_waiter_unblocked() {
568 if let Some(m) = metrics() {
569 m.dedup_waiters_unblocked_total.inc();
570 }
571}
572
573pub fn inc_inference_timeout() {
579 if let Some(m) = metrics() {
580 m.inference_timeouts_total.inc();
581 }
582}
583
584pub fn inc_expired() {
595 if let Some(m) = metrics() {
596 m.requests_expired_total.inc();
597 }
598}
599
600pub fn inc_dlq_lock_poisoned() {
610 if let Some(m) = metrics() {
611 m.dlq_lock_poisoned_total.inc();
612 }
613}
614
615pub fn inc_rag_expired() {
626 if let Some(m) = metrics() {
627 m.rag_requests_expired_total.inc();
628 }
629}
630
631pub fn inc_cb_transition(state: &str) {
641 if let Some(m) = metrics() {
642 if let Ok(c) = m
643 .circuit_breaker_state_transitions_total
644 .get_metric_with_label_values(&[state])
645 {
646 c.inc();
647 }
648 }
649}
650
651pub fn inc_cb_rejected() {
661 if let Some(m) = metrics() {
662 m.circuit_breaker_requests_rejected_total.inc();
663 }
664}
665
666pub fn inc_config_reload_error() {
676 if let Some(m) = metrics() {
677 m.config_reload_errors_total.inc();
678 }
679}
680
681pub fn inc_worker_error(worker: &str, error_kind: &str) {
691 if let Some(m) = metrics() {
692 if let Ok(c) = m
693 .worker_errors_total
694 .get_metric_with_label_values(&[worker, error_kind])
695 {
696 c.inc();
697 }
698 }
699}
700
701pub fn inc_dedup_hash_collision() {
712 if let Some(m) = metrics() {
713 m.dedup_hash_collisions_total.inc();
714 }
715}
716
717pub fn inc_cache_hit() {
727 if let Some(m) = metrics() {
728 m.cache_hits_total.inc();
729 }
730}
731
732pub fn inc_cache_miss() {
742 if let Some(m) = metrics() {
743 m.cache_misses_total.inc();
744 }
745}
746
747pub fn set_rate_limiter_tokens(session: &str, tokens: f64) {
758 if let Some(m) = metrics() {
759 if let Ok(g) = m
760 .rate_limiter_tokens_remaining
761 .get_metric_with_label_values(&[session])
762 {
763 g.set(tokens);
764 }
765 }
766}
767
768pub fn record_config_reload_duration(result: &str, d: Duration) {
778 if let Some(m) = metrics() {
779 if let Ok(h) = m
780 .config_reload_duration_seconds
781 .get_metric_with_label_values(&[result])
782 {
783 h.observe(d.as_secs_f64());
784 }
785 }
786}
787
788pub fn inc_session_affinity_hit() {
798 if let Some(m) = metrics() {
799 m.session_affinity_hits_total.inc();
800 }
801}
802
803pub fn inc_session_affinity_miss() {
814 if let Some(m) = metrics() {
815 m.session_affinity_misses_total.inc();
816 }
817}
818
819pub fn gather() -> Vec<prometheus::proto::MetricFamily> {
827 metrics().map_or_else(Vec::new, |m| m.registry.gather())
828}
829
830pub fn gather_metrics() -> String {
839 let families = gather();
840 if families.is_empty() {
841 return String::new();
842 }
843 let encoder = TextEncoder::new();
844 let mut buffer = Vec::new();
845 if encoder.encode(&families, &mut buffer).is_err() {
846 return String::new();
847 }
848 String::from_utf8(buffer).unwrap_or_default()
849}
850
851#[derive(Debug, Default)]
853pub struct MetricsSummary {
854 pub requests_total: HashMap<String, u64>,
856 pub requests_shed: HashMap<String, u64>,
858 pub errors_total: HashMap<String, u64>,
860}
861
862pub fn get_metrics_summary() -> MetricsSummary {
870 let Some(m) = metrics() else {
871 return MetricsSummary::default();
872 };
873
874 let mut summary = MetricsSummary::default();
875
876 for family in m.requests_total.collect() {
877 for metric in family.get_metric() {
878 let stage = metric
879 .get_label()
880 .iter()
881 .find(|l| l.get_name() == "stage")
882 .map_or("unknown", |l| l.get_value());
883 let value = metric.get_counter().get_value() as u64;
884 summary.requests_total.insert(stage.to_string(), value);
885 }
886 }
887
888 for family in m.requests_shed.collect() {
889 for metric in family.get_metric() {
890 let stage = metric
891 .get_label()
892 .iter()
893 .find(|l| l.get_name() == "stage")
894 .map_or("unknown", |l| l.get_value());
895 let value = metric.get_counter().get_value() as u64;
896 summary.requests_shed.insert(stage.to_string(), value);
897 }
898 }
899
900 for family in m.errors_total.collect() {
901 for metric in family.get_metric() {
902 let stage = metric
903 .get_label()
904 .iter()
905 .find(|l| l.get_name() == "stage")
906 .map_or("unknown", |l| l.get_value());
907 let err_type = metric
908 .get_label()
909 .iter()
910 .find(|l| l.get_name() == "err_type")
911 .map_or("unknown", |l| l.get_value());
912 let key = format!("{stage}:{err_type}");
913 let value = metric.get_counter().get_value() as u64;
914 summary.errors_total.insert(key, value);
915 }
916 }
917
918 summary
919}
920
921#[cfg(test)]
922mod tests {
923 use super::*;
924
925 fn make_test_metrics() -> Metrics {
930 let registry = Registry::new();
931
932 let requests_total =
933 CounterVec::new(Opts::new("t_requests_total", "test counter"), &["stage"])
934 .expect("CounterVec construction must succeed in tests");
935 registry
936 .register(Box::new(requests_total.clone()))
937 .expect("register must succeed in tests");
938
939 let requests_shed = CounterVec::new(
940 Opts::new("t_requests_shed_total", "test counter"),
941 &["stage"],
942 )
943 .expect("CounterVec construction must succeed in tests");
944 registry
945 .register(Box::new(requests_shed.clone()))
946 .expect("register must succeed in tests");
947
948 let requests_dropped = CounterVec::new(
949 Opts::new("t_requests_dropped_total", "test counter"),
950 &["stage"],
951 )
952 .expect("CounterVec construction must succeed in tests");
953 registry
954 .register(Box::new(requests_dropped.clone()))
955 .expect("register must succeed in tests");
956
957 let errors_total = CounterVec::new(
958 Opts::new("t_errors_total", "test counter"),
959 &["stage", "err_type"],
960 )
961 .expect("CounterVec construction must succeed in tests");
962 registry
963 .register(Box::new(errors_total.clone()))
964 .expect("register must succeed in tests");
965
966 let stage_duration = HistogramVec::new(
967 HistogramOpts::new("t_stage_duration_seconds", "test histogram"),
968 &["stage"],
969 )
970 .expect("HistogramVec construction must succeed in tests");
971 registry
972 .register(Box::new(stage_duration.clone()))
973 .expect("register must succeed in tests");
974
975 let queue_depth = IntGaugeVec::new(Opts::new("t_queue_depth", "test gauge"), &["stage"])
976 .expect("IntGaugeVec construction must succeed in tests");
977 registry
978 .register(Box::new(queue_depth.clone()))
979 .expect("register must succeed in tests");
980
981 let inference_cost_usd =
982 Counter::with_opts(Opts::new("orchestrator_inference_cost_usd_total", "test"))
983 .expect("Counter construction must succeed in tests");
984 registry
985 .register(Box::new(inference_cost_usd.clone()))
986 .expect("register must succeed in tests");
987
988 let ttft_seconds = HistogramVec::new(
989 HistogramOpts::new("t_ttft_seconds", "test ttft histogram"),
990 &["worker", "model"],
991 )
992 .expect("HistogramVec construction must succeed in tests");
993 registry
994 .register(Box::new(ttft_seconds.clone()))
995 .expect("register must succeed in tests");
996
997 let dedup_hits_total = Counter::with_opts(Opts::new("t_dedup_hits_total", "test counter"))
998 .expect("Counter construction must succeed in tests");
999 registry
1000 .register(Box::new(dedup_hits_total.clone()))
1001 .expect("register must succeed in tests");
1002
1003 let dedup_waiters_unblocked_total =
1004 Counter::with_opts(Opts::new("t_dedup_waiters_unblocked_total", "test counter"))
1005 .expect("Counter construction must succeed in tests");
1006 registry
1007 .register(Box::new(dedup_waiters_unblocked_total.clone()))
1008 .expect("register must succeed in tests");
1009
1010 let inference_timeouts_total =
1011 Counter::with_opts(Opts::new("t_inference_timeouts_total", "test counter"))
1012 .expect("Counter construction must succeed in tests");
1013 registry
1014 .register(Box::new(inference_timeouts_total.clone()))
1015 .expect("register must succeed in tests");
1016
1017 let requests_expired_total =
1018 Counter::with_opts(Opts::new("t_requests_expired_total", "test counter"))
1019 .expect("Counter construction must succeed in tests");
1020 registry
1021 .register(Box::new(requests_expired_total.clone()))
1022 .expect("register must succeed in tests");
1023
1024 let dlq_lock_poisoned_total =
1025 Counter::with_opts(Opts::new("t_dlq_lock_poisoned_total", "test counter"))
1026 .expect("Counter construction must succeed in tests");
1027 registry
1028 .register(Box::new(dlq_lock_poisoned_total.clone()))
1029 .expect("register must succeed in tests");
1030
1031 let rag_requests_expired_total =
1032 Counter::with_opts(Opts::new("t_rag_requests_expired_total", "test counter"))
1033 .expect("Counter construction must succeed in tests");
1034 registry
1035 .register(Box::new(rag_requests_expired_total.clone()))
1036 .expect("register must succeed in tests");
1037
1038 let circuit_breaker_state_transitions_total = CounterVec::new(
1039 Opts::new("t_circuit_breaker_state_transitions_total", "test counter"),
1040 &["state"],
1041 )
1042 .expect("CounterVec construction must succeed in tests");
1043 registry
1044 .register(Box::new(circuit_breaker_state_transitions_total.clone()))
1045 .expect("register must succeed in tests");
1046
1047 let circuit_breaker_requests_rejected_total = Counter::with_opts(Opts::new(
1048 "t_circuit_breaker_requests_rejected_total",
1049 "test counter",
1050 ))
1051 .expect("Counter construction must succeed in tests");
1052 registry
1053 .register(Box::new(circuit_breaker_requests_rejected_total.clone()))
1054 .expect("register must succeed in tests");
1055
1056 let config_reload_errors_total =
1057 Counter::with_opts(Opts::new("t_config_reload_errors_total", "test counter"))
1058 .expect("Counter construction must succeed in tests");
1059 registry
1060 .register(Box::new(config_reload_errors_total.clone()))
1061 .expect("register must succeed in tests");
1062
1063 let worker_errors_total = CounterVec::new(
1064 Opts::new("t_worker_errors_total", "test counter"),
1065 &["worker", "error_kind"],
1066 )
1067 .expect("CounterVec construction must succeed in tests");
1068 registry
1069 .register(Box::new(worker_errors_total.clone()))
1070 .expect("register must succeed in tests");
1071
1072 let cache_hits_total =
1073 Counter::with_opts(Opts::new("t_cache_hits_total", "test counter"))
1074 .expect("Counter construction must succeed in tests");
1075 registry
1076 .register(Box::new(cache_hits_total.clone()))
1077 .expect("register must succeed in tests");
1078
1079 let cache_misses_total =
1080 Counter::with_opts(Opts::new("t_cache_misses_total", "test counter"))
1081 .expect("Counter construction must succeed in tests");
1082 registry
1083 .register(Box::new(cache_misses_total.clone()))
1084 .expect("register must succeed in tests");
1085
1086 let rate_limiter_tokens_remaining = GaugeVec::new(
1087 Opts::new("t_rate_limiter_tokens_remaining", "test gauge"),
1088 &["session"],
1089 )
1090 .expect("GaugeVec construction must succeed in tests");
1091 registry
1092 .register(Box::new(rate_limiter_tokens_remaining.clone()))
1093 .expect("register must succeed in tests");
1094
1095 let config_reload_duration_seconds = HistogramVec::new(
1096 HistogramOpts::new("t_config_reload_duration_seconds", "test histogram"),
1097 &["result"],
1098 )
1099 .expect("HistogramVec construction must succeed in tests");
1100 registry
1101 .register(Box::new(config_reload_duration_seconds.clone()))
1102 .expect("register must succeed in tests");
1103
1104 let session_affinity_hits_total =
1105 Counter::with_opts(Opts::new("t_session_affinity_hits_total", "test counter"))
1106 .expect("Counter construction must succeed in tests");
1107 registry
1108 .register(Box::new(session_affinity_hits_total.clone()))
1109 .expect("register must succeed in tests");
1110
1111 let session_affinity_misses_total =
1112 Counter::with_opts(Opts::new("t_session_affinity_misses_total", "test counter"))
1113 .expect("Counter construction must succeed in tests");
1114 registry
1115 .register(Box::new(session_affinity_misses_total.clone()))
1116 .expect("register must succeed in tests");
1117
1118 let dedup_hash_collisions_total =
1119 Counter::with_opts(Opts::new("t_dedup_hash_collisions_total", "test counter"))
1120 .expect("Counter construction must succeed in tests");
1121 registry
1122 .register(Box::new(dedup_hash_collisions_total.clone()))
1123 .expect("register must succeed in tests");
1124
1125 Metrics {
1126 registry,
1127 requests_total,
1128 requests_shed,
1129 requests_dropped,
1130 errors_total,
1131 stage_duration,
1132 queue_depth,
1133 inference_cost_usd,
1134 ttft_seconds,
1135 dedup_hits_total,
1136 dedup_waiters_unblocked_total,
1137 inference_timeouts_total,
1138 requests_expired_total,
1139 dlq_lock_poisoned_total,
1140 rag_requests_expired_total,
1141 circuit_breaker_state_transitions_total,
1142 circuit_breaker_requests_rejected_total,
1143 config_reload_errors_total,
1144 worker_errors_total,
1145 cache_hits_total,
1146 cache_misses_total,
1147 rate_limiter_tokens_remaining,
1148 config_reload_duration_seconds,
1149 session_affinity_hits_total,
1150 session_affinity_misses_total,
1151 dedup_hash_collisions_total,
1152 }
1153 }
1154
1155 #[test]
1156 fn test_init_metrics_succeeds_once() {
1157 let result = init_metrics();
1158 assert!(result.is_ok(), "init_metrics should succeed: {result:?}");
1159 }
1160
1161 #[test]
1162 fn test_init_metrics_idempotent_second_call_is_noop() {
1163 let _ = init_metrics();
1164 let result2 = init_metrics();
1165 assert!(result2.is_ok(), "second call must be a no-op returning Ok");
1166 }
1167
1168 #[test]
1169 fn test_record_stage_latency_before_init_does_not_panic() {
1170 record_stage_latency("pre-init-stage", Duration::from_millis(5));
1172 }
1173
1174 #[test]
1175 fn test_record_stage_latency_records_observation_in_isolated_metrics() {
1176 let m = make_test_metrics();
1177 m.stage_duration
1178 .get_metric_with_label_values(&["rag"])
1179 .expect("label values must be valid")
1180 .observe(0.005);
1181 let families = m.registry.gather();
1182 assert!(
1183 !families.is_empty(),
1184 "should have at least one metric family"
1185 );
1186 let family = families
1187 .iter()
1188 .find(|f| f.get_name() == "t_stage_duration_seconds")
1189 .expect("histogram family must be present");
1190 let count = family.get_metric()[0].get_histogram().get_sample_count();
1191 assert_eq!(count, 1, "one observation should have been recorded");
1192 }
1193
1194 #[test]
1195 fn test_inc_request_increments_counter_by_one() {
1196 let m = make_test_metrics();
1197 m.requests_total
1198 .get_metric_with_label_values(&["rag"])
1199 .expect("label ok")
1200 .inc();
1201 m.requests_total
1202 .get_metric_with_label_values(&["rag"])
1203 .expect("label ok")
1204 .inc();
1205
1206 let families = m.registry.gather();
1207 let family = families
1208 .iter()
1209 .find(|f| f.get_name() == "t_requests_total")
1210 .expect("family must exist");
1211 let value = family.get_metric()[0].get_counter().get_value();
1212 assert!(
1213 (value - 2.0).abs() < f64::EPSILON,
1214 "counter must be 2.0, got {value}"
1215 );
1216 }
1217
1218 #[test]
1219 fn test_inc_shed_increments_shed_counter() {
1220 let m = make_test_metrics();
1221 m.requests_shed
1222 .get_metric_with_label_values(&["assemble"])
1223 .expect("label ok")
1224 .inc();
1225
1226 let families = m.registry.gather();
1227 let family = families
1228 .iter()
1229 .find(|f| f.get_name() == "t_requests_shed_total")
1230 .expect("family must exist");
1231 let value = family.get_metric()[0].get_counter().get_value();
1232 assert!((value - 1.0).abs() < f64::EPSILON);
1233 }
1234
1235 #[test]
1236 fn test_inc_error_increments_error_counter_with_correct_labels() {
1237 let m = make_test_metrics();
1238 m.errors_total
1239 .get_metric_with_label_values(&["inference", "timeout"])
1240 .expect("label ok")
1241 .inc();
1242
1243 let families = m.registry.gather();
1244 let family = families
1245 .iter()
1246 .find(|f| f.get_name() == "t_errors_total")
1247 .expect("family must exist");
1248 let value = family.get_metric()[0].get_counter().get_value();
1249 assert!((value - 1.0).abs() < f64::EPSILON);
1250 }
1251
1252 #[test]
1253 fn test_set_queue_depth_sets_gauge_to_exact_value() {
1254 let m = make_test_metrics();
1255 m.queue_depth
1256 .get_metric_with_label_values(&["rag"])
1257 .expect("label ok")
1258 .set(42);
1259
1260 let families = m.registry.gather();
1261 let family = families
1262 .iter()
1263 .find(|f| f.get_name() == "t_queue_depth")
1264 .expect("family must exist");
1265 let value = family.get_metric()[0].get_gauge().get_value();
1266 assert!(
1267 (value - 42.0).abs() < f64::EPSILON,
1268 "gauge must be 42.0, got {value}"
1269 );
1270 }
1271
1272 #[test]
1273 fn test_gather_metrics_returns_valid_utf8_string() {
1274 let _ = init_metrics();
1275 let output = gather_metrics();
1276 assert!(
1277 std::str::from_utf8(output.as_bytes()).is_ok(),
1278 "gather_metrics output must be valid UTF-8"
1279 );
1280 }
1281
1282 #[test]
1283 fn test_gather_metrics_does_not_panic_before_init() {
1284 let _ = gather_metrics();
1286 }
1287
1288 #[test]
1289 fn test_get_metrics_summary_returns_valid_struct() {
1290 let summary = get_metrics_summary();
1291 let _rt = summary.requests_total.len();
1293 let _rs = summary.requests_shed.len();
1294 let _et = summary.errors_total.len();
1295 }
1296
1297 #[test]
1298 fn test_gather_returns_non_empty_after_observation() {
1299 let _ = init_metrics();
1303 inc_request("gather-test-stage");
1304 let families = gather();
1305 assert!(
1306 !families.is_empty(),
1307 "gather() must return at least one MetricFamily after an observation"
1308 );
1309 }
1310
1311 #[test]
1312 fn test_set_queue_depth_global_helper_does_not_panic() {
1313 let _ = init_metrics();
1314 set_queue_depth("rag", 7);
1315 }
1317
1318 #[test]
1319 fn test_inc_dropped_increments_dropped_counter() {
1320 let m = make_test_metrics();
1321 m.requests_dropped
1322 .get_metric_with_label_values(&["rag"])
1323 .expect("label ok")
1324 .inc();
1325 m.requests_dropped
1326 .get_metric_with_label_values(&["rag"])
1327 .expect("label ok")
1328 .inc();
1329
1330 let families = m.registry.gather();
1331 let family = families
1332 .iter()
1333 .find(|f| f.get_name() == "t_requests_dropped_total")
1334 .expect("dropped counter family must be present");
1335 let value = family.get_metric()[0].get_counter().get_value();
1336 assert!(
1337 (value - 2.0).abs() < f64::EPSILON,
1338 "counter must be 2.0, got {value}"
1339 );
1340 }
1341
1342 #[test]
1343 fn test_record_ttft_records_observation_with_worker_model_labels() {
1344 let m = make_test_metrics();
1345 m.ttft_seconds
1346 .get_metric_with_label_values(&["openai", "gpt-4o"])
1347 .expect("label values must be valid")
1348 .observe(0.25);
1349
1350 let families = m.registry.gather();
1351 let family = families
1352 .iter()
1353 .find(|f| f.get_name() == "t_ttft_seconds")
1354 .expect("ttft histogram family must be present");
1355 let count = family.get_metric()[0].get_histogram().get_sample_count();
1356 assert_eq!(count, 1, "one TTFT observation should have been recorded");
1357 }
1358
1359 #[test]
1360 fn test_inc_expired_increments_expired_counter() {
1361 let m = make_test_metrics();
1362 m.requests_expired_total.inc();
1363 m.requests_expired_total.inc();
1364
1365 let families = m.registry.gather();
1366 let family = families
1367 .iter()
1368 .find(|f| f.get_name() == "t_requests_expired_total")
1369 .expect("expired counter family must be present");
1370 let value = family.get_metric()[0].get_counter().get_value();
1371 assert!(
1372 (value - 2.0).abs() < f64::EPSILON,
1373 "counter must be 2.0, got {value}"
1374 );
1375 }
1376}