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
@@ -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);
+130
View File
@@ -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, 164 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 })))
}
+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.