Skip to main content

tokio_prompt_orchestrator/
metrics.rs

1//! Prometheus metrics for the orchestrator pipeline.
2//!
3//! ## Usage
4//!
5//! Call [`init_metrics`] once at process startup **before** spawning any pipeline
6//! stages. The helper functions (`record_stage_latency`, `inc_request`, …) are
7//! no-ops if `init_metrics` was never called, so the pipeline is always safe to
8//! run  -  observability simply degrades gracefully.
9//!
10//! ## Metrics Exposed
11//!
12//! | Name | Type | Labels |
13//! |------|------|--------|
14//! | `orchestrator_requests_total` | Counter | `stage` |
15//! | `orchestrator_requests_shed_total` | Counter | `stage` |
16//! | `orchestrator_requests_dropped_total` | Counter | `stage` |
17//! | `orchestrator_errors_total` | Counter | `stage`, `err_type` |
18//! | `orchestrator_stage_duration_seconds` | Histogram | `stage` |
19//! | `orchestrator_queue_depth` | Gauge | `stage` |
20//! | `inference_time_to_first_token_seconds` | Histogram | `worker`, `model` |
21//! | `orchestrator_requests_expired_total` | Counter | (none) |
22
23use 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
32//  Internal metrics bundle
33
34/// All Prometheus metrics for the orchestrator, bundled together so they can
35/// be stored in a single [`OnceLock`] and initialised atomically.
36pub struct Metrics {
37    /// Prometheus registry that owns all metric descriptors.
38    pub registry: Registry,
39    /// Total requests processed per stage.
40    pub requests_total: CounterVec,
41    /// Requests shed (queue full) per stage.
42    pub requests_shed: CounterVec,
43    /// Requests dropped due to full queue, labelled by stage.
44    pub requests_dropped: CounterVec,
45    /// Errors by stage and error type.
46    pub errors_total: CounterVec,
47    /// Stage processing latency histogram.
48    pub stage_duration: HistogramVec,
49    /// Current queue depth per stage.
50    pub queue_depth: IntGaugeVec,
51    /// Cumulative USD cost of all inference calls.
52    pub inference_cost_usd: prometheus::Counter,
53    /// Time from request start to first streaming token, labelled by worker and model.
54    pub ttft_seconds: HistogramVec,
55    /// Total dedup hits (in-progress or cached responses returned without new inference).
56    pub dedup_hits_total: Counter,
57    /// Total dedup waiters unblocked (requests that waited and received a broadcast result).
58    pub dedup_waiters_unblocked_total: Counter,
59    /// Total inference timeouts.
60    pub inference_timeouts_total: Counter,
61    /// Requests dropped because their deadline had already passed at dequeue time.
62    pub requests_expired_total: Counter,
63    /// Incremented when a `DeadLetterQueue` mutex is recovered from a poisoned state.
64    pub dlq_lock_poisoned_total: Counter,
65    /// Incremented when a RAG-stage request is dropped because its deadline expired.
66    pub rag_requests_expired_total: Counter,
67    /// Circuit breaker state transitions, labelled by target state.
68    pub circuit_breaker_state_transitions_total: CounterVec,
69    /// Requests rejected by the circuit breaker (open state).
70    pub circuit_breaker_requests_rejected_total: Counter,
71    /// Config hot-reload attempts that failed validation or parse.
72    pub config_reload_errors_total: Counter,
73    /// Errors returned by a specific named worker, labelled by worker name.
74    pub worker_errors_total: CounterVec,
75    /// Cache hits on the response cache (in-memory or Redis).
76    pub cache_hits_total: Counter,
77    /// Cache misses on the response cache (in-memory or Redis).
78    pub cache_misses_total: Counter,
79    /// Current number of tokens remaining in the rate-limiter bucket per session.
80    pub rate_limiter_tokens_remaining: GaugeVec,
81    /// Duration of config hot-reload operations (parse + validate), in seconds.
82    pub config_reload_duration_seconds: HistogramVec,
83    /// Session affinity routing hits (request routed to its preferred shard).
84    pub session_affinity_hits_total: Counter,
85    /// Session affinity routing misses (preferred shard unavailable, rerouted).
86    pub session_affinity_misses_total: Counter,
87    /// Dedup hash collisions: two different prompts produced the same hash key.
88    pub dedup_hash_collisions_total: Counter,
89}
90
91static METRICS: OnceLock<Metrics> = OnceLock::new();
92
93//  Initialisation
94
95/// Initialise all Prometheus metrics and register them with a private registry.
96///
97/// Must be called once at process startup before any pipeline stage is spawned.
98/// Calling it a second time is a no-op (returns `Ok(())`).
99///
100/// # Errors
101///
102/// Returns [`OrchestratorError::Other`] if metric construction or registry
103/// registration fails (e.g., duplicate descriptor names).
104///
105/// # Panics
106///
107/// This function never panics.
108pub 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    // If another thread raced us, the first one wins  -  both initializations
367    // produce identical metric descriptors, so neither outcome is incorrect.
368    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
400/// Return a reference to the initialised [`Metrics`], or `None` if
401/// [`init_metrics`] has not been called yet.
402fn metrics() -> Option<&'static Metrics> {
403    METRICS.get()
404}
405
406//  Public helper functions
407
408/// Record the processing latency for a pipeline stage.
409///
410/// No-op if metrics have not been initialised.
411///
412/// # Panics
413///
414/// This function never panics.
415pub 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
423/// Increment the request counter for a pipeline stage.
424///
425/// No-op if metrics have not been initialised.
426///
427/// # Panics
428///
429/// This function never panics.
430pub 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
438/// Increment the shed-request counter for a pipeline stage.
439///
440/// No-op if metrics have not been initialised.
441///
442/// # Panics
443///
444/// This function never panics.
445pub 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
453/// Increment the dropped-request counter for a pipeline stage.
454///
455/// This counter tracks requests that were discarded because the downstream
456/// channel was full (backpressure shed).  It is incremented in addition to
457/// `inc_shed` so callers can query drops independently.
458///
459/// No-op if metrics have not been initialised.
460///
461/// # Panics
462///
463/// This function never panics.
464pub 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
472/// Record the time-to-first-token (TTFT) for a streaming inference call.
473///
474/// `worker` is a short identifier such as `"openai"` or `"anthropic"`.
475/// `model` is the model name string reported by the worker.
476/// `d` is the elapsed duration from request start until the first token arrived.
477///
478/// No-op if metrics have not been initialised.
479///
480/// # Panics
481///
482/// This function never panics.
483pub 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
494/// Increment the error counter for a pipeline stage and error type.
495///
496/// No-op if metrics have not been initialised.
497///
498/// # Panics
499///
500/// This function never panics.
501pub 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
512/// Set the queue depth gauge for a pipeline stage.
513///
514/// No-op if metrics have not been initialised.
515///
516/// # Panics
517///
518/// This function never panics.
519pub 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
527/// Record USD cost for an inference call.
528///
529/// No-op if metrics have not been initialised.
530///
531/// # Panics
532///
533/// This function never panics.
534pub fn record_inference_cost(usd: f64) {
535    if let Some(m) = metrics() {
536        m.inference_cost_usd.inc_by(usd);
537    }
538}
539
540/// Return the total USD spent on inference calls this session.
541///
542/// Returns `0.0` if metrics have not been initialised.
543///
544/// # Panics
545///
546/// This function never panics.
547pub fn total_inference_cost_usd() -> f64 {
548    metrics().map_or(0.0, |m| m.inference_cost_usd.get())
549}
550
551/// Increment the dedup hit counter.
552///
553/// Call when a duplicate request is detected (InProgress or Cached result returned).
554///
555/// No-op if metrics have not been initialised.
556pub fn inc_dedup_hit() {
557    if let Some(m) = metrics() {
558        m.dedup_hits_total.inc();
559    }
560}
561
562/// Increment the dedup waiters-unblocked counter.
563///
564/// Call when `wait_for_result` successfully receives a broadcast result.
565///
566/// No-op if metrics have not been initialised.
567pub fn inc_dedup_waiter_unblocked() {
568    if let Some(m) = metrics() {
569        m.dedup_waiters_unblocked_total.inc();
570    }
571}
572
573/// Increment the inference timeout counter.
574///
575/// Call when an inference call is cancelled because it exceeded the configured timeout.
576///
577/// No-op if metrics have not been initialised.
578pub fn inc_inference_timeout() {
579    if let Some(m) = metrics() {
580        m.inference_timeouts_total.inc();
581    }
582}
583
584/// Increment the deadline-expired counter.
585///
586/// Called by the inference stage when a request is dropped because
587/// `Instant::now()` is past its `deadline`.
588///
589/// No-op if metrics have not been initialised.
590///
591/// # Panics
592///
593/// This function never panics.
594pub fn inc_expired() {
595    if let Some(m) = metrics() {
596        m.requests_expired_total.inc();
597    }
598}
599
600/// Increment the DLQ lock-poisoned counter.
601///
602/// Call when a `DeadLetterQueue` mutex is recovered from a poisoned state.
603///
604/// No-op if metrics have not been initialised.
605///
606/// # Panics
607///
608/// This function never panics.
609pub fn inc_dlq_lock_poisoned() {
610    if let Some(m) = metrics() {
611        m.dlq_lock_poisoned_total.inc();
612    }
613}
614
615/// Increment the RAG-stage deadline-expired counter.
616///
617/// Call when a request is dropped at the RAG stage because its deadline
618/// has already passed.
619///
620/// No-op if metrics have not been initialised.
621///
622/// # Panics
623///
624/// This function never panics.
625pub fn inc_rag_expired() {
626    if let Some(m) = metrics() {
627        m.rag_requests_expired_total.inc();
628    }
629}
630
631/// Increment the circuit-breaker state-transition counter for `state`.
632///
633/// `state` should be `"open"`, `"half_open"`, or `"closed"`.
634///
635/// No-op if metrics have not been initialised.
636///
637/// # Panics
638///
639/// This function never panics.
640pub 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
651/// Increment the circuit-breaker rejected-requests counter.
652///
653/// Call when a request is rejected because the circuit breaker is open.
654///
655/// No-op if metrics have not been initialised.
656///
657/// # Panics
658///
659/// This function never panics.
660pub fn inc_cb_rejected() {
661    if let Some(m) = metrics() {
662        m.circuit_breaker_requests_rejected_total.inc();
663    }
664}
665
666/// Increment the config reload error counter.
667///
668/// Call when a config hot-reload attempt fails validation or parse.
669///
670/// No-op if metrics have not been initialised.
671///
672/// # Panics
673///
674/// This function never panics.
675pub fn inc_config_reload_error() {
676    if let Some(m) = metrics() {
677        m.config_reload_errors_total.inc();
678    }
679}
680
681/// Increment the per-worker error counter.
682///
683/// Call when a named worker (e.g. `"openai"`, `"anthropic"`) returns an error.
684///
685/// No-op if metrics have not been initialised.
686///
687/// # Panics
688///
689/// This function never panics.
690pub 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
701/// Increment the dedup hash collision counter.
702///
703/// Call when two different prompts are found to produce the same hash key in the
704/// deduplication layer, indicating a hash collision.
705///
706/// No-op if metrics have not been initialised.
707///
708/// # Panics
709///
710/// This function never panics.
711pub fn inc_dedup_hash_collision() {
712    if let Some(m) = metrics() {
713        m.dedup_hash_collisions_total.inc();
714    }
715}
716
717/// Increment the cache hit counter.
718///
719/// Call when the response cache returns a cached value.
720///
721/// No-op if metrics have not been initialised.
722///
723/// # Panics
724///
725/// This function never panics.
726pub fn inc_cache_hit() {
727    if let Some(m) = metrics() {
728        m.cache_hits_total.inc();
729    }
730}
731
732/// Increment the cache miss counter.
733///
734/// Call when the response cache returns `None` (no entry or expired).
735///
736/// No-op if metrics have not been initialised.
737///
738/// # Panics
739///
740/// This function never panics.
741pub fn inc_cache_miss() {
742    if let Some(m) = metrics() {
743        m.cache_misses_total.inc();
744    }
745}
746
747/// Set the rate-limiter tokens-remaining gauge for a session.
748///
749/// Call after each rate-limit check to update the gauge with the current
750/// number of tokens remaining in that session's bucket.
751///
752/// No-op if metrics have not been initialised.
753///
754/// # Panics
755///
756/// This function never panics.
757pub 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
768/// Record the duration of a config hot-reload operation.
769///
770/// `result` should be `"ok"` on success or `"error"` on failure.
771///
772/// No-op if metrics have not been initialised.
773///
774/// # Panics
775///
776/// This function never panics.
777pub 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
788/// Increment the session affinity hit counter.
789///
790/// Call when a request is successfully routed to its preferred affinity shard.
791///
792/// No-op if metrics have not been initialised.
793///
794/// # Panics
795///
796/// This function never panics.
797pub fn inc_session_affinity_hit() {
798    if let Some(m) = metrics() {
799        m.session_affinity_hits_total.inc();
800    }
801}
802
803/// Increment the session affinity miss counter.
804///
805/// Call when a request's preferred affinity shard is unavailable and it must
806/// be rerouted to a different shard.
807///
808/// No-op if metrics have not been initialised.
809///
810/// # Panics
811///
812/// This function never panics.
813pub fn inc_session_affinity_miss() {
814    if let Some(m) = metrics() {
815        m.session_affinity_misses_total.inc();
816    }
817}
818
819/// Gather all registered metrics as a raw list of metric families.
820///
821/// Returns an empty `Vec` if metrics have not been initialised.
822///
823/// # Panics
824///
825/// This function never panics.
826pub fn gather() -> Vec<prometheus::proto::MetricFamily> {
827    metrics().map_or_else(Vec::new, |m| m.registry.gather())
828}
829
830/// Gather and encode all metrics in the Prometheus text exposition format.
831///
832/// Returns an empty string if metrics have not been initialised or if
833/// encoding fails. Observability degrades gracefully rather than panicking.
834///
835/// # Panics
836///
837/// This function never panics.
838pub 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/// A structured snapshot of key metric counters, used by the health endpoint.
852#[derive(Debug, Default)]
853pub struct MetricsSummary {
854    /// Total request counts keyed by stage label.
855    pub requests_total: HashMap<String, u64>,
856    /// Shed request counts keyed by stage label.
857    pub requests_shed: HashMap<String, u64>,
858    /// Error counts keyed by `"stage:err_type"`.
859    pub errors_total: HashMap<String, u64>,
860}
861
862/// Return a structured summary of current metric counter values.
863///
864/// Returns a zeroed [`MetricsSummary`] if metrics have not been initialised.
865///
866/// # Panics
867///
868/// This function never panics.
869pub 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    /// Build a fresh, isolated [`Metrics`] bundle backed by its own registry.
926    ///
927    /// We cannot reset the global `METRICS` OnceLock between tests, so tests
928    /// that need to verify exact counter values build a local bundle instead.
929    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        // Cannot reset OnceLock; just verify no panic occurs.
1171        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        // OnceLock may already be set; verify no panic in either case.
1285        let _ = gather_metrics();
1286    }
1287
1288    #[test]
1289    fn test_get_metrics_summary_returns_valid_struct() {
1290        let summary = get_metrics_summary();
1291        // Must not panic; fields must be valid (possibly empty) maps.
1292        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        // prometheus-rs gather() skips MetricFamily entries that have zero
1300        // recorded time-series (i.e. no label combinations ever observed).
1301        // We must record at least one value before gather() returns non-empty.
1302        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        // Primary assertion: no panic.
1316    }
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}