Skip to main content

manabrew_protocol/
telemetry.rs

1//! How fast an engine answered, for one finished game.
2//!
3//! The hosted node publishes its own decision timings to Prometheus. Nothing
4//! measured the engines that run on a player's machine, and the browser Forge
5//! build made that gap matter: the whole point of it is latency, and the only
6//! numbers we had came from a laptop under a test harness.
7//!
8//! [`EnginePlayStats`] is one report per game, aggregates only: no deck, no
9//! cards, no opponent, nothing that identifies a player. It travels two ways —
10//! over the relay for a game the relay already knows about, and to the hub for
11//! offline play, where no server is in the loop at all.
12//!
13//! [`OfflinePlayGame`] is the other half, and it is not anonymous: it names
14//! players, because the hosted node recorded exactly these fields for exactly
15//! these games until Play vs AI moved into the browser. Account erasure has to
16//! reach it, which `Storage::delete_account` does by handle.
17
18use serde::{Deserialize, Serialize};
19use ts_rs::TS;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
22#[serde(rename_all = "camelCase")]
23#[ts(export, export_to = "telemetry.ts")]
24pub struct EnginePlayStats {
25    /// Generated by the client, so a retry cannot double-count the game.
26    pub report_id: String,
27    /// Which game these timings belong to: the relay's `game_id` for a relay
28    /// game, and [`OfflinePlayGame::report_id`] for an offline one. Without it
29    /// a report is an orphan: the timings are there, but nothing says what was
30    /// played, who won, or how it ended.
31    ///
32    /// It points at a record that names players. The report itself stays
33    /// anonymous, and erasure reaches the record it points at by handle, so
34    /// the id survives a scrub while what it leads to no longer names anyone.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    #[ts(optional)]
37    pub game_id: Option<String>,
38    /// Which engine actually ran, which is not always the one the room asked
39    /// for: a browser with the Forge engine on hosts a "Manabrew" room on it.
40    pub engine: String,
41    pub client_version: String,
42    pub platform: String,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    #[ts(optional)]
45    pub format: Option<String>,
46    pub seats: u32,
47    pub multiplayer: bool,
48    pub duration_s: u32,
49    pub end_reason: String,
50    /// Client-side turnaround: answer sent to next prompt landing. This is the
51    /// interval the player feels, and the one comparable across engines.
52    pub turnaround: EngineTurnaround,
53    /// `turnaround` cut at the first reply frame reaching the client.
54    /// `reply_wait` is everything outside the player's machine: the server,
55    /// the wire, and the transfer of the reply. `client_work` is everything on
56    /// it: parsing, applying the state, rendering, until the prompt is
57    /// handled. Absent from clients that predate the cut and from engines with
58    /// no frame boundary to stamp.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    #[ts(optional)]
61    pub reply_wait: Option<EngineTurnaround>,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    #[ts(optional)]
64    pub client_work: Option<EngineTurnaround>,
65    /// The engine's own think time, when it reports one.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    #[ts(optional)]
68    pub engine_think: Option<EngineTurnaround>,
69    /// `engine_think` split by whether an opponent turn happened inside the
70    /// window. A window is answer-received to next-prompt-ready, so in a game
71    /// against the AI the cross-turn half carries whole opponent turns and is
72    /// not a measure of one decision.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    #[ts(optional)]
75    pub engine_think_same_turn: Option<EngineTurnaround>,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    #[ts(optional)]
78    pub engine_think_cross_turn: Option<EngineTurnaround>,
79    /// Windows dropped because the tab was backgrounded for part of them: the
80    /// engine times itself in wall clock, which keeps running while the worker
81    /// is descheduled.
82    #[serde(default)]
83    pub think_samples_hidden: u32,
84    #[serde(default)]
85    pub by_type: Vec<EngineTypeTurnaround>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
89#[serde(rename_all = "camelCase")]
90#[ts(export, export_to = "telemetry.ts")]
91pub struct EngineTurnaround {
92    pub n: u32,
93    pub p50: u32,
94    pub p90: u32,
95    pub max: u32,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
99#[serde(rename_all = "camelCase")]
100#[ts(export, export_to = "telemetry.ts")]
101pub struct EngineTypeTurnaround {
102    #[serde(rename = "type")]
103    pub prompt_type: String,
104    pub n: u32,
105    pub p50: u32,
106    pub max: u32,
107}
108
109impl EnginePlayStats {
110    /// Whether this is worth storing. Rejects the shapes a mistake or a hostile
111    /// client produces: an unparseable id, empty or oversized text, a table
112    /// with impossible seats, a report of no decisions at all.
113    pub fn is_plausible(&self) -> bool {
114        let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
115        uuid_shaped(&self.report_id)
116            && text(&self.engine, 40)
117            && text(&self.client_version, 40)
118            && text(&self.platform, 20)
119            && text(&self.end_reason, 20)
120            && self.format.as_ref().is_none_or(|f| f.len() <= 40)
121            && (1..=8).contains(&self.seats)
122            && self.by_type.len() <= 32
123            && self.turnaround.n > 0
124    }
125
126    /// The game this report belongs to, when the id is one a store can key on.
127    /// A bad id costs the link, never the report: the timings are the point,
128    /// and dropping a whole game over a malformed field is how telemetry ends
129    /// up measuring nothing.
130    pub fn linked_game_id(&self) -> Option<&str> {
131        self.game_id.as_deref().filter(|id| uuid_shaped(id))
132    }
133}
134
135/// The relay's `game_started` + `game_ended` + `deck_selected` collapsed into
136/// one record: offline play has no connection to stream them over.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
138#[serde(rename_all = "camelCase")]
139#[ts(export, export_to = "telemetry.ts")]
140pub struct OfflinePlayGame {
141    /// Client-generated, so a retry cannot double-count the game. Stands in for
142    /// the relay's `game_id` downstream.
143    pub report_id: String,
144    pub started_at: String,
145    pub ended_at: String,
146    pub duration_s: u32,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    #[ts(optional)]
149    pub format: Option<String>,
150    pub engine: String,
151    pub starting_life: i32,
152    /// The relay's vocabulary, not the client's: `game_over`, `abandoned`.
153    pub end_reason: String,
154    pub game_over: bool,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    #[ts(optional)]
157    pub winner: Option<String>,
158    #[serde(default)]
159    pub conceded: Vec<String>,
160    pub client_version: String,
161    pub platform: String,
162    pub players: Vec<OfflinePlaySeat>,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
166#[serde(rename_all = "camelCase")]
167#[ts(export, export_to = "telemetry.ts")]
168pub struct OfflinePlaySeat {
169    /// The account handle when there is one, the local display name otherwise.
170    pub username: String,
171    pub is_bot: bool,
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    #[ts(optional)]
174    pub deck_name: Option<String>,
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    #[ts(optional)]
177    pub commander: Option<String>,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    #[ts(optional)]
180    pub published_deck_id: Option<String>,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    #[ts(optional)]
183    pub deck_fingerprint: Option<String>,
184    #[serde(default)]
185    pub sideboard_count: u32,
186    #[serde(default)]
187    pub cards: Vec<OfflinePlayCard>,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
191#[serde(rename_all = "camelCase")]
192#[ts(export, export_to = "telemetry.ts")]
193pub struct OfflinePlayCard {
194    pub name: String,
195    pub set_code: String,
196    pub count: u32,
197}
198
199const MAX_CARDS_PER_SEAT: usize = 400;
200
201impl OfflinePlayGame {
202    /// Whether this is worth storing. Same job as
203    /// [`EnginePlayStats::is_plausible`], over a wider record.
204    pub fn is_plausible(&self) -> bool {
205        let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
206        uuid_shaped(&self.report_id)
207            && text(&self.started_at, 40)
208            && text(&self.ended_at, 40)
209            && text(&self.engine, 40)
210            && text(&self.client_version, 40)
211            && text(&self.platform, 20)
212            && text(&self.end_reason, 30)
213            && self.format.as_ref().is_none_or(|f| f.len() <= 40)
214            && self.winner.as_ref().is_none_or(|w| w.len() <= 80)
215            && self.conceded.len() <= 8
216            && self.conceded.iter().all(|name| text(name, 80))
217            && (1..=8).contains(&self.players.len())
218            && self.players.iter().all(OfflinePlaySeat::is_plausible)
219    }
220}
221
222impl OfflinePlaySeat {
223    fn is_plausible(&self) -> bool {
224        let opt =
225            |value: &Option<String>, max: usize| value.as_ref().is_none_or(|v| v.len() <= max);
226        !self.username.is_empty()
227            && self.username.len() <= 80
228            && opt(&self.deck_name, 120)
229            && opt(&self.commander, 120)
230            && opt(&self.published_deck_id, 200)
231            // The same 64 lowercase hex the deck-play endpoint takes.
232            && self
233                .deck_fingerprint
234                .as_ref()
235                .is_none_or(|value| value.len() == 64 && value.bytes().all(is_lower_hex))
236            && self.cards.len() <= MAX_CARDS_PER_SEAT
237            && self.cards.iter().all(OfflinePlayCard::is_plausible)
238    }
239}
240
241impl OfflinePlayCard {
242    fn is_plausible(&self) -> bool {
243        !self.name.is_empty()
244            && self.name.len() <= 200
245            && self.set_code.len() <= 20
246            && (1..=1000).contains(&self.count)
247    }
248}
249
250fn is_lower_hex(byte: u8) -> bool {
251    byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
252}
253
254fn uuid_shaped(value: &str) -> bool {
255    value.len() == 36
256        && value.bytes().enumerate().all(|(index, byte)| match index {
257            8 | 13 | 18 | 23 => byte == b'-',
258            _ => byte.is_ascii_hexdigit(),
259        })
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    fn sample() -> EnginePlayStats {
267        EnginePlayStats {
268            report_id: "11111111-2222-3333-4444-555555555555".to_string(),
269            game_id: Some("66666666-7777-8888-9999-aaaaaaaaaaaa".to_string()),
270            engine: "forge-wasm".to_string(),
271            client_version: "3.18.5".to_string(),
272            platform: "web".to_string(),
273            format: Some("standard".to_string()),
274            seats: 2,
275            multiplayer: false,
276            duration_s: 400,
277            end_reason: "gameOver".to_string(),
278            turnaround: EngineTurnaround {
279                n: 180,
280                p50: 46,
281                p90: 78,
282                max: 320,
283            },
284            reply_wait: None,
285            client_work: None,
286            engine_think: None,
287            engine_think_same_turn: None,
288            engine_think_cross_turn: None,
289            think_samples_hidden: 0,
290            by_type: vec![],
291        }
292    }
293
294    #[test]
295    fn accepts_a_real_report() {
296        assert!(sample().is_plausible());
297        assert_eq!(
298            sample().linked_game_id(),
299            Some("66666666-7777-8888-9999-aaaaaaaaaaaa")
300        );
301    }
302
303    /// The timings are the point. A game id that cannot be keyed on costs the
304    /// link to the game and nothing else, because dropping the report over it
305    /// would lose the measurement this whole path exists to take.
306    #[test]
307    fn a_bad_game_id_costs_the_link_not_the_report() {
308        let mut report = sample();
309        report.game_id = Some("not-a-uuid".to_string());
310        assert!(report.is_plausible());
311        assert_eq!(report.linked_game_id(), None);
312
313        report.game_id = None;
314        assert!(report.is_plausible());
315        assert_eq!(report.linked_game_id(), None);
316    }
317
318    #[test]
319    fn rejects_the_shapes_a_hostile_client_sends() {
320        let cases: Vec<(&str, Box<dyn Fn(&mut EnginePlayStats)>)> = vec![
321            (
322                "not a uuid",
323                Box::new(|s: &mut EnginePlayStats| s.report_id = "nope".to_string()),
324            ),
325            (
326                "empty engine",
327                Box::new(|s: &mut EnginePlayStats| s.engine = String::new()),
328            ),
329            (
330                "huge engine",
331                Box::new(|s: &mut EnginePlayStats| s.engine = "x".repeat(41)),
332            ),
333            ("no seats", Box::new(|s: &mut EnginePlayStats| s.seats = 0)),
334            (
335                "too many seats",
336                Box::new(|s: &mut EnginePlayStats| s.seats = 9),
337            ),
338            (
339                "no decisions",
340                Box::new(|s: &mut EnginePlayStats| s.turnaround.n = 0),
341            ),
342        ];
343        for (name, break_it) in cases {
344            let mut report = sample();
345            break_it(&mut report);
346            assert!(!report.is_plausible(), "{name} should have been rejected");
347        }
348    }
349}