self_hosted_node/
metrics.rs1use 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}