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