Skip to main content

manabrew_server/analytics/
event.rs

1use serde::Serialize;
2use serde_json::Value;
3
4use crate::protocol::{EngineKind, GameFormat, SeatTransportReport};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
7#[serde(rename_all = "snake_case")]
8pub enum GameEndReason {
9    GameOver,
10    EngineFatal,
11    HostEnded,
12    ReconnectTimeout,
13    HostLost,
14    StaleExpired,
15    Abandoned,
16}
17
18impl GameEndReason {
19    pub fn as_str(self) -> &'static str {
20        match self {
21            Self::GameOver => "game_over",
22            Self::EngineFatal => "engine_fatal",
23            Self::HostEnded => "host_ended",
24            Self::ReconnectTimeout => "reconnect_timeout",
25            Self::HostLost => "host_lost",
26            Self::StaleExpired => "stale_expired",
27            Self::Abandoned => "abandoned",
28        }
29    }
30}
31
32#[derive(Debug, Serialize)]
33pub struct SeatInfo {
34    pub username: String,
35    pub is_bot: bool,
36    pub deck_name: Option<String>,
37    pub commander: Option<String>,
38    pub published_deck_id: Option<String>,
39    pub deck_fingerprint: Option<String>,
40}
41
42#[derive(Debug, Serialize)]
43pub struct CardEntry {
44    pub name: String,
45    pub set_code: String,
46    pub count: u32,
47}
48
49// An event is built, serialised to one JSONL line and dropped; nothing holds
50// a collection of them, so the size of the widest variant (EngineStats, a
51// column per timing) costs nothing worth boxing for.
52#[allow(clippy::large_enum_variant)]
53#[derive(Debug, Serialize)]
54#[serde(tag = "event", rename_all = "snake_case")]
55pub enum AnalyticsEvent {
56    ClientConnected {
57        ts: String,
58        username: String,
59        platform: String,
60        /// The client's reported app version. `None` means a build from before
61        /// clients reported one, which is the only handle we have on how much
62        /// of the player base is running a stale install.
63        #[serde(skip_serializing_if = "Option::is_none")]
64        version: Option<String>,
65        reconnected: bool,
66        engine_gate: String,
67    },
68    GameStarted {
69        ts: String,
70        game_id: String,
71        room_id: String,
72        format: GameFormat,
73        engine: EngineKind,
74        hosted: bool,
75        official: bool,
76        #[serde(skip_serializing_if = "Option::is_none")]
77        table_style: Option<String>,
78        starting_life: i32,
79        players: Vec<SeatInfo>,
80    },
81    GameEnded {
82        ts: String,
83        game_id: String,
84        room_id: String,
85        reason: GameEndReason,
86        duration_s: f64,
87        game_over: bool,
88        winner: Option<String>,
89        conceded: Vec<String>,
90        fatal_message: Option<String>,
91        /// Whether the host filed the outcome; false means the reason is the
92        /// room lifecycle's fallback and `game_over` says nothing.
93        reported: bool,
94        #[serde(skip_serializing_if = "Option::is_none")]
95        turns: Option<u32>,
96    },
97    /// Which seats left the relay's data plane, as reported by the host.
98    TransportUsed {
99        ts: String,
100        room_id: String,
101        game_id: String,
102        host: String,
103        seats: Vec<SeatTransportReport>,
104    },
105    /// One end's account of one direct-plane attempt, whether or not it worked.
106    PlaneQuality {
107        ts: String,
108        #[serde(skip_serializing_if = "Option::is_none")]
109        room_id: Option<String>,
110        /// Relay-attested; never client supplied.
111        username: String,
112        /// The other end, as the reporter named it.
113        peer: String,
114        plane: String,
115        outcome: String,
116        /// `settled` or `measured`; a connected peer reports both.
117        phase: String,
118        #[serde(skip_serializing_if = "Option::is_none")]
119        connect_ms: Option<u32>,
120        #[serde(skip_serializing_if = "Option::is_none")]
121        rtt_ms: Option<u32>,
122        #[serde(skip_serializing_if = "Option::is_none")]
123        relay_rtt_ms: Option<u32>,
124        #[serde(skip_serializing_if = "Option::is_none")]
125        candidate_pair: Option<String>,
126    },
127    EngineStats {
128        ts: String,
129        /// The room the seat was in when the report landed, which at game over
130        /// is often none: the report outlives the seat's membership. Nothing
131        /// downstream reads it, so it stays a breadcrumb rather than a key.
132        #[serde(skip_serializing_if = "Option::is_none")]
133        room_id: Option<String>,
134        username: String,
135        report_id: String,
136        #[serde(skip_serializing_if = "Option::is_none")]
137        game_id: Option<String>,
138        engine: String,
139        client_version: String,
140        platform: String,
141        #[serde(skip_serializing_if = "Option::is_none")]
142        format: Option<String>,
143        seats: u32,
144        multiplayer: bool,
145        duration_s: u32,
146        end_reason: String,
147        decisions: u32,
148        turnaround_p50: u32,
149        turnaround_p90: u32,
150        turnaround_max: u32,
151        #[serde(skip_serializing_if = "Option::is_none")]
152        engine_p50: Option<u32>,
153        #[serde(skip_serializing_if = "Option::is_none")]
154        engine_p90: Option<u32>,
155        #[serde(skip_serializing_if = "Option::is_none")]
156        engine_max: Option<u32>,
157        #[serde(skip_serializing_if = "Option::is_none")]
158        engine_same_p50: Option<u32>,
159        #[serde(skip_serializing_if = "Option::is_none")]
160        engine_same_p90: Option<u32>,
161        #[serde(skip_serializing_if = "Option::is_none")]
162        engine_same_max: Option<u32>,
163        #[serde(skip_serializing_if = "Option::is_none")]
164        engine_cross_p50: Option<u32>,
165        #[serde(skip_serializing_if = "Option::is_none")]
166        engine_cross_p90: Option<u32>,
167        #[serde(skip_serializing_if = "Option::is_none")]
168        engine_cross_max: Option<u32>,
169        think_hidden: u32,
170        /// `turnaround` cut at the first reply frame reaching the client:
171        /// server, wire and transfer on one side, parse, apply and render on
172        /// the other. Absent from clients that predate the cut.
173        #[serde(skip_serializing_if = "Option::is_none")]
174        reply_wait_p50: Option<u32>,
175        #[serde(skip_serializing_if = "Option::is_none")]
176        reply_wait_p90: Option<u32>,
177        #[serde(skip_serializing_if = "Option::is_none")]
178        reply_wait_max: Option<u32>,
179        #[serde(skip_serializing_if = "Option::is_none")]
180        client_work_p50: Option<u32>,
181        #[serde(skip_serializing_if = "Option::is_none")]
182        client_work_p90: Option<u32>,
183        #[serde(skip_serializing_if = "Option::is_none")]
184        client_work_max: Option<u32>,
185        /// `engine_*` split by who owned the time: bot prompts on one side,
186        /// the rules engine on the other. Absent from an engine that does not
187        /// tag its windows.
188        #[serde(skip_serializing_if = "Option::is_none")]
189        engine_bot_p50: Option<u32>,
190        #[serde(skip_serializing_if = "Option::is_none")]
191        engine_bot_p90: Option<u32>,
192        #[serde(skip_serializing_if = "Option::is_none")]
193        engine_bot_max: Option<u32>,
194        #[serde(skip_serializing_if = "Option::is_none")]
195        engine_rules_p50: Option<u32>,
196        #[serde(skip_serializing_if = "Option::is_none")]
197        engine_rules_p90: Option<u32>,
198        #[serde(skip_serializing_if = "Option::is_none")]
199        engine_rules_max: Option<u32>,
200        #[serde(skip_serializing_if = "Option::is_none")]
201        checkpoints: Option<manabrew_protocol::telemetry::EngineCheckpointStats>,
202    },
203
204    DeckSelected {
205        ts: String,
206        room_id: String,
207        username: String,
208        is_bot: bool,
209        deck_name: String,
210        commander: Option<String>,
211        cards: Vec<CardEntry>,
212        sideboard_count: usize,
213    },
214    SeatJoined {
215        ts: String,
216        room_id: String,
217        username: String,
218        is_bot: bool,
219        observer: bool,
220    },
221    SeatLeft {
222        ts: String,
223        room_id: String,
224        username: String,
225    },
226}
227
228#[derive(Serialize)]
229pub struct CaptureLine<'a> {
230    pub ts: String,
231    pub from: &'a str,
232    pub envelope: &'a Value,
233    /// Set only on envelopes a player sent, where `from` is that player and the
234    /// figure is theirs. Lets a decision be read against the link it crossed
235    /// rather than against a fleet-wide average.
236    #[serde(rename = "clientRttMs", skip_serializing_if = "Option::is_none")]
237    pub client_rtt_ms: Option<u32>,
238}