Files
Ferrous-Solitaire/solitaire_engine/src/analytics_plugin.rs
T
funman300 539779d78b 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>
2026-05-13 21:10:15 -07:00

189 lines
5.5 KiB
Rust

//! 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 `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::{matomo_client::MatomoClient, settings::SyncBackend, Settings};
use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent};
use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
// ---------------------------------------------------------------------------
// Resource
// ---------------------------------------------------------------------------
/// Holds the active Matomo client. `None` when the feature is disabled.
#[derive(Resource)]
pub struct AnalyticsResource {
pub client: Option<Arc<MatomoClient>>,
flush_timer: Timer,
}
impl Default for AnalyticsResource {
fn default() -> Self {
Self {
client: None,
flush_timer: Timer::from_seconds(60.0, TimerMode::Repeating),
}
}
}
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Registers analytics systems. Add after `SettingsPlugin` in the app.
pub struct AnalyticsPlugin;
impl Plugin for AnalyticsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AnalyticsResource>()
.add_systems(Startup, init_analytics)
.add_systems(
Update,
(
react_to_settings_change,
on_game_won,
on_forfeit,
on_new_game,
on_achievement_unlocked,
tick_flush_timer,
),
);
}
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
fn init_analytics(settings: Res<SettingsResource>, mut analytics: ResMut<AnalyticsResource>) {
analytics.client = client_for(&settings.0);
}
fn react_to_settings_change(
mut events: MessageReader<SettingsChangedEvent>,
mut analytics: ResMut<AnalyticsResource>,
) {
for ev in events.read() {
analytics.client = client_for(&ev.0);
}
}
fn on_game_won(
mut wins: MessageReader<GameWonEvent>,
analytics: Res<AnalyticsResource>,
settings: Res<SettingsResource>,
) {
let Some(client) = analytics.client.clone() else {
return;
};
for ev in wins.read() {
client.event("Game", "Won", None, Some(ev.score as f64));
fire_flush(client.clone(), &settings.0);
}
}
fn on_forfeit(
mut forfeits: MessageReader<ForfeitEvent>,
analytics: Res<AnalyticsResource>,
settings: Res<SettingsResource>,
) {
let Some(client) = analytics.client.clone() else {
return;
};
for _ev in forfeits.read() {
client.event("Game", "Forfeit", None, None);
fire_flush(client.clone(), &settings.0);
}
}
fn on_new_game(
mut requests: MessageReader<NewGameRequestEvent>,
analytics: Res<AnalyticsResource>,
game: Res<GameStateResource>,
) {
let Some(client) = analytics.client.clone() else {
return;
};
for ev in requests.read() {
if !ev.confirmed {
continue;
}
let mode = ev.mode.unwrap_or(game.0.mode);
client.event("Game", "Start", Some(mode_str(mode)), None);
}
}
fn on_achievement_unlocked(
mut achievements: MessageReader<AchievementUnlockedEvent>,
analytics: Res<AnalyticsResource>,
) {
let Some(client) = analytics.client.clone() else {
return;
};
for ev in achievements.read() {
client.event("Achievement", "Unlocked", Some(&ev.0.id), None);
}
}
fn tick_flush_timer(
time: Res<Time>,
mut analytics: ResMut<AnalyticsResource>,
settings: Res<SettingsResource>,
) {
analytics.flush_timer.tick(time.delta());
if !analytics.flush_timer.just_finished() {
return;
}
if let Some(client) = analytics.client.clone() {
fire_flush(client, &settings.0);
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn client_for(settings: &Settings) -> Option<Arc<MatomoClient>> {
if !settings.analytics_enabled {
return None;
}
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());
}
})
.detach();
}
fn mode_str(mode: GameMode) -> &'static str {
match mode {
GameMode::Classic => "classic",
GameMode::Zen => "zen",
GameMode::Challenge => "challenge",
GameMode::TimeAttack => "time_attack",
GameMode::Difficulty(_) => "difficulty",
}
}