Skip to main content

rust_web_server/metrics/
mod.rs

1//! Server-wide and per-route Prometheus metrics.
2//!
3//! **Server-wide counters** (`REQUESTS_TOTAL`, `ERRORS_TOTAL`,
4//! `ACTIVE_CONNECTIONS`) are updated by the server core automatically.
5//!
6//! **Per-route metrics** are opt-in: wrap your application with
7//! [`MetricsLayer`] and each request will be attributed to its
8//! `(method, path)` pair, emitting:
9//! - `rws_route_requests_total{method,path,status}` — request counts
10//! - `rws_route_duration_seconds{method,path}` — latency histogram
11//!
12//! **HTTP server duration** — `http_server_request_duration_seconds`, the
13//! OpenTelemetry `http.server.request.duration` metric, recorded by the server
14//! core for every request ([`http`]); native-histogram buckets are served in
15//! the protobuf format ([`exposition`]) to a scraper that asks for it.
16//!
17//! **Circuit breaker state** — `rws_circuit_breaker_state{backend}` (gauge,
18//! `0`=closed, `1`=half_open, `2`=open) is emitted automatically for every
19//! backend known to [`crate::circuit_breaker::global`], with no opt-in layer
20//! needed — see [`crate::circuit_breaker`] and
21//! [`crate::proxy::ReverseProxy::with_circuit_breaker`].
22//!
23//! # Example
24//!
25//! ```rust,no_run
26//! use rust_web_server::app::App;
27//! use rust_web_server::core::New;
28//! use rust_web_server::metrics::MetricsLayer;
29//!
30//! let app = App::new().wrap(MetricsLayer);
31//! ```
32
33use std::collections::HashMap;
34use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
35use std::sync::{Mutex, OnceLock};
36use std::time::Instant;
37
38use crate::application::Application;
39use crate::middleware::Middleware;
40use crate::request::Request;
41use crate::response::Response;
42use crate::server::ConnectionInfo;
43
44pub mod exposition;
45pub mod http;
46#[cfg(not(target_arch = "wasm32"))]
47pub mod llm;
48pub mod tls;
49
50// ── server-wide atomics ───────────────────────────────────────────────────────
51
52/// Set to `true` after [`crate::server::Server::setup`] completes.
53/// The `/readyz` controller returns `503` until this is `true`.
54/// Set back to `false` when a shutdown signal is received so that
55/// Kubernetes stops routing traffic before the pod exits.
56pub static SERVER_READY: AtomicBool = AtomicBool::new(false);
57
58/// Total HTTP requests handled across all connections and protocols.
59pub static REQUESTS_TOTAL: AtomicU64 = AtomicU64::new(0);
60
61/// Requests that caused an application-level error (app.execute returned Err).
62pub static ERRORS_TOTAL: AtomicU64 = AtomicU64::new(0);
63
64/// Number of currently open TCP/QUIC connections.
65pub static ACTIVE_CONNECTIONS: AtomicI64 = AtomicI64::new(0);
66
67/// Jobs queued in the thread pool waiting for a free worker.
68pub static THREAD_POOL_QUEUED: AtomicI64 = AtomicI64::new(0);
69
70pub fn record_request() {
71    REQUESTS_TOTAL.fetch_add(1, Ordering::Relaxed);
72}
73
74pub fn record_error() {
75    ERRORS_TOTAL.fetch_add(1, Ordering::Relaxed);
76}
77
78pub fn connection_open() {
79    ACTIVE_CONNECTIONS.fetch_add(1, Ordering::Relaxed);
80}
81
82pub fn connection_close() {
83    ACTIVE_CONNECTIONS.fetch_sub(1, Ordering::Relaxed);
84}
85
86// ── per-route store ───────────────────────────────────────────────────────────
87
88/// Histogram bucket upper bounds (seconds). Matches the default Prometheus
89/// buckets used by `prometheus_client` and most Go instrumentation libraries.
90const BUCKET_BOUNDS: [f64; 11] = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0];
91
92struct HistogramEntry {
93    /// `buckets[i]` = cumulative count of observations where duration ≤ `BUCKET_BOUNDS[i]`.
94    buckets: [u64; 11],
95    sum: f64,
96    count: u64,
97}
98
99impl HistogramEntry {
100    fn new() -> Self {
101        HistogramEntry { buckets: [0; 11], sum: 0.0, count: 0 }
102    }
103
104    fn observe(&mut self, secs: f64) {
105        for (i, &upper) in BUCKET_BOUNDS.iter().enumerate() {
106            if secs <= upper {
107                self.buckets[i] += 1;
108            }
109        }
110        self.sum += secs;
111        self.count += 1;
112    }
113}
114
115struct RouteEntry {
116    counts: HashMap<i16, u64>,
117    latency: HistogramEntry,
118}
119
120impl RouteEntry {
121    fn new() -> Self {
122        RouteEntry { counts: HashMap::new(), latency: HistogramEntry::new() }
123    }
124}
125
126struct RouteStore {
127    /// Key: `(method, path)` — path has query string stripped.
128    entries: HashMap<(String, String), RouteEntry>,
129}
130
131static ROUTE_STORE: OnceLock<Mutex<RouteStore>> = OnceLock::new();
132
133fn route_store() -> &'static Mutex<RouteStore> {
134    ROUTE_STORE.get_or_init(|| Mutex::new(RouteStore { entries: HashMap::new() }))
135}
136
137/// Record a completed request in the per-route store.
138///
139/// `path` must have the query string already stripped. Called automatically by
140/// [`MetricsLayer`]; exposed publicly for testing and custom instrumentation.
141pub fn record_route(method: &str, path: &str, status: i16, elapsed_secs: f64) {
142    let key = (method.to_string(), path.to_string());
143    let mut guard = route_store().lock().unwrap();
144    let entry = guard.entries.entry(key).or_insert_with(RouteEntry::new);
145    *entry.counts.entry(status).or_insert(0) += 1;
146    entry.latency.observe(elapsed_secs);
147}
148
149/// Strip query string from a URI so `/users?page=2` → `/users`.
150fn strip_query(uri: &str) -> &str {
151    match uri.find('?') {
152        Some(i) => &uri[..i],
153        None => uri,
154    }
155}
156
157// ── MetricsLayer middleware ───────────────────────────────────────────────────
158
159/// Middleware that records per-route request counts and latency histograms.
160///
161/// Wrap any application with this layer once at startup; the data is collected
162/// into a global store and emitted via `GET /metrics`.
163///
164/// ```rust,no_run
165/// use rust_web_server::app::App;
166/// use rust_web_server::core::New;
167/// use rust_web_server::metrics::MetricsLayer;
168///
169/// let app = App::new().wrap(MetricsLayer);
170/// ```
171pub struct MetricsLayer;
172
173impl Middleware for MetricsLayer {
174    /// Headers only: a streamed body passes through untouched.
175    fn accepts_streamed_body(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
176        true
177    }
178
179
180    fn handle(
181        &self,
182        request: &Request,
183        connection: &ConnectionInfo,
184        next: &dyn Application,
185    ) -> Result<Response, String> {
186        let start = Instant::now();
187        let result = next.execute(request, connection);
188        let elapsed = start.elapsed().as_secs_f64();
189
190        let path = strip_query(&request.request_uri).to_string();
191        let status = match &result {
192            Ok(r) => r.status_code,
193            Err(_) => 500,
194        };
195        record_route(&request.method, &path, status, elapsed);
196
197        result
198    }
199
200    /// In-memory only: safe on the event loop.
201    fn runs_inline(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
202        true
203    }
204}
205
206// ── prometheus output ─────────────────────────────────────────────────────────
207
208#[cfg(test)]
209mod tests;
210
211/// Returns a Prometheus text-format snapshot of all server-wide and per-route metrics.
212pub fn prometheus_text() -> String {
213    let mut out = prometheus_text_without_http();
214    let http_text = http::prometheus_text();
215    if !http_text.is_empty() {
216        out.push('\n');
217        out.push_str(&http_text);
218    }
219    out
220}
221
222/// Everything but `http_server_request_duration_seconds` (which the protobuf
223/// format builds itself, with its native buckets).
224fn prometheus_text_without_http() -> String {
225    let requests = REQUESTS_TOTAL.load(Ordering::Relaxed);
226    let errors   = ERRORS_TOTAL.load(Ordering::Relaxed);
227    let active   = ACTIVE_CONNECTIONS.load(Ordering::Relaxed);
228
229    let mut out = format!(
230        "# HELP rws_requests_total Total HTTP requests handled\n\
231         # TYPE rws_requests_total counter\n\
232         rws_requests_total {}\n\n\
233         # HELP rws_errors_total HTTP requests that returned an application error\n\
234         # TYPE rws_errors_total counter\n\
235         rws_errors_total {}\n\n\
236         # HELP rws_active_connections Currently open connections\n\
237         # TYPE rws_active_connections gauge\n\
238         rws_active_connections {}\n",
239        requests, errors, active
240    );
241
242    let route_text = route_prometheus_text();
243    if !route_text.is_empty() {
244        out.push('\n');
245        out.push_str(&route_text);
246    }
247
248    let upstream_text = crate::proxy_config::upstream::prometheus_text();
249    if !upstream_text.is_empty() {
250        out.push('\n');
251        out.push_str(&upstream_text);
252    }
253
254    #[cfg(not(target_arch = "wasm32"))]
255    {
256        let llm_text = llm::prometheus_text();
257        if !llm_text.is_empty() {
258            out.push('\n');
259            out.push_str(&llm_text);
260        }
261    }
262
263    let tls_text = tls::prometheus_text();
264    if !tls_text.is_empty() {
265        out.push('\n');
266        out.push_str(&tls_text);
267    }
268    let breaker_text = circuit_breaker_prometheus_text();
269    if !breaker_text.is_empty() {
270        out.push('\n');
271        out.push_str(&breaker_text);
272    }
273
274    out
275}
276
277/// `rws_circuit_breaker_state{backend}` — the state of every backend known to
278/// the process-wide [`crate::circuit_breaker::global`] breaker, encoded as
279/// `0` (Closed/healthy), `1` (HalfOpen/probing), or `2` (Open/unhealthy).
280///
281/// Only the in-memory global breaker is covered — `RedisCircuitBreaker`
282/// state can't be enumerated here (no `SCAN`/`KEYS` support in the minimal
283/// hand-rolled RESP client; see [`crate::circuit_breaker::CircuitBreaker::all_states`]
284/// docs). A backend never seen by the global breaker (e.g. one whose
285/// `ReverseProxy` was wired to a different, non-global breaker instance)
286/// emits no line rather than a synthetic `0` — there's nothing to report.
287// `circuit_breaker` is native-only (backed by `redis_protocol`'s `TcpStream`)
288// — see spec/WASM_SHIM.md. No breaker state exists to report under wasm32.
289#[cfg(target_arch = "wasm32")]
290fn circuit_breaker_prometheus_text() -> String {
291    String::new()
292}
293
294#[cfg(not(target_arch = "wasm32"))]
295fn circuit_breaker_prometheus_text() -> String {
296    let mut states = crate::circuit_breaker::global().lock().unwrap().all_states();
297    if states.is_empty() {
298        return String::new();
299    }
300    states.sort_by(|a, b| a.0.cmp(&b.0));
301
302    let mut out = String::new();
303    out.push_str("# HELP rws_circuit_breaker_state Circuit breaker state per backend (0=closed, 1=half_open, 2=open)\n");
304    out.push_str("# TYPE rws_circuit_breaker_state gauge\n");
305    for (backend, state) in states {
306        let value = match state {
307            crate::circuit_breaker::BreakerState::Closed => 0,
308            crate::circuit_breaker::BreakerState::HalfOpen => 1,
309            crate::circuit_breaker::BreakerState::Open => 2,
310        };
311        out.push_str(&format!("rws_circuit_breaker_state{{backend=\"{}\"}} {}\n", backend, value));
312    }
313    out
314}
315
316fn route_prometheus_text() -> String {
317    let guard = route_store().lock().unwrap();
318    if guard.entries.is_empty() {
319        return String::new();
320    }
321
322    // Sort for deterministic output.
323    let mut keys: Vec<&(String, String)> = guard.entries.keys().collect();
324    keys.sort();
325
326    let mut out = String::new();
327
328    // ── rws_route_requests_total ──────────────────────────────────────────────
329    out.push_str("# HELP rws_route_requests_total Total requests handled per route\n");
330    out.push_str("# TYPE rws_route_requests_total counter\n");
331    for key in &keys {
332        let entry = &guard.entries[key];
333        let (method, path) = key;
334        let mut statuses: Vec<i16> = entry.counts.keys().cloned().collect();
335        statuses.sort();
336        for status in statuses {
337            let count = entry.counts[&status];
338            out.push_str(&format!(
339                "rws_route_requests_total{{method=\"{}\",path=\"{}\",status=\"{}\"}} {}\n",
340                method, path, status, count,
341            ));
342        }
343    }
344
345    // ── rws_route_duration_seconds histogram ──────────────────────────────────
346    out.push('\n');
347    out.push_str("# HELP rws_route_duration_seconds Request duration in seconds per route\n");
348    out.push_str("# TYPE rws_route_duration_seconds histogram\n");
349    for key in &keys {
350        let entry = &guard.entries[key];
351        let (method, path) = key;
352        let lat = &entry.latency;
353
354        for (i, &upper) in BUCKET_BOUNDS.iter().enumerate() {
355            out.push_str(&format!(
356                "rws_route_duration_seconds_bucket{{method=\"{}\",path=\"{}\",le=\"{}\"}} {}\n",
357                method, path, upper, lat.buckets[i],
358            ));
359        }
360        out.push_str(&format!(
361            "rws_route_duration_seconds_bucket{{method=\"{}\",path=\"{}\",le=\"+Inf\"}} {}\n",
362            method, path, lat.count,
363        ));
364        out.push_str(&format!(
365            "rws_route_duration_seconds_sum{{method=\"{}\",path=\"{}\"}} {:.9}\n",
366            method, path, lat.sum,
367        ));
368        out.push_str(&format!(
369            "rws_route_duration_seconds_count{{method=\"{}\",path=\"{}\"}} {}\n",
370            method, path, lat.count,
371        ));
372    }
373
374    out
375}