feat(analytics): opt-in usage analytics with server ingest and settings toggle
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Arc<AnalyticsClient>>,
|
||||
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::<AnalyticsResource>()
|
||||
.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<SettingsResource>, mut analytics: ResMut<AnalyticsResource>) {
|
||||
analytics.client = client_for(&settings.0);
|
||||
}
|
||||
|
||||
fn react_to_settings_change(
|
||||
mut events: MessageReader<SettingsChangedEvent>,
|
||||
mut analytics: ResMut<AnalyticsResource>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
analytics.client = client_for(&ev.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn on_game_won(
|
||||
mut wins: MessageReader<GameWonEvent>,
|
||||
analytics: Res<AnalyticsResource>,
|
||||
settings: Res<SettingsResource>,
|
||||
) {
|
||||
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<ForfeitEvent>,
|
||||
analytics: Res<AnalyticsResource>,
|
||||
settings: Res<SettingsResource>,
|
||||
) {
|
||||
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<NewGameRequestEvent>,
|
||||
analytics: Res<AnalyticsResource>,
|
||||
game: Res<GameStateResource>,
|
||||
) {
|
||||
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<AchievementUnlockedEvent>,
|
||||
analytics: Res<AnalyticsResource>,
|
||||
) {
|
||||
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<Time>,
|
||||
mut analytics: ResMut<AnalyticsResource>,
|
||||
settings: Res<SettingsResource>,
|
||||
) {
|
||||
analytics.flush_timer.tick(time.delta());
|
||||
if !analytics.flush_timer.just_finished() {
|
||||
return;
|
||||
}
|
||||
if let Some(client) = analytics.client.clone() {
|
||||
fire_flush(client, &settings.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn client_for(settings: &Settings) -> Option<Arc<AnalyticsClient>> {
|
||||
if !settings.analytics_enabled {
|
||||
return None;
|
||||
}
|
||||
match &settings.sync_backend {
|
||||
SyncBackend::SolitaireServer { url, .. } => {
|
||||
Some(Arc::new(AnalyticsClient::new(url.clone())))
|
||||
}
|
||||
SyncBackend::Local => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn fire_flush(client: Arc<AnalyticsClient>, settings: &Settings) {
|
||||
let user_id = match &settings.sync_backend {
|
||||
SyncBackend::SolitaireServer { username, .. } => Some(username.clone()),
|
||||
SyncBackend::Local => None,
|
||||
};
|
||||
AsyncComputeTaskPool::get()
|
||||
.spawn(async move {
|
||||
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
rt.block_on(client.flush(user_id));
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn mode_str(mode: GameMode) -> &'static str {
|
||||
match mode {
|
||||
GameMode::Classic => "classic",
|
||||
GameMode::Zen => "zen",
|
||||
GameMode::Challenge => "challenge",
|
||||
GameMode::TimeAttack => "time_attack",
|
||||
GameMode::Difficulty(_) => "difficulty",
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod android_clipboard;
|
||||
pub mod assets;
|
||||
pub mod card_animation;
|
||||
pub mod achievement_plugin;
|
||||
pub mod analytics_plugin;
|
||||
pub mod animation_plugin;
|
||||
pub mod auto_complete_plugin;
|
||||
pub mod audio_plugin;
|
||||
@@ -60,6 +61,7 @@ pub use theme::{
|
||||
ThemeRegistryPlugin,
|
||||
};
|
||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
||||
pub use analytics_plugin::{AnalyticsPlugin, AnalyticsResource};
|
||||
pub use challenge_plugin::{
|
||||
challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL,
|
||||
};
|
||||
|
||||
@@ -166,6 +166,11 @@ struct WinnableDealsOnlyText;
|
||||
#[derive(Component, Debug)]
|
||||
struct SmartDefaultSizeText;
|
||||
|
||||
/// Marks the `Text` node showing the current "Share usage data" (analytics)
|
||||
/// state ("ON" / "OFF") in the Privacy section.
|
||||
#[derive(Component, Debug)]
|
||||
struct AnalyticsEnabledText;
|
||||
|
||||
/// Marks the scrollable inner card so the mouse-wheel system can target it.
|
||||
#[derive(Component, Debug)]
|
||||
struct SettingsPanelScrollable;
|
||||
@@ -236,6 +241,10 @@ enum SettingsButton {
|
||||
/// flag only affects launches without saved geometry — the
|
||||
/// player's last window size always wins.
|
||||
ToggleSmartDefaultSize,
|
||||
/// Toggle [`Settings::analytics_enabled`]. Only rendered when a
|
||||
/// sync server is configured — there is no server to send to in
|
||||
/// local-only mode.
|
||||
ToggleAnalytics,
|
||||
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
|
||||
ScanThemes,
|
||||
SyncNow,
|
||||
@@ -283,6 +292,8 @@ impl SettingsButton {
|
||||
SettingsButton::ReplayMoveIntervalUp => 49,
|
||||
// Smart-default-size toggle — sits at the end of Gameplay.
|
||||
SettingsButton::ToggleSmartDefaultSize => 50,
|
||||
// Privacy section — just before Sync.
|
||||
SettingsButton::ToggleAnalytics => 89,
|
||||
// Cosmetic section
|
||||
SettingsButton::ToggleTheme => 55,
|
||||
SettingsButton::ToggleColorBlind => 60,
|
||||
@@ -398,10 +409,11 @@ impl Plugin for SettingsPlugin {
|
||||
update_replay_move_interval_text,
|
||||
update_winnable_deals_only_text,
|
||||
update_smart_default_size_text,
|
||||
update_analytics_enabled_text,
|
||||
attach_focusable_to_settings_buttons,
|
||||
scroll_focus_into_view,
|
||||
),
|
||||
);
|
||||
app.add_systems(Update, scroll_focus_into_view);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -763,6 +775,20 @@ fn update_winnable_deals_only_text(
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the live "Share usage data" toggle value in the Privacy section
|
||||
/// whenever `SettingsResource` changes.
|
||||
fn update_analytics_enabled_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<AnalyticsEnabledText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = on_off_label(settings.0.analytics_enabled);
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the live "Smart window size" toggle value whenever
|
||||
/// `SettingsResource` changes. The flag is stored negatively as
|
||||
/// `disable_smart_default_size`, so the label inverts.
|
||||
@@ -1049,6 +1075,12 @@ fn handle_settings_buttons(
|
||||
// The Text node is refreshed by `update_winnable_deals_only_text`
|
||||
// on the next frame via `settings.is_changed()`.
|
||||
}
|
||||
SettingsButton::ToggleAnalytics => {
|
||||
settings.0.analytics_enabled = !settings.0.analytics_enabled;
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
// Text refreshed by `update_analytics_enabled_text` next frame.
|
||||
}
|
||||
SettingsButton::ToggleSmartDefaultSize => {
|
||||
settings.0.disable_smart_default_size =
|
||||
!settings.0.disable_smart_default_size;
|
||||
@@ -1650,6 +1682,20 @@ fn spawn_settings_panel(
|
||||
}
|
||||
import_themes_row(body, font_res);
|
||||
|
||||
// --- Privacy (only shown when a sync server is configured) ---
|
||||
if matches!(settings.sync_backend, SyncBackend::SolitaireServer { .. }) {
|
||||
section_label(body, "Privacy", font_res);
|
||||
toggle_row(
|
||||
body,
|
||||
"Share usage data",
|
||||
AnalyticsEnabledText,
|
||||
on_off_label(settings.analytics_enabled),
|
||||
SettingsButton::ToggleAnalytics,
|
||||
"Sends anonymous game events to your server. No personal data is collected.",
|
||||
font_res,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sync ---
|
||||
section_label(body, "Sync", font_res);
|
||||
sync_row(body, sync_status, &settings.sync_backend, font_res);
|
||||
|
||||
Reference in New Issue
Block a user