Skip to main content

manabrew_server/analytics/
mod.rs

1mod 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}