Skip to main content

self_hosted_node/
metrics.rs

1use std::time::{Duration, Instant};
2
3use metrics::{counter, gauge, histogram};
4use metrics_exporter_prometheus::PrometheusBuilder;
5use tracing::{info, warn};
6
7const ROOMS_HOSTED: &str = "manabrew_node_rooms_hosted";
8const GAMES_ACTIVE: &str = "manabrew_node_games_active";
9const GAME_DURATION_SECONDS: &str = "manabrew_node_game_duration_seconds";
10const ENGINE_ERRORS: &str = "manabrew_node_engine_errors_total";
11const RELAY_RECONNECTS: &str = "manabrew_node_relay_reconnects_total";
12const BUILD_INFO: &str = "manabrew_node_build_info";
13
14const LABEL_POOL: &str = "pool";
15const LABEL_CLEAN: &str = "clean";
16const LABEL_PLAYERS: &str = "players";
17const LABEL_SIGNATURE: &str = "signature";
18const LABEL_VERSION: &str = "version";
19
20const ENV_PUSH_URL: &str = "SELF_HOSTED_NODE_METRICS_PUSH_URL";
21const ENV_PUSH_USERNAME: &str = "SELF_HOSTED_NODE_METRICS_PUSH_USERNAME";
22const ENV_PUSH_PASSWORD: &str = "SELF_HOSTED_NODE_METRICS_PUSH_PASSWORD";
23
24const PUSH_INTERVAL: Duration = Duration::from_secs(15);
25
26#[derive(Clone, Copy)]
27pub enum PoolKind {
28    Solo,
29    Pod,
30}
31
32impl PoolKind {
33    fn as_str(self) -> &'static str {
34        match self {
35            Self::Solo => "solo",
36            Self::Pod => "pod",
37        }
38    }
39}
40
41#[derive(Clone, Copy)]
42enum ErrorSignature {
43    IndexOob,
44    UnsupportedKind,
45    Comparator,
46    UnwrapNone,
47    Trigger,
48    Other,
49}
50
51impl ErrorSignature {
52    fn bucket(message: &str) -> Self {
53        let lower = message.to_lowercase();
54        if lower.contains("index out of bounds") || lower.contains("indexoutofbounds") {
55            Self::IndexOob
56        } else if lower.contains("unsupported") || lower.contains("unimplemented") {
57            Self::UnsupportedKind
58        } else if lower.contains("comparator") {
59            Self::Comparator
60        } else if lower.contains("unwrap") || lower.contains("nullpointer") {
61            Self::UnwrapNone
62        } else if lower.contains("trigger") {
63            Self::Trigger
64        } else {
65            Self::Other
66        }
67    }
68
69    fn as_str(self) -> &'static str {
70        match self {
71            Self::IndexOob => "index_oob",
72            Self::UnsupportedKind => "unsupported_kind",
73            Self::Comparator => "comparator",
74            Self::UnwrapNone => "unwrap_none",
75            Self::Trigger => "trigger",
76            Self::Other => "other",
77        }
78    }
79}
80
81pub fn init_from_env() {
82    let Some(url) = std::env::var(ENV_PUSH_URL).ok().filter(|v| !v.is_empty()) else {
83        return;
84    };
85    let username = std::env::var(ENV_PUSH_USERNAME)
86        .ok()
87        .filter(|v| !v.is_empty());
88    let password = std::env::var(ENV_PUSH_PASSWORD)
89        .ok()
90        .filter(|v| !v.is_empty());
91    let _ = rustls::crypto::ring::default_provider().install_default();
92    let builder = match PrometheusBuilder::new().with_push_gateway(
93        &url,
94        PUSH_INTERVAL,
95        username,
96        password,
97        false,
98    ) {
99        Ok(builder) => builder,
100        Err(error) => {
101            warn!(%error, url, "invalid metrics push gateway config");
102            return;
103        }
104    };
105    match builder.install() {
106        Ok(()) => {
107            gauge!(BUILD_INFO, LABEL_VERSION => env!("CARGO_PKG_VERSION")).set(1.0);
108            info!(url, "metrics push exporter installed");
109        }
110        Err(error) => warn!(%error, url, "failed to install metrics push exporter"),
111    }
112}
113
114pub struct RoomHostedGuard {
115    pool: PoolKind,
116}
117
118impl RoomHostedGuard {
119    pub fn new(pool: PoolKind) -> Self {
120        gauge!(ROOMS_HOSTED, LABEL_POOL => pool.as_str()).increment(1.0);
121        RoomHostedGuard { pool }
122    }
123}
124
125impl Drop for RoomHostedGuard {
126    fn drop(&mut self) {
127        gauge!(ROOMS_HOSTED, LABEL_POOL => self.pool.as_str()).decrement(1.0);
128    }
129}
130
131pub fn record_relay_reconnect() {
132    counter!(RELAY_RECONNECTS).increment(1);
133}
134
135pub fn record_engine_session_started() {
136    gauge!(GAMES_ACTIVE).increment(1.0);
137}
138
139pub fn record_engine_session_finished(players: usize, started: Instant, fatal: Option<&str>) {
140    gauge!(GAMES_ACTIVE).decrement(1.0);
141    let clean = if fatal.is_none() { "true" } else { "false" };
142    histogram!(
143        GAME_DURATION_SECONDS,
144        LABEL_PLAYERS => players.to_string(),
145        LABEL_CLEAN => clean
146    )
147    .record(started.elapsed().as_secs_f64());
148    if let Some(message) = fatal {
149        counter!(ENGINE_ERRORS, LABEL_SIGNATURE => ErrorSignature::bucket(message).as_str())
150            .increment(1);
151    }
152}