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
+18 -40
View File
@@ -1,16 +1,15 @@
//! Analytics plugin — buffers game-play events and flushes them to the
//! configured server in the background.
//! Matomo analytics plugin — buffers game-play events and flushes them to
//! the configured Matomo instance in the background.
//!
//! Disabled by default (opt-in via Settings → Privacy). Only active when
//! `settings.analytics_enabled` is `true` AND `sync_backend` is a
//! `SolitaireServer` with a URL to send to.
//! `settings.analytics_enabled` is `true` AND `settings.matomo_url` is set.
use std::sync::Arc;
use bevy::prelude::*;
use bevy::tasks::AsyncComputeTaskPool;
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::resources::GameStateResource;
@@ -20,10 +19,10 @@ use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
// 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)]
pub struct AnalyticsResource {
pub client: Option<Arc<AnalyticsClient>>,
pub client: Option<Arc<MatomoClient>>,
flush_timer: Timer,
}
@@ -87,13 +86,7 @@ fn on_game_won(
return;
};
for ev in wins.read() {
client.record(
"game_won",
serde_json::json!({
"score": ev.score,
"time_seconds": ev.time_seconds,
}),
);
client.event("Game", "Won", None, Some(ev.score as f64));
fire_flush(client.clone(), &settings.0);
}
}
@@ -107,7 +100,7 @@ fn on_forfeit(
return;
};
for _ev in forfeits.read() {
client.record("game_forfeit", serde_json::json!({}));
client.event("Game", "Forfeit", None, None);
fire_flush(client.clone(), &settings.0);
}
}
@@ -121,19 +114,11 @@ fn on_new_game(
return;
};
for ev in requests.read() {
// Only record confirmed starts — skip the first unconfirmed request
// that spawns the "abandon game?" modal.
if !ev.confirmed {
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);
client.record(
"game_start",
serde_json::json!({ "mode": mode_str(mode) }),
);
client.event("Game", "Start", Some(mode_str(mode)), None);
}
}
@@ -145,10 +130,7 @@ fn on_achievement_unlocked(
return;
};
for ev in achievements.read() {
client.record(
"achievement_unlocked",
serde_json::json!({ "achievement_id": ev.0.id }),
);
client.event("Achievement", "Unlocked", Some(&ev.0.id), None);
}
}
@@ -170,30 +152,26 @@ fn tick_flush_timer(
// Helpers
// ---------------------------------------------------------------------------
fn client_for(settings: &Settings) -> Option<Arc<AnalyticsClient>> {
fn client_for(settings: &Settings) -> Option<Arc<MatomoClient>> {
if !settings.analytics_enabled {
return None;
}
match &settings.sync_backend {
SyncBackend::SolitaireServer { url, .. } => {
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 {
let url = settings.matomo_url.as_deref()?;
let uid = match &settings.sync_backend {
SyncBackend::SolitaireServer { username, .. } => Some(username.clone()),
SyncBackend::Local => None,
};
Some(Arc::new(MatomoClient::new(url, settings.matomo_site_id, uid)))
}
fn fire_flush(client: Arc<MatomoClient>, _settings: &Settings) {
AsyncComputeTaskPool::get()
.spawn(async move {
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
rt.block_on(client.flush(user_id));
rt.block_on(client.flush());
}
})
.detach();
+3 -3
View File
@@ -1682,8 +1682,8 @@ fn spawn_settings_panel(
}
import_themes_row(body, font_res);
// --- Privacy (only shown when a sync server is configured) ---
if matches!(settings.sync_backend, SyncBackend::SolitaireServer { .. }) {
// --- Privacy (only shown when a Matomo URL is configured) ---
if settings.matomo_url.is_some() {
section_label(body, "Privacy", font_res);
toggle_row(
body,
@@ -1691,7 +1691,7 @@ fn spawn_settings_panel(
AnalyticsEnabledText,
on_off_label(settings.analytics_enabled),
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,
);
}