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:
funman300
2026-05-13 21:10:15 -07:00
parent f6506c57e5
commit 539779d78b
20 changed files with 390 additions and 352 deletions
-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,
};
pub mod analytics_client;
pub use analytics_client::AnalyticsClient;
pub mod matomo_client;
pub use matomo_client::MatomoClient;
pub mod platform;
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)]
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)]`.
/// are sent to the configured Matomo instance. Opt-in; defaults to `false`.
/// Requires `matomo_url` to be set. Older `settings.json` files deserialize
/// cleanly to `false` via `#[serde(default)]`.
#[serde(default)]
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 {
@@ -318,6 +326,10 @@ fn default_replay_move_interval_secs() -> f32 {
0.45
}
fn default_matomo_site_id() -> u32 {
1
}
/// Lower bound of the player-tunable replay-playback per-move interval,
/// in seconds. Below this the cards barely register visually before
/// the next move fires; the cap keeps the playback legible.
@@ -372,6 +384,8 @@ impl Default for Settings {
leaderboard_display_name: None,
take_from_foundation: false,
analytics_enabled: false,
matomo_url: None,
matomo_site_id: default_matomo_site_id(),
}
}
}