manabrew_server/analytics/
mod.rs1mod capture;
2mod event;
3mod sink;
4
5use std::collections::HashMap;
6use std::path::PathBuf;
7
8use serde_json::Value;
9use tokio::sync::mpsc;
10
11pub use event::{AnalyticsEvent, CardEntry, GameEndReason, SeatInfo};
12
13use crate::config::ServerConfig;
14use crate::lobby::StartedGame;
15use crate::metrics;
16use crate::replay::GameReplayCache;
17use crate::room::Room;
18use manabrew_protocol::deck_dto::Deck;
19
20const CHANNEL_CAPACITY: usize = 8192;
21
22#[derive(Clone)]
23pub struct AnalyticsHandle {
24 events: Option<mpsc::Sender<AnalyticsEvent>>,
25 capture: Option<std::sync::mpsc::SyncSender<capture::CaptureMessage>>,
26}
27
28impl AnalyticsHandle {
29 pub fn disabled() -> Self {
30 AnalyticsHandle {
31 events: None,
32 capture: None,
33 }
34 }
35
36 pub fn from_config(config: &ServerConfig) -> Self {
37 let hub = config
38 .hub_url
39 .clone()
40 .zip(config.hub_token.clone())
41 .map(|(url, token)| sink::HubTarget { url, token });
42 let spool = config.events_dir.clone().map(PathBuf::from);
43 let events = (hub.is_some() || spool.is_some()).then(|| {
44 let (tx, rx) = mpsc::channel(CHANNEL_CAPACITY);
45 sink::spawn(rx, spool, hub);
46 tx
47 });
48 let capture = config.capture_dir.clone().map(|dir| {
49 let (tx, rx) = std::sync::mpsc::sync_channel(CHANNEL_CAPACITY);
50 capture::spawn(rx, PathBuf::from(dir), config.capture_max_bytes());
51 tx
52 });
53 AnalyticsHandle { events, capture }
54 }
55
56 pub fn events_enabled(&self) -> bool {
57 self.events.is_some()
58 }
59
60 pub fn capture_enabled(&self) -> bool {
61 self.capture.is_some()
62 }
63
64 pub fn emit(&self, event: AnalyticsEvent) {
65 match &event {
66 AnalyticsEvent::GameStarted { game_id, .. } => {
67 self.send_capture(capture::CaptureMessage::Open {
68 game_id: game_id.clone(),
69 header: json_line(&event),
70 });
71 }
72 AnalyticsEvent::GameEnded { game_id, .. } => {
73 self.send_capture(capture::CaptureMessage::Close {
74 game_id: game_id.clone(),
75 footer: json_line(&event),
76 });
77 }
78 _ => {}
79 }
80 if let Some(tx) = &self.events {
81 if tx.try_send(event).is_err() {
82 metrics::record_analytics_dropped();
83 }
84 }
85 }
86
87 pub fn capture_envelope(
88 &self,
89 game_id: &str,
90 from_player: &str,
91 envelope: &Value,
92 client_rtt_ms: Option<u32>,
93 ) {
94 if self.capture.is_none() {
95 return;
96 }
97 let line = json_line(&event::CaptureLine {
98 ts: now_ts(),
99 from: from_player,
100 envelope,
101 client_rtt_ms,
102 });
103 self.send_capture(capture::CaptureMessage::Line {
104 game_id: game_id.to_string(),
105 line,
106 });
107 }
108
109 fn send_capture(&self, message: capture::CaptureMessage) {
110 if let Some(tx) = &self.capture {
111 if tx.try_send(message).is_err() {
112 metrics::record_analytics_dropped();
113 }
114 }
115 }
116}
117
118pub fn now_ts() -> String {
119 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
120}
121
122fn json_line<T: serde::Serialize>(value: &T) -> String {
123 serde_json::to_string(value).unwrap_or_default()
124}
125
126pub fn game_started_event(started: &StartedGame) -> AnalyticsEvent {
127 let players = started
128 .player_order
129 .iter()
130 .map(|username| {
131 let deck = started
132 .player_decks
133 .iter()
134 .find(|deck| &deck.username == username);
135 let is_bot = started
136 .room_info
137 .players
138 .iter()
139 .any(|player| &player.username == username && player.is_bot);
140 SeatInfo {
141 username: username.clone(),
142 is_bot,
143 deck_name: deck.map(|deck| deck.deck_name.clone()),
144 commander: deck.and_then(|deck| deck.commander_name.clone()),
145 published_deck_id: deck.and_then(|deck| deck.published_deck_id.clone()),
146 deck_fingerprint: deck
147 .map(|deck| manabrew_protocol::deck_dto::deck_fingerprint(&deck.deck)),
148 }
149 })
150 .collect();
151 AnalyticsEvent::GameStarted {
152 ts: now_ts(),
153 game_id: started.game_id.clone(),
154 room_id: started.room_id.clone(),
155 format: started.room_info.format.clone(),
156 engine: started.room_info.engine,
157 hosted: started.room_info.hosted,
158 official: started.room_info.official,
159 table_style: started.room_info.table_style.clone(),
160 starting_life: started.starting_life,
161 players,
162 }
163}
164
165pub fn emit_game_ended(
166 handle: &AnalyticsHandle,
167 room: &Room,
168 replay: &GameReplayCache,
169 fallback: GameEndReason,
170) {
171 let reason = if replay.outcome.game_over {
172 GameEndReason::GameOver
173 } else if replay.outcome.fatal_message.is_some() {
174 GameEndReason::EngineFatal
175 } else {
176 fallback
177 };
178 metrics::record_game_ended(reason);
179 let winner = replay
180 .outcome
181 .winner_slot
182 .as_deref()
183 .and_then(|slot| replay.username_for_slot(slot));
184 let conceded = replay
185 .outcome
186 .conceded_slots
187 .iter()
188 .filter_map(|slot| replay.username_for_slot(slot))
189 .collect();
190 handle.emit(AnalyticsEvent::GameEnded {
191 ts: now_ts(),
192 game_id: replay.game_id.clone(),
193 room_id: room.room_id.clone(),
194 reason,
195 duration_s: replay.started_at.elapsed().as_secs_f64(),
196 game_over: replay.outcome.game_over,
197 winner,
198 conceded,
199 fatal_message: replay.outcome.fatal_message.clone(),
200 reported: replay.outcome.reported,
201 turns: replay.outcome.turns,
202 });
203}
204
205pub fn aggregate_deck_cards(deck: &Deck) -> Vec<CardEntry> {
206 let mut counts: HashMap<(String, String), u32> = HashMap::new();
207 let commanders = deck.commanders.iter().flatten();
208 for card in deck.cards.iter().chain(commanders) {
209 *counts
210 .entry((card.identity.name.clone(), card.identity.set_code.clone()))
211 .or_insert(0) += 1;
212 }
213 let mut cards: Vec<CardEntry> = counts
214 .into_iter()
215 .map(|((name, set_code), count)| CardEntry {
216 name,
217 set_code,
218 count,
219 })
220 .collect();
221 cards.sort_by(|a, b| a.name.cmp(&b.name));
222 cards
223}