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