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