Skip to main content

reddb_server/server/
http_handler_metrics.rs

1//! Prometheus metrics for HTTP admission and synchronous execution.
2//!
3//! Exposes the legacy handler-envelope series plus execution-admission
4//! gauges and queue-wait histograms per ADR-0017 (Prometheus / Grafana
5//! adapters), so operators can distinguish front-door pressure from engine
6//! saturation:
7//!
8//! - `http_active_handler_threads{transport}` — legacy-named gauge of admitted
9//!   requests, sourced from `HttpConnectionLimiter::current()` at scrape time.
10//! - `http_handler_cap{transport}` — static gauge, sourced from the
11//!   limiter's cap. Same value for `http` and `https` since slice 3
12//!   collapsed both transports onto one cap.
13//! - `http_handler_rejected_total{transport, reason}` — counter,
14//!   incremented per admission or deadline rejection.
15//! - `http_handler_duration_seconds{transport}` — histogram of total
16//!   handler wall-clock time, sampled on every handler exit
17//!   (happy-path and timeout). Buckets are the standard Prometheus
18//!   client default set so existing dashboards render quantiles via
19//!   `histogram_quantile` without configuration.
20//! - `http_execution_{workers,active,queued,queue_capacity}` — live execution
21//!   lane state.
22//! - `http_execution_queue_wait_seconds{transport}` — time admitted requests
23//!   spend waiting for a synchronous execution slot.
24//!
25//! Counters and histogram updates are plain `AtomicU64` operations on
26//! the hot path. No registry, no mutex.
27
28use std::fmt::Write;
29use std::sync::atomic::{AtomicU64, Ordering};
30use std::sync::Arc;
31
32use super::http_connection_limiter::HttpConnectionLimiter;
33use crate::transport::execution_admission::ExecutionAdmission;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum HttpTransport {
37    Http,
38    Https,
39}
40
41impl HttpTransport {
42    fn label(self) -> &'static str {
43        match self {
44            HttpTransport::Http => "http",
45            HttpTransport::Https => "https",
46        }
47    }
48
49    fn index(self) -> usize {
50        match self {
51            HttpTransport::Http => 0,
52            HttpTransport::Https => 1,
53        }
54    }
55}
56
57#[derive(Debug, Clone, Copy)]
58pub enum HttpRejectReason {
59    CapExhausted,
60    HandlerTimeout,
61    ExecutionQueueFull,
62    ExecutionQueueTimeout,
63    /// Issue #934 — a principal hit its per-principal concurrent in-flight
64    /// cap (the global cap still had room; this caller did not).
65    PrincipalCapExhausted,
66}
67
68/// Number of distinct [`HttpRejectReason`] variants — array width for the
69/// per-transport rejection counters.
70const REJECT_REASONS: usize = 5;
71
72impl HttpRejectReason {
73    fn label(self) -> &'static str {
74        match self {
75            HttpRejectReason::CapExhausted => "cap_exhausted",
76            HttpRejectReason::HandlerTimeout => "handler_timeout",
77            HttpRejectReason::ExecutionQueueFull => "execution_queue_full",
78            HttpRejectReason::ExecutionQueueTimeout => "execution_queue_timeout",
79            HttpRejectReason::PrincipalCapExhausted => "principal_cap_exhausted",
80        }
81    }
82
83    fn index(self) -> usize {
84        match self {
85            HttpRejectReason::CapExhausted => 0,
86            HttpRejectReason::HandlerTimeout => 1,
87            HttpRejectReason::ExecutionQueueFull => 2,
88            HttpRejectReason::ExecutionQueueTimeout => 3,
89            HttpRejectReason::PrincipalCapExhausted => 4,
90        }
91    }
92
93    /// All variants, for render-time iteration.
94    fn all() -> [HttpRejectReason; REJECT_REASONS] {
95        [
96            HttpRejectReason::CapExhausted,
97            HttpRejectReason::HandlerTimeout,
98            HttpRejectReason::ExecutionQueueFull,
99            HttpRejectReason::ExecutionQueueTimeout,
100            HttpRejectReason::PrincipalCapExhausted,
101        ]
102    }
103}
104
105/// Prometheus client default histogram buckets, in seconds. Aligned
106/// with `prometheus.DefBuckets` so operator dashboards render
107/// `histogram_quantile` without per-deployment tuning.
108const DURATION_BUCKETS_SECONDS: [f64; 11] = [
109    0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
110];
111
112#[derive(Debug)]
113struct TransportHistogram {
114    /// One counter per bucket in `DURATION_BUCKETS_SECONDS`, plus a
115    /// trailing `+Inf` slot that holds the total sample count.
116    buckets: [AtomicU64; 11],
117    inf: AtomicU64,
118    /// Sum of observed durations in microseconds (we keep the sum in
119    /// `u64` and convert to seconds at render time).
120    sum_micros: AtomicU64,
121}
122
123impl TransportHistogram {
124    fn new() -> Self {
125        Self {
126            buckets: [
127                AtomicU64::new(0),
128                AtomicU64::new(0),
129                AtomicU64::new(0),
130                AtomicU64::new(0),
131                AtomicU64::new(0),
132                AtomicU64::new(0),
133                AtomicU64::new(0),
134                AtomicU64::new(0),
135                AtomicU64::new(0),
136                AtomicU64::new(0),
137                AtomicU64::new(0),
138            ],
139            inf: AtomicU64::new(0),
140            sum_micros: AtomicU64::new(0),
141        }
142    }
143
144    fn observe_seconds(&self, value: f64) {
145        let micros = (value * 1_000_000.0).round().clamp(0.0, u64::MAX as f64) as u64;
146        self.sum_micros.fetch_add(micros, Ordering::Relaxed);
147        self.inf.fetch_add(1, Ordering::Relaxed);
148        for (i, le) in DURATION_BUCKETS_SECONDS.iter().enumerate() {
149            if value <= *le {
150                self.buckets[i].fetch_add(1, Ordering::Relaxed);
151            }
152        }
153    }
154}
155
156#[derive(Debug)]
157struct Inner {
158    rejected: [[AtomicU64; REJECT_REASONS]; 2],
159    duration: [TransportHistogram; 2],
160    queue_wait: [TransportHistogram; 2],
161}
162
163/// One fresh zeroed rejection-counter row (`REJECT_REASONS` wide).
164fn zeroed_reject_row() -> [AtomicU64; REJECT_REASONS] {
165    std::array::from_fn(|_| AtomicU64::new(0))
166}
167
168#[derive(Debug, Clone)]
169pub struct HttpHandlerMetrics {
170    inner: Arc<Inner>,
171}
172
173impl HttpHandlerMetrics {
174    pub fn new() -> Self {
175        Self {
176            inner: Arc::new(Inner {
177                rejected: [zeroed_reject_row(), zeroed_reject_row()],
178                duration: [TransportHistogram::new(), TransportHistogram::new()],
179                queue_wait: [TransportHistogram::new(), TransportHistogram::new()],
180            }),
181        }
182    }
183
184    pub fn record_reject(&self, transport: HttpTransport, reason: HttpRejectReason) {
185        self.inner.rejected[transport.index()][reason.index()].fetch_add(1, Ordering::Relaxed);
186    }
187
188    pub fn record_duration(&self, transport: HttpTransport, seconds: f64) {
189        if !seconds.is_finite() || seconds < 0.0 {
190            return;
191        }
192        self.inner.duration[transport.index()].observe_seconds(seconds);
193    }
194
195    pub fn record_queue_wait(&self, transport: HttpTransport, seconds: f64) {
196        if !seconds.is_finite() || seconds < 0.0 {
197            return;
198        }
199        self.inner.queue_wait[transport.index()].observe_seconds(seconds);
200    }
201
202    pub fn rejected_count(&self, transport: HttpTransport, reason: HttpRejectReason) -> u64 {
203        self.inner.rejected[transport.index()][reason.index()].load(Ordering::Relaxed)
204    }
205
206    pub fn duration_sample_count(&self, transport: HttpTransport) -> u64 {
207        self.inner.duration[transport.index()]
208            .inf
209            .load(Ordering::Relaxed)
210    }
211
212    pub fn queue_wait_sample_count(&self, transport: HttpTransport) -> u64 {
213        self.inner.queue_wait[transport.index()]
214            .inf
215            .load(Ordering::Relaxed)
216    }
217
218    /// Render admission counters and accumulated histograms in Prometheus text
219    /// exposition format. Kept as the source-compatible public formatter for
220    /// callers that do not own the server's live execution gate.
221    pub fn render(&self, body: &mut String, limiter: &HttpConnectionLimiter) {
222        self.render_inner(body, limiter, None);
223    }
224
225    /// Render admission plus live execution-gate series for `/metrics`.
226    pub(crate) fn render_with_execution(
227        &self,
228        body: &mut String,
229        limiter: &HttpConnectionLimiter,
230        execution: &ExecutionAdmission,
231    ) {
232        self.render_inner(body, limiter, Some(execution));
233    }
234
235    fn render_inner(
236        &self,
237        body: &mut String,
238        limiter: &HttpConnectionLimiter,
239        execution: Option<&ExecutionAdmission>,
240    ) {
241        let cap = limiter.cap();
242        let current = limiter.current();
243
244        // `http_active_handler_threads{transport}` — legacy-named gauge.
245        // The clear-text and TLS accept loops share a single limiter
246        // (slice 3), so the live count is duplicated on both labels
247        // for dashboard ergonomics: an operator can graph the per-
248        // transport pane without special-casing the shared cap.
249        let _ = writeln!(
250            body,
251            "# HELP http_active_handler_threads Live admitted HTTP/HTTPS requests holding a limiter permit."
252        );
253        let _ = writeln!(body, "# TYPE http_active_handler_threads gauge");
254        let _ = writeln!(
255            body,
256            "http_active_handler_threads{{transport=\"http\"}} {}",
257            current
258        );
259        let _ = writeln!(
260            body,
261            "http_active_handler_threads{{transport=\"https\"}} {}",
262            current
263        );
264
265        // `http_handler_cap{transport}` — static gauge.
266        let _ = writeln!(
267            body,
268            "# HELP http_handler_cap Configured maximum admitted in-flight HTTP/HTTPS requests."
269        );
270        let _ = writeln!(body, "# TYPE http_handler_cap gauge");
271        let _ = writeln!(body, "http_handler_cap{{transport=\"http\"}} {}", cap);
272        let _ = writeln!(body, "http_handler_cap{{transport=\"https\"}} {}", cap);
273
274        if let Some(execution) = execution {
275            let execution_active = execution.active();
276            let execution_queued = execution.queued();
277            let execution_workers = execution.active_max();
278            let execution_queue_capacity = execution.queued_max();
279            let _ = writeln!(
280                body,
281                "# HELP http_execution_workers Configured maximum concurrent buffered non-health HTTP engine calls."
282            );
283            let _ = writeln!(body, "# TYPE http_execution_workers gauge");
284            let _ = writeln!(body, "http_execution_workers {execution_workers}");
285            let _ = writeln!(
286                body,
287                "# HELP http_execution_active Live buffered non-health HTTP engine calls."
288            );
289            let _ = writeln!(body, "# TYPE http_execution_active gauge");
290            let _ = writeln!(body, "http_execution_active {execution_active}");
291            let _ = writeln!(
292                body,
293                "# HELP http_execution_queued Buffered non-health HTTP requests waiting for an engine worker."
294            );
295            let _ = writeln!(body, "# TYPE http_execution_queued gauge");
296            let _ = writeln!(body, "http_execution_queued {execution_queued}");
297            let _ = writeln!(
298                body,
299                "# HELP http_execution_queue_capacity Maximum buffered non-health HTTP requests allowed to wait for execution."
300            );
301            let _ = writeln!(body, "# TYPE http_execution_queue_capacity gauge");
302            let _ = writeln!(
303                body,
304                "http_execution_queue_capacity {execution_queue_capacity}"
305            );
306        }
307
308        // `http_handler_rejected_total{transport, reason}` — counter.
309        let _ = writeln!(
310            body,
311            "# HELP http_handler_rejected_total HTTP/HTTPS handler rejections by reason since process start."
312        );
313        let _ = writeln!(body, "# TYPE http_handler_rejected_total counter");
314        for transport in [HttpTransport::Http, HttpTransport::Https] {
315            for reason in HttpRejectReason::all() {
316                let _ = writeln!(
317                    body,
318                    "http_handler_rejected_total{{transport=\"{}\",reason=\"{}\"}} {}",
319                    transport.label(),
320                    reason.label(),
321                    self.rejected_count(transport, reason)
322                );
323            }
324        }
325
326        // `http_handler_duration_seconds{transport}` — histogram.
327        let _ = writeln!(
328            body,
329            "# HELP http_handler_duration_seconds Wall-clock handler duration per transport."
330        );
331        let _ = writeln!(body, "# TYPE http_handler_duration_seconds histogram");
332        for transport in [HttpTransport::Http, HttpTransport::Https] {
333            let hist = &self.inner.duration[transport.index()];
334            for (i, le) in DURATION_BUCKETS_SECONDS.iter().enumerate() {
335                let _ = writeln!(
336                    body,
337                    "http_handler_duration_seconds_bucket{{transport=\"{}\",le=\"{}\"}} {}",
338                    transport.label(),
339                    format_bucket_le(*le),
340                    hist.buckets[i].load(Ordering::Relaxed)
341                );
342            }
343            let inf = hist.inf.load(Ordering::Relaxed);
344            let _ = writeln!(
345                body,
346                "http_handler_duration_seconds_bucket{{transport=\"{}\",le=\"+Inf\"}} {}",
347                transport.label(),
348                inf
349            );
350            let sum_secs = (hist.sum_micros.load(Ordering::Relaxed) as f64) / 1_000_000.0;
351            let _ = writeln!(
352                body,
353                "http_handler_duration_seconds_sum{{transport=\"{}\"}} {}",
354                transport.label(),
355                sum_secs
356            );
357            let _ = writeln!(
358                body,
359                "http_handler_duration_seconds_count{{transport=\"{}\"}} {}",
360                transport.label(),
361                inf
362            );
363        }
364
365        let _ = writeln!(
366            body,
367            "# HELP http_execution_queue_wait_seconds Time an admitted HTTP request waited for an engine worker."
368        );
369        let _ = writeln!(body, "# TYPE http_execution_queue_wait_seconds histogram");
370        for transport in [HttpTransport::Http, HttpTransport::Https] {
371            let histogram = &self.inner.queue_wait[transport.index()];
372            for (index, upper_bound) in DURATION_BUCKETS_SECONDS.iter().enumerate() {
373                let _ = writeln!(
374                    body,
375                    "http_execution_queue_wait_seconds_bucket{{transport=\"{}\",le=\"{}\"}} {}",
376                    transport.label(),
377                    format_bucket_le(*upper_bound),
378                    histogram.buckets[index].load(Ordering::Relaxed)
379                );
380            }
381            let count = histogram.inf.load(Ordering::Relaxed);
382            let _ = writeln!(
383                body,
384                "http_execution_queue_wait_seconds_bucket{{transport=\"{}\",le=\"+Inf\"}} {}",
385                transport.label(),
386                count
387            );
388            let sum_seconds = (histogram.sum_micros.load(Ordering::Relaxed) as f64) / 1_000_000.0;
389            let _ = writeln!(
390                body,
391                "http_execution_queue_wait_seconds_sum{{transport=\"{}\"}} {}",
392                transport.label(),
393                sum_seconds
394            );
395            let _ = writeln!(
396                body,
397                "http_execution_queue_wait_seconds_count{{transport=\"{}\"}} {}",
398                transport.label(),
399                count
400            );
401        }
402    }
403}
404
405impl Default for HttpHandlerMetrics {
406    fn default() -> Self {
407        Self::new()
408    }
409}
410
411fn format_bucket_le(le: f64) -> String {
412    // Match Prometheus's exposition formatting: trailing zeros are
413    // significant for canonical bucket labels, so we use the same
414    // shape that the upstream client library emits.
415    if le == le.trunc() && le.abs() < 1e16 {
416        format!("{le:.1}")
417    } else {
418        format!("{le}")
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn rejected_counters_isolated_by_label() {
428        let m = HttpHandlerMetrics::new();
429        m.record_reject(HttpTransport::Http, HttpRejectReason::CapExhausted);
430        m.record_reject(HttpTransport::Http, HttpRejectReason::CapExhausted);
431        m.record_reject(HttpTransport::Https, HttpRejectReason::HandlerTimeout);
432        assert_eq!(
433            m.rejected_count(HttpTransport::Http, HttpRejectReason::CapExhausted),
434            2
435        );
436        assert_eq!(
437            m.rejected_count(HttpTransport::Http, HttpRejectReason::HandlerTimeout),
438            0
439        );
440        assert_eq!(
441            m.rejected_count(HttpTransport::Https, HttpRejectReason::HandlerTimeout),
442            1
443        );
444        assert_eq!(
445            m.rejected_count(HttpTransport::Https, HttpRejectReason::CapExhausted),
446            0
447        );
448    }
449
450    #[test]
451    fn duration_histogram_buckets_are_cumulative() {
452        let m = HttpHandlerMetrics::new();
453        m.record_duration(HttpTransport::Http, 0.003);
454        m.record_duration(HttpTransport::Http, 0.04);
455        m.record_duration(HttpTransport::Http, 3.0);
456        assert_eq!(m.duration_sample_count(HttpTransport::Http), 3);
457
458        let limiter = HttpConnectionLimiter::new(4);
459        let execution = ExecutionAdmission::new(2, 2);
460        let mut body = String::new();
461        m.render_with_execution(&mut body, &limiter, &execution);
462
463        // `le="0.005"` includes only the 3ms sample (cumulative).
464        assert!(body
465            .contains("http_handler_duration_seconds_bucket{transport=\"http\",le=\"0.005\"} 1"));
466        // `le="0.05"` includes the 3ms + 40ms samples.
467        assert!(
468            body.contains("http_handler_duration_seconds_bucket{transport=\"http\",le=\"0.05\"} 2")
469        );
470        // `le="+Inf"` sees all 3 samples.
471        assert!(
472            body.contains("http_handler_duration_seconds_bucket{transport=\"http\",le=\"+Inf\"} 3")
473        );
474        // HTTPS labelset present but empty.
475        assert!(body
476            .contains("http_handler_duration_seconds_bucket{transport=\"https\",le=\"+Inf\"} 0"));
477    }
478
479    #[test]
480    fn render_includes_cap_and_current_from_limiter() {
481        let limiter = HttpConnectionLimiter::new(7);
482        let execution = ExecutionAdmission::new(2, 5);
483        let _p = limiter.try_acquire().unwrap();
484        let m = HttpHandlerMetrics::new();
485        let mut body = String::new();
486        m.render_with_execution(&mut body, &limiter, &execution);
487        assert!(body.contains("http_handler_cap{transport=\"http\"} 7"));
488        assert!(body.contains("http_handler_cap{transport=\"https\"} 7"));
489        assert!(body.contains("http_active_handler_threads{transport=\"http\"} 1"));
490        assert!(body.contains("http_active_handler_threads{transport=\"https\"} 1"));
491        assert!(body.contains("http_execution_workers 2"));
492        assert!(body.contains("http_execution_queue_capacity 5"));
493    }
494
495    #[test]
496    fn render_emits_all_rejection_labels() {
497        let m = HttpHandlerMetrics::new();
498        let limiter = HttpConnectionLimiter::new(1);
499        let execution = ExecutionAdmission::new(1, 0);
500        let mut body = String::new();
501        m.render_with_execution(&mut body, &limiter, &execution);
502        for transport in ["http", "https"] {
503            for reason in [
504                "cap_exhausted",
505                "handler_timeout",
506                "execution_queue_full",
507                "execution_queue_timeout",
508                "principal_cap_exhausted",
509            ] {
510                let expected = format!(
511                    "http_handler_rejected_total{{transport=\"{transport}\",reason=\"{reason}\"}} 0"
512                );
513                assert!(body.contains(&expected), "missing line: {expected}");
514            }
515        }
516    }
517
518    #[test]
519    fn negative_or_nan_durations_are_ignored() {
520        let m = HttpHandlerMetrics::new();
521        m.record_duration(HttpTransport::Http, -1.0);
522        m.record_duration(HttpTransport::Http, f64::NAN);
523        m.record_duration(HttpTransport::Http, f64::INFINITY);
524        assert_eq!(m.duration_sample_count(HttpTransport::Http), 0);
525    }
526
527    #[test]
528    fn queue_wait_histogram_is_distinct_from_handler_duration() {
529        let metrics = HttpHandlerMetrics::new();
530        metrics.record_queue_wait(HttpTransport::Http, 0.04);
531        assert_eq!(metrics.queue_wait_sample_count(HttpTransport::Http), 1);
532        assert_eq!(metrics.duration_sample_count(HttpTransport::Http), 0);
533
534        let limiter = HttpConnectionLimiter::new(4);
535        let execution = ExecutionAdmission::new(1, 3);
536        let mut body = String::new();
537        metrics.render_with_execution(&mut body, &limiter, &execution);
538        assert!(body.contains(
539            "http_execution_queue_wait_seconds_bucket{transport=\"http\",le=\"0.05\"} 1"
540        ));
541        assert!(body.contains("http_execution_queue_wait_seconds_count{transport=\"http\"} 1"));
542    }
543}