use std::collections::BTreeMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use dashmap::DashMap;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RoomStats {
pub messages_in: u64,
pub messages_out: u64,
pub bytes_in: u64,
pub bytes_out: u64,
pub drops: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Stats {
pub connections: u64,
pub rooms: u64,
pub messages_in: u64,
pub messages_out: u64,
pub bytes_in: u64,
pub bytes_out: u64,
pub drops: u64,
pub per_room: BTreeMap<String, RoomStats>,
}
#[derive(Debug, Default)]
struct Globals {
connections: AtomicU64,
rooms: AtomicU64,
messages_in: AtomicU64,
messages_out: AtomicU64,
bytes_in: AtomicU64,
bytes_out: AtomicU64,
drops: AtomicU64,
}
#[derive(Debug, Default)]
struct RoomCounters {
messages_in: AtomicU64,
messages_out: AtomicU64,
bytes_in: AtomicU64,
bytes_out: AtomicU64,
drops: AtomicU64,
}
#[derive(Debug, Default)]
struct Inner {
globals: Globals,
rooms: DashMap<String, RoomCounters>,
}
#[derive(Clone, Debug, Default)]
pub struct StatsRecorder {
inner: Arc<Inner>,
}
impl StatsRecorder {
pub fn new() -> Self {
Self::default()
}
pub fn inc_connections(&self) -> u64 {
let v = self
.inner
.globals
.connections
.fetch_add(1, Ordering::Relaxed)
+ 1;
#[cfg(feature = "metrics")]
::metrics::gauge!("ws_kit_connections").set(v as f64);
v
}
pub fn dec_connections(&self) -> u64 {
let v = saturating_dec(&self.inner.globals.connections);
#[cfg(feature = "metrics")]
::metrics::gauge!("ws_kit_connections").set(v as f64);
v
}
pub fn room_opened(&self) {
let v = self.inner.globals.rooms.fetch_add(1, Ordering::Relaxed) + 1;
#[cfg(feature = "metrics")]
::metrics::gauge!("ws_kit_rooms").set(v as f64);
#[cfg(not(feature = "metrics"))]
let _ = v;
}
pub fn room_closed(&self) {
let v = saturating_dec(&self.inner.globals.rooms);
#[cfg(feature = "metrics")]
::metrics::gauge!("ws_kit_rooms").set(v as f64);
#[cfg(not(feature = "metrics"))]
let _ = v;
}
pub fn record_message_in(&self, bytes: u64) {
self.inner
.globals
.messages_in
.fetch_add(1, Ordering::Relaxed);
self.inner
.globals
.bytes_in
.fetch_add(bytes, Ordering::Relaxed);
#[cfg(feature = "metrics")]
{
::metrics::counter!("ws_kit_messages_in_total").increment(1);
::metrics::counter!("ws_kit_bytes_in_total").increment(bytes);
}
}
pub fn record_message_out(&self, count: u64, bytes: u64) {
self.inner
.globals
.messages_out
.fetch_add(count, Ordering::Relaxed);
self.inner
.globals
.bytes_out
.fetch_add(bytes, Ordering::Relaxed);
#[cfg(feature = "metrics")]
{
::metrics::counter!("ws_kit_messages_out_total").increment(count);
::metrics::counter!("ws_kit_bytes_out_total").increment(bytes);
}
}
pub fn record_drop(&self) {
self.inner.globals.drops.fetch_add(1, Ordering::Relaxed);
#[cfg(feature = "metrics")]
::metrics::counter!("ws_kit_drops_total").increment(1);
}
pub fn record_room_message_in(&self, room: &str, bytes: u64) {
let counters = self.room_counters(room);
counters.messages_in.fetch_add(1, Ordering::Relaxed);
counters.bytes_in.fetch_add(bytes, Ordering::Relaxed);
drop(counters);
self.record_message_in(bytes);
}
pub fn record_room_message_out(&self, room: &str, count: u64, bytes: u64) {
let counters = self.room_counters(room);
counters.messages_out.fetch_add(count, Ordering::Relaxed);
counters.bytes_out.fetch_add(bytes, Ordering::Relaxed);
drop(counters);
self.record_message_out(count, bytes);
}
pub fn record_room_drop(&self, room: &str) {
let counters = self.room_counters(room);
counters.drops.fetch_add(1, Ordering::Relaxed);
drop(counters);
self.record_drop();
}
pub fn snapshot(&self) -> Stats {
let g = &self.inner.globals;
let per_room: BTreeMap<String, RoomStats> = self
.inner
.rooms
.iter()
.map(|e| {
let c = e.value();
(
e.key().clone(),
RoomStats {
messages_in: c.messages_in.load(Ordering::Relaxed),
messages_out: c.messages_out.load(Ordering::Relaxed),
bytes_in: c.bytes_in.load(Ordering::Relaxed),
bytes_out: c.bytes_out.load(Ordering::Relaxed),
drops: c.drops.load(Ordering::Relaxed),
},
)
})
.collect();
Stats {
connections: g.connections.load(Ordering::Relaxed),
rooms: g.rooms.load(Ordering::Relaxed),
messages_in: g.messages_in.load(Ordering::Relaxed),
messages_out: g.messages_out.load(Ordering::Relaxed),
bytes_in: g.bytes_in.load(Ordering::Relaxed),
bytes_out: g.bytes_out.load(Ordering::Relaxed),
drops: g.drops.load(Ordering::Relaxed),
per_room,
}
}
fn room_counters(&self, room: &str) -> dashmap::mapref::one::Ref<'_, String, RoomCounters> {
self.inner
.rooms
.entry(room.to_string())
.or_default()
.downgrade()
}
}
fn saturating_dec(counter: &AtomicU64) -> u64 {
let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
if v == 0 {
None
} else {
Some(v - 1)
}
});
counter.load(Ordering::Relaxed)
}
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn initial_snapshot_zeroed() {
let s = StatsRecorder::new().snapshot();
assert_eq!(s, Stats::default());
}
#[test]
fn connection_counter_saturates() {
let stats = StatsRecorder::new();
assert_eq!(stats.inc_connections(), 1);
assert_eq!(stats.inc_connections(), 2);
assert_eq!(stats.dec_connections(), 1);
assert_eq!(stats.dec_connections(), 0);
stats.dec_connections(); assert_eq!(stats.snapshot().connections, 0);
}
#[test]
fn room_lifecycle_gauge() {
let stats = StatsRecorder::new();
stats.room_opened();
stats.room_opened();
assert_eq!(stats.snapshot().rooms, 2);
stats.room_closed();
stats.room_closed();
stats.room_closed(); assert_eq!(stats.snapshot().rooms, 0);
}
#[test]
fn global_traffic_counters() {
let stats = StatsRecorder::new();
stats.record_message_in(128);
stats.record_message_out(3, 96);
stats.record_drop();
let s = stats.snapshot();
assert_eq!(s.messages_in, 1);
assert_eq!(s.bytes_in, 128);
assert_eq!(s.messages_out, 3);
assert_eq!(s.bytes_out, 96);
assert_eq!(s.drops, 1);
}
#[test]
fn room_counters_roll_up_to_globals() {
let stats = StatsRecorder::new();
stats.record_room_message_in("lobby", 10);
stats.record_room_message_out("lobby", 2, 20);
stats.record_room_drop("lobby");
let s = stats.snapshot();
let lobby = s.per_room.get("lobby").unwrap();
assert_eq!(lobby.messages_in, 1);
assert_eq!(lobby.bytes_in, 10);
assert_eq!(lobby.messages_out, 2);
assert_eq!(lobby.bytes_out, 20);
assert_eq!(lobby.drops, 1);
assert_eq!(s.messages_in, 1);
assert_eq!(s.messages_out, 2);
assert_eq!(s.bytes_out, 20);
assert_eq!(s.drops, 1);
}
#[test]
fn rooms_are_isolated_in_snapshot() {
let stats = StatsRecorder::new();
stats.record_room_message_out("a", 1, 5);
stats.record_room_message_out("b", 2, 10);
let s = stats.snapshot();
assert_eq!(s.per_room.len(), 2);
assert_eq!(s.per_room.get("a").unwrap().messages_out, 1);
assert_eq!(s.per_room.get("b").unwrap().messages_out, 2);
assert_eq!(s.messages_out, 3);
}
#[test]
fn clone_shares_counters() {
let stats = StatsRecorder::new();
let clone = stats.clone();
stats.record_message_in(7);
assert_eq!(clone.snapshot().messages_in, 1);
}
#[tokio::test]
async fn concurrent_recording_no_lost_updates() {
let stats = StatsRecorder::new();
let mut handles = Vec::new();
for _ in 0..8 {
let s = stats.clone();
handles.push(tokio::spawn(async move {
for _ in 0..1000 {
s.record_message_in(1);
}
}));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(stats.snapshot().messages_in, 8000);
assert_eq!(stats.snapshot().bytes_in, 8000);
}
}