From 0dcb783e94ad63603d2b3289a3e5c43582c1f80b Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 13 May 2026 20:06:21 -0700 Subject: [PATCH] feat(analytics): opt-in usage analytics with server ingest and settings toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server: POST /api/analytics endpoint with per-IP rate limit (5/min), batch validation (≤50 events, event_type regex, UUID dedup, clock check), INSERT OR IGNORE for idempotency, and migration 004_analytics.sql - Client (solitaire_data): AnalyticsClient with in-memory Mutex buffer, UUID session_id per launch, async flush via background task - Engine: AnalyticsPlugin records game_won, game_forfeit, game_start, achievement_unlocked; flushes immediately on game-end, every 60 s otherwise - Settings UI: Privacy section with ON/OFF toggle, hidden in local-only mode - Default: analytics_enabled = false (explicit opt-in required) Co-Authored-By: Claude Sonnet 4.6 --- ...d8fc67e71f3930609f1cf14061d35d6de8ec3.json | 12 + Cargo.lock | 1 + solitaire_app/src/lib.rs | 3 +- solitaire_data/Cargo.toml | 1 + solitaire_data/src/analytics_client.rs | 97 ++++++++ solitaire_data/src/lib.rs | 3 + solitaire_data/src/settings.rs | 8 + solitaire_engine/Cargo.toml | 1 + solitaire_engine/src/analytics_plugin.rs | 210 ++++++++++++++++++ solitaire_engine/src/lib.rs | 2 + solitaire_engine/src/settings_plugin.rs | 48 +++- solitaire_server/migrations/004_analytics.sql | 15 ++ solitaire_server/src/analytics.rs | 130 +++++++++++ solitaire_server/src/lib.rs | 19 ++ 14 files changed, 548 insertions(+), 2 deletions(-) create mode 100644 .sqlx/query-f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3.json create mode 100644 solitaire_data/src/analytics_client.rs create mode 100644 solitaire_engine/src/analytics_plugin.rs create mode 100644 solitaire_server/migrations/004_analytics.sql create mode 100644 solitaire_server/src/analytics.rs diff --git a/.sqlx/query-f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3.json b/.sqlx/query-f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3.json new file mode 100644 index 0000000..37ea9f8 --- /dev/null +++ b/.sqlx/query-f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR IGNORE INTO analytics_events\n (id, user_id, session_id, event_type, payload, client_time, received_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3" +} diff --git a/Cargo.lock b/Cargo.lock index 000696b..e602e00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7018,6 +7018,7 @@ dependencies = [ "resvg", "ron", "serde", + "serde_json", "solitaire_core", "solitaire_data", "solitaire_sync", diff --git a/solitaire_app/src/lib.rs b/solitaire_app/src/lib.rs index 43385e9..8fdc0b1 100644 --- a/solitaire_app/src/lib.rs +++ b/solitaire_app/src/lib.rs @@ -25,7 +25,7 @@ use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow}; use bevy::winit::WinitWindows; use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings}; use solitaire_engine::{ - register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, + register_theme_asset_sources, AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin, AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin, @@ -194,6 +194,7 @@ pub fn run() { .add_plugins(OnboardingPlugin) .add_plugins(SyncPlugin::new(sync_provider)) .add_plugins(SyncSetupPlugin) + .add_plugins(AnalyticsPlugin) .add_plugins(LeaderboardPlugin) .add_plugins(WinSummaryPlugin) .add_plugins(UiModalPlugin) diff --git a/solitaire_data/Cargo.toml b/solitaire_data/Cargo.toml index 24005ca..e76bd30 100644 --- a/solitaire_data/Cargo.toml +++ b/solitaire_data/Cargo.toml @@ -15,6 +15,7 @@ async-trait = { workspace = true } dirs = { workspace = true } reqwest = { workspace = true } tokio = { workspace = true } +uuid = { workspace = true } # `keyring-core` is the typed Entry/Error API used by # `auth_tokens`. The crate's own dependency tree pulls in diff --git a/solitaire_data/src/analytics_client.rs b/solitaire_data/src/analytics_client.rs new file mode 100644 index 0000000..2a9289a --- /dev/null +++ b/solitaire_data/src/analytics_client.rs @@ -0,0 +1,97 @@ +//! Fire-and-forget analytics client. +//! +//! Events are buffered in memory and flushed in a background task. Errors are +//! silently discarded — analytics must never affect gameplay or block the UI. + +use std::sync::Mutex; + +use chrono::{DateTime, Utc}; +use reqwest::Client; +use serde_json::Value; +use uuid::Uuid; + +/// Buffers game-play events and flushes them to `POST /api/analytics`. +/// +/// Construct once per session and share via `Arc`. `record` is cheap and +/// can be called from the Bevy main thread; `flush` is async and must be +/// called from a background task. +pub struct AnalyticsClient { + base_url: String, + /// Stable across the whole app session — one UUID per launch. + session_id: String, + client: Client, + pending: Mutex>, +} + +struct PendingEvent { + id: String, + event_type: String, + payload: Value, + client_time: DateTime, +} + +impl AnalyticsClient { + /// Create a new client for the given server base URL. + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_owned(), + session_id: Uuid::new_v4().to_string(), + client: Client::new(), + pending: Mutex::new(Vec::new()), + } + } + + /// Buffer one event. Never blocks; never fails visibly. + /// + /// When the buffer exceeds 100 events the oldest 50 are dropped to + /// prevent unbounded memory growth during extended offline play. + pub fn record(&self, event_type: &str, payload: Value) { + let Ok(mut guard) = self.pending.lock() else { + return; + }; + guard.push(PendingEvent { + id: Uuid::new_v4().to_string(), + event_type: event_type.to_owned(), + payload, + client_time: Utc::now(), + }); + if guard.len() > 100 { + guard.drain(0..50); + } + } + + /// Drain the pending buffer and POST it to the server. + /// + /// The buffer is drained *before* the HTTP call so new events recorded + /// during an in-flight flush are not lost. On network failure the drained + /// events are silently discarded (fire-and-forget semantics). + pub async fn flush(&self, user_id: Option) { + let events = { + let Ok(mut guard) = self.pending.lock() else { + return; + }; + if guard.is_empty() { + return; + } + std::mem::take(&mut *guard) + }; + + let batch = serde_json::json!({ + "session_id": self.session_id, + "user_id": user_id, + "events": events.iter().map(|e| serde_json::json!({ + "id": e.id, + "event_type": e.event_type, + "payload": e.payload, + "client_time": e.client_time.to_rfc3339(), + })).collect::>(), + }); + + let _ = self + .client + .post(format!("{}/api/analytics", self.base_url)) + .json(&batch) + .send() + .await; + } +} diff --git a/solitaire_data/src/lib.rs b/solitaire_data/src/lib.rs index b695b57..91668f2 100644 --- a/solitaire_data/src/lib.rs +++ b/solitaire_data/src/lib.rs @@ -163,5 +163,8 @@ pub use replay::{ REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, }; +pub mod analytics_client; +pub use analytics_client::AnalyticsClient; + pub mod platform; pub use platform::data_dir; diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index 3ba45f3..3fe0763 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -243,6 +243,13 @@ pub struct Settings { /// `false` via `#[serde(default)]`. #[serde(default)] pub take_from_foundation: bool, + /// When `true`, anonymous game-play events (game start, game won, etc.) + /// are sent to the configured sync server for aggregate analytics. Opt-in; + /// defaults to `false`. Only active when `sync_backend` is + /// `SolitaireServer`. Older `settings.json` files deserialize cleanly to + /// `false` via `#[serde(default)]`. + #[serde(default)] + pub analytics_enabled: bool, } fn default_draw_mode() -> DrawMode { @@ -364,6 +371,7 @@ impl Default for Settings { last_difficulty: None, leaderboard_display_name: None, take_from_foundation: false, + analytics_enabled: false, } } } diff --git a/solitaire_engine/Cargo.toml b/solitaire_engine/Cargo.toml index ebeaaf0..43616e6 100644 --- a/solitaire_engine/Cargo.toml +++ b/solitaire_engine/Cargo.toml @@ -14,6 +14,7 @@ chrono = { workspace = true } uuid = { workspace = true } tokio = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } usvg = { workspace = true } resvg = { workspace = true } diff --git a/solitaire_engine/src/analytics_plugin.rs b/solitaire_engine/src/analytics_plugin.rs new file mode 100644 index 0000000..52b0a1b --- /dev/null +++ b/solitaire_engine/src/analytics_plugin.rs @@ -0,0 +1,210 @@ +//! Analytics plugin — buffers game-play events and flushes them to the +//! configured server in the background. +//! +//! Disabled by default (opt-in via Settings → Privacy). Only active when +//! `settings.analytics_enabled` is `true` AND `sync_backend` is a +//! `SolitaireServer` with a URL to send to. + +use std::sync::Arc; + +use bevy::prelude::*; +use bevy::tasks::AsyncComputeTaskPool; +use solitaire_core::game_state::GameMode; +use solitaire_data::{analytics_client::AnalyticsClient, settings::SyncBackend, Settings}; + +use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent}; +use crate::resources::GameStateResource; +use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; + +// --------------------------------------------------------------------------- +// Resource +// --------------------------------------------------------------------------- + +/// Holds the active analytics client. `None` when the feature is disabled. +#[derive(Resource)] +pub struct AnalyticsResource { + pub client: Option>, + flush_timer: Timer, +} + +impl Default for AnalyticsResource { + fn default() -> Self { + Self { + client: None, + flush_timer: Timer::from_seconds(60.0, TimerMode::Repeating), + } + } +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +/// Registers analytics systems. Add after `SettingsPlugin` in the app. +pub struct AnalyticsPlugin; + +impl Plugin for AnalyticsPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Startup, init_analytics) + .add_systems( + Update, + ( + react_to_settings_change, + on_game_won, + on_forfeit, + on_new_game, + on_achievement_unlocked, + tick_flush_timer, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Systems +// --------------------------------------------------------------------------- + +fn init_analytics(settings: Res, mut analytics: ResMut) { + analytics.client = client_for(&settings.0); +} + +fn react_to_settings_change( + mut events: MessageReader, + mut analytics: ResMut, +) { + for ev in events.read() { + analytics.client = client_for(&ev.0); + } +} + +fn on_game_won( + mut wins: MessageReader, + analytics: Res, + settings: Res, +) { + let Some(client) = analytics.client.clone() else { + return; + }; + for ev in wins.read() { + client.record( + "game_won", + serde_json::json!({ + "score": ev.score, + "time_seconds": ev.time_seconds, + }), + ); + fire_flush(client.clone(), &settings.0); + } +} + +fn on_forfeit( + mut forfeits: MessageReader, + analytics: Res, + settings: Res, +) { + let Some(client) = analytics.client.clone() else { + return; + }; + for _ev in forfeits.read() { + client.record("game_forfeit", serde_json::json!({})); + fire_flush(client.clone(), &settings.0); + } +} + +fn on_new_game( + mut requests: MessageReader, + analytics: Res, + game: Res, +) { + let Some(client) = analytics.client.clone() else { + return; + }; + for ev in requests.read() { + // Only record confirmed starts — skip the first unconfirmed request + // that spawns the "abandon game?" modal. + if !ev.confirmed { + continue; + } + // mode = None means "reuse current game mode". Reading from + // GameStateResource at this point gives the still-active game's mode, + // which is exactly what the new game will inherit. + let mode = ev.mode.unwrap_or(game.0.mode); + client.record( + "game_start", + serde_json::json!({ "mode": mode_str(mode) }), + ); + } +} + +fn on_achievement_unlocked( + mut achievements: MessageReader, + analytics: Res, +) { + let Some(client) = analytics.client.clone() else { + return; + }; + for ev in achievements.read() { + client.record( + "achievement_unlocked", + serde_json::json!({ "achievement_id": ev.0.id }), + ); + } +} + +fn tick_flush_timer( + time: Res