Skip to main content

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