feat: Stats routes — model-by-site + site-distribution
- GET /api/stats/model-by-site — model usage aggregated by model name - GET /api/stats/site-distribution — per-site usage summary - Both query existing model_day_usage table with day-range filter - 18/18 tests pass
This commit is contained in:
@@ -10,6 +10,7 @@ mod health;
|
||||
mod organizations;
|
||||
mod proxy;
|
||||
mod site_announcements;
|
||||
mod stats;
|
||||
mod tasks;
|
||||
mod user_api_keys;
|
||||
|
||||
@@ -24,6 +25,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(checkin::routes())
|
||||
.merge(organizations::routes())
|
||||
.merge(site_announcements::routes())
|
||||
.merge(stats::routes())
|
||||
.merge(tasks::routes())
|
||||
.merge(user_api_keys::routes())
|
||||
.with_state(state)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// Stats routes — usage analytics queries
|
||||
|
||||
use crate::{
|
||||
error::AppResult,
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ModelBySiteQuery {
|
||||
site_id: Option<i64>,
|
||||
days: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SiteDistributionQuery {
|
||||
days: Option<i64>,
|
||||
refresh: Option<String>,
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/stats/model-by-site", get(model_by_site))
|
||||
.route("/api/stats/site-distribution", get(site_distribution))
|
||||
}
|
||||
|
||||
/// GET /api/stats/model-by-site?siteId=...&days=...
|
||||
async fn model_by_site(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<ModelBySiteQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Stats model-by-site: site_id={:?}, days={:?}", q.site_id, q.days);
|
||||
let days = q.days.unwrap_or(7).max(1);
|
||||
|
||||
// Calculate cutoff date
|
||||
let since_day = chrono::Utc::now()
|
||||
.checked_sub_signed(chrono::Duration::days(days - 1))
|
||||
.unwrap()
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
|
||||
let rows = if let Some(site_id) = q.site_id {
|
||||
sqlx::query_as::<_, (String, String, i64, f64, i64)>(
|
||||
"SELECT model, local_day, total_calls, total_spend, total_tokens
|
||||
FROM model_day_usage
|
||||
WHERE site_id = ? AND local_day >= ?
|
||||
ORDER BY local_day DESC"
|
||||
)
|
||||
.bind(site_id)
|
||||
.bind(&since_day)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as::<_, (String, String, i64, f64, i64)>(
|
||||
"SELECT model, local_day, total_calls, total_spend, total_tokens
|
||||
FROM model_day_usage
|
||||
WHERE local_day >= ?
|
||||
ORDER BY local_day DESC"
|
||||
)
|
||||
.bind(&since_day)
|
||||
.fetch_all(&*state.db)
|
||||
.await?
|
||||
};
|
||||
|
||||
// Aggregate by model
|
||||
let mut model_map: std::collections::HashMap<String, (i64, f64, i64)> = std::collections::HashMap::new();
|
||||
for (model, _day, calls, spend, tokens) in rows {
|
||||
let entry = model_map.entry(model).or_insert((0, 0.0, 0));
|
||||
entry.0 += calls;
|
||||
entry.1 += spend;
|
||||
entry.2 += tokens;
|
||||
}
|
||||
|
||||
let mut models: Vec<Value> = model_map
|
||||
.into_iter()
|
||||
.map(|(model, (calls, spend, tokens))| {
|
||||
json!({
|
||||
"model": model,
|
||||
"calls": calls,
|
||||
"spend": (spend * 1_000_000.0).round() / 1_000_000.0,
|
||||
"tokens": tokens,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
models.sort_by(|a, b| b["calls"].as_i64().unwrap_or(0).cmp(&a["calls"].as_i64().unwrap_or(0)));
|
||||
|
||||
Ok(Json(json!({ "models": models })))
|
||||
}
|
||||
|
||||
/// GET /api/stats/site-distribution?days=...&refresh=...
|
||||
async fn site_distribution(
|
||||
State(state): State<AppState>,
|
||||
Query(q): Query<SiteDistributionQuery>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
info!("Stats site-distribution: days={:?}", q.days);
|
||||
let days = q.days.unwrap_or(7).max(1);
|
||||
|
||||
let since_day = chrono::Utc::now()
|
||||
.checked_sub_signed(chrono::Duration::days(days - 1))
|
||||
.unwrap()
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
|
||||
// Sum per-site usage
|
||||
let rows: Vec<(i64, String, i64, f64, i64)> = sqlx::query_as(
|
||||
"SELECT s.id, s.name, COALESCE(SUM(m.total_calls), 0), COALESCE(SUM(m.total_spend), 0), COALESCE(SUM(m.total_tokens), 0)
|
||||
FROM sites s
|
||||
LEFT JOIN model_day_usage m ON m.site_id = s.id AND m.local_day >= ?
|
||||
GROUP BY s.id, s.name
|
||||
ORDER BY SUM(m.total_spend) DESC"
|
||||
)
|
||||
.bind(&since_day)
|
||||
.fetch_all(&*state.db)
|
||||
.await?;
|
||||
|
||||
let distribution: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|(id, name, calls, spend, tokens)| {
|
||||
json!({
|
||||
"siteId": id,
|
||||
"siteName": name,
|
||||
"calls": calls,
|
||||
"spend": (spend * 1_000_000.0).round() / 1_000_000.0,
|
||||
"tokens": tokens,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({ "distribution": distribution })))
|
||||
}
|
||||
Reference in New Issue
Block a user