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:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user