Skip to main content

manabrew_server/
metrics.rs

1use std::collections::{BTreeMap, HashSet};
2
3use metrics::{counter, gauge, histogram};
4use metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle};
5
6use crate::analytics::GameEndReason;
7use crate::protocol::{EngineKind, LocalGameKind, RoomStatus};
8use crate::state::ServerState;
9
10const CONNECTIONS: &str = "manabrew_relay_connections";
11const PLAYERS: &str = "manabrew_relay_players";
12const ROOMS: &str = "manabrew_relay_rooms";
13const LOCAL_GAMES: &str = "manabrew_relay_local_games";
14const GAMES_STARTED: &str = "manabrew_relay_games_started_total";
15const GAMES_ENDED: &str = "manabrew_relay_games_ended_total";
16const CLIENT_REJECTIONS: &str = "manabrew_relay_client_rejections_total";
17const RECONNECT_RESYNCS: &str = "manabrew_relay_reconnect_resyncs_total";
18const SESSION_TAKEOVERS: &str = "manabrew_relay_session_takeovers_total";
19const ANALYTICS_DROPPED: &str = "manabrew_relay_analytics_dropped_total";
20const ANALYTICS_DELIVERED: &str = "manabrew_relay_analytics_delivered_total";
21const STATE_PATCH_DOWNGRADES: &str = "manabrew_relay_state_patch_downgrades_total";
22const ENGINE_REPORTS: &str = "manabrew_relay_engine_reports_total";
23const TRANSPORT_ANNOUNCEMENTS: &str = "manabrew_relay_transport_announcements_total";
24const TRANSPORT_ROSTERS: &str = "manabrew_relay_transport_rosters_total";
25const PEER_SIGNALS: &str = "manabrew_relay_peer_signals_total";
26const PLANE_ATTEMPTS: &str = "manabrew_relay_plane_attempts_total";
27const PLANE_RTT: &str = "manabrew_relay_plane_rtt_ms";
28const PLANE_RELAY_RTT: &str = "manabrew_relay_plane_relay_rtt_ms";
29const PLANE_CONNECT: &str = "manabrew_relay_plane_connect_ms";
30const GAME_OUTCOME_REPORTS: &str = "manabrew_relay_game_outcome_reports_total";
31const CLIENT_RTT: &str = "manabrew_relay_client_rtt_ms";
32const STATE_HANDLING: &str = "manabrew_relay_state_handling_seconds";
33const SOCKET_WRITE: &str = "manabrew_relay_socket_write_seconds";
34const OUTBOUND_BACKLOG: &str = "manabrew_relay_outbound_backlog";
35const WEBSOCKET_PAYLOAD_BYTES: &str = "manabrew_relay_websocket_payload_bytes_total";
36
37const LABEL_KIND: &str = "kind";
38const LABEL_STATUS: &str = "status";
39const LABEL_HOSTED: &str = "hosted";
40const LABEL_ENGINE: &str = "engine";
41const LABEL_REASON: &str = "reason";
42const LABEL_SEATS: &str = "seats";
43const LABEL_OUTCOME: &str = "outcome";
44const LABEL_PLANE: &str = "plane";
45const LABEL_PAIR: &str = "pair";
46const LABEL_DIRECTION: &str = "direction";
47
48pub const REJECTION_OUTDATED_WIRE: &str = "outdated_wire";
49
50pub const ENGINE_REPORT_ACCEPTED: &str = "accepted";
51/// Accepted, but the seat had already left the room it played in. Normal at
52/// game over rather than a fault; kept apart from `accepted` because a sharp
53/// rise in it means seats are leaving earlier than they used to.
54pub const ENGINE_REPORT_ROOMLESS: &str = "accepted_roomless";
55pub const ENGINE_REPORT_IMPLAUSIBLE: &str = "implausible";
56
57pub const OUTCOME_REPORT_ACCEPTED: &str = "accepted";
58pub const OUTCOME_REPORT_REJECTED: &str = "rejected";
59
60pub const ANALYTICS_LIVE: &str = "live";
61pub const ANALYTICS_SPOOLED: &str = "spooled";
62pub const ANALYTICS_DRAINED: &str = "drained";
63
64#[derive(Clone, Copy)]
65enum ConnectionKind {
66    Human,
67    Service,
68    Bot,
69}
70
71impl ConnectionKind {
72    fn as_str(self) -> &'static str {
73        match self {
74            Self::Human => "human",
75            Self::Service => "service",
76            Self::Bot => "bot",
77        }
78    }
79}
80
81// Only the state-handling metric gets buckets. Everything else stays a summary,
82// whose quantiles are per process and cannot be aggregated.
83const STATE_HANDLING_BUCKETS: &[f64] = &[
84    0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0,
85];
86
87const BACKLOG_BUCKETS: &[f64] = &[1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 512.0, 2048.0];
88
89fn builder() -> PrometheusBuilder {
90    PrometheusBuilder::new()
91        .set_buckets_for_metric(
92            Matcher::Full(STATE_HANDLING.to_string()),
93            STATE_HANDLING_BUCKETS,
94        )
95        .expect("state handling buckets are a non-empty literal")
96        .set_buckets_for_metric(
97            Matcher::Full(SOCKET_WRITE.to_string()),
98            STATE_HANDLING_BUCKETS,
99        )
100        .expect("socket write buckets are a non-empty literal")
101        .set_buckets_for_metric(Matcher::Full(OUTBOUND_BACKLOG.to_string()), BACKLOG_BUCKETS)
102        .expect("backlog buckets are a non-empty literal")
103}
104
105pub fn install() -> PrometheusHandle {
106    builder()
107        .install_recorder()
108        .expect("failed to install metrics recorder")
109}
110
111pub fn detached_handle() -> PrometheusHandle {
112    builder().build_recorder().handle()
113}
114
115/// How long the relay itself spends on one state envelope: folding the patch
116/// into the cached board, deciding whether a full board is needed, and handing
117/// it to the audience. Labelled by seat count because the node emits one
118/// envelope per seat plus an observer, so the work per decision scales with the
119/// room even when the engine's own time does not.
120///
121/// This interval had no metric at all. Replaying captures put a four-seat room
122/// at a p99 of 2.3s outside the rules engine against 0.27s for two seats, and
123/// the only way to see that was to subtract `engineMs` from capture timestamps
124/// in a script, after the fact, on a box.
125pub fn record_state_handling(seats: usize, elapsed: std::time::Duration) {
126    histogram!(STATE_HANDLING, LABEL_SEATS => seats.to_string()).record(elapsed.as_secs_f64());
127}
128
129/// The websocket write itself, and how many messages were already waiting when
130/// this one was taken off the queue.
131///
132/// Everything else between the engine and the player is now measured; this was
133/// the last gap. The outbound channel is unbounded and the writer awaits the
134/// sink, so a client that stops draining applies backpressure and every later
135/// message queues behind it. That is invisible in the handling metric, which
136/// stops at the hand-off, and it grows with seat count because a four-seat room
137/// enqueues five envelopes per decision where a two-seat room enqueues three.
138pub fn record_socket_write(backlog: usize, elapsed: std::time::Duration, bytes: Option<usize>) {
139    histogram!(SOCKET_WRITE).record(elapsed.as_secs_f64());
140    histogram!(OUTBOUND_BACKLOG).record(backlog as f64);
141    if let Some(bytes) = bytes {
142        counter!(WEBSOCKET_PAYLOAD_BYTES, LABEL_DIRECTION => "outbound").increment(bytes as u64);
143    }
144}
145
146pub fn record_socket_read(bytes: usize) {
147    counter!(WEBSOCKET_PAYLOAD_BYTES, LABEL_DIRECTION => "inbound").increment(bytes as u64);
148}
149
150pub fn record_game_started(engine: EngineKind) {
151    counter!(GAMES_STARTED, LABEL_ENGINE => engine_label(engine)).increment(1);
152}
153
154pub fn record_game_ended(reason: GameEndReason) {
155    counter!(GAMES_ENDED, LABEL_REASON => reason.as_str()).increment(1);
156}
157
158pub fn record_rejection(reason: &'static str) {
159    counter!(CLIENT_REJECTIONS, LABEL_REASON => reason).increment(1);
160}
161
162/// A state patch was expanded back into a full state because a recipient's
163/// client is too old to apply it. Reaching zero is the signal that
164/// `SELF_HOSTED_NODE_STATE_DELTA` is saving bandwidth for everyone.
165pub fn record_state_patch_downgrade() {
166    counter!(STATE_PATCH_DOWNGRADES).increment(1);
167}
168
169/// What became of a client's end-of-game engine report. Both drop paths are
170/// silent by design — a report is not worth an error to a player — so without
171/// a count the only visible symptom is a dashboard that undercounts games and
172/// no way to tell a client that never sent from a relay that threw it away.
173pub fn record_engine_report(outcome: &'static str) {
174    counter!(ENGINE_REPORTS, LABEL_OUTCOME => outcome).increment(1);
175}
176
177/// A rise in `rejected` means a seat other than the host, or a host naming a
178/// game the relay is not running, is filing outcomes.
179pub fn record_game_outcome_report(kind: &'static str) {
180    counter!(GAME_OUTCOME_REPORTS, LABEL_KIND => kind).increment(1);
181}
182
183/// Round trip from the relay to a client and back, taken from the websocket
184/// heartbeat. The heartbeat carries the send time and RFC 6455 requires the
185/// peer to echo a ping's payload, so this is measured entirely on the relay's
186/// own clock and needs nothing from the client.
187pub fn record_client_rtt(ms: f64) {
188    histogram!(CLIENT_RTT).record(ms);
189}
190
191/// `kind` is announce, withdraw or rejected.
192pub fn record_transport_announcement(kind: &'static str) {
193    counter!(TRANSPORT_ANNOUNCEMENTS, LABEL_KIND => kind).increment(1);
194}
195
196/// `kind` is sent or withheld.
197pub fn record_transport_roster(kind: &'static str) {
198    counter!(TRANSPORT_ROSTERS, LABEL_KIND => kind).increment(1);
199}
200
201/// `kind` is forwarded, disabled, oversize, no_sender, no_target, self or offline.
202pub fn record_peer_signal(kind: &'static str) {
203    counter!(PEER_SIGNALS, LABEL_KIND => kind).increment(1);
204}
205
206/// `pair` must already be bounded to a known set by the caller.
207pub fn record_plane_attempt(plane: &'static str, outcome: &'static str, pair: &'static str) {
208    counter!(PLANE_ATTEMPTS, LABEL_PLANE => plane, LABEL_OUTCOME => outcome, LABEL_PAIR => pair)
209        .increment(1);
210}
211
212/// Client-measured, unlike [`record_client_rtt`].
213pub fn record_plane_rtt(plane: &'static str, rtt_ms: u32, relay_rtt_ms: Option<u32>) {
214    histogram!(PLANE_RTT, LABEL_PLANE => plane).record(rtt_ms as f64);
215    if let Some(relay) = relay_rtt_ms {
216        histogram!(PLANE_RELAY_RTT, LABEL_PLANE => plane).record(relay as f64);
217    }
218}
219
220pub fn record_plane_connect(plane: &'static str, connect_ms: u32) {
221    histogram!(PLANE_CONNECT, LABEL_PLANE => plane).record(connect_ms as f64);
222}
223
224pub fn record_resync() {
225    counter!(RECONNECT_RESYNCS).increment(1);
226}
227
228pub fn record_session_takeover(identity: &'static str) {
229    counter!(SESSION_TAKEOVERS, LABEL_KIND => identity).increment(1);
230}
231
232pub fn record_analytics_dropped() {
233    counter!(ANALYTICS_DROPPED).increment(1);
234}
235
236/// `spooled` lines wait on disk for the hub; `drained` is what left the disk.
237/// The two should meet, and `spooled` without `drained` means the hub is gone.
238pub fn record_analytics_delivered(path: &'static str, lines: usize) {
239    counter!(ANALYTICS_DELIVERED, "path" => path).increment(lines as u64);
240}
241
242pub fn refresh_gauges(state: &ServerState) {
243    let mut bot_usernames = HashSet::new();
244    let mut lobby_player = 0u32;
245    let mut lobby_hosted = 0u32;
246    let mut in_game_player = 0u32;
247    let mut in_game_hosted = 0u32;
248    let mut lobby_human_players = 0u32;
249    let mut lobby_bot_players = 0u32;
250    let mut in_game_human_players = 0u32;
251    let mut in_game_bot_players = 0u32;
252    for entry in state.rooms.iter() {
253        let room = entry.value();
254        for slot in &room.players {
255            if slot.is_bot {
256                bot_usernames.insert(slot.username.clone());
257            }
258            if !slot.connected {
259                continue;
260            }
261            match (&room.status, slot.is_bot) {
262                (RoomStatus::Lobby, false) => lobby_human_players += 1,
263                (RoomStatus::Lobby, true) => lobby_bot_players += 1,
264                (RoomStatus::InGame, false) => in_game_human_players += 1,
265                (RoomStatus::InGame, true) => in_game_bot_players += 1,
266            }
267        }
268        match (&room.status, room.hosted) {
269            (RoomStatus::Lobby, false) => lobby_player += 1,
270            (RoomStatus::Lobby, true) => lobby_hosted += 1,
271            (RoomStatus::InGame, false) => in_game_player += 1,
272            (RoomStatus::InGame, true) => in_game_hosted += 1,
273        }
274    }
275
276    let mut human = 0u32;
277    let mut service = 0u32;
278    let mut bot = 0u32;
279    let mut local_games: BTreeMap<&'static str, u32> = BTreeMap::new();
280    for entry in state.players.iter() {
281        let player = entry.value();
282        if !player.connected {
283            continue;
284        }
285        // A seat in a room is counted by the room gauges; this is the play the
286        // relay would otherwise never see.
287        if let Some(kind) = player.local_game.filter(|_| player.room_id.is_none()) {
288            *local_games.entry(kind.as_str()).or_insert(0) += 1;
289        }
290        if player.is_service {
291            service += 1;
292        } else if bot_usernames.contains(&player.username) {
293            bot += 1;
294        } else {
295            human += 1;
296        }
297    }
298
299    set_connections(ConnectionKind::Human, human);
300    set_connections(ConnectionKind::Service, service);
301    set_connections(ConnectionKind::Bot, bot);
302    set_players(
303        RoomStatus::Lobby,
304        ConnectionKind::Human,
305        lobby_human_players,
306    );
307    set_players(RoomStatus::Lobby, ConnectionKind::Bot, lobby_bot_players);
308    set_players(
309        RoomStatus::InGame,
310        ConnectionKind::Human,
311        in_game_human_players,
312    );
313    set_players(RoomStatus::InGame, ConnectionKind::Bot, in_game_bot_players);
314    set_rooms(RoomStatus::Lobby, false, lobby_player);
315    set_rooms(RoomStatus::Lobby, true, lobby_hosted);
316    set_rooms(RoomStatus::InGame, false, in_game_player);
317    set_rooms(RoomStatus::InGame, true, in_game_hosted);
318    for kind in LocalGameKind::ALL {
319        let count = local_games.get(kind.as_str()).copied().unwrap_or(0);
320        gauge!(LOCAL_GAMES, LABEL_KIND => kind.as_str()).set(count as f64);
321    }
322}
323
324fn set_connections(kind: ConnectionKind, count: u32) {
325    gauge!(CONNECTIONS, LABEL_KIND => kind.as_str()).set(count as f64);
326}
327
328fn set_players(status: RoomStatus, kind: ConnectionKind, count: u32) {
329    gauge!(PLAYERS, LABEL_STATUS => status_label(status), LABEL_KIND => kind.as_str())
330        .set(count as f64);
331}
332
333fn set_rooms(status: RoomStatus, hosted: bool, count: u32) {
334    gauge!(ROOMS, LABEL_STATUS => status_label(status), LABEL_HOSTED => bool_label(hosted))
335        .set(count as f64);
336}
337
338fn engine_label(engine: EngineKind) -> &'static str {
339    match engine {
340        EngineKind::Manabrew => "manabrew",
341        EngineKind::Forge => "forge",
342        EngineKind::Ironsmith => "ironsmith",
343    }
344}
345
346fn status_label(status: RoomStatus) -> &'static str {
347    match status {
348        RoomStatus::Lobby => "lobby",
349        RoomStatus::InGame => "in_game",
350    }
351}
352
353fn bool_label(value: bool) -> &'static str {
354    if value {
355        "true"
356    } else {
357        "false"
358    }
359}