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    /// `engine_think` split by who owned the time: the part of each window
80    /// spent on bot prompts, and the rest, which is the rules engine resolving
81    /// what the table did. Absent from an engine that does not tag its
82    /// windows.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    #[ts(optional)]
85    pub engine_think_bot: Option<EngineTurnaround>,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    #[ts(optional)]
88    pub engine_think_rules: Option<EngineTurnaround>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    #[ts(optional)]
91    pub checkpoints: Option<EngineCheckpointStats>,
92    /// Windows dropped because the tab was backgrounded for part of them: the
93    /// engine times itself in wall clock, which keeps running while the worker
94    /// is descheduled.
95    #[serde(default)]
96    pub think_samples_hidden: u32,
97    #[serde(default)]
98    pub by_type: Vec<EngineTypeTurnaround>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
102#[serde(rename_all = "camelCase")]
103#[ts(export, export_to = "telemetry.ts")]
104pub struct EngineTurnaround {
105    pub n: u32,
106    pub p50: u32,
107    pub p90: u32,
108    pub max: u32,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
112#[serde(rename_all = "camelCase")]
113#[ts(export, export_to = "telemetry.ts")]
114pub struct EngineTypeTurnaround {
115    #[serde(rename = "type")]
116    pub prompt_type: String,
117    pub n: u32,
118    pub p50: u32,
119    pub max: u32,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
123#[serde(rename_all = "camelCase")]
124#[ts(export, export_to = "telemetry.ts")]
125pub struct EngineCheckpointTiming {
126    pub n: u32,
127    #[ts(type = "number")]
128    pub sum_us: u64,
129    #[ts(type = "number")]
130    pub p50_us: u64,
131    #[ts(type = "number")]
132    pub p95_us: u64,
133    #[ts(type = "number")]
134    pub p99_us: u64,
135    #[ts(type = "number")]
136    pub max_us: u64,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
140#[serde(rename_all = "camelCase")]
141#[ts(export, export_to = "telemetry.ts")]
142pub struct EngineCheckpointStats {
143    pub copy: EngineCheckpointTiming,
144    pub bookkeeping: EngineCheckpointTiming,
145    pub total: EngineCheckpointTiming,
146    pub decision: EngineCheckpointTiming,
147    pub hidden: u32,
148    pub dropped: u32,
149}
150
151impl EngineCheckpointTiming {
152    fn is_plausible(&self) -> bool {
153        self.n <= 4000
154            && self.p50_us <= self.p95_us
155            && self.p95_us <= self.p99_us
156            && self.p99_us <= self.max_us
157            && self.max_us <= self.sum_us
158            && self.sum_us <= 86_400_000_000
159            && (self.n > 0 || self.sum_us == 0)
160    }
161}
162
163impl EngineCheckpointStats {
164    fn is_plausible(&self) -> bool {
165        [&self.copy, &self.bookkeeping, &self.total, &self.decision]
166            .iter()
167            .all(|timing| timing.is_plausible())
168            && self.copy.n == self.bookkeeping.n
169            && self.copy.n == self.total.n
170            && self.total.sum_us == self.copy.sum_us + self.bookkeeping.sum_us
171    }
172}
173
174impl EnginePlayStats {
175    /// Whether this is worth storing. Rejects the shapes a mistake or a hostile
176    /// client produces: an unparseable id, empty or oversized text, a table
177    /// with impossible seats, a report of no decisions at all.
178    pub fn is_plausible(&self) -> bool {
179        let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
180        uuid_shaped(&self.report_id)
181            && text(&self.engine, 40)
182            && text(&self.client_version, 40)
183            && text(&self.platform, 20)
184            && text(&self.end_reason, 20)
185            && self.format.as_ref().is_none_or(|f| f.len() <= 40)
186            && (1..=8).contains(&self.seats)
187            && self.by_type.len() <= 32
188            && self.turnaround.n > 0
189            && self
190                .checkpoints
191                .as_ref()
192                .is_none_or(EngineCheckpointStats::is_plausible)
193    }
194
195    /// The game this report belongs to, when the id is one a store can key on.
196    /// A bad id costs the link, never the report: the timings are the point,
197    /// and dropping a whole game over a malformed field is how telemetry ends
198    /// up measuring nothing.
199    pub fn linked_game_id(&self) -> Option<&str> {
200        self.game_id.as_deref().filter(|id| uuid_shaped(id))
201    }
202}
203
204/// The relay's `game_started` + `game_ended` + `deck_selected` collapsed into
205/// one record: offline play has no connection to stream them over.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
207#[serde(rename_all = "camelCase")]
208#[ts(export, export_to = "telemetry.ts")]
209pub struct OfflinePlayGame {
210    /// Client-generated, so a retry cannot double-count the game. Stands in for
211    /// the relay's `game_id` downstream.
212    pub report_id: String,
213    pub started_at: String,
214    pub ended_at: String,
215    pub duration_s: u32,
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    #[ts(optional)]
218    pub format: Option<String>,
219    pub engine: String,
220    pub starting_life: i32,
221    pub end_reason: String,
222    pub game_over: bool,
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    #[ts(optional)]
225    pub engine_error: Option<String>,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    #[ts(optional)]
228    pub winner: Option<String>,
229    #[serde(default)]
230    pub conceded: Vec<String>,
231    pub client_version: String,
232    pub platform: String,
233    pub players: Vec<OfflinePlaySeat>,
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
237#[serde(rename_all = "camelCase")]
238#[ts(export, export_to = "telemetry.ts")]
239pub struct OfflinePlaySeat {
240    /// The account handle when there is one, the local display name otherwise.
241    pub username: String,
242    pub is_bot: bool,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    #[ts(optional)]
245    pub deck_name: Option<String>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    #[ts(optional)]
248    pub commander: Option<String>,
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    #[ts(optional)]
251    pub published_deck_id: Option<String>,
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    #[ts(optional)]
254    pub deck_fingerprint: Option<String>,
255    #[serde(default)]
256    pub sideboard_count: u32,
257    #[serde(default)]
258    pub cards: Vec<OfflinePlayCard>,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
262#[serde(rename_all = "camelCase")]
263#[ts(export, export_to = "telemetry.ts")]
264pub struct OfflinePlayCard {
265    pub name: String,
266    pub set_code: String,
267    pub count: u32,
268}
269
270const MAX_CARDS_PER_SEAT: usize = 400;
271
272impl OfflinePlayGame {
273    /// Whether this is worth storing. Same job as
274    /// [`EnginePlayStats::is_plausible`], over a wider record.
275    pub fn is_plausible(&self) -> bool {
276        let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
277        uuid_shaped(&self.report_id)
278            && text(&self.started_at, 40)
279            && text(&self.ended_at, 40)
280            && text(&self.engine, 40)
281            && text(&self.client_version, 40)
282            && text(&self.platform, 20)
283            && text(&self.end_reason, 30)
284            && self.format.as_ref().is_none_or(|f| f.len() <= 40)
285            && self.winner.as_ref().is_none_or(|w| w.len() <= 80)
286            && self.conceded.len() <= 8
287            && self.conceded.iter().all(|name| text(name, 80))
288            && (1..=8).contains(&self.players.len())
289            && self.players.iter().all(OfflinePlaySeat::is_plausible)
290    }
291}
292
293impl OfflinePlaySeat {
294    fn is_plausible(&self) -> bool {
295        let opt =
296            |value: &Option<String>, max: usize| value.as_ref().is_none_or(|v| v.len() <= max);
297        !self.username.is_empty()
298            && self.username.len() <= 80
299            && opt(&self.deck_name, 120)
300            && opt(&self.commander, 120)
301            && opt(&self.published_deck_id, 200)
302            // The same 64 lowercase hex the deck-play endpoint takes.
303            && self
304                .deck_fingerprint
305                .as_ref()
306                .is_none_or(|value| value.len() == 64 && value.bytes().all(is_lower_hex))
307            && self.cards.len() <= MAX_CARDS_PER_SEAT
308            && self.cards.iter().all(OfflinePlayCard::is_plausible)
309    }
310}
311
312impl OfflinePlayCard {
313    fn is_plausible(&self) -> bool {
314        !self.name.is_empty()
315            && self.name.len() <= 200
316            && self.set_code.len() <= 20
317            && (1..=1000).contains(&self.count)
318    }
319}
320
321fn is_lower_hex(byte: u8) -> bool {
322    byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
323}
324
325fn uuid_shaped(value: &str) -> bool {
326    value.len() == 36
327        && value.bytes().enumerate().all(|(index, byte)| match index {
328            8 | 13 | 18 | 23 => byte == b'-',
329            _ => byte.is_ascii_hexdigit(),
330        })
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    fn sample() -> EnginePlayStats {
338        EnginePlayStats {
339            report_id: "11111111-2222-3333-4444-555555555555".to_string(),
340            game_id: Some("66666666-7777-8888-9999-aaaaaaaaaaaa".to_string()),
341            engine: "forge-wasm".to_string(),
342            client_version: "3.18.5".to_string(),
343            platform: "web".to_string(),
344            format: Some("standard".to_string()),
345            seats: 2,
346            multiplayer: false,
347            duration_s: 400,
348            end_reason: "gameOver".to_string(),
349            turnaround: EngineTurnaround {
350                n: 180,
351                p50: 46,
352                p90: 78,
353                max: 320,
354            },
355            reply_wait: None,
356            client_work: None,
357            engine_think: None,
358            engine_think_same_turn: None,
359            engine_think_cross_turn: None,
360            engine_think_bot: None,
361            engine_think_rules: None,
362            checkpoints: None,
363            think_samples_hidden: 0,
364            by_type: vec![],
365        }
366    }
367
368    #[test]
369    fn accepts_a_real_report() {
370        assert!(sample().is_plausible());
371        assert_eq!(
372            sample().linked_game_id(),
373            Some("66666666-7777-8888-9999-aaaaaaaaaaaa")
374        );
375    }
376
377    /// The timings are the point. A game id that cannot be keyed on costs the
378    /// link to the game and nothing else, because dropping the report over it
379    /// would lose the measurement this whole path exists to take.
380    #[test]
381    fn a_bad_game_id_costs_the_link_not_the_report() {
382        let mut report = sample();
383        report.game_id = Some("not-a-uuid".to_string());
384        assert!(report.is_plausible());
385        assert_eq!(report.linked_game_id(), None);
386
387        report.game_id = None;
388        assert!(report.is_plausible());
389        assert_eq!(report.linked_game_id(), None);
390    }
391
392    #[test]
393    fn rejects_the_shapes_a_hostile_client_sends() {
394        let cases: Vec<(&str, Box<dyn Fn(&mut EnginePlayStats)>)> = vec![
395            (
396                "not a uuid",
397                Box::new(|s: &mut EnginePlayStats| s.report_id = "nope".to_string()),
398            ),
399            (
400                "empty engine",
401                Box::new(|s: &mut EnginePlayStats| s.engine = String::new()),
402            ),
403            (
404                "huge engine",
405                Box::new(|s: &mut EnginePlayStats| s.engine = "x".repeat(41)),
406            ),
407            ("no seats", Box::new(|s: &mut EnginePlayStats| s.seats = 0)),
408            (
409                "too many seats",
410                Box::new(|s: &mut EnginePlayStats| s.seats = 9),
411            ),
412            (
413                "no decisions",
414                Box::new(|s: &mut EnginePlayStats| s.turnaround.n = 0),
415            ),
416        ];
417        for (name, break_it) in cases {
418            let mut report = sample();
419            break_it(&mut report);
420            assert!(!report.is_plausible(), "{name} should have been rejected");
421        }
422    }
423}