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 organizations;
|
||||||
mod proxy;
|
mod proxy;
|
||||||
mod site_announcements;
|
mod site_announcements;
|
||||||
|
mod stats;
|
||||||
mod tasks;
|
mod tasks;
|
||||||
mod user_api_keys;
|
mod user_api_keys;
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.merge(checkin::routes())
|
.merge(checkin::routes())
|
||||||
.merge(organizations::routes())
|
.merge(organizations::routes())
|
||||||
.merge(site_announcements::routes())
|
.merge(site_announcements::routes())
|
||||||
|
.merge(stats::routes())
|
||||||
.merge(tasks::routes())
|
.merge(tasks::routes())
|
||||||
.merge(user_api_keys::routes())
|
.merge(user_api_keys::routes())
|
||||||
.with_state(state)
|
.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