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