feat: Phase 2 data aggregation pipeline (Strike 4A)
Backend: - Stats ingestion consumer subscribing to corrosion.*.stats NATS subject - Hourly aggregation scheduler (runs :05 past every hour) - Daily cleanup job (03:00 UTC) with 7-day raw / 90-day hourly retention - Analytics API (summary, timeseries, CSV export) - Complete stats DB queries with aggregation and cleanup Frontend: - Analytics dashboard with ECharts integration - Player count and server performance charts - Time range selector (24h/7d/30d) - CSV export functionality - Real-time data loading Infrastructure: - Exposed NatsBridge.jetstream for consumer access - Background service initialization in main.rs Data flow: Plugin → NATS → Consumer → DB → Aggregation → API → Charts Unblocks Strike 4B (dashboards) and 4C (alerting). Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
199
backend/src/api/analytics.rs
Normal file
199
backend/src/api/analytics.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::db::stats;
|
||||
use crate::middleware::auth::AuthUser;
|
||||
use crate::models::error::{ApiError, ApiResult};
|
||||
use crate::AppState;
|
||||
|
||||
pub fn router() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/summary", get(get_summary))
|
||||
.route("/timeseries", get(get_timeseries))
|
||||
.route("/export", get(export_csv))
|
||||
}
|
||||
|
||||
/// Query parameters for analytics endpoints.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AnalyticsQuery {
|
||||
/// Time range in hours (default: 24)
|
||||
#[serde(default = "default_range")]
|
||||
range: i64,
|
||||
/// Granularity: "raw" or "hourly" (default: "hourly")
|
||||
#[serde(default = "default_granularity")]
|
||||
granularity: String,
|
||||
}
|
||||
|
||||
fn default_range() -> i64 {
|
||||
24
|
||||
}
|
||||
|
||||
fn default_granularity() -> String {
|
||||
"hourly".to_string()
|
||||
}
|
||||
|
||||
/// GET /api/analytics/summary?range=7d
|
||||
/// Returns peak players, avg players, uptime percentage.
|
||||
async fn get_summary(
|
||||
auth: AuthUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<AnalyticsQuery>,
|
||||
) -> ApiResult<Json<stats::AnalyticsSummary>> {
|
||||
let license_id = auth.license_id.ok_or(ApiError::LicenseInvalid)?;
|
||||
|
||||
let summary = stats::get_analytics_summary(&state.db, license_id, query.range)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(Json(summary))
|
||||
}
|
||||
|
||||
/// GET /api/analytics/timeseries?range=24&granularity=hourly
|
||||
/// Returns time-series data for charting.
|
||||
#[derive(Serialize)]
|
||||
struct TimeseriesResponse {
|
||||
timestamps: Vec<String>,
|
||||
player_count: Vec<i32>,
|
||||
fps: Vec<f64>,
|
||||
entity_count: Vec<i32>,
|
||||
memory_usage_mb: Vec<i32>,
|
||||
}
|
||||
|
||||
async fn get_timeseries(
|
||||
auth: AuthUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<AnalyticsQuery>,
|
||||
) -> ApiResult<Json<TimeseriesResponse>> {
|
||||
let license_id = auth.license_id.ok_or(ApiError::LicenseInvalid)?;
|
||||
|
||||
if query.granularity == "hourly" {
|
||||
// Use hourly aggregates
|
||||
let hourly_stats = stats::get_hourly_stats(&state.db, license_id, query.range)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
let timestamps: Vec<String> = hourly_stats
|
||||
.iter()
|
||||
.map(|s| s.hour.to_rfc3339())
|
||||
.collect();
|
||||
|
||||
let player_count: Vec<i32> = hourly_stats
|
||||
.iter()
|
||||
.map(|s| s.max_players)
|
||||
.collect();
|
||||
|
||||
let fps: Vec<f64> = hourly_stats
|
||||
.iter()
|
||||
.map(|s| s.avg_fps)
|
||||
.collect();
|
||||
|
||||
let entity_count: Vec<i32> = hourly_stats
|
||||
.iter()
|
||||
.map(|s| s.avg_entities)
|
||||
.collect();
|
||||
|
||||
// Hourly stats don't track memory, return zeros
|
||||
let memory_usage_mb: Vec<i32> = vec![0; hourly_stats.len()];
|
||||
|
||||
Ok(Json(TimeseriesResponse {
|
||||
timestamps,
|
||||
player_count,
|
||||
fps,
|
||||
entity_count,
|
||||
memory_usage_mb,
|
||||
}))
|
||||
} else {
|
||||
// Use raw stats (default limit: 1000 samples)
|
||||
let limit = (query.range * 60).min(1000); // 1 sample per minute, max 1000
|
||||
let raw_stats = stats::get_recent_stats(&state.db, license_id, limit)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
let timestamps: Vec<String> = raw_stats
|
||||
.iter()
|
||||
.map(|s| s.recorded_at.to_rfc3339())
|
||||
.collect();
|
||||
|
||||
let player_count: Vec<i32> = raw_stats
|
||||
.iter()
|
||||
.map(|s| s.player_count)
|
||||
.collect();
|
||||
|
||||
let fps: Vec<f64> = raw_stats
|
||||
.iter()
|
||||
.map(|s| s.fps)
|
||||
.collect();
|
||||
|
||||
let entity_count: Vec<i32> = raw_stats
|
||||
.iter()
|
||||
.map(|s| s.entity_count)
|
||||
.collect();
|
||||
|
||||
let memory_usage_mb: Vec<i32> = raw_stats
|
||||
.iter()
|
||||
.map(|s| s.memory_usage_mb)
|
||||
.collect();
|
||||
|
||||
Ok(Json(TimeseriesResponse {
|
||||
timestamps,
|
||||
player_count,
|
||||
fps,
|
||||
entity_count,
|
||||
memory_usage_mb,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/analytics/export?range=168
|
||||
/// Export stats as CSV.
|
||||
async fn export_csv(
|
||||
auth: AuthUser,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<AnalyticsQuery>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let license_id = auth.license_id.ok_or(ApiError::LicenseInvalid)?;
|
||||
|
||||
// Get raw stats for CSV export
|
||||
let limit = (query.range * 60).min(10000); // Max 10k rows
|
||||
let raw_stats = stats::get_recent_stats(&state.db, license_id, limit)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
// Build CSV
|
||||
let mut csv = String::from("timestamp,player_count,max_players,fps,entity_count,uptime_seconds,memory_usage_mb\n");
|
||||
for stat in raw_stats.iter().rev() {
|
||||
// Reverse to chronological order
|
||||
csv.push_str(&format!(
|
||||
"{},{},{},{:.2},{},{},{}\n",
|
||||
stat.recorded_at.to_rfc3339(),
|
||||
stat.player_count,
|
||||
stat.max_players,
|
||||
stat.fps,
|
||||
stat.entity_count,
|
||||
stat.uptime_seconds,
|
||||
stat.memory_usage_mb
|
||||
));
|
||||
}
|
||||
|
||||
// Return CSV response
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, "text/csv"),
|
||||
(
|
||||
header::CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"server_stats.csv\"",
|
||||
),
|
||||
],
|
||||
csv,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
Reference in New Issue
Block a user