Skip to main content

mockforge_foundation/
rate_counters.rs

1//! Per-second rate counters for HTTP traffic.
2//!
3//! Simple monotonic atomic counters. The HTTP metrics middleware bumps
4//! the response counters; the TCP-accept counter is bumped at the listener
5//! level (see `mockforge-http`'s `CountingTcpListener` and `mockforge-chaos`'s
6//! `ChaosTcpListener`).
7//!
8//! These are sampled at fixed intervals by the admin dashboard collector
9//! to derive per-second rates:
10//!
11//! * **TPS** — successful (2xx/3xx) responses per second
12//! * **RPS** — 200-OK responses per second
13//! * **CPS** — accepted TCP connections per second  *(plain HTTP only;
14//!   the TLS path uses `axum_server`'s own accept loop and is not yet
15//!   instrumented)*
16//!
17//! The "successful API transaction" definition for TPS is `200..=399`,
18//! matching how load-testing tools (k6, JMeter, etc.) classify a successful
19//! request — anything that wasn't a 4xx/5xx error.
20//!
21//! Connection-lifecycle counters (added in 0.3.134 / issue #79 round 6 —
22//! Srikanth's "how many open at any time" ask):
23//!
24//! * `HTTP_CONNECTIONS_OPEN` — currently open (incremented on accept,
25//!   decremented on connection drop). The dashboard sampler reads this
26//!   for the live "Active Connections" gauge in the TUI / admin UI.
27//! * `HTTP_CONNECTIONS_CLOSED_TOTAL` — lifetime closed. `accepts - closed`
28//!   equals open at any sampling instant; we keep both so a single load
29//!   can read either without doing subtraction.
30
31use std::sync::atomic::{AtomicU64, Ordering};
32
33/// Total successful HTTP responses (status `200..=399`).
34pub static SUCCESSFUL_RESPONSES_TOTAL: AtomicU64 = AtomicU64::new(0);
35
36/// Total HTTP `200 OK` responses.
37pub static OK_RESPONSES_TOTAL: AtomicU64 = AtomicU64::new(0);
38
39/// Total accepted TCP connections (plain HTTP path only — see module docs).
40pub static HTTP_ACCEPTS_TOTAL: AtomicU64 = AtomicU64::new(0);
41
42/// Currently-open HTTP connections (incremented on accept, decremented on
43/// connection drop). Issue #79 round 6.
44pub static HTTP_CONNECTIONS_OPEN: AtomicU64 = AtomicU64::new(0);
45
46/// Lifetime closed HTTP connections. Issue #79 round 6.
47pub static HTTP_CONNECTIONS_CLOSED_TOTAL: AtomicU64 = AtomicU64::new(0);
48
49/// Bump the response counters according to the response's status code.
50#[inline]
51pub fn record_response(status_code: u16) {
52    if (200..=399).contains(&status_code) {
53        SUCCESSFUL_RESPONSES_TOTAL.fetch_add(1, Ordering::Relaxed);
54    }
55    if status_code == 200 {
56        OK_RESPONSES_TOTAL.fetch_add(1, Ordering::Relaxed);
57    }
58}
59
60/// Bump the TCP-accept counter AND the currently-open gauge. Call once per
61/// accepted connection — paired with [`record_close`] when the connection
62/// terminates.
63#[inline]
64pub fn record_accept() {
65    HTTP_ACCEPTS_TOTAL.fetch_add(1, Ordering::Relaxed);
66    HTTP_CONNECTIONS_OPEN.fetch_add(1, Ordering::Relaxed);
67}
68
69/// Decrement the currently-open gauge and bump the lifetime-closed counter.
70/// Call once when a previously-accepted connection terminates.
71#[inline]
72pub fn record_close() {
73    // Saturating decrement — a stray `record_close` without a matching
74    // `record_accept` should not wrap around to `u64::MAX`.
75    let prev = HTTP_CONNECTIONS_OPEN.load(Ordering::Relaxed);
76    if prev > 0 {
77        HTTP_CONNECTIONS_OPEN.fetch_sub(1, Ordering::Relaxed);
78    }
79    HTTP_CONNECTIONS_CLOSED_TOTAL.fetch_add(1, Ordering::Relaxed);
80}
81
82/// Point-in-time snapshot of the rate counters.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
84pub struct CounterSnapshot {
85    pub successful: u64,
86    pub ok: u64,
87    pub accepts: u64,
88    /// Currently-open HTTP connections.
89    pub connections_open: u64,
90    /// Lifetime closed HTTP connections.
91    pub connections_closed: u64,
92}
93
94/// Read all counters atomically (each load is independent — a snapshot
95/// is not transactional, but for sampling rates this is fine).
96pub fn snapshot() -> CounterSnapshot {
97    CounterSnapshot {
98        successful: SUCCESSFUL_RESPONSES_TOTAL.load(Ordering::Relaxed),
99        ok: OK_RESPONSES_TOTAL.load(Ordering::Relaxed),
100        accepts: HTTP_ACCEPTS_TOTAL.load(Ordering::Relaxed),
101        connections_open: HTTP_CONNECTIONS_OPEN.load(Ordering::Relaxed),
102        connections_closed: HTTP_CONNECTIONS_CLOSED_TOTAL.load(Ordering::Relaxed),
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use std::sync::Mutex;
110
111    // The counters are global; serialize tests to avoid cross-test interference.
112    static TEST_LOCK: Mutex<()> = Mutex::new(());
113
114    fn reset_counters() {
115        SUCCESSFUL_RESPONSES_TOTAL.store(0, Ordering::Relaxed);
116        OK_RESPONSES_TOTAL.store(0, Ordering::Relaxed);
117        HTTP_ACCEPTS_TOTAL.store(0, Ordering::Relaxed);
118        HTTP_CONNECTIONS_OPEN.store(0, Ordering::Relaxed);
119        HTTP_CONNECTIONS_CLOSED_TOTAL.store(0, Ordering::Relaxed);
120    }
121
122    #[test]
123    fn record_response_classifies_2xx_as_successful() {
124        let _g = TEST_LOCK.lock().unwrap();
125        reset_counters();
126        record_response(200);
127        record_response(204);
128        record_response(301);
129        let s = snapshot();
130        assert_eq!(s.successful, 3, "200, 204, 301 are all successful");
131        assert_eq!(s.ok, 1, "only one 200");
132    }
133
134    #[test]
135    fn record_response_excludes_4xx_5xx_from_successful() {
136        let _g = TEST_LOCK.lock().unwrap();
137        reset_counters();
138        record_response(404);
139        record_response(429);
140        record_response(500);
141        record_response(503);
142        let s = snapshot();
143        assert_eq!(s.successful, 0);
144        assert_eq!(s.ok, 0);
145    }
146
147    #[test]
148    fn record_accept_increments() {
149        let _g = TEST_LOCK.lock().unwrap();
150        reset_counters();
151        record_accept();
152        record_accept();
153        record_accept();
154        let s = snapshot();
155        assert_eq!(s.accepts, 3);
156    }
157
158    #[test]
159    fn snapshot_returns_current_values() {
160        let _g = TEST_LOCK.lock().unwrap();
161        reset_counters();
162        record_response(200);
163        record_response(200);
164        record_accept();
165        let s = snapshot();
166        assert_eq!(s.successful, 2);
167        assert_eq!(s.ok, 2);
168        assert_eq!(s.accepts, 1);
169    }
170
171    #[test]
172    fn record_accept_bumps_open_gauge() {
173        let _g = TEST_LOCK.lock().unwrap();
174        reset_counters();
175        record_accept();
176        record_accept();
177        let s = snapshot();
178        assert_eq!(s.accepts, 2, "lifetime accepts");
179        assert_eq!(s.connections_open, 2, "currently open");
180        assert_eq!(s.connections_closed, 0);
181    }
182
183    #[test]
184    fn record_close_decrements_open_and_bumps_closed() {
185        let _g = TEST_LOCK.lock().unwrap();
186        reset_counters();
187        record_accept();
188        record_accept();
189        record_close();
190        let s = snapshot();
191        assert_eq!(s.accepts, 2);
192        assert_eq!(s.connections_open, 1, "one still open");
193        assert_eq!(s.connections_closed, 1);
194    }
195
196    #[test]
197    fn record_close_without_matching_accept_does_not_underflow() {
198        let _g = TEST_LOCK.lock().unwrap();
199        reset_counters();
200        // Defensive: a stray close (e.g. across reset boundaries during shutdown)
201        // must not underflow to u64::MAX.
202        record_close();
203        record_close();
204        let s = snapshot();
205        assert_eq!(s.connections_open, 0, "saturating decrement");
206        assert_eq!(s.connections_closed, 2, "closed still tallied");
207    }
208
209    #[test]
210    fn ok_counter_only_for_status_200() {
211        let _g = TEST_LOCK.lock().unwrap();
212        reset_counters();
213        record_response(200);
214        record_response(201);
215        record_response(204);
216        let s = snapshot();
217        assert_eq!(s.ok, 1, "only 200 increments ok counter");
218        assert_eq!(s.successful, 3, "all three are successful");
219    }
220}