feat(analytics): opt-in usage analytics with server ingest and settings toggle
Build and Deploy / build-and-push (push) Successful in 4m17s
Build and Deploy / build-and-push (push) Successful in 4m17s
- 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:
+12
@@ -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"
|
||||||
|
}
|
||||||
Generated
+1
@@ -7018,6 +7018,7 @@ dependencies = [
|
|||||||
"resvg",
|
"resvg",
|
||||||
"ron",
|
"ron",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"solitaire_core",
|
"solitaire_core",
|
||||||
"solitaire_data",
|
"solitaire_data",
|
||||||
"solitaire_sync",
|
"solitaire_sync",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow};
|
|||||||
use bevy::winit::WinitWindows;
|
use bevy::winit::WinitWindows;
|
||||||
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
|
||||||
use solitaire_engine::{
|
use solitaire_engine::{
|
||||||
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
|
register_theme_asset_sources, AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin,
|
||||||
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
AudioPlugin, AutoCompletePlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, LeaderboardPlugin,
|
||||||
@@ -194,6 +194,7 @@ pub fn run() {
|
|||||||
.add_plugins(OnboardingPlugin)
|
.add_plugins(OnboardingPlugin)
|
||||||
.add_plugins(SyncPlugin::new(sync_provider))
|
.add_plugins(SyncPlugin::new(sync_provider))
|
||||||
.add_plugins(SyncSetupPlugin)
|
.add_plugins(SyncSetupPlugin)
|
||||||
|
.add_plugins(AnalyticsPlugin)
|
||||||
.add_plugins(LeaderboardPlugin)
|
.add_plugins(LeaderboardPlugin)
|
||||||
.add_plugins(WinSummaryPlugin)
|
.add_plugins(WinSummaryPlugin)
|
||||||
.add_plugins(UiModalPlugin)
|
.add_plugins(UiModalPlugin)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ async-trait = { workspace = true }
|
|||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
# `keyring-core` is the typed Entry/Error API used by
|
# `keyring-core` is the typed Entry/Error API used by
|
||||||
# `auth_tokens`. The crate's own dependency tree pulls in
|
# `auth_tokens`. The crate's own dependency tree pulls in
|
||||||
|
|||||||
@@ -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<Vec<PendingEvent>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PendingEvent {
|
||||||
|
id: String,
|
||||||
|
event_type: String,
|
||||||
|
payload: Value,
|
||||||
|
client_time: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnalyticsClient {
|
||||||
|
/// Create a new client for the given server base URL.
|
||||||
|
pub fn new(base_url: impl Into<String>) -> 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<String>) {
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = self
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/analytics", self.base_url))
|
||||||
|
.json(&batch)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,5 +163,8 @@ pub use replay::{
|
|||||||
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
|
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub mod analytics_client;
|
||||||
|
pub use analytics_client::AnalyticsClient;
|
||||||
|
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub use platform::data_dir;
|
pub use platform::data_dir;
|
||||||
|
|||||||
@@ -243,6 +243,13 @@ pub struct Settings {
|
|||||||
/// `false` via `#[serde(default)]`.
|
/// `false` via `#[serde(default)]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub take_from_foundation: bool,
|
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 {
|
fn default_draw_mode() -> DrawMode {
|
||||||
@@ -364,6 +371,7 @@ impl Default for Settings {
|
|||||||
last_difficulty: None,
|
last_difficulty: None,
|
||||||
leaderboard_display_name: None,
|
leaderboard_display_name: None,
|
||||||
take_from_foundation: false,
|
take_from_foundation: false,
|
||||||
|
analytics_enabled: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ chrono = { workspace = true }
|
|||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
usvg = { workspace = true }
|
usvg = { workspace = true }
|
||||||
resvg = { workspace = true }
|
resvg = { workspace = true }
|
||||||
|
|||||||
@@ -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 assets;
|
||||||
pub mod card_animation;
|
pub mod card_animation;
|
||||||
pub mod achievement_plugin;
|
pub mod achievement_plugin;
|
||||||
|
pub mod analytics_plugin;
|
||||||
pub mod animation_plugin;
|
pub mod animation_plugin;
|
||||||
pub mod auto_complete_plugin;
|
pub mod auto_complete_plugin;
|
||||||
pub mod audio_plugin;
|
pub mod audio_plugin;
|
||||||
@@ -60,6 +61,7 @@ pub use theme::{
|
|||||||
ThemeRegistryPlugin,
|
ThemeRegistryPlugin,
|
||||||
};
|
};
|
||||||
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
|
||||||
|
pub use analytics_plugin::{AnalyticsPlugin, AnalyticsResource};
|
||||||
pub use challenge_plugin::{
|
pub use challenge_plugin::{
|
||||||
challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL,
|
challenge_progress_label, ChallengeAdvancedEvent, ChallengePlugin, CHALLENGE_UNLOCK_LEVEL,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -166,6 +166,11 @@ struct WinnableDealsOnlyText;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct SmartDefaultSizeText;
|
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.
|
/// Marks the scrollable inner card so the mouse-wheel system can target it.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct SettingsPanelScrollable;
|
struct SettingsPanelScrollable;
|
||||||
@@ -236,6 +241,10 @@ enum SettingsButton {
|
|||||||
/// flag only affects launches without saved geometry — the
|
/// flag only affects launches without saved geometry — the
|
||||||
/// player's last window size always wins.
|
/// player's last window size always wins.
|
||||||
ToggleSmartDefaultSize,
|
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.
|
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
|
||||||
ScanThemes,
|
ScanThemes,
|
||||||
SyncNow,
|
SyncNow,
|
||||||
@@ -283,6 +292,8 @@ impl SettingsButton {
|
|||||||
SettingsButton::ReplayMoveIntervalUp => 49,
|
SettingsButton::ReplayMoveIntervalUp => 49,
|
||||||
// Smart-default-size toggle — sits at the end of Gameplay.
|
// Smart-default-size toggle — sits at the end of Gameplay.
|
||||||
SettingsButton::ToggleSmartDefaultSize => 50,
|
SettingsButton::ToggleSmartDefaultSize => 50,
|
||||||
|
// Privacy section — just before Sync.
|
||||||
|
SettingsButton::ToggleAnalytics => 89,
|
||||||
// Cosmetic section
|
// Cosmetic section
|
||||||
SettingsButton::ToggleTheme => 55,
|
SettingsButton::ToggleTheme => 55,
|
||||||
SettingsButton::ToggleColorBlind => 60,
|
SettingsButton::ToggleColorBlind => 60,
|
||||||
@@ -398,10 +409,11 @@ impl Plugin for SettingsPlugin {
|
|||||||
update_replay_move_interval_text,
|
update_replay_move_interval_text,
|
||||||
update_winnable_deals_only_text,
|
update_winnable_deals_only_text,
|
||||||
update_smart_default_size_text,
|
update_smart_default_size_text,
|
||||||
|
update_analytics_enabled_text,
|
||||||
attach_focusable_to_settings_buttons,
|
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
|
/// Refreshes the live "Smart window size" toggle value whenever
|
||||||
/// `SettingsResource` changes. The flag is stored negatively as
|
/// `SettingsResource` changes. The flag is stored negatively as
|
||||||
/// `disable_smart_default_size`, so the label inverts.
|
/// `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`
|
// The Text node is refreshed by `update_winnable_deals_only_text`
|
||||||
// on the next frame via `settings.is_changed()`.
|
// 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 => {
|
SettingsButton::ToggleSmartDefaultSize => {
|
||||||
settings.0.disable_smart_default_size =
|
settings.0.disable_smart_default_size =
|
||||||
!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);
|
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 ---
|
// --- Sync ---
|
||||||
section_label(body, "Sync", font_res);
|
section_label(body, "Sync", font_res);
|
||||||
sync_row(body, sync_status, &settings.sync_backend, font_res);
|
sync_row(body, sync_status, &settings.sync_backend, font_res);
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Analytics event store.
|
||||||
|
-- Events are write-only; the server never modifies rows after insert.
|
||||||
|
-- `INSERT OR IGNORE` on `id` makes submissions idempotent.
|
||||||
|
CREATE TABLE IF NOT EXISTS analytics_events (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL, -- UUID v4 minted by the client
|
||||||
|
user_id TEXT, -- optional username; NULL = anonymous
|
||||||
|
session_id TEXT NOT NULL, -- UUID v4, one per app launch
|
||||||
|
event_type TEXT NOT NULL, -- e.g. "game_won", "game_start"
|
||||||
|
payload TEXT NOT NULL DEFAULT '{}', -- JSON blob, event-specific fields
|
||||||
|
client_time TEXT NOT NULL, -- ISO-8601, from the client clock
|
||||||
|
received_at TEXT NOT NULL -- ISO-8601, server clock at ingest
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_event_type ON analytics_events(event_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_received_at ON analytics_events(received_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_user_id ON analytics_events(user_id);
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
//! Analytics ingest endpoint.
|
||||||
|
//!
|
||||||
|
//! `POST /api/analytics` — accept a batch of game-play events from an
|
||||||
|
//! opted-in client. No authentication required; the endpoint is public
|
||||||
|
//! so events can be captured before the player logs in.
|
||||||
|
//!
|
||||||
|
//! Each event is validated individually — a bad event is skipped rather
|
||||||
|
//! than rejecting the whole batch. Duplicate event IDs are silently
|
||||||
|
//! ignored via `INSERT OR IGNORE` so clients may safely retry a failed
|
||||||
|
//! batch without creating duplicate rows.
|
||||||
|
|
||||||
|
use axum::{extract::State, Json};
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{error::AppError, AppState};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wire types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Batch of events from a single client session.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AnalyticsBatch {
|
||||||
|
/// UUID v4 generated once per app launch by the client.
|
||||||
|
pub session_id: String,
|
||||||
|
/// Optional username — populated when the player is logged in.
|
||||||
|
pub user_id: Option<String>,
|
||||||
|
/// Events to ingest. Batches with more than 50 events are rejected.
|
||||||
|
pub events: Vec<AnalyticsEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One game-play event within a batch.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AnalyticsEvent {
|
||||||
|
/// UUID v4 minted client-side. Used as idempotency key.
|
||||||
|
pub id: String,
|
||||||
|
/// Lowercase snake-case type, e.g. `"game_won"`. Max 64 chars.
|
||||||
|
pub event_type: String,
|
||||||
|
/// Event-specific JSON payload.
|
||||||
|
pub payload: Value,
|
||||||
|
/// ISO-8601 timestamp from the client clock.
|
||||||
|
pub client_time: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validated, ready-to-insert form of an event.
|
||||||
|
struct ValidEvent {
|
||||||
|
id: String,
|
||||||
|
event_type: String,
|
||||||
|
payload_json: String,
|
||||||
|
client_time: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Handler
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `POST /api/analytics` — ingest a batch of analytics events.
|
||||||
|
pub async fn ingest(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(batch): Json<AnalyticsBatch>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
if batch.events.len() > 50 {
|
||||||
|
return Err(AppError::BadRequest("batch may contain at most 50 events".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let received_at = now.to_rfc3339();
|
||||||
|
// Reject events whose client_time claims to be more than 24 h in the future
|
||||||
|
// (clock skew protection; stale events from the past are fine).
|
||||||
|
let future_cutoff = now + chrono::Duration::hours(24);
|
||||||
|
|
||||||
|
let valid: Vec<ValidEvent> = batch
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| {
|
||||||
|
// Idempotency key must be a valid UUID.
|
||||||
|
if Uuid::parse_str(&e.id).is_err() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// event_type: lowercase letters and underscores only, 1–64 chars.
|
||||||
|
if e.event_type.is_empty()
|
||||||
|
|| e.event_type.len() > 64
|
||||||
|
|| !e.event_type.chars().all(|c| c.is_ascii_lowercase() || c == '_')
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// client_time must parse and not be too far in the future.
|
||||||
|
let parsed = e.client_time.parse::<chrono::DateTime<Utc>>().ok()?;
|
||||||
|
if parsed > future_cutoff {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let payload_json = serde_json::to_string(&e.payload).ok()?;
|
||||||
|
Some(ValidEvent {
|
||||||
|
id: e.id.clone(),
|
||||||
|
event_type: e.event_type.clone(),
|
||||||
|
payload_json,
|
||||||
|
client_time: parsed.to_rfc3339(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if valid.is_empty() {
|
||||||
|
return Ok(Json(serde_json::json!({ "ok": true, "accepted": 0 })));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = state.pool.begin().await?;
|
||||||
|
for ev in &valid {
|
||||||
|
sqlx::query!(
|
||||||
|
r#"INSERT OR IGNORE INTO analytics_events
|
||||||
|
(id, user_id, session_id, event_type, payload, client_time, received_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)"#,
|
||||||
|
ev.id,
|
||||||
|
batch.user_id,
|
||||||
|
batch.session_id,
|
||||||
|
ev.event_type,
|
||||||
|
ev.payload_json,
|
||||||
|
ev.client_time,
|
||||||
|
received_at,
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
let accepted = valid.len() as i64;
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true, "accepted": accepted })))
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
//! application against an in-memory SQLite database without starting a real
|
//! application against an in-memory SQLite database without starting a real
|
||||||
//! TCP listener.
|
//! TCP listener.
|
||||||
|
|
||||||
|
pub mod analytics;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod challenge;
|
pub mod challenge;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
@@ -189,6 +190,23 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
|||||||
auth_routes
|
auth_routes
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Analytics endpoint — public, but throttled at 5 batches/min per IP to
|
||||||
|
// limit abuse. Rate limiting is skipped in tests (same pattern as auth).
|
||||||
|
let analytics_route = Router::new().route("/api/analytics", post(analytics::ingest));
|
||||||
|
let analytics_route = if rate_limit {
|
||||||
|
let governor_conf = Arc::new(
|
||||||
|
GovernorConfigBuilder::default()
|
||||||
|
.key_extractor(SmartIpKeyExtractor)
|
||||||
|
.per_second(12) // 1 token / 12 s = 5 / min steady-state
|
||||||
|
.burst_size(5)
|
||||||
|
.finish()
|
||||||
|
.expect("invalid analytics governor config"),
|
||||||
|
);
|
||||||
|
analytics_route.layer(GovernorLayer::new(governor_conf))
|
||||||
|
} else {
|
||||||
|
analytics_route
|
||||||
|
};
|
||||||
|
|
||||||
// Public endpoints (no auth, no rate limit beyond defaults).
|
// Public endpoints (no auth, no rate limit beyond defaults).
|
||||||
let public = Router::new()
|
let public = Router::new()
|
||||||
.route("/api/daily-challenge", get(challenge::daily_challenge))
|
.route("/api/daily-challenge", get(challenge::daily_challenge))
|
||||||
@@ -233,6 +251,7 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.merge(protected)
|
.merge(protected)
|
||||||
.merge(auth_routes)
|
.merge(auth_routes)
|
||||||
|
.merge(analytics_route)
|
||||||
.merge(public)
|
.merge(public)
|
||||||
.merge(web)
|
.merge(web)
|
||||||
// Reject request bodies larger than 1 MB.
|
// Reject request bodies larger than 1 MB.
|
||||||
|
|||||||
Reference in New Issue
Block a user