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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
pub mod analytics_client;
|
||||
pub use analytics_client::AnalyticsClient;
|
||||
|
||||
pub mod platform;
|
||||
pub use platform::data_dir;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user