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    },
67    GameStarted {
68        ts: String,
69        game_id: String,
70        room_id: String,
71        format: GameFormat,
72        engine: EngineKind,
73        hosted: bool,
74        official: bool,
75        #[serde(skip_serializing_if = "Option::is_none")]
76        table_style: Option<String>,
77        starting_life: i32,
78        players: Vec<SeatInfo>,
79    },
80    GameEnded {
81        ts: String,
82        game_id: String,
83        room_id: String,
84        reason: GameEndReason,
85        duration_s: f64,
86        game_over: bool,
87        winner: Option<String>,
88        conceded: Vec<String>,
89        fatal_message: Option<String>,
90        /// Whether the host filed the outcome; false means the reason is the
91        /// room lifecycle's fallback and `game_over` says nothing.
92        reported: bool,
93        #[serde(skip_serializing_if = "Option::is_none")]
94        turns: Option<u32>,
95    },
96    /// Which seats left the relay's data plane, as reported by the host.
97    TransportUsed {
98        ts: String,
99        room_id: String,
100        game_id: String,
101        host: String,
102        seats: Vec<SeatTransportReport>,
103    },
104    /// One end's account of one direct-plane attempt, whether or not it worked.
105    PlaneQuality {
106        ts: String,
107        #[serde(skip_serializing_if = "Option::is_none")]
108        room_id: Option<String>,
109        /// Relay-attested; never client supplied.
110        username: String,
111        /// The other end, as the reporter named it.
112        peer: String,
113        plane: String,
114        outcome: String,
115        /// `settled` or `measured`; a connected peer reports both.
116        phase: String,
117        #[serde(skip_serializing_if = "Option::is_none")]
118        connect_ms: Option<u32>,
119        #[serde(skip_serializing_if = "Option::is_none")]
120        rtt_ms: Option<u32>,
121        #[serde(skip_serializing_if = "Option::is_none")]
122        relay_rtt_ms: Option<u32>,
123        #[serde(skip_serializing_if = "Option::is_none")]
124        candidate_pair: Option<String>,
125    },
126    EngineStats {
127        ts: String,
128        /// The room the seat was in when the report landed, which at game over
129        /// is often none: the report outlives the seat's membership. Nothing
130        /// downstream reads it, so it stays a breadcrumb rather than a key.
131        #[serde(skip_serializing_if = "Option::is_none")]
132        room_id: Option<String>,
133        username: String,
134        report_id: String,
135        #[serde(skip_serializing_if = "Option::is_none")]
136        game_id: Option<String>,
137        engine: String,
138        client_version: String,
139        platform: String,
140        #[serde(skip_serializing_if = "Option::is_none")]
141        format: Option<String>,
142        seats: u32,
143        multiplayer: bool,
144        duration_s: u32,
145        end_reason: String,
146        decisions: u32,
147        turnaround_p50: u32,
148        turnaround_p90: u32,
149        turnaround_max: u32,
150        #[serde(skip_serializing_if = "Option::is_none")]
151        engine_p50: Option<u32>,
152        #[serde(skip_serializing_if = "Option::is_none")]
153        engine_p90: Option<u32>,
154        #[serde(skip_serializing_if = "Option::is_none")]
155        engine_max: Option<u32>,
156        #[serde(skip_serializing_if = "Option::is_none")]
157        engine_same_p50: Option<u32>,
158        #[serde(skip_serializing_if = "Option::is_none")]
159        engine_same_p90: Option<u32>,
160        #[serde(skip_serializing_if = "Option::is_none")]
161        engine_same_max: Option<u32>,
162        #[serde(skip_serializing_if = "Option::is_none")]
163        engine_cross_p50: Option<u32>,
164        #[serde(skip_serializing_if = "Option::is_none")]
165        engine_cross_p90: Option<u32>,
166        #[serde(skip_serializing_if = "Option::is_none")]
167        engine_cross_max: Option<u32>,
168        think_hidden: u32,
169        /// `turnaround` cut at the first reply frame reaching the client:
170        /// server, wire and transfer on one side, parse, apply and render on
171        /// the other. Absent from clients that predate the cut.
172        #[serde(skip_serializing_if = "Option::is_none")]
173        reply_wait_p50: Option<u32>,
174        #[serde(skip_serializing_if = "Option::is_none")]
175        reply_wait_p90: Option<u32>,
176        #[serde(skip_serializing_if = "Option::is_none")]
177        reply_wait_max: Option<u32>,
178        #[serde(skip_serializing_if = "Option::is_none")]
179        client_work_p50: Option<u32>,
180        #[serde(skip_serializing_if = "Option::is_none")]
181        client_work_p90: Option<u32>,
182        #[serde(skip_serializing_if = "Option::is_none")]
183        client_work_max: Option<u32>,
184        /// `engine_*` split by who owned the time: bot prompts on one side,
185        /// the rules engine on the other. Absent from an engine that does not
186        /// tag its windows.
187        #[serde(skip_serializing_if = "Option::is_none")]
188        engine_bot_p50: Option<u32>,
189        #[serde(skip_serializing_if = "Option::is_none")]
190        engine_bot_p90: Option<u32>,
191        #[serde(skip_serializing_if = "Option::is_none")]
192        engine_bot_max: Option<u32>,
193        #[serde(skip_serializing_if = "Option::is_none")]
194        engine_rules_p50: Option<u32>,
195        #[serde(skip_serializing_if = "Option::is_none")]
196        engine_rules_p90: Option<u32>,
197        #[serde(skip_serializing_if = "Option::is_none")]
198        engine_rules_max: Option<u32>,
199    },
200
201    DeckSelected {
202        ts: String,
203        room_id: String,
204        username: String,
205        is_bot: bool,
206        deck_name: String,
207        commander: Option<String>,
208        cards: Vec<CardEntry>,
209        sideboard_count: usize,
210    },
211    SeatJoined {
212        ts: String,
213        room_id: String,
214        username: String,
215        is_bot: bool,
216        observer: bool,
217    },
218    SeatLeft {
219        ts: String,
220        room_id: String,
221        username: String,
222    },
223}
224
225#[derive(Serialize)]
226pub struct CaptureLine<'a> {
227    pub ts: String,
228    pub from: &'a str,
229    pub envelope: &'a Value,
230    /// Set only on envelopes a player sent, where `from` is that player and the
231    /// figure is theirs. Lets a decision be read against the link it crossed
232    /// rather than against a fleet-wide average.
233    #[serde(rename = "clientRttMs", skip_serializing_if = "Option::is_none")]
234    pub client_rtt_ms: Option<u32>,
235}