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    #[cfg(not(target_arch = "wasm32"))]
249    let upstream_text = crate::proxy_config::upstream::prometheus_text();
250    #[cfg(target_arch = "wasm32")]
251    let upstream_text = String::new();
252    if !upstream_text.is_empty() {
253        out.push('\n');
254        out.push_str(&upstream_text);
255    }
256    let waf_text = crate::waf::prometheus_text();
257    if !waf_text.is_empty() {
258        out.push('\n');
259        out.push_str(&waf_text);
260    }
261
262    #[cfg(not(target_arch = "wasm32"))]
263    {
264        let mirror_text = crate::proxy::mirror::prometheus_text();
265        if !mirror_text.is_empty() {
266            out.push('\n');
267            out.push_str(&mirror_text);
268        }
269    }
270
271    let disk_text = crate::disk_guard::prometheus_text();
272    if !disk_text.is_empty() {
273        out.push('\n');
274        out.push_str(&disk_text);
275    }
276
277    let hints_text = crate::early_hints::prometheus_text();
278    if !hints_text.is_empty() {
279        out.push('\n');
280        out.push_str(&hints_text);
281    }
282
283    #[cfg(feature = "wasm-plugins")]
284    {
285        let wasm_text = crate::wasm_plugin::metrics::prometheus_text();
286        if !wasm_text.is_empty() {
287            out.push('\n');
288            out.push_str(&wasm_text);
289        }
290    }
291
292    #[cfg(not(target_arch = "wasm32"))]
293    {
294        let llm_text = llm::prometheus_text();
295        if !llm_text.is_empty() {
296            out.push('\n');
297            out.push_str(&llm_text);
298        }
299    }
300
301    let tls_text = tls::prometheus_text();
302    if !tls_text.is_empty() {
303        out.push('\n');
304        out.push_str(&tls_text);
305    }
306    let breaker_text = circuit_breaker_prometheus_text();
307    if !breaker_text.is_empty() {
308        out.push('\n');
309        out.push_str(&breaker_text);
310    }
311
312    out
313}
314
315/// `rws_circuit_breaker_state{backend}` — the state of every backend known to
316/// the process-wide [`crate::circuit_breaker::global`] breaker, encoded as
317/// `0` (Closed/healthy), `1` (HalfOpen/probing), or `2` (Open/unhealthy).
318///
319/// Only the in-memory global breaker is covered — `RedisCircuitBreaker`
320/// state can't be enumerated here (no `SCAN`/`KEYS` support in the minimal
321/// hand-rolled RESP client; see [`crate::circuit_breaker::CircuitBreaker::all_states`]
322/// docs). A backend never seen by the global breaker (e.g. one whose
323/// `ReverseProxy` was wired to a different, non-global breaker instance)
324/// emits no line rather than a synthetic `0` — there's nothing to report.
325// `circuit_breaker` is native-only (backed by `redis_protocol`'s `TcpStream`)
326// — see spec/WASM_SHIM.md. No breaker state exists to report under wasm32.
327#[cfg(target_arch = "wasm32")]
328fn circuit_breaker_prometheus_text() -> String {
329    String::new()
330}
331
332#[cfg(not(target_arch = "wasm32"))]
333fn circuit_breaker_prometheus_text() -> String {
334    let mut states = crate::circuit_breaker::global().lock().unwrap().all_states();
335    if states.is_empty() {
336        return String::new();
337    }
338    states.sort_by(|a, b| a.0.cmp(&b.0));
339
340    let mut out = String::new();
341    out.push_str("# HELP rws_circuit_breaker_state Circuit breaker state per backend (0=closed, 1=half_open, 2=open)\n");
342    out.push_str("# TYPE rws_circuit_breaker_state gauge\n");
343    for (backend, state) in states {
344        let value = match state {
345            crate::circuit_breaker::BreakerState::Closed => 0,
346            crate::circuit_breaker::BreakerState::HalfOpen => 1,
347            crate::circuit_breaker::BreakerState::Open => 2,
348        };
349        out.push_str(&format!("rws_circuit_breaker_state{{backend=\"{}\"}} {}\n", backend, value));
350    }
351    out
352}
353
354fn route_prometheus_text() -> String {
355    let guard = route_store().lock().unwrap();
356    if guard.entries.is_empty() {
357        return String::new();
358    }
359
360    // Sort for deterministic output.
361    let mut keys: Vec<&(String, String)> = guard.entries.keys().collect();
362    keys.sort();
363
364    let mut out = String::new();
365
366    // ── rws_route_requests_total ──────────────────────────────────────────────
367    out.push_str("# HELP rws_route_requests_total Total requests handled per route\n");
368    out.push_str("# TYPE rws_route_requests_total counter\n");
369    for key in &keys {
370        let entry = &guard.entries[*key];
371        let (method, path) = key;
372        let mut statuses: Vec<i16> = entry.counts.keys().cloned().collect();
373        statuses.sort();
374        for status in statuses {
375            let count = entry.counts[&status];
376            out.push_str(&format!(
377                "rws_route_requests_total{{method=\"{}\",path=\"{}\",status=\"{}\"}} {}\n",
378                method, path, status, count,
379            ));
380        }
381    }
382
383    // ── rws_route_duration_seconds histogram ──────────────────────────────────
384    out.push('\n');
385    out.push_str("# HELP rws_route_duration_seconds Request duration in seconds per route\n");
386    out.push_str("# TYPE rws_route_duration_seconds histogram\n");
387    for key in &keys {
388        let entry = &guard.entries[*key];
389        let (method, path) = key;
390        let lat = &entry.latency;
391
392        for (i, &upper) in BUCKET_BOUNDS.iter().enumerate() {
393            out.push_str(&format!(
394                "rws_route_duration_seconds_bucket{{method=\"{}\",path=\"{}\",le=\"{}\"}} {}\n",
395                method, path, upper, lat.buckets[i],
396            ));
397        }
398        out.push_str(&format!(
399            "rws_route_duration_seconds_bucket{{method=\"{}\",path=\"{}\",le=\"+Inf\"}} {}\n",
400            method, path, lat.count,
401        ));
402        out.push_str(&format!(
403            "rws_route_duration_seconds_sum{{method=\"{}\",path=\"{}\"}} {:.9}\n",
404            method, path, lat.sum,
405        ));
406        out.push_str(&format!(
407            "rws_route_duration_seconds_count{{method=\"{}\",path=\"{}\"}} {}\n",
408            method, path, lat.count,
409        ));
410    }
411
412    out
413}