Skip to main content

proof_engine/networking/
analytics.rs

1//! Opt-in analytics and telemetry.
2//!
3//! Records gameplay events, performance stats, and session data.
4//! All data is opt-in, aggregated, and contains no PII.
5//! Players can view their own data or export it to CSV.
6
7use std::collections::VecDeque;
8use std::time::{Duration, Instant};
9use crate::networking::http::{HttpClient, HttpRequest};
10
11// ── AnalyticsEvent ────────────────────────────────────────────────────────────
12
13#[derive(Debug, Clone)]
14pub enum AnalyticsEvent {
15    SessionStart   { build: String, platform: String },
16    SessionEnd     { duration_secs: f32, outcome: String },
17    Death          { floor: u32, cause: String, score: i64, time_secs: f32 },
18    BossEncounter  { name: String, floor: u32, defeated: bool, attempts: u32 },
19    Achievement    { id: String, name: String },
20    ItemPickup     { item: String, rarity: String, floor: u32 },
21    FloorComplete  { floor: u32, time_secs: f32, score: i64 },
22    FrameTime      { avg_ms: f32, p99_ms: f32, min_ms: f32, max_ms: f32 },
23    MemoryUsage    { bytes: u64, peak_bytes: u64 },
24    Custom         { name: String, value: f64, tags: Vec<String> },
25}
26
27impl AnalyticsEvent {
28    pub fn name(&self) -> &str {
29        match self {
30            Self::SessionStart  { .. } => "session_start",
31            Self::SessionEnd    { .. } => "session_end",
32            Self::Death         { .. } => "death",
33            Self::BossEncounter { .. } => "boss_encounter",
34            Self::Achievement   { .. } => "achievement",
35            Self::ItemPickup    { .. } => "item_pickup",
36            Self::FloorComplete { .. } => "floor_complete",
37            Self::FrameTime     { .. } => "frame_time",
38            Self::MemoryUsage   { .. } => "memory_usage",
39            Self::Custom { name, .. } => name.as_str(),
40        }
41    }
42
43    pub fn to_json(&self) -> String {
44        match self {
45            Self::SessionStart { build, platform } =>
46                format!(r#"{{"event":"session_start","build":"{}","platform":"{}"}}"#, build, platform),
47            Self::SessionEnd { duration_secs, outcome } =>
48                format!(r#"{{"event":"session_end","duration":{:.2},"outcome":"{}"}}"#, duration_secs, outcome),
49            Self::Death { floor, cause, score, time_secs } =>
50                format!(r#"{{"event":"death","floor":{},"cause":"{}","score":{},"time":{:.2}}}"#,
51                    floor, cause, score, time_secs),
52            Self::BossEncounter { name, floor, defeated, attempts } =>
53                format!(r#"{{"event":"boss","name":"{}","floor":{},"defeated":{},"attempts":{}}}"#,
54                    name, floor, defeated, attempts),
55            Self::FloorComplete { floor, time_secs, score } =>
56                format!(r#"{{"event":"floor_complete","floor":{},"time":{:.2},"score":{}}}"#,
57                    floor, time_secs, score),
58            Self::FrameTime { avg_ms, p99_ms, min_ms, max_ms } =>
59                format!(r#"{{"event":"frame_time","avg":{:.2},"p99":{:.2},"min":{:.2},"max":{:.2}}}"#,
60                    avg_ms, p99_ms, min_ms, max_ms),
61            Self::Custom { name, value, tags } =>
62                format!(r#"{{"event":"{}","value":{:.6},"tags":[{}]}}"#,
63                    name, value,
64                    tags.iter().map(|t| format!("\"{}\"", t)).collect::<Vec<_>>().join(",")),
65            _ => format!(r#"{{"event":"{}"}}"#, self.name()),
66        }
67    }
68}
69
70// ── SessionStats ──────────────────────────────────────────────────────────────
71
72/// Rolling statistics for the current session.
73#[derive(Debug, Clone, Default)]
74pub struct SessionStats {
75    pub deaths:       u32,
76    pub kills:        u32,
77    pub floors:       u32,
78    pub items:        u32,
79    pub score:        i64,
80    pub play_time:    f32,
81    pub bosses_seen:  u32,
82    pub bosses_killed: u32,
83    pub max_floor:    u32,
84    pub total_damage_dealt: f64,
85    pub total_damage_taken: f64,
86    pub frame_times:  Vec<f32>,
87}
88
89impl SessionStats {
90    pub fn avg_frame_ms(&self) -> f32 {
91        if self.frame_times.is_empty() { return 0.0; }
92        self.frame_times.iter().sum::<f32>() / self.frame_times.len() as f32
93    }
94
95    pub fn p99_frame_ms(&self) -> f32 {
96        if self.frame_times.is_empty() { return 0.0; }
97        let mut sorted = self.frame_times.clone();
98        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
99        let idx = ((sorted.len() as f32 * 0.99) as usize).min(sorted.len()-1);
100        sorted[idx]
101    }
102
103    pub fn to_csv_row(&self) -> String {
104        format!("{},{},{},{},{},{:.2},{},{},{:.2},{:.2},{:.2},{:.2}",
105            self.deaths, self.kills, self.floors, self.items, self.score,
106            self.play_time, self.bosses_seen, self.bosses_killed,
107            self.total_damage_dealt, self.total_damage_taken,
108            self.avg_frame_ms(), self.p99_frame_ms())
109    }
110
111    pub fn csv_header() -> &'static str {
112        "deaths,kills,floors,items,score,play_time,bosses_seen,bosses_killed,damage_dealt,damage_taken,avg_frame_ms,p99_frame_ms"
113    }
114}
115
116// ── Analytics ─────────────────────────────────────────────────────────────────
117
118/// Opt-in analytics system. Call `record()` to log events.
119/// `tick()` batches them and uploads when enough accumulate or on flush.
120pub struct Analytics {
121    /// Whether telemetry is enabled (user opt-in).
122    pub enabled:         bool,
123    pub endpoint:        Option<String>,
124    pub build_version:   String,
125    pub platform:        String,
126    pub session_id:      String,
127    pub stats:           SessionStats,
128
129    pending:             Vec<AnalyticsEvent>,
130    local_log:           VecDeque<(String, String)>, // (timestamp, json)
131    http:                Option<HttpClient>,
132    session_start:       Instant,
133    flush_interval:      f32,
134    flush_timer:         f32,
135    batch_size:          usize,
136    max_local_log:       usize,
137    uploads_sent:        u32,
138}
139
140impl Analytics {
141    pub fn new(build: impl Into<String>, platform: impl Into<String>) -> Self {
142        Self {
143            enabled:        false, // Opt-in by default
144            endpoint:       None,
145            build_version:  build.into(),
146            platform:       platform.into(),
147            session_id:     Self::generate_session_id(),
148            stats:          SessionStats::default(),
149            pending:        Vec::new(),
150            local_log:      VecDeque::new(),
151            http:           None,
152            session_start:  Instant::now(),
153            flush_interval: 60.0, // flush every 60s
154            flush_timer:    0.0,
155            batch_size:     100,
156            max_local_log:  10_000,
157            uploads_sent:   0,
158        }
159    }
160
161    pub fn enable(&mut self, endpoint: impl Into<String>) {
162        self.enabled  = true;
163        self.endpoint = Some(endpoint.into());
164        self.http     = Some(HttpClient::new());
165        self.record(AnalyticsEvent::SessionStart {
166            build:    self.build_version.clone(),
167            platform: self.platform.clone(),
168        });
169    }
170
171    pub fn disable(&mut self) {
172        self.enabled = false;
173    }
174
175    /// Record an analytics event. No-op if disabled.
176    pub fn record(&mut self, event: AnalyticsEvent) {
177        if !self.enabled { return; }
178
179        // Update session stats
180        match &event {
181            AnalyticsEvent::Death { .. } => self.stats.deaths += 1,
182            AnalyticsEvent::BossEncounter { defeated, .. } => {
183                self.stats.bosses_seen += 1;
184                if *defeated { self.stats.bosses_killed += 1; }
185            }
186            AnalyticsEvent::ItemPickup { .. } => self.stats.items += 1,
187            AnalyticsEvent::FloorComplete { floor, .. } => {
188                self.stats.floors += 1;
189                self.stats.max_floor = self.stats.max_floor.max(*floor);
190            }
191            AnalyticsEvent::FrameTime { avg_ms, .. } => {
192                self.stats.frame_times.push(*avg_ms);
193                // Keep last 1000 frame time samples
194                if self.stats.frame_times.len() > 1000 {
195                    self.stats.frame_times.remove(0);
196                }
197            }
198            _ => {}
199        }
200
201        let ts  = format!("{}", self.session_start.elapsed().as_secs());
202        let json = event.to_json();
203
204        // Add session_id to every event
205        let full_json = format!(r#"{{"session":"{}","t":{},"data":{}}}"#,
206            self.session_id, ts, json);
207
208        if self.local_log.len() < self.max_local_log {
209            self.local_log.push_back((ts, full_json.clone()));
210        }
211        self.pending.push(event);
212    }
213
214    /// Drive uploads. Call once per frame.
215    pub fn tick(&mut self, dt: f32) {
216        if !self.enabled { return; }
217
218        self.stats.play_time += dt;
219        self.flush_timer += dt;
220
221        let should_flush = self.flush_timer >= self.flush_interval
222            || self.pending.len() >= self.batch_size;
223
224        if should_flush && !self.pending.is_empty() {
225            self.flush();
226        }
227
228        if let Some(ref mut http) = self.http {
229            http.tick(dt);
230            // Drain http events (fire-and-forget analytics)
231            let _: Vec<_> = http.drain_events().collect();
232        }
233    }
234
235    /// Force-upload all pending events.
236    pub fn flush(&mut self) {
237        if self.pending.is_empty() { return; }
238
239        let batch: Vec<String> = self.local_log.iter()
240            .rev()
241            .take(self.pending.len())
242            .map(|(_, j)| j.clone())
243            .collect();
244
245        if let (Some(ref endpoint), Some(ref mut http)) = (self.endpoint.clone(), self.http.as_mut()) {
246            let url  = format!("{}/events", endpoint);
247            let body = format!("[{}]", batch.join(","));
248            let req  = HttpRequest::post_json(url, body)
249                .with_header("X-Session-Id", self.session_id.clone());
250            http.send(req);
251            self.uploads_sent += 1;
252        }
253
254        self.pending.clear();
255        self.flush_timer = 0.0;
256    }
257
258    /// Export the local event log to CSV.
259    pub fn export_csv(&self) -> String {
260        let mut csv = String::from("timestamp,event_json\n");
261        for (ts, json) in &self.local_log {
262            csv.push_str(&format!("{},{}\n", ts, json.replace(',', ";")));
263        }
264        csv
265    }
266
267    /// Get the local log as a string slice view (newest last).
268    pub fn local_log_entries(&self) -> impl Iterator<Item = &(String, String)> {
269        self.local_log.iter()
270    }
271
272    /// Clear the local log.
273    pub fn clear_local_log(&mut self) { self.local_log.clear(); }
274
275    pub fn session_duration(&self) -> Duration { self.session_start.elapsed() }
276    pub fn uploads_sent(&self) -> u32 { self.uploads_sent }
277
278    fn generate_session_id() -> String {
279        // Simple deterministic ID from process start time + counter
280        use std::sync::atomic::{AtomicU64, Ordering};
281        static COUNTER: AtomicU64 = AtomicU64::new(1);
282        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
283        // Poor man's UUID-ish hex
284        format!("{:016x}{:016x}", n, n.wrapping_mul(0xdeadbeef_cafebabe))
285    }
286}