feat(analytics): replace custom pipeline with Matomo
Build and Deploy / build-and-push (push) Successful in 4m36s

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:
funman300
2026-05-13 21:10:15 -07:00
parent 18ed1549e0
commit 3e006a1e94
22 changed files with 403 additions and 362 deletions
-41
View File
@@ -19,47 +19,6 @@ spec:
imagePullSecrets: imagePullSecrets:
- name: gitea-registry - name: gitea-registry
containers: containers:
- name: analytics
image: datasetteproject/datasette:0.65.1
args:
- serve
- /data/sol.db
- --host
- "0.0.0.0"
- --port
- "8001"
- --readonly
- --setting
- sql_time_limit_ms
- "5000"
- --setting
- max_returned_rows
- "1000"
ports:
- containerPort: 8001
volumeMounts:
- name: db-data
mountPath: /data
readOnly: true
livenessProbe:
httpGet:
path: /-/alive
port: 8001
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /-/alive
port: 8001
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
cpu: 25m
memory: 48Mi
limits:
cpu: 200m
memory: 128Mi
- name: server - name: server
image: solitaire-server image: solitaire-server
imagePullPolicy: Always imagePullPolicy: Always
+2 -3
View File
@@ -6,7 +6,6 @@ metadata:
annotations: annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: websecure traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.middlewares: solitaire-analytics-auth@kubernetescrd
spec: spec:
ingressClassName: traefik ingressClassName: traefik
rules: rules:
@@ -17,9 +16,9 @@ spec:
pathType: Prefix pathType: Prefix
backend: backend:
service: service:
name: solitaire-server name: matomo
port: port:
name: analytics name: http
tls: tls:
- hosts: - hosts:
- analytics.aleshym.co - analytics.aleshym.co
+7 -2
View File
@@ -7,8 +7,13 @@ resources:
- deployment.yaml - deployment.yaml
- service.yaml - service.yaml
- ingress.yaml - ingress.yaml
- middleware-analytics-auth.yaml - mariadb-pvc.yaml
- secret-analytics-auth.yaml - mariadb-deployment.yaml
- mariadb-service.yaml
- matomo-pvc.yaml
- matomo-secret.yaml
- matomo-deployment.yaml
- matomo-service.yaml
- ingress-analytics.yaml - ingress-analytics.yaml
# CI updates this block automatically via `kustomize edit set image`. # CI updates this block automatically via `kustomize edit set image`.
+72
View File
@@ -0,0 +1,72 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: mariadb
namespace: solitaire
spec:
replicas: 1
selector:
matchLabels:
app: mariadb
strategy:
type: Recreate
template:
metadata:
labels:
app: mariadb
spec:
containers:
- name: mariadb
image: mariadb:11
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_ROOT_PASSWORD
- name: MYSQL_DATABASE
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_DATABASE
- name: MYSQL_USER
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_USER
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_PASSWORD
ports:
- containerPort: 3306
volumeMounts:
- name: mariadb-data
mountPath: /var/lib/mysql
livenessProbe:
exec:
command:
- healthcheck.sh
- --connect
- --innodb_initialized
initialDelaySeconds: 30
periodSeconds: 30
readinessProbe:
exec:
command:
- healthcheck.sh
- --connect
initialDelaySeconds: 10
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: mariadb-data
persistentVolumeClaim:
claimName: mariadb-data
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mariadb-data
namespace: solitaire
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: mariadb
namespace: solitaire
spec:
selector:
app: mariadb
ports:
- name: mysql
port: 3306
targetPort: 3306
clusterIP: None
+85
View File
@@ -0,0 +1,85 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: matomo
namespace: solitaire
spec:
replicas: 1
selector:
matchLabels:
app: matomo
strategy:
type: Recreate
template:
metadata:
labels:
app: matomo
spec:
containers:
- name: matomo
image: bitnami/matomo:5
env:
- name: MATOMO_DATABASE_HOST
value: mariadb
- name: MATOMO_DATABASE_PORT_NUMBER
value: "3306"
- name: MATOMO_DATABASE_NAME
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_DATABASE
- name: MATOMO_DATABASE_USER
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_USER
- name: MATOMO_DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: matomo-secret
key: MYSQL_PASSWORD
- name: MATOMO_USERNAME
value: admin
- name: MATOMO_PASSWORD
valueFrom:
secretKeyRef:
name: matomo-secret
key: MATOMO_ADMIN_PASSWORD
- name: MATOMO_EMAIL
value: funman300@gmail.com
- name: MATOMO_WEBSITE_NAME
value: "Solitaire Quest"
- name: MATOMO_WEBSITE_HOST
value: "https://klondike.aleshym.co"
- name: MATOMO_HOST
value: analytics.aleshym.co
- name: MATOMO_ENABLE_PROXY_URI_HEADER
value: "yes"
ports:
- containerPort: 8080
volumeMounts:
- name: matomo-data
mountPath: /bitnami/matomo
livenessProbe:
httpGet:
path: /index.php
port: 8080
initialDelaySeconds: 60
periodSeconds: 30
readinessProbe:
httpGet:
path: /index.php
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: matomo-data
persistentVolumeClaim:
claimName: matomo-data
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: matomo-data
namespace: solitaire
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
+13
View File
@@ -0,0 +1,13 @@
# Credentials for MariaDB and the Matomo admin account.
# Regenerate with: python3 -c "import secrets; print(secrets.token_urlsafe(18))"
apiVersion: v1
kind: Secret
metadata:
name: matomo-secret
namespace: solitaire
stringData:
MYSQL_ROOT_PASSWORD: "jspRn-QU18sZhB55FR-JfrMJ"
MYSQL_DATABASE: matomo
MYSQL_USER: matomo
MYSQL_PASSWORD: "ZxDp648UuL5fsN7eQI23E7ue"
MATOMO_ADMIN_PASSWORD: "J6QUtbroK4Z7zao4Dnl0J7e2"
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: matomo
namespace: solitaire
spec:
selector:
app: matomo
ports:
- name: http
port: 80
targetPort: 8080
-8
View File
@@ -1,8 +0,0 @@
apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
name: analytics-auth
namespace: solitaire
spec:
basicAuth:
secret: analytics-auth-secret
-10
View File
@@ -1,10 +0,0 @@
# Default credentials: admin / solitaire-analytics
# To change: htpasswd -nbm admin 'NEWPASSWORD'
# Then replace the users value below and re-apply.
apiVersion: v1
kind: Secret
metadata:
name: analytics-auth-secret
namespace: solitaire
stringData:
users: "admin:$apr1$AAZ1wWNs$kMdJTfbPTcUZVX5ryY5gP1"
-3
View File
@@ -10,6 +10,3 @@ spec:
- name: http - name: http
port: 80 port: 80
targetPort: 8080 targetPort: 8080
- name: analytics
port: 8001
targetPort: 8001
-97
View File
@@ -1,97 +0,0 @@
//! 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;
}
}
+2 -2
View File
@@ -163,8 +163,8 @@ pub use replay::{
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
}; };
pub mod analytics_client; pub mod matomo_client;
pub use analytics_client::AnalyticsClient; pub use matomo_client::MatomoClient;
pub mod platform; pub mod platform;
pub use platform::data_dir; pub use platform::data_dir;
+122
View File
@@ -0,0 +1,122 @@
//! Matomo HTTP Tracking API client.
//!
//! Buffers game-play events and flushes them via the Matomo bulk tracking
//! endpoint. Errors are silently discarded — analytics must never affect
//! gameplay or block the UI.
use std::sync::Mutex;
use reqwest::Client;
use uuid::Uuid;
/// Sends game-play events to a self-hosted Matomo instance via the
/// [HTTP Tracking API](https://developer.matomo.org/api-reference/tracking-api).
///
/// Construct once per session and share via `Arc`. `event` is cheap and
/// can be called from the Bevy main thread; `flush` is async and must be
/// called from a background task.
pub struct MatomoClient {
tracking_url: String,
site_id: u32,
/// 16 hex-char visitor ID, stable for the lifetime of this client.
visitor_id: String,
uid: Option<String>,
client: Client,
/// Pre-encoded query strings, one per buffered event.
pending: Mutex<Vec<String>>,
}
impl MatomoClient {
/// Create a new client targeting `base_url` (e.g. `"https://analytics.example.com"`).
pub fn new(base_url: impl AsRef<str>, site_id: u32, uid: Option<String>) -> Self {
let base = base_url.as_ref().trim_end_matches('/');
let tracking_url = format!("{}/matomo.php", base);
// Take the lower 64 bits of a v4 UUID and format as 16 hex chars.
let visitor_id = format!("{:016x}", Uuid::new_v4().as_u128() as u64);
Self {
tracking_url,
site_id,
visitor_id,
uid,
client: Client::new(),
pending: Mutex::new(Vec::new()),
}
}
/// Buffer one Matomo custom 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 event(
&self,
category: &str,
action: &str,
name: Option<&str>,
value: Option<f64>,
) {
let Ok(mut guard) = self.pending.lock() else {
return;
};
let mut qs = format!(
"idsite={}&rec=1&apiv=1&send_image=0\
&url=game%3A%2F%2Fsolitaire%2Fevent\
&_id={}&e_c={}&e_a={}",
self.site_id,
self.visitor_id,
url_encode(category),
url_encode(action),
);
if let Some(n) = name {
qs.push_str(&format!("&e_n={}", url_encode(n)));
}
if let Some(v) = value {
qs.push_str(&format!("&e_v={v}"));
}
if let Some(uid) = &self.uid {
qs.push_str(&format!("&uid={}", url_encode(uid)));
}
guard.push(qs);
if guard.len() > 100 {
guard.drain(0..50);
}
}
/// Drain the pending buffer and POST it to Matomo's bulk tracking endpoint.
///
/// The buffer is drained *before* the HTTP call so events recorded during
/// an in-flight flush are not lost. Network errors are silently discarded.
pub async fn flush(&self) {
let pending = {
let Ok(mut guard) = self.pending.lock() else {
return;
};
if guard.is_empty() {
return;
}
std::mem::take(&mut *guard)
};
let requests: Vec<String> = pending.into_iter().map(|qs| format!("?{qs}")).collect();
let body = serde_json::json!({ "requests": requests });
let _ = self
.client
.post(&self.tracking_url)
.json(&body)
.send()
.await;
}
}
fn url_encode(s: &str) -> String {
s.chars()
.flat_map(|c| match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
vec![c]
}
c => format!("%{:02X}", c as u32).chars().collect(),
})
.collect()
}
+18 -4
View File
@@ -244,12 +244,20 @@ pub struct Settings {
#[serde(default)] #[serde(default)]
pub take_from_foundation: bool, pub take_from_foundation: bool,
/// When `true`, anonymous game-play events (game start, game won, etc.) /// When `true`, anonymous game-play events (game start, game won, etc.)
/// are sent to the configured sync server for aggregate analytics. Opt-in; /// are sent to the configured Matomo instance. Opt-in; defaults to `false`.
/// defaults to `false`. Only active when `sync_backend` is /// Requires `matomo_url` to be set. Older `settings.json` files deserialize
/// `SolitaireServer`. Older `settings.json` files deserialize cleanly to /// cleanly to `false` via `#[serde(default)]`.
/// `false` via `#[serde(default)]`.
#[serde(default)] #[serde(default)]
pub analytics_enabled: bool, pub analytics_enabled: bool,
/// Base URL of the Matomo instance to send events to, e.g.
/// `"https://analytics.example.com"`. When `None` the analytics toggle has
/// no effect. Older `settings.json` files deserialize cleanly to `None`.
#[serde(default)]
pub matomo_url: Option<String>,
/// Matomo site ID assigned when the tracked site was created in Matomo.
/// Defaults to `1` (the first site created in a fresh Matomo install).
#[serde(default = "default_matomo_site_id")]
pub matomo_site_id: u32,
} }
fn default_draw_mode() -> DrawMode { fn default_draw_mode() -> DrawMode {
@@ -318,6 +326,10 @@ fn default_replay_move_interval_secs() -> f32 {
0.45 0.45
} }
fn default_matomo_site_id() -> u32 {
1
}
/// Lower bound of the player-tunable replay-playback per-move interval, /// Lower bound of the player-tunable replay-playback per-move interval,
/// in seconds. Below this the cards barely register visually before /// in seconds. Below this the cards barely register visually before
/// the next move fires; the cap keeps the playback legible. /// the next move fires; the cap keeps the playback legible.
@@ -372,6 +384,8 @@ impl Default for Settings {
leaderboard_display_name: None, leaderboard_display_name: None,
take_from_foundation: false, take_from_foundation: false,
analytics_enabled: false, analytics_enabled: false,
matomo_url: None,
matomo_site_id: default_matomo_site_id(),
} }
} }
} }
+18 -40
View File
@@ -1,16 +1,15 @@
//! Analytics plugin — buffers game-play events and flushes them to the //! Matomo analytics plugin — buffers game-play events and flushes them to
//! configured server in the background. //! the configured Matomo instance in the background.
//! //!
//! Disabled by default (opt-in via Settings → Privacy). Only active when //! Disabled by default (opt-in via Settings → Privacy). Only active when
//! `settings.analytics_enabled` is `true` AND `sync_backend` is a //! `settings.analytics_enabled` is `true` AND `settings.matomo_url` is set.
//! `SolitaireServer` with a URL to send to.
use std::sync::Arc; use std::sync::Arc;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::AsyncComputeTaskPool; use bevy::tasks::AsyncComputeTaskPool;
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::GameMode;
use solitaire_data::{analytics_client::AnalyticsClient, settings::SyncBackend, Settings}; use solitaire_data::{matomo_client::MatomoClient, settings::SyncBackend, Settings};
use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent}; use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent};
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
@@ -20,10 +19,10 @@ use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
// Resource // Resource
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Holds the active analytics client. `None` when the feature is disabled. /// Holds the active Matomo client. `None` when the feature is disabled.
#[derive(Resource)] #[derive(Resource)]
pub struct AnalyticsResource { pub struct AnalyticsResource {
pub client: Option<Arc<AnalyticsClient>>, pub client: Option<Arc<MatomoClient>>,
flush_timer: Timer, flush_timer: Timer,
} }
@@ -87,13 +86,7 @@ fn on_game_won(
return; return;
}; };
for ev in wins.read() { for ev in wins.read() {
client.record( client.event("Game", "Won", None, Some(ev.score as f64));
"game_won",
serde_json::json!({
"score": ev.score,
"time_seconds": ev.time_seconds,
}),
);
fire_flush(client.clone(), &settings.0); fire_flush(client.clone(), &settings.0);
} }
} }
@@ -107,7 +100,7 @@ fn on_forfeit(
return; return;
}; };
for _ev in forfeits.read() { for _ev in forfeits.read() {
client.record("game_forfeit", serde_json::json!({})); client.event("Game", "Forfeit", None, None);
fire_flush(client.clone(), &settings.0); fire_flush(client.clone(), &settings.0);
} }
} }
@@ -121,19 +114,11 @@ fn on_new_game(
return; return;
}; };
for ev in requests.read() { for ev in requests.read() {
// Only record confirmed starts — skip the first unconfirmed request
// that spawns the "abandon game?" modal.
if !ev.confirmed { if !ev.confirmed {
continue; continue;
} }
// mode = None means "reuse current game mode". Reading from
// GameStateResource at this point gives the still-active game's mode,
// which is exactly what the new game will inherit.
let mode = ev.mode.unwrap_or(game.0.mode); let mode = ev.mode.unwrap_or(game.0.mode);
client.record( client.event("Game", "Start", Some(mode_str(mode)), None);
"game_start",
serde_json::json!({ "mode": mode_str(mode) }),
);
} }
} }
@@ -145,10 +130,7 @@ fn on_achievement_unlocked(
return; return;
}; };
for ev in achievements.read() { for ev in achievements.read() {
client.record( client.event("Achievement", "Unlocked", Some(&ev.0.id), None);
"achievement_unlocked",
serde_json::json!({ "achievement_id": ev.0.id }),
);
} }
} }
@@ -170,30 +152,26 @@ fn tick_flush_timer(
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
fn client_for(settings: &Settings) -> Option<Arc<AnalyticsClient>> { fn client_for(settings: &Settings) -> Option<Arc<MatomoClient>> {
if !settings.analytics_enabled { if !settings.analytics_enabled {
return None; return None;
} }
match &settings.sync_backend { let url = settings.matomo_url.as_deref()?;
SyncBackend::SolitaireServer { url, .. } => { let uid = match &settings.sync_backend {
Some(Arc::new(AnalyticsClient::new(url.clone())))
}
SyncBackend::Local => None,
}
}
fn fire_flush(client: Arc<AnalyticsClient>, settings: &Settings) {
let user_id = match &settings.sync_backend {
SyncBackend::SolitaireServer { username, .. } => Some(username.clone()), SyncBackend::SolitaireServer { username, .. } => Some(username.clone()),
SyncBackend::Local => None, SyncBackend::Local => None,
}; };
Some(Arc::new(MatomoClient::new(url, settings.matomo_site_id, uid)))
}
fn fire_flush(client: Arc<MatomoClient>, _settings: &Settings) {
AsyncComputeTaskPool::get() AsyncComputeTaskPool::get()
.spawn(async move { .spawn(async move {
if let Ok(rt) = tokio::runtime::Builder::new_current_thread() if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
.build() .build()
{ {
rt.block_on(client.flush(user_id)); rt.block_on(client.flush());
} }
}) })
.detach(); .detach();
+3 -3
View File
@@ -1682,8 +1682,8 @@ fn spawn_settings_panel(
} }
import_themes_row(body, font_res); import_themes_row(body, font_res);
// --- Privacy (only shown when a sync server is configured) --- // --- Privacy (only shown when a Matomo URL is configured) ---
if matches!(settings.sync_backend, SyncBackend::SolitaireServer { .. }) { if settings.matomo_url.is_some() {
section_label(body, "Privacy", font_res); section_label(body, "Privacy", font_res);
toggle_row( toggle_row(
body, body,
@@ -1691,7 +1691,7 @@ fn spawn_settings_panel(
AnalyticsEnabledText, AnalyticsEnabledText,
on_off_label(settings.analytics_enabled), on_off_label(settings.analytics_enabled),
SettingsButton::ToggleAnalytics, SettingsButton::ToggleAnalytics,
"Sends anonymous game events to your server. No personal data is collected.", "Sends anonymous game events to Matomo for aggregate analytics.",
font_res, font_res,
); );
} }
-130
View File
@@ -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, 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,7 +4,6 @@
//! application against an in-memory SQLite database without starting a real //! application against an in-memory SQLite database without starting a real
//! TCP listener. //! TCP listener.
pub mod analytics;
pub mod auth; pub mod auth;
pub mod challenge; pub mod challenge;
pub mod error; pub mod error;
@@ -190,23 +189,6 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
auth_routes 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). // Public endpoints (no auth, no rate limit beyond defaults).
let public = Router::new() let public = Router::new()
.route("/api/daily-challenge", get(challenge::daily_challenge)) .route("/api/daily-challenge", get(challenge::daily_challenge))
@@ -251,7 +233,6 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
Router::new() Router::new()
.merge(protected) .merge(protected)
.merge(auth_routes) .merge(auth_routes)
.merge(analytics_route)
.merge(public) .merge(public)
.merge(web) .merge(web)
// Reject request bodies larger than 1 MB. // Reject request bodies larger than 1 MB.
+14
View File
@@ -5,6 +5,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ferrous Solitaire — Play</title> <title>Ferrous Solitaire — Play</title>
<link rel="stylesheet" href="/web/game.css"> <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> </head>
<body> <body>
<header> <header>