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:
@@ -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
|
||||||
|
|||||||
@@ -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,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`.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: mariadb-data
|
||||||
|
namespace: solitaire
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 2Gi
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: matomo-data
|
||||||
|
namespace: solitaire
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: matomo
|
||||||
|
namespace: solitaire
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: matomo
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: 8080
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
apiVersion: traefik.containo.us/v1alpha1
|
|
||||||
kind: Middleware
|
|
||||||
metadata:
|
|
||||||
name: analytics-auth
|
|
||||||
namespace: solitaire
|
|
||||||
spec:
|
|
||||||
basicAuth:
|
|
||||||
secret: analytics-auth-secret
|
|
||||||
@@ -10,6 +10,3 @@ spec:
|
|||||||
- name: http
|
- name: http
|
||||||
port: 80
|
port: 80
|
||||||
targetPort: 8080
|
targetPort: 8080
|
||||||
- name: analytics
|
|
||||||
port: 8001
|
|
||||||
targetPort: 8001
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, 1–64 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 })))
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user