mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat(stats): 新增聚合读路径与回填机制并重构 dashboard/usage 读取链路
- 新增 stats_user_summary 及 user_daily_provider/api_format/cost_savings 等聚合表 - 扩展 stats_daily/hourly 有效 token 与响应时间等字段,maintenance runtime 同步写入 - 新增 backfill 模块与 --apply-backfills 命令补齐历史聚合数据 - 重写 dashboard_filters、usage_heatmap、user_rollups 查询改走聚合表 - 同步更新 baseline_v2.sql 与 migration 集,README/dev.sh 补充回填用法
This commit is contained in:
853
apps/aether-gateway/src/query/dashboard_stats.rs
Normal file
853
apps/aether-gateway/src/query/dashboard_stats.rs
Normal file
@@ -0,0 +1,853 @@
|
||||
use aether_data::postgres::PostgresPool;
|
||||
use aether_data_contracts::repository::usage::StoredUsageDashboardSummary;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures_util::TryStreamExt;
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::GatewayError;
|
||||
|
||||
fn internal(err: impl ToString) -> GatewayError {
|
||||
GatewayError::Internal(err.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct DashboardDailyTotalsAggregateRow {
|
||||
pub(crate) date: String,
|
||||
pub(crate) requests: u64,
|
||||
pub(crate) total_tokens: u64,
|
||||
pub(crate) total_cost_usd: f64,
|
||||
pub(crate) response_time_sum_ms: f64,
|
||||
pub(crate) response_time_samples: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct DashboardDailyModelAggregateRow {
|
||||
pub(crate) date: String,
|
||||
pub(crate) model: String,
|
||||
pub(crate) requests: u64,
|
||||
pub(crate) total_tokens: u64,
|
||||
pub(crate) total_cost_usd: f64,
|
||||
pub(crate) response_time_sum_ms: f64,
|
||||
pub(crate) response_time_samples: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct DashboardDailyProviderAggregateRow {
|
||||
pub(crate) date: String,
|
||||
pub(crate) provider: String,
|
||||
pub(crate) requests: u64,
|
||||
pub(crate) total_tokens: u64,
|
||||
pub(crate) total_cost_usd: f64,
|
||||
}
|
||||
|
||||
pub(crate) async fn summarize_dashboard_usage_from_daily_aggregates(
|
||||
pool: &PostgresPool,
|
||||
start_day_utc: DateTime<Utc>,
|
||||
end_day_utc: DateTime<Utc>,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<StoredUsageDashboardSummary, GatewayError> {
|
||||
let row = if let Some(user_id) = user_id {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
COALESCE(SUM(total_requests), 0)::BIGINT AS total_requests,
|
||||
COALESCE(SUM(input_tokens), 0)::BIGINT AS input_tokens,
|
||||
COALESCE(SUM(effective_input_tokens), 0)::BIGINT AS effective_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0)::BIGINT AS output_tokens,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0)::BIGINT AS total_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0)::BIGINT AS cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0)::BIGINT AS cache_read_tokens,
|
||||
COALESCE(SUM(total_input_context), 0)::BIGINT AS total_input_context,
|
||||
CAST(COALESCE(SUM(cache_creation_cost), 0) AS DOUBLE PRECISION) AS cache_creation_cost_usd,
|
||||
CAST(COALESCE(SUM(cache_read_cost), 0) AS DOUBLE PRECISION) AS cache_read_cost_usd,
|
||||
CAST(COALESCE(SUM(total_cost), 0) AS DOUBLE PRECISION) AS total_cost_usd,
|
||||
CAST(COALESCE(SUM(actual_total_cost), 0) AS DOUBLE PRECISION) AS actual_total_cost_usd,
|
||||
COALESCE(SUM(error_requests), 0)::BIGINT AS error_requests,
|
||||
COALESCE(SUM(response_time_sum_ms), 0) AS response_time_sum_ms,
|
||||
COALESCE(SUM(response_time_samples), 0)::BIGINT AS response_time_samples
|
||||
FROM stats_user_daily
|
||||
WHERE user_id = $1
|
||||
AND date >= $2
|
||||
AND date < $3
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(start_day_utc)
|
||||
.bind(end_day_utc)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|err| internal(format!("user daily aggregate summary lookup failed: {err}")))?
|
||||
} else {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
COALESCE(SUM(total_requests), 0)::BIGINT AS total_requests,
|
||||
COALESCE(SUM(input_tokens), 0)::BIGINT AS input_tokens,
|
||||
COALESCE(SUM(effective_input_tokens), 0)::BIGINT AS effective_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0)::BIGINT AS output_tokens,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0)::BIGINT AS total_tokens,
|
||||
COALESCE(SUM(cache_creation_tokens), 0)::BIGINT AS cache_creation_tokens,
|
||||
COALESCE(SUM(cache_read_tokens), 0)::BIGINT AS cache_read_tokens,
|
||||
COALESCE(SUM(total_input_context), 0)::BIGINT AS total_input_context,
|
||||
CAST(COALESCE(SUM(cache_creation_cost), 0) AS DOUBLE PRECISION) AS cache_creation_cost_usd,
|
||||
CAST(COALESCE(SUM(cache_read_cost), 0) AS DOUBLE PRECISION) AS cache_read_cost_usd,
|
||||
CAST(COALESCE(SUM(total_cost), 0) AS DOUBLE PRECISION) AS total_cost_usd,
|
||||
CAST(COALESCE(SUM(actual_total_cost), 0) AS DOUBLE PRECISION) AS actual_total_cost_usd,
|
||||
COALESCE(SUM(error_requests), 0)::BIGINT AS error_requests,
|
||||
COALESCE(SUM(response_time_sum_ms), 0) AS response_time_sum_ms,
|
||||
COALESCE(SUM(response_time_samples), 0)::BIGINT AS response_time_samples
|
||||
FROM stats_daily
|
||||
WHERE date >= $1
|
||||
AND date < $2
|
||||
"#,
|
||||
)
|
||||
.bind(start_day_utc)
|
||||
.bind(end_day_utc)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|err| internal(format!("daily aggregate summary lookup failed: {err}")))?
|
||||
};
|
||||
|
||||
Ok(StoredUsageDashboardSummary {
|
||||
total_requests: row
|
||||
.try_get::<i64, _>("total_requests")
|
||||
.map_err(|err| internal(format!("aggregate summary decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
input_tokens: row
|
||||
.try_get::<i64, _>("input_tokens")
|
||||
.map_err(|err| internal(format!("aggregate summary decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
effective_input_tokens: row
|
||||
.try_get::<i64, _>("effective_input_tokens")
|
||||
.map_err(|err| internal(format!("aggregate summary decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
output_tokens: row
|
||||
.try_get::<i64, _>("output_tokens")
|
||||
.map_err(|err| internal(format!("aggregate summary decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
total_tokens: row
|
||||
.try_get::<i64, _>("total_tokens")
|
||||
.map_err(|err| internal(format!("aggregate summary decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
cache_creation_tokens: row
|
||||
.try_get::<i64, _>("cache_creation_tokens")
|
||||
.map_err(|err| internal(format!("aggregate summary decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
cache_read_tokens: row
|
||||
.try_get::<i64, _>("cache_read_tokens")
|
||||
.map_err(|err| internal(format!("aggregate summary decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
total_input_context: row
|
||||
.try_get::<i64, _>("total_input_context")
|
||||
.map_err(|err| internal(format!("aggregate summary decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
cache_creation_cost_usd: row
|
||||
.try_get::<f64, _>("cache_creation_cost_usd")
|
||||
.map_err(|err| internal(format!("aggregate summary decode failed: {err}")))?,
|
||||
cache_read_cost_usd: row
|
||||
.try_get::<f64, _>("cache_read_cost_usd")
|
||||
.map_err(|err| internal(format!("aggregate summary decode failed: {err}")))?,
|
||||
total_cost_usd: row
|
||||
.try_get::<f64, _>("total_cost_usd")
|
||||
.map_err(|err| internal(format!("aggregate summary decode failed: {err}")))?,
|
||||
actual_total_cost_usd: row
|
||||
.try_get::<f64, _>("actual_total_cost_usd")
|
||||
.map_err(|err| internal(format!("aggregate summary decode failed: {err}")))?,
|
||||
error_requests: row
|
||||
.try_get::<i64, _>("error_requests")
|
||||
.map_err(|err| internal(format!("aggregate summary decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
response_time_sum_ms: row
|
||||
.try_get::<f64, _>("response_time_sum_ms")
|
||||
.map_err(|err| internal(format!("aggregate summary decode failed: {err}")))?,
|
||||
response_time_samples: row
|
||||
.try_get::<i64, _>("response_time_samples")
|
||||
.map_err(|err| internal(format!("aggregate summary decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn read_stats_hourly_cutoff(
|
||||
pool: &PostgresPool,
|
||||
) -> Result<Option<DateTime<Utc>>, GatewayError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT MAX(hour_utc) AS latest_hour
|
||||
FROM stats_hourly
|
||||
WHERE is_complete IS TRUE
|
||||
"#,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|err| internal(format!("hourly aggregate cutoff lookup failed: {err}")))?;
|
||||
let latest_hour = row
|
||||
.try_get::<Option<DateTime<Utc>>, _>("latest_hour")
|
||||
.map_err(|err| internal(format!("hourly aggregate cutoff decode failed: {err}")))?;
|
||||
Ok(latest_hour.map(|value| value + chrono::Duration::hours(1)))
|
||||
}
|
||||
|
||||
pub(crate) async fn list_admin_dashboard_daily_totals_aggregates(
|
||||
pool: &PostgresPool,
|
||||
start_day_utc: DateTime<Utc>,
|
||||
end_day_utc: DateTime<Utc>,
|
||||
) -> Result<Vec<DashboardDailyTotalsAggregateRow>, GatewayError> {
|
||||
let mut rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
date,
|
||||
total_requests,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
COALESCE(total_cost, 0)::DOUBLE PRECISION AS total_cost,
|
||||
response_time_sum_ms,
|
||||
response_time_samples
|
||||
FROM stats_daily
|
||||
WHERE date >= $1
|
||||
AND date < $2
|
||||
ORDER BY date ASC
|
||||
"#,
|
||||
)
|
||||
.bind(start_day_utc)
|
||||
.bind(end_day_utc)
|
||||
.fetch(pool);
|
||||
|
||||
let mut items = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|err| internal(format!("daily aggregate totals read failed: {err}")))?
|
||||
{
|
||||
let date = row
|
||||
.try_get::<DateTime<Utc>, _>("date")
|
||||
.map_err(|err| internal(format!("daily aggregate totals decode failed: {err}")))?;
|
||||
let input_tokens = row
|
||||
.try_get::<i64, _>("input_tokens")
|
||||
.map_err(|err| internal(format!("daily aggregate totals decode failed: {err}")))?;
|
||||
let output_tokens = row
|
||||
.try_get::<i64, _>("output_tokens")
|
||||
.map_err(|err| internal(format!("daily aggregate totals decode failed: {err}")))?;
|
||||
items.push(DashboardDailyTotalsAggregateRow {
|
||||
date: date.date_naive().to_string(),
|
||||
requests: row
|
||||
.try_get::<i32, _>("total_requests")
|
||||
.map_err(|err| internal(format!("daily aggregate totals decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
total_tokens: input_tokens.saturating_add(output_tokens).max(0) as u64,
|
||||
total_cost_usd: row
|
||||
.try_get::<f64, _>("total_cost")
|
||||
.map_err(|err| internal(format!("daily aggregate totals decode failed: {err}")))?,
|
||||
response_time_sum_ms: row
|
||||
.try_get::<f64, _>("response_time_sum_ms")
|
||||
.map_err(|err| internal(format!("daily aggregate totals decode failed: {err}")))?,
|
||||
response_time_samples: row
|
||||
.try_get::<i64, _>("response_time_samples")
|
||||
.map_err(|err| internal(format!("daily aggregate totals decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_admin_dashboard_hourly_totals_aggregates(
|
||||
pool: &PostgresPool,
|
||||
start_utc: DateTime<Utc>,
|
||||
end_utc: DateTime<Utc>,
|
||||
tz_offset_minutes: i32,
|
||||
) -> Result<Vec<DashboardDailyTotalsAggregateRow>, GatewayError> {
|
||||
let mut rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
CAST(DATE(hour_utc + ($3::integer * INTERVAL '1 minute')) AS TEXT) AS date,
|
||||
COALESCE(SUM(total_requests), 0)::BIGINT AS total_requests,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0)::BIGINT AS total_tokens,
|
||||
CAST(COALESCE(SUM(total_cost), 0) AS DOUBLE PRECISION) AS total_cost,
|
||||
CAST(COALESCE(SUM(response_time_sum_ms), 0) AS DOUBLE PRECISION) AS response_time_sum_ms,
|
||||
COALESCE(SUM(response_time_samples), 0)::BIGINT AS response_time_samples
|
||||
FROM stats_hourly
|
||||
WHERE hour_utc >= $1
|
||||
AND hour_utc < $2
|
||||
GROUP BY date
|
||||
ORDER BY date ASC
|
||||
"#,
|
||||
)
|
||||
.bind(start_utc)
|
||||
.bind(end_utc)
|
||||
.bind(tz_offset_minutes)
|
||||
.fetch(pool);
|
||||
|
||||
let mut items = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|err| internal(format!("hourly aggregate totals read failed: {err}")))?
|
||||
{
|
||||
items.push(DashboardDailyTotalsAggregateRow {
|
||||
date: row
|
||||
.try_get::<String, _>("date")
|
||||
.map_err(|err| internal(format!("hourly aggregate totals decode failed: {err}")))?,
|
||||
requests: row
|
||||
.try_get::<i64, _>("total_requests")
|
||||
.map_err(|err| internal(format!("hourly aggregate totals decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
total_tokens: row
|
||||
.try_get::<i64, _>("total_tokens")
|
||||
.map_err(|err| internal(format!("hourly aggregate totals decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
total_cost_usd: row
|
||||
.try_get::<f64, _>("total_cost")
|
||||
.map_err(|err| internal(format!("hourly aggregate totals decode failed: {err}")))?,
|
||||
response_time_sum_ms: row
|
||||
.try_get::<f64, _>("response_time_sum_ms")
|
||||
.map_err(|err| internal(format!("hourly aggregate totals decode failed: {err}")))?,
|
||||
response_time_samples: row
|
||||
.try_get::<i64, _>("response_time_samples")
|
||||
.map_err(|err| internal(format!("hourly aggregate totals decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_admin_dashboard_daily_model_aggregates(
|
||||
pool: &PostgresPool,
|
||||
start_day_utc: DateTime<Utc>,
|
||||
end_day_utc: DateTime<Utc>,
|
||||
) -> Result<Vec<DashboardDailyModelAggregateRow>, GatewayError> {
|
||||
let mut rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
date,
|
||||
model,
|
||||
total_requests,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
COALESCE(total_cost, 0)::DOUBLE PRECISION AS total_cost,
|
||||
response_time_sum_ms,
|
||||
response_time_samples
|
||||
FROM stats_daily_model
|
||||
WHERE date >= $1
|
||||
AND date < $2
|
||||
ORDER BY date ASC, total_cost DESC, model ASC
|
||||
"#,
|
||||
)
|
||||
.bind(start_day_utc)
|
||||
.bind(end_day_utc)
|
||||
.fetch(pool);
|
||||
|
||||
let mut items = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|err| internal(format!("daily aggregate model read failed: {err}")))?
|
||||
{
|
||||
let date = row
|
||||
.try_get::<DateTime<Utc>, _>("date")
|
||||
.map_err(|err| internal(format!("daily aggregate model decode failed: {err}")))?;
|
||||
let input_tokens = row
|
||||
.try_get::<i64, _>("input_tokens")
|
||||
.map_err(|err| internal(format!("daily aggregate model decode failed: {err}")))?;
|
||||
let output_tokens = row
|
||||
.try_get::<i64, _>("output_tokens")
|
||||
.map_err(|err| internal(format!("daily aggregate model decode failed: {err}")))?;
|
||||
items.push(DashboardDailyModelAggregateRow {
|
||||
date: date.date_naive().to_string(),
|
||||
model: row
|
||||
.try_get::<String, _>("model")
|
||||
.map_err(|err| internal(format!("daily aggregate model decode failed: {err}")))?,
|
||||
requests: row
|
||||
.try_get::<i32, _>("total_requests")
|
||||
.map_err(|err| internal(format!("daily aggregate model decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
total_tokens: input_tokens.saturating_add(output_tokens).max(0) as u64,
|
||||
total_cost_usd: row
|
||||
.try_get::<f64, _>("total_cost")
|
||||
.map_err(|err| internal(format!("daily aggregate model decode failed: {err}")))?,
|
||||
response_time_sum_ms: row
|
||||
.try_get::<f64, _>("response_time_sum_ms")
|
||||
.map_err(|err| internal(format!("daily aggregate model decode failed: {err}")))?,
|
||||
response_time_samples: row
|
||||
.try_get::<i64, _>("response_time_samples")
|
||||
.map_err(|err| internal(format!("daily aggregate model decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_admin_dashboard_hourly_model_aggregates(
|
||||
pool: &PostgresPool,
|
||||
start_utc: DateTime<Utc>,
|
||||
end_utc: DateTime<Utc>,
|
||||
tz_offset_minutes: i32,
|
||||
) -> Result<Vec<DashboardDailyModelAggregateRow>, GatewayError> {
|
||||
let mut rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
CAST(DATE(hour_utc + ($3::integer * INTERVAL '1 minute')) AS TEXT) AS date,
|
||||
model,
|
||||
COALESCE(SUM(total_requests), 0)::BIGINT AS total_requests,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0)::BIGINT AS total_tokens,
|
||||
CAST(COALESCE(SUM(total_cost), 0) AS DOUBLE PRECISION) AS total_cost,
|
||||
CAST(COALESCE(SUM(response_time_sum_ms), 0) AS DOUBLE PRECISION) AS response_time_sum_ms,
|
||||
COALESCE(SUM(response_time_samples), 0)::BIGINT AS response_time_samples
|
||||
FROM stats_hourly_model
|
||||
WHERE hour_utc >= $1
|
||||
AND hour_utc < $2
|
||||
GROUP BY date, model
|
||||
ORDER BY date ASC, total_cost DESC, model ASC
|
||||
"#,
|
||||
)
|
||||
.bind(start_utc)
|
||||
.bind(end_utc)
|
||||
.bind(tz_offset_minutes)
|
||||
.fetch(pool);
|
||||
|
||||
let mut items = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|err| internal(format!("hourly aggregate model read failed: {err}")))?
|
||||
{
|
||||
items.push(DashboardDailyModelAggregateRow {
|
||||
date: row
|
||||
.try_get::<String, _>("date")
|
||||
.map_err(|err| internal(format!("hourly aggregate model decode failed: {err}")))?,
|
||||
model: row
|
||||
.try_get::<String, _>("model")
|
||||
.map_err(|err| internal(format!("hourly aggregate model decode failed: {err}")))?,
|
||||
requests: row
|
||||
.try_get::<i64, _>("total_requests")
|
||||
.map_err(|err| internal(format!("hourly aggregate model decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
total_tokens: row
|
||||
.try_get::<i64, _>("total_tokens")
|
||||
.map_err(|err| internal(format!("hourly aggregate model decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
total_cost_usd: row
|
||||
.try_get::<f64, _>("total_cost")
|
||||
.map_err(|err| internal(format!("hourly aggregate model decode failed: {err}")))?,
|
||||
response_time_sum_ms: row
|
||||
.try_get::<f64, _>("response_time_sum_ms")
|
||||
.map_err(|err| internal(format!("hourly aggregate model decode failed: {err}")))?,
|
||||
response_time_samples: row
|
||||
.try_get::<i64, _>("response_time_samples")
|
||||
.map_err(|err| internal(format!("hourly aggregate model decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_admin_dashboard_daily_provider_aggregates(
|
||||
pool: &PostgresPool,
|
||||
start_day_utc: DateTime<Utc>,
|
||||
end_day_utc: DateTime<Utc>,
|
||||
) -> Result<Vec<DashboardDailyProviderAggregateRow>, GatewayError> {
|
||||
let mut rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
date,
|
||||
provider_name,
|
||||
total_requests,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
COALESCE(total_cost, 0)::DOUBLE PRECISION AS total_cost
|
||||
FROM stats_daily_provider
|
||||
WHERE date >= $1
|
||||
AND date < $2
|
||||
ORDER BY date ASC, total_cost DESC, provider_name ASC
|
||||
"#,
|
||||
)
|
||||
.bind(start_day_utc)
|
||||
.bind(end_day_utc)
|
||||
.fetch(pool);
|
||||
|
||||
let mut items = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|err| internal(format!("daily aggregate provider read failed: {err}")))?
|
||||
{
|
||||
let date = row
|
||||
.try_get::<DateTime<Utc>, _>("date")
|
||||
.map_err(|err| internal(format!("daily aggregate provider decode failed: {err}")))?;
|
||||
let input_tokens = row
|
||||
.try_get::<i64, _>("input_tokens")
|
||||
.map_err(|err| internal(format!("daily aggregate provider decode failed: {err}")))?;
|
||||
let output_tokens = row
|
||||
.try_get::<i64, _>("output_tokens")
|
||||
.map_err(|err| internal(format!("daily aggregate provider decode failed: {err}")))?;
|
||||
items.push(DashboardDailyProviderAggregateRow {
|
||||
date: date.date_naive().to_string(),
|
||||
provider: row.try_get::<String, _>("provider_name").map_err(|err| {
|
||||
internal(format!("daily aggregate provider decode failed: {err}"))
|
||||
})?,
|
||||
requests: row
|
||||
.try_get::<i32, _>("total_requests")
|
||||
.map_err(|err| internal(format!("daily aggregate provider decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
total_tokens: input_tokens.saturating_add(output_tokens).max(0) as u64,
|
||||
total_cost_usd: row.try_get::<f64, _>("total_cost").map_err(|err| {
|
||||
internal(format!("daily aggregate provider decode failed: {err}"))
|
||||
})?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_admin_dashboard_hourly_provider_aggregates(
|
||||
pool: &PostgresPool,
|
||||
start_utc: DateTime<Utc>,
|
||||
end_utc: DateTime<Utc>,
|
||||
tz_offset_minutes: i32,
|
||||
) -> Result<Vec<DashboardDailyProviderAggregateRow>, GatewayError> {
|
||||
let mut rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
CAST(DATE(hour_utc + ($3::integer * INTERVAL '1 minute')) AS TEXT) AS date,
|
||||
provider_name,
|
||||
COALESCE(SUM(total_requests), 0)::BIGINT AS total_requests,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0)::BIGINT AS total_tokens,
|
||||
CAST(COALESCE(SUM(total_cost), 0) AS DOUBLE PRECISION) AS total_cost
|
||||
FROM stats_hourly_provider
|
||||
WHERE hour_utc >= $1
|
||||
AND hour_utc < $2
|
||||
GROUP BY date, provider_name
|
||||
ORDER BY date ASC, total_cost DESC, provider_name ASC
|
||||
"#,
|
||||
)
|
||||
.bind(start_utc)
|
||||
.bind(end_utc)
|
||||
.bind(tz_offset_minutes)
|
||||
.fetch(pool);
|
||||
|
||||
let mut items = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|err| internal(format!("hourly aggregate provider read failed: {err}")))?
|
||||
{
|
||||
items.push(DashboardDailyProviderAggregateRow {
|
||||
date: row.try_get::<String, _>("date").map_err(|err| {
|
||||
internal(format!("hourly aggregate provider decode failed: {err}"))
|
||||
})?,
|
||||
provider: row.try_get::<String, _>("provider_name").map_err(|err| {
|
||||
internal(format!("hourly aggregate provider decode failed: {err}"))
|
||||
})?,
|
||||
requests: row
|
||||
.try_get::<i64, _>("total_requests")
|
||||
.map_err(|err| internal(format!("hourly aggregate provider decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
total_tokens: row
|
||||
.try_get::<i64, _>("total_tokens")
|
||||
.map_err(|err| internal(format!("hourly aggregate provider decode failed: {err}")))?
|
||||
.max(0) as u64,
|
||||
total_cost_usd: row.try_get::<f64, _>("total_cost").map_err(|err| {
|
||||
internal(format!("hourly aggregate provider decode failed: {err}"))
|
||||
})?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_user_dashboard_daily_totals_aggregates(
|
||||
pool: &PostgresPool,
|
||||
start_day_utc: DateTime<Utc>,
|
||||
end_day_utc: DateTime<Utc>,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<DashboardDailyTotalsAggregateRow>, GatewayError> {
|
||||
let mut rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
date,
|
||||
total_requests,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
COALESCE(total_cost, 0)::DOUBLE PRECISION AS total_cost,
|
||||
response_time_sum_ms,
|
||||
response_time_samples
|
||||
FROM stats_user_daily
|
||||
WHERE user_id = $1
|
||||
AND date >= $2
|
||||
AND date < $3
|
||||
ORDER BY date ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(start_day_utc)
|
||||
.bind(end_day_utc)
|
||||
.fetch(pool);
|
||||
|
||||
let mut items = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|err| internal(format!("user daily aggregate totals read failed: {err}")))?
|
||||
{
|
||||
let date = row
|
||||
.try_get::<DateTime<Utc>, _>("date")
|
||||
.map_err(|err| internal(format!("user daily aggregate totals decode failed: {err}")))?;
|
||||
let input_tokens = row
|
||||
.try_get::<i64, _>("input_tokens")
|
||||
.map_err(|err| internal(format!("user daily aggregate totals decode failed: {err}")))?;
|
||||
let output_tokens = row
|
||||
.try_get::<i64, _>("output_tokens")
|
||||
.map_err(|err| internal(format!("user daily aggregate totals decode failed: {err}")))?;
|
||||
items.push(DashboardDailyTotalsAggregateRow {
|
||||
date: date.date_naive().to_string(),
|
||||
requests: row
|
||||
.try_get::<i32, _>("total_requests")
|
||||
.map_err(|err| {
|
||||
internal(format!("user daily aggregate totals decode failed: {err}"))
|
||||
})?
|
||||
.max(0) as u64,
|
||||
total_tokens: input_tokens.saturating_add(output_tokens).max(0) as u64,
|
||||
total_cost_usd: row.try_get::<f64, _>("total_cost").map_err(|err| {
|
||||
internal(format!("user daily aggregate totals decode failed: {err}"))
|
||||
})?,
|
||||
response_time_sum_ms: row
|
||||
.try_get::<f64, _>("response_time_sum_ms")
|
||||
.map_err(|err| {
|
||||
internal(format!("user daily aggregate totals decode failed: {err}"))
|
||||
})?,
|
||||
response_time_samples: row
|
||||
.try_get::<i64, _>("response_time_samples")
|
||||
.map_err(|err| {
|
||||
internal(format!("user daily aggregate totals decode failed: {err}"))
|
||||
})?
|
||||
.max(0) as u64,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_user_dashboard_hourly_totals_aggregates(
|
||||
pool: &PostgresPool,
|
||||
start_utc: DateTime<Utc>,
|
||||
end_utc: DateTime<Utc>,
|
||||
tz_offset_minutes: i32,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<DashboardDailyTotalsAggregateRow>, GatewayError> {
|
||||
let mut rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
CAST(DATE(hour_utc + ($4::integer * INTERVAL '1 minute')) AS TEXT) AS date,
|
||||
COALESCE(SUM(total_requests), 0)::BIGINT AS total_requests,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0)::BIGINT AS total_tokens,
|
||||
CAST(COALESCE(SUM(total_cost), 0) AS DOUBLE PRECISION) AS total_cost,
|
||||
CAST(COALESCE(SUM(response_time_sum_ms), 0) AS DOUBLE PRECISION) AS response_time_sum_ms,
|
||||
COALESCE(SUM(response_time_samples), 0)::BIGINT AS response_time_samples
|
||||
FROM stats_hourly_user
|
||||
WHERE user_id = $1
|
||||
AND hour_utc >= $2
|
||||
AND hour_utc < $3
|
||||
GROUP BY date
|
||||
ORDER BY date ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(start_utc)
|
||||
.bind(end_utc)
|
||||
.bind(tz_offset_minutes)
|
||||
.fetch(pool);
|
||||
|
||||
let mut items = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|err| internal(format!("user hourly aggregate totals read failed: {err}")))?
|
||||
{
|
||||
items.push(DashboardDailyTotalsAggregateRow {
|
||||
date: row.try_get::<String, _>("date").map_err(|err| {
|
||||
internal(format!("user hourly aggregate totals decode failed: {err}"))
|
||||
})?,
|
||||
requests: row
|
||||
.try_get::<i64, _>("total_requests")
|
||||
.map_err(|err| {
|
||||
internal(format!("user hourly aggregate totals decode failed: {err}"))
|
||||
})?
|
||||
.max(0) as u64,
|
||||
total_tokens: row
|
||||
.try_get::<i64, _>("total_tokens")
|
||||
.map_err(|err| {
|
||||
internal(format!("user hourly aggregate totals decode failed: {err}"))
|
||||
})?
|
||||
.max(0) as u64,
|
||||
total_cost_usd: row.try_get::<f64, _>("total_cost").map_err(|err| {
|
||||
internal(format!("user hourly aggregate totals decode failed: {err}"))
|
||||
})?,
|
||||
response_time_sum_ms: row
|
||||
.try_get::<f64, _>("response_time_sum_ms")
|
||||
.map_err(|err| {
|
||||
internal(format!("user hourly aggregate totals decode failed: {err}"))
|
||||
})?,
|
||||
response_time_samples: row
|
||||
.try_get::<i64, _>("response_time_samples")
|
||||
.map_err(|err| {
|
||||
internal(format!("user hourly aggregate totals decode failed: {err}"))
|
||||
})?
|
||||
.max(0) as u64,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_user_dashboard_daily_model_aggregates(
|
||||
pool: &PostgresPool,
|
||||
start_day_utc: DateTime<Utc>,
|
||||
end_day_utc: DateTime<Utc>,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<DashboardDailyModelAggregateRow>, GatewayError> {
|
||||
let mut rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
date,
|
||||
model,
|
||||
total_requests,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
COALESCE(total_cost, 0)::DOUBLE PRECISION AS total_cost,
|
||||
response_time_sum_ms,
|
||||
response_time_samples
|
||||
FROM stats_user_daily_model
|
||||
WHERE user_id = $1
|
||||
AND date >= $2
|
||||
AND date < $3
|
||||
ORDER BY date ASC, total_cost DESC, model ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(start_day_utc)
|
||||
.bind(end_day_utc)
|
||||
.fetch(pool);
|
||||
|
||||
let mut items = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|err| internal(format!("user daily aggregate model read failed: {err}")))?
|
||||
{
|
||||
let date = row
|
||||
.try_get::<DateTime<Utc>, _>("date")
|
||||
.map_err(|err| internal(format!("user daily aggregate model decode failed: {err}")))?;
|
||||
let input_tokens = row
|
||||
.try_get::<i64, _>("input_tokens")
|
||||
.map_err(|err| internal(format!("user daily aggregate model decode failed: {err}")))?;
|
||||
let output_tokens = row
|
||||
.try_get::<i64, _>("output_tokens")
|
||||
.map_err(|err| internal(format!("user daily aggregate model decode failed: {err}")))?;
|
||||
items.push(DashboardDailyModelAggregateRow {
|
||||
date: date.date_naive().to_string(),
|
||||
model: row.try_get::<String, _>("model").map_err(|err| {
|
||||
internal(format!("user daily aggregate model decode failed: {err}"))
|
||||
})?,
|
||||
requests: row
|
||||
.try_get::<i32, _>("total_requests")
|
||||
.map_err(|err| {
|
||||
internal(format!("user daily aggregate model decode failed: {err}"))
|
||||
})?
|
||||
.max(0) as u64,
|
||||
total_tokens: input_tokens.saturating_add(output_tokens).max(0) as u64,
|
||||
total_cost_usd: row.try_get::<f64, _>("total_cost").map_err(|err| {
|
||||
internal(format!("user daily aggregate model decode failed: {err}"))
|
||||
})?,
|
||||
response_time_sum_ms: row
|
||||
.try_get::<f64, _>("response_time_sum_ms")
|
||||
.map_err(|err| {
|
||||
internal(format!("user daily aggregate model decode failed: {err}"))
|
||||
})?,
|
||||
response_time_samples: row
|
||||
.try_get::<i64, _>("response_time_samples")
|
||||
.map_err(|err| {
|
||||
internal(format!("user daily aggregate model decode failed: {err}"))
|
||||
})?
|
||||
.max(0) as u64,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_user_dashboard_hourly_model_aggregates(
|
||||
pool: &PostgresPool,
|
||||
start_utc: DateTime<Utc>,
|
||||
end_utc: DateTime<Utc>,
|
||||
tz_offset_minutes: i32,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<DashboardDailyModelAggregateRow>, GatewayError> {
|
||||
let mut rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
CAST(DATE(hour_utc + ($4::integer * INTERVAL '1 minute')) AS TEXT) AS date,
|
||||
model,
|
||||
COALESCE(SUM(total_requests), 0)::BIGINT AS total_requests,
|
||||
COALESCE(SUM(input_tokens + output_tokens), 0)::BIGINT AS total_tokens,
|
||||
CAST(COALESCE(SUM(total_cost), 0) AS DOUBLE PRECISION) AS total_cost,
|
||||
CAST(COALESCE(SUM(response_time_sum_ms), 0) AS DOUBLE PRECISION) AS response_time_sum_ms,
|
||||
COALESCE(SUM(response_time_samples), 0)::BIGINT AS response_time_samples
|
||||
FROM stats_hourly_user_model
|
||||
WHERE user_id = $1
|
||||
AND hour_utc >= $2
|
||||
AND hour_utc < $3
|
||||
GROUP BY date, model
|
||||
ORDER BY date ASC, total_cost DESC, model ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(start_utc)
|
||||
.bind(end_utc)
|
||||
.bind(tz_offset_minutes)
|
||||
.fetch(pool);
|
||||
|
||||
let mut items = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|err| internal(format!("user hourly aggregate model read failed: {err}")))?
|
||||
{
|
||||
items.push(DashboardDailyModelAggregateRow {
|
||||
date: row.try_get::<String, _>("date").map_err(|err| {
|
||||
internal(format!("user hourly aggregate model decode failed: {err}"))
|
||||
})?,
|
||||
model: row.try_get::<String, _>("model").map_err(|err| {
|
||||
internal(format!("user hourly aggregate model decode failed: {err}"))
|
||||
})?,
|
||||
requests: row
|
||||
.try_get::<i64, _>("total_requests")
|
||||
.map_err(|err| {
|
||||
internal(format!("user hourly aggregate model decode failed: {err}"))
|
||||
})?
|
||||
.max(0) as u64,
|
||||
total_tokens: row
|
||||
.try_get::<i64, _>("total_tokens")
|
||||
.map_err(|err| {
|
||||
internal(format!("user hourly aggregate model decode failed: {err}"))
|
||||
})?
|
||||
.max(0) as u64,
|
||||
total_cost_usd: row.try_get::<f64, _>("total_cost").map_err(|err| {
|
||||
internal(format!("user hourly aggregate model decode failed: {err}"))
|
||||
})?,
|
||||
response_time_sum_ms: row
|
||||
.try_get::<f64, _>("response_time_sum_ms")
|
||||
.map_err(|err| {
|
||||
internal(format!("user hourly aggregate model decode failed: {err}"))
|
||||
})?,
|
||||
response_time_samples: row
|
||||
.try_get::<i64, _>("response_time_samples")
|
||||
.map_err(|err| {
|
||||
internal(format!("user hourly aggregate model decode failed: {err}"))
|
||||
})?
|
||||
.max(0) as u64,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
pub(crate) mod billing;
|
||||
pub(crate) mod dashboard_stats;
|
||||
pub(crate) mod monitoring;
|
||||
pub(crate) mod usage_heatmap;
|
||||
pub(crate) mod user_rollups;
|
||||
|
||||
176
apps/aether-gateway/src/query/usage_heatmap.rs
Normal file
176
apps/aether-gateway/src/query/usage_heatmap.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use aether_data::postgres::PostgresPool;
|
||||
use aether_data_contracts::repository::usage::StoredUsageDailySummary;
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use futures_util::TryStreamExt;
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::GatewayError;
|
||||
|
||||
const USER_HEATMAP_AGGREGATE_SQL: &str = r#"
|
||||
SELECT
|
||||
date,
|
||||
total_requests,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_creation_tokens,
|
||||
cache_read_tokens,
|
||||
COALESCE(total_cost, 0)::DOUBLE PRECISION AS total_cost,
|
||||
COALESCE(actual_total_cost, 0)::DOUBLE PRECISION AS actual_total_cost
|
||||
FROM stats_user_daily
|
||||
WHERE user_id = $1
|
||||
AND date >= $2
|
||||
AND date < $3
|
||||
ORDER BY date ASC
|
||||
"#;
|
||||
|
||||
const GLOBAL_HEATMAP_AGGREGATE_SQL: &str = r#"
|
||||
SELECT
|
||||
date,
|
||||
total_requests,
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_creation_tokens,
|
||||
cache_read_tokens,
|
||||
COALESCE(total_cost, 0)::DOUBLE PRECISION AS total_cost,
|
||||
COALESCE(actual_total_cost, 0)::DOUBLE PRECISION AS actual_total_cost
|
||||
FROM stats_daily
|
||||
WHERE date >= $1
|
||||
AND date < $2
|
||||
ORDER BY date ASC
|
||||
"#;
|
||||
|
||||
fn internal(err: impl ToString) -> GatewayError {
|
||||
GatewayError::Internal(err.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn read_stats_daily_cutoff_date(
|
||||
pool: &PostgresPool,
|
||||
) -> Result<Option<DateTime<Utc>>, GatewayError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT cutoff_date
|
||||
FROM stats_summary
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|err| internal(format!("stats summary cutoff lookup failed: {err}")))?;
|
||||
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
row.try_get::<DateTime<Utc>, _>("cutoff_date")
|
||||
.map(Some)
|
||||
.map_err(|err| internal(format!("stats summary cutoff decode failed: {err}")))
|
||||
}
|
||||
|
||||
pub(crate) async fn list_usage_heatmap_aggregate_rows(
|
||||
pool: &PostgresPool,
|
||||
start_date: NaiveDate,
|
||||
end_date_exclusive: NaiveDate,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<Vec<StoredUsageDailySummary>, GatewayError> {
|
||||
if start_date >= end_date_exclusive {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let start_at = DateTime::<Utc>::from_naive_utc_and_offset(
|
||||
start_date
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.expect("midnight should be valid"),
|
||||
Utc,
|
||||
);
|
||||
let end_at = DateTime::<Utc>::from_naive_utc_and_offset(
|
||||
end_date_exclusive
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.expect("midnight should be valid"),
|
||||
Utc,
|
||||
);
|
||||
|
||||
let mut rows = if let Some(user_id) = user_id {
|
||||
sqlx::query(USER_HEATMAP_AGGREGATE_SQL)
|
||||
.bind(user_id)
|
||||
.bind(start_at)
|
||||
.bind(end_at)
|
||||
.fetch(pool)
|
||||
} else {
|
||||
sqlx::query(GLOBAL_HEATMAP_AGGREGATE_SQL)
|
||||
.bind(start_at)
|
||||
.bind(end_at)
|
||||
.fetch(pool)
|
||||
};
|
||||
|
||||
let mut items = Vec::new();
|
||||
while let Some(row) = rows
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|err| internal(format!("aggregate heatmap read failed: {err}")))?
|
||||
{
|
||||
let date = row
|
||||
.try_get::<DateTime<Utc>, _>("date")
|
||||
.map_err(|err| internal(format!("aggregate heatmap date decode failed: {err}")))?;
|
||||
let requests = row
|
||||
.try_get::<i32, _>("total_requests")
|
||||
.map_err(|err| internal(format!("aggregate heatmap request decode failed: {err}")))?;
|
||||
let input_tokens = row
|
||||
.try_get::<i64, _>("input_tokens")
|
||||
.map_err(|err| internal(format!("aggregate heatmap token decode failed: {err}")))?;
|
||||
let output_tokens = row
|
||||
.try_get::<i64, _>("output_tokens")
|
||||
.map_err(|err| internal(format!("aggregate heatmap token decode failed: {err}")))?;
|
||||
let cache_creation_tokens = row
|
||||
.try_get::<i64, _>("cache_creation_tokens")
|
||||
.map_err(|err| internal(format!("aggregate heatmap token decode failed: {err}")))?;
|
||||
let cache_read_tokens = row
|
||||
.try_get::<i64, _>("cache_read_tokens")
|
||||
.map_err(|err| internal(format!("aggregate heatmap token decode failed: {err}")))?;
|
||||
let total_cost_usd = row
|
||||
.try_get::<f64, _>("total_cost")
|
||||
.map_err(|err| internal(format!("aggregate heatmap cost decode failed: {err}")))?;
|
||||
let actual_total_cost_usd = row.try_get::<f64, _>("actual_total_cost").map_err(|err| {
|
||||
internal(format!(
|
||||
"aggregate heatmap actual cost decode failed: {err}"
|
||||
))
|
||||
})?;
|
||||
items.push(StoredUsageDailySummary {
|
||||
date: date.date_naive().to_string(),
|
||||
requests: u64::try_from(requests.max(0)).unwrap_or_default(),
|
||||
total_tokens: u64::try_from(
|
||||
input_tokens
|
||||
.saturating_add(output_tokens)
|
||||
.saturating_add(cache_creation_tokens)
|
||||
.saturating_add(cache_read_tokens)
|
||||
.max(0),
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
total_cost_usd,
|
||||
actual_total_cost_usd,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{GLOBAL_HEATMAP_AGGREGATE_SQL, USER_HEATMAP_AGGREGATE_SQL};
|
||||
|
||||
#[test]
|
||||
fn user_heatmap_query_casts_cost_columns_to_double_precision() {
|
||||
assert!(USER_HEATMAP_AGGREGATE_SQL
|
||||
.contains("COALESCE(total_cost, 0)::DOUBLE PRECISION AS total_cost"));
|
||||
assert!(USER_HEATMAP_AGGREGATE_SQL
|
||||
.contains("COALESCE(actual_total_cost, 0)::DOUBLE PRECISION AS actual_total_cost"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_heatmap_query_casts_cost_columns_to_double_precision() {
|
||||
assert!(GLOBAL_HEATMAP_AGGREGATE_SQL
|
||||
.contains("COALESCE(total_cost, 0)::DOUBLE PRECISION AS total_cost"));
|
||||
assert!(GLOBAL_HEATMAP_AGGREGATE_SQL
|
||||
.contains("COALESCE(actual_total_cost, 0)::DOUBLE PRECISION AS actual_total_cost"));
|
||||
}
|
||||
}
|
||||
154
apps/aether-gateway/src/query/user_rollups.rs
Normal file
154
apps/aether-gateway/src/query/user_rollups.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aether_data::postgres::PostgresPool;
|
||||
use aether_data_contracts::repository::usage::StoredUsageUserTotals;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures_util::TryStreamExt;
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::query::usage_heatmap::read_stats_daily_cutoff_date;
|
||||
use crate::GatewayError;
|
||||
|
||||
fn internal(err: impl ToString) -> GatewayError {
|
||||
GatewayError::Internal(err.to_string())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_user_usage_totals_from_stats_summary(
|
||||
pool: &PostgresPool,
|
||||
user_ids: &[String],
|
||||
) -> Result<Option<Vec<StoredUsageUserTotals>>, GatewayError> {
|
||||
if user_ids.is_empty() {
|
||||
return Ok(Some(Vec::new()));
|
||||
}
|
||||
|
||||
let Some(cutoff_date) = read_stats_daily_cutoff_date(pool).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut totals = load_stats_user_summary_rows(pool, user_ids).await?;
|
||||
absorb_stats_user_summary_tail(pool, cutoff_date, user_ids, &mut totals).await?;
|
||||
|
||||
let mut items = user_ids
|
||||
.iter()
|
||||
.map(|user_id| {
|
||||
totals
|
||||
.remove(user_id)
|
||||
.unwrap_or_else(|| StoredUsageUserTotals {
|
||||
user_id: user_id.clone(),
|
||||
request_count: 0,
|
||||
total_tokens: 0,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by(|left, right| left.user_id.cmp(&right.user_id));
|
||||
Ok(Some(items))
|
||||
}
|
||||
|
||||
async fn load_stats_user_summary_rows(
|
||||
pool: &PostgresPool,
|
||||
user_ids: &[String],
|
||||
) -> Result<BTreeMap<String, StoredUsageUserTotals>, GatewayError> {
|
||||
let mut rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
user_id,
|
||||
COALESCE(all_time_requests, 0)::BIGINT AS request_count,
|
||||
COALESCE(
|
||||
all_time_input_tokens
|
||||
+ all_time_output_tokens
|
||||
+ all_time_cache_creation_tokens
|
||||
+ all_time_cache_read_tokens,
|
||||
0
|
||||
)::BIGINT AS total_tokens
|
||||
FROM stats_user_summary
|
||||
WHERE user_id = ANY($1::TEXT[])
|
||||
ORDER BY user_id ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_ids)
|
||||
.fetch(pool);
|
||||
|
||||
let mut items = BTreeMap::new();
|
||||
while let Some(row) = rows
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|err| internal(format!("stats_user_summary lookup failed: {err}")))?
|
||||
{
|
||||
let user_id = row
|
||||
.try_get::<String, _>("user_id")
|
||||
.map_err(|err| internal(format!("stats_user_summary decode failed: {err}")))?;
|
||||
let request_count = row
|
||||
.try_get::<i64, _>("request_count")
|
||||
.map_err(|err| internal(format!("stats_user_summary decode failed: {err}")))?
|
||||
.max(0) as u64;
|
||||
let total_tokens = row
|
||||
.try_get::<i64, _>("total_tokens")
|
||||
.map_err(|err| internal(format!("stats_user_summary decode failed: {err}")))?
|
||||
.max(0) as u64;
|
||||
items.insert(
|
||||
user_id.clone(),
|
||||
StoredUsageUserTotals {
|
||||
user_id,
|
||||
request_count,
|
||||
total_tokens,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
async fn absorb_stats_user_summary_tail(
|
||||
pool: &PostgresPool,
|
||||
cutoff_date: DateTime<Utc>,
|
||||
user_ids: &[String],
|
||||
totals: &mut BTreeMap<String, StoredUsageUserTotals>,
|
||||
) -> Result<(), GatewayError> {
|
||||
let mut rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
"usage".user_id,
|
||||
COUNT(*)::BIGINT AS request_count,
|
||||
COALESCE(SUM(GREATEST(COALESCE("usage".total_tokens, 0), 0)), 0)::BIGINT AS total_tokens
|
||||
FROM "usage"
|
||||
WHERE "usage".user_id = ANY($1::TEXT[])
|
||||
AND "usage".created_at >= $2
|
||||
AND "usage".status NOT IN ('pending', 'streaming')
|
||||
AND "usage".provider_name NOT IN ('unknown', 'pending')
|
||||
GROUP BY "usage".user_id
|
||||
ORDER BY "usage".user_id ASC
|
||||
"#,
|
||||
)
|
||||
.bind(user_ids)
|
||||
.bind(cutoff_date)
|
||||
.fetch(pool);
|
||||
|
||||
while let Some(row) = rows
|
||||
.try_next()
|
||||
.await
|
||||
.map_err(|err| internal(format!("stats_user_summary tail lookup failed: {err}")))?
|
||||
{
|
||||
let user_id = row
|
||||
.try_get::<String, _>("user_id")
|
||||
.map_err(|err| internal(format!("stats_user_summary tail decode failed: {err}")))?;
|
||||
let request_count = row
|
||||
.try_get::<i64, _>("request_count")
|
||||
.map_err(|err| internal(format!("stats_user_summary tail decode failed: {err}")))?
|
||||
.max(0) as u64;
|
||||
let total_tokens = row
|
||||
.try_get::<i64, _>("total_tokens")
|
||||
.map_err(|err| internal(format!("stats_user_summary tail decode failed: {err}")))?
|
||||
.max(0) as u64;
|
||||
let entry = totals
|
||||
.entry(user_id.clone())
|
||||
.or_insert_with(|| StoredUsageUserTotals {
|
||||
user_id,
|
||||
request_count: 0,
|
||||
total_tokens: 0,
|
||||
});
|
||||
entry.request_count = entry.request_count.saturating_add(request_count);
|
||||
entry.total_tokens = entry.total_tokens.saturating_add(total_tokens);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user