feat(analytics): replace custom pipeline with Matomo
Removes the hand-rolled analytics endpoint and SQLite event table in favour of Matomo — a self-hosted, full-featured analytics platform. k8s: - Deploy MariaDB 11 + Bitnami Matomo 5 in the solitaire namespace - Route analytics.aleshym.co ingress to the Matomo service - Remove Datasette sidecar and its BasicAuth middleware/secret - Remove the analytics port from the solitaire-server Service Rust: - Replace AnalyticsClient (custom HTTP endpoint) with MatomoClient (Matomo HTTP Tracking API bulk endpoint); maps game events to Matomo categories - Add matomo_url + matomo_site_id fields to Settings (serde default → None/1) - Privacy toggle in Settings now activates when matomo_url is set (not tied to SyncBackend::SolitaireServer) - Remove POST /api/analytics route from solitaire_server Web: - Add Matomo JS tracking snippet to game.html (/play page) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,130 +0,0 @@
|
||||
//! 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,7 +4,6 @@
|
||||
//! 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;
|
||||
@@ -190,23 +189,6 @@ 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))
|
||||
@@ -251,7 +233,6 @@ 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.
|
||||
|
||||
@@ -5,6 +5,20 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ferrous Solitaire — Play</title>
|
||||
<link rel="stylesheet" href="/web/game.css">
|
||||
<!-- Matomo -->
|
||||
<script>
|
||||
var _paq = window._paq = window._paq || [];
|
||||
_paq.push(['trackPageView']);
|
||||
_paq.push(['enableLinkTracking']);
|
||||
(function() {
|
||||
var u = "https://analytics.aleshym.co/";
|
||||
_paq.push(['setTrackerUrl', u + 'matomo.php']);
|
||||
_paq.push(['setSiteId', '1']);
|
||||
var d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
|
||||
g.async = true; g.src = u + 'matomo.js'; s.parentNode.insertBefore(g, s);
|
||||
})();
|
||||
</script>
|
||||
<!-- End Matomo -->
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
|
||||
Reference in New Issue
Block a user