Skip to main content

ws_kit/
stats.rs

1//! Backpressure and traffic metrics.
2//!
3//! [`StatsRecorder`] is a cheap-to-clone handle over atomic counters —
4//! global (all connections/rooms) and per-room. It is the single hook for
5//! the numbers an operator needs under load:
6//!
7//! - **connections** — accepted sockets (integrator accept loop)
8//! - **rooms** — live room count (wired into [`crate::room::RoomManager`])
9//! - **messages/bytes in/out** — traffic volume; "in" is recorded by the
10//!   integrator's read loop (ws-kit provides building blocks, not the read
11//!   loop), "out" is recorded by room broadcasts and by hub callers
12//! - **drops** — messages that hit a closed/empty broadcast channel
13//!   (`WsError::BroadcastFull`), i.e. backpressure that lost data
14//!
15//! Read state via [`StatsRecorder::snapshot`] — an immutable [`Stats`]
16//! struct for logging, health endpoints, or assertion in tests.
17//!
18//! With the optional `metrics` feature (off by default) the same counters
19//! are additionally emitted through the [`metrics`] facade
20//! (`ws_kit_*` counters/gauges), exportable to Prometheus/OTLP with any
21//! exporter — the same pattern the `breaker` crate uses.
22//!
23//! # Example
24//!
25//! ```
26//! use ws_kit::stats::StatsRecorder;
27//!
28//! let stats = StatsRecorder::new();
29//! stats.inc_connections();
30//! stats.record_message_in(128);
31//! let snap = stats.snapshot();
32//! assert_eq!(snap.connections, 1);
33//! assert_eq!(snap.messages_in, 1);
34//! assert_eq!(snap.bytes_in, 128);
35//! ```
36
37use std::collections::BTreeMap;
38use std::sync::atomic::{AtomicU64, Ordering};
39use std::sync::Arc;
40
41use dashmap::DashMap;
42
43/// Traffic counters for a single room.
44#[derive(Debug, Clone, Default, PartialEq, Eq)]
45pub struct RoomStats {
46    /// Messages recorded as received in this room (integrator read loop).
47    pub messages_in: u64,
48    /// Messages fanned out to this room's receivers.
49    pub messages_out: u64,
50    /// Payload bytes received for this room.
51    pub bytes_in: u64,
52    /// Payload bytes written to this room's receivers.
53    pub bytes_out: u64,
54    /// Broadcast attempts that found no live receiver (data lost).
55    pub drops: u64,
56}
57
58/// Point-in-time snapshot of global and per-room counters.
59///
60/// Produced by [`StatsRecorder::snapshot`]; deltas between two snapshots
61/// give rates.
62#[derive(Debug, Clone, Default, PartialEq, Eq)]
63pub struct Stats {
64    /// Live connection count (accept minus close).
65    pub connections: u64,
66    /// Live room count.
67    pub rooms: u64,
68    /// Global messages recorded inbound (includes all room traffic).
69    pub messages_in: u64,
70    /// Global messages fanned outbound (includes all room traffic).
71    pub messages_out: u64,
72    /// Global inbound payload bytes.
73    pub bytes_in: u64,
74    /// Global outbound payload bytes.
75    pub bytes_out: u64,
76    /// Global drops: broadcasts with no live receiver (backpressure loss).
77    pub drops: u64,
78    /// Per-room counters, keyed by room id (sorted for stable output).
79    pub per_room: BTreeMap<String, RoomStats>,
80}
81
82#[derive(Debug, Default)]
83struct Globals {
84    connections: AtomicU64,
85    rooms: AtomicU64,
86    messages_in: AtomicU64,
87    messages_out: AtomicU64,
88    bytes_in: AtomicU64,
89    bytes_out: AtomicU64,
90    drops: AtomicU64,
91}
92
93#[derive(Debug, Default)]
94struct RoomCounters {
95    messages_in: AtomicU64,
96    messages_out: AtomicU64,
97    bytes_in: AtomicU64,
98    bytes_out: AtomicU64,
99    drops: AtomicU64,
100}
101
102#[derive(Debug, Default)]
103struct Inner {
104    globals: Globals,
105    rooms: DashMap<String, RoomCounters>,
106}
107
108/// Cheap-to-clone handle over global + per-room atomic counters.
109///
110/// All recording methods are lock-free (`AtomicU64` / sharded `DashMap`)
111/// and safe to call from any thread. Clone handles share state.
112#[derive(Clone, Debug, Default)]
113pub struct StatsRecorder {
114    inner: Arc<Inner>,
115}
116
117impl StatsRecorder {
118    /// Create a recorder with all counters at zero.
119    pub fn new() -> Self {
120        Self::default()
121    }
122
123    /// Record a connection accepted. Returns the new count.
124    pub fn inc_connections(&self) -> u64 {
125        let v = self
126            .inner
127            .globals
128            .connections
129            .fetch_add(1, Ordering::Relaxed)
130            + 1;
131        #[cfg(feature = "metrics")]
132        ::metrics::gauge!("ws_kit_connections").set(v as f64);
133        v
134    }
135
136    /// Record a connection closed (saturating at zero). Returns the new count.
137    pub fn dec_connections(&self) -> u64 {
138        let v = saturating_dec(&self.inner.globals.connections);
139        #[cfg(feature = "metrics")]
140        ::metrics::gauge!("ws_kit_connections").set(v as f64);
141        v
142    }
143
144    /// Record a room coming into existence (manager created it).
145    pub fn room_opened(&self) {
146        let v = self.inner.globals.rooms.fetch_add(1, Ordering::Relaxed) + 1;
147        #[cfg(feature = "metrics")]
148        ::metrics::gauge!("ws_kit_rooms").set(v as f64);
149        #[cfg(not(feature = "metrics"))]
150        let _ = v;
151    }
152
153    /// Record a room removed (saturating at zero).
154    pub fn room_closed(&self) {
155        let v = saturating_dec(&self.inner.globals.rooms);
156        #[cfg(feature = "metrics")]
157        ::metrics::gauge!("ws_kit_rooms").set(v as f64);
158        #[cfg(not(feature = "metrics"))]
159        let _ = v;
160    }
161
162    /// Record an inbound message of `bytes` payload bytes (global only —
163    /// use [`StatsRecorder::record_room_message_in`] to also attribute it
164    /// to a room).
165    pub fn record_message_in(&self, bytes: u64) {
166        self.inner
167            .globals
168            .messages_in
169            .fetch_add(1, Ordering::Relaxed);
170        self.inner
171            .globals
172            .bytes_in
173            .fetch_add(bytes, Ordering::Relaxed);
174        #[cfg(feature = "metrics")]
175        {
176            ::metrics::counter!("ws_kit_messages_in_total").increment(1);
177            ::metrics::counter!("ws_kit_bytes_in_total").increment(bytes);
178        }
179    }
180
181    /// Record `count` outbound messages totalling `bytes` payload bytes
182    /// (global only).
183    pub fn record_message_out(&self, count: u64, bytes: u64) {
184        self.inner
185            .globals
186            .messages_out
187            .fetch_add(count, Ordering::Relaxed);
188        self.inner
189            .globals
190            .bytes_out
191            .fetch_add(bytes, Ordering::Relaxed);
192        #[cfg(feature = "metrics")]
193        {
194            ::metrics::counter!("ws_kit_messages_out_total").increment(count);
195            ::metrics::counter!("ws_kit_bytes_out_total").increment(bytes);
196        }
197    }
198
199    /// Record a drop: a broadcast that found no live receiver.
200    pub fn record_drop(&self) {
201        self.inner.globals.drops.fetch_add(1, Ordering::Relaxed);
202        #[cfg(feature = "metrics")]
203        ::metrics::counter!("ws_kit_drops_total").increment(1);
204    }
205
206    /// Record an inbound message attributed to `room` (also bumps globals).
207    pub fn record_room_message_in(&self, room: &str, bytes: u64) {
208        let counters = self.room_counters(room);
209        counters.messages_in.fetch_add(1, Ordering::Relaxed);
210        counters.bytes_in.fetch_add(bytes, Ordering::Relaxed);
211        drop(counters);
212        self.record_message_in(bytes);
213    }
214
215    /// Record a room broadcast reaching `count` receivers with `bytes`
216    /// total payload bytes (also bumps globals).
217    pub fn record_room_message_out(&self, room: &str, count: u64, bytes: u64) {
218        let counters = self.room_counters(room);
219        counters.messages_out.fetch_add(count, Ordering::Relaxed);
220        counters.bytes_out.fetch_add(bytes, Ordering::Relaxed);
221        drop(counters);
222        self.record_message_out(count, bytes);
223    }
224
225    /// Record a drop attributed to `room` (also bumps the global count).
226    pub fn record_room_drop(&self, room: &str) {
227        let counters = self.room_counters(room);
228        counters.drops.fetch_add(1, Ordering::Relaxed);
229        drop(counters);
230        self.record_drop();
231    }
232
233    /// Snapshot all counters. `Stats::per_room` includes every room that
234    /// ever recorded traffic (zeroed rooms remain listed).
235    pub fn snapshot(&self) -> Stats {
236        let g = &self.inner.globals;
237        let per_room: BTreeMap<String, RoomStats> = self
238            .inner
239            .rooms
240            .iter()
241            .map(|e| {
242                let c = e.value();
243                (
244                    e.key().clone(),
245                    RoomStats {
246                        messages_in: c.messages_in.load(Ordering::Relaxed),
247                        messages_out: c.messages_out.load(Ordering::Relaxed),
248                        bytes_in: c.bytes_in.load(Ordering::Relaxed),
249                        bytes_out: c.bytes_out.load(Ordering::Relaxed),
250                        drops: c.drops.load(Ordering::Relaxed),
251                    },
252                )
253            })
254            .collect();
255        Stats {
256            connections: g.connections.load(Ordering::Relaxed),
257            rooms: g.rooms.load(Ordering::Relaxed),
258            messages_in: g.messages_in.load(Ordering::Relaxed),
259            messages_out: g.messages_out.load(Ordering::Relaxed),
260            bytes_in: g.bytes_in.load(Ordering::Relaxed),
261            bytes_out: g.bytes_out.load(Ordering::Relaxed),
262            drops: g.drops.load(Ordering::Relaxed),
263            per_room,
264        }
265    }
266
267    fn room_counters(&self, room: &str) -> dashmap::mapref::one::Ref<'_, String, RoomCounters> {
268        self.inner
269            .rooms
270            .entry(room.to_string())
271            .or_default()
272            .downgrade()
273    }
274}
275
276fn saturating_dec(counter: &AtomicU64) -> u64 {
277    let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
278        if v == 0 {
279            None
280        } else {
281            Some(v - 1)
282        }
283    });
284    counter.load(Ordering::Relaxed)
285}
286
287// Tests exercise counter invariants directly; unwrap/expect and panicking
288// asserts are the test signal here.
289#[allow(
290    clippy::unwrap_used,
291    clippy::expect_used,
292    clippy::indexing_slicing,
293    clippy::panic
294)]
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn initial_snapshot_zeroed() {
301        let s = StatsRecorder::new().snapshot();
302        assert_eq!(s, Stats::default());
303    }
304
305    #[test]
306    fn connection_counter_saturates() {
307        let stats = StatsRecorder::new();
308        assert_eq!(stats.inc_connections(), 1);
309        assert_eq!(stats.inc_connections(), 2);
310        assert_eq!(stats.dec_connections(), 1);
311        assert_eq!(stats.dec_connections(), 0);
312        stats.dec_connections(); // saturate
313        assert_eq!(stats.snapshot().connections, 0);
314    }
315
316    #[test]
317    fn room_lifecycle_gauge() {
318        let stats = StatsRecorder::new();
319        stats.room_opened();
320        stats.room_opened();
321        assert_eq!(stats.snapshot().rooms, 2);
322        stats.room_closed();
323        stats.room_closed();
324        stats.room_closed(); // saturate
325        assert_eq!(stats.snapshot().rooms, 0);
326    }
327
328    #[test]
329    fn global_traffic_counters() {
330        let stats = StatsRecorder::new();
331        stats.record_message_in(128);
332        stats.record_message_out(3, 96);
333        stats.record_drop();
334        let s = stats.snapshot();
335        assert_eq!(s.messages_in, 1);
336        assert_eq!(s.bytes_in, 128);
337        assert_eq!(s.messages_out, 3);
338        assert_eq!(s.bytes_out, 96);
339        assert_eq!(s.drops, 1);
340    }
341
342    #[test]
343    fn room_counters_roll_up_to_globals() {
344        let stats = StatsRecorder::new();
345        stats.record_room_message_in("lobby", 10);
346        stats.record_room_message_out("lobby", 2, 20);
347        stats.record_room_drop("lobby");
348
349        let s = stats.snapshot();
350        let lobby = s.per_room.get("lobby").unwrap();
351        assert_eq!(lobby.messages_in, 1);
352        assert_eq!(lobby.bytes_in, 10);
353        assert_eq!(lobby.messages_out, 2);
354        assert_eq!(lobby.bytes_out, 20);
355        assert_eq!(lobby.drops, 1);
356
357        // Roll-up: globals include room traffic.
358        assert_eq!(s.messages_in, 1);
359        assert_eq!(s.messages_out, 2);
360        assert_eq!(s.bytes_out, 20);
361        assert_eq!(s.drops, 1);
362    }
363
364    #[test]
365    fn rooms_are_isolated_in_snapshot() {
366        let stats = StatsRecorder::new();
367        stats.record_room_message_out("a", 1, 5);
368        stats.record_room_message_out("b", 2, 10);
369        let s = stats.snapshot();
370        assert_eq!(s.per_room.len(), 2);
371        assert_eq!(s.per_room.get("a").unwrap().messages_out, 1);
372        assert_eq!(s.per_room.get("b").unwrap().messages_out, 2);
373        assert_eq!(s.messages_out, 3);
374    }
375
376    #[test]
377    fn clone_shares_counters() {
378        let stats = StatsRecorder::new();
379        let clone = stats.clone();
380        stats.record_message_in(7);
381        assert_eq!(clone.snapshot().messages_in, 1);
382    }
383
384    #[tokio::test]
385    async fn concurrent_recording_no_lost_updates() {
386        let stats = StatsRecorder::new();
387        let mut handles = Vec::new();
388        for _ in 0..8 {
389            let s = stats.clone();
390            handles.push(tokio::spawn(async move {
391                for _ in 0..1000 {
392                    s.record_message_in(1);
393                }
394            }));
395        }
396        for h in handles {
397            h.await.unwrap();
398        }
399        assert_eq!(stats.snapshot().messages_in, 8000);
400        assert_eq!(stats.snapshot().bytes_in, 8000);
401    }
402}