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
+19
View File
@@ -4,6 +4,7 @@
//! application against an in-memory SQLite database without starting a real
//! TCP listener.
pub mod analytics;
pub mod auth;
pub mod challenge;
pub mod error;
@@ -189,6 +190,23 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
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).
let public = Router::new()
.route("/api/daily-challenge", get(challenge::daily_challenge))
@@ -233,6 +251,7 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
Router::new()
.merge(protected)
.merge(auth_routes)
.merge(analytics_route)
.merge(public)
.merge(web)
// Reject request bodies larger than 1 MB.