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:
funman300
2026-05-13 20:06:21 -07:00
parent ea17f94b6c
commit 0dcb783e94
14 changed files with 548 additions and 2 deletions
+1
View File
@@ -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
+97
View File
@@ -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;
}
}
+3
View File
@@ -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;
+8
View File
@@ -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,
}
}
}