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:
fawney19
2026-04-22 17:29:39 +08:00
parent 063ef02306
commit a5e6bd3b62
45 changed files with 15323 additions and 1039 deletions

View 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)
}

View File

@@ -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;

View 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"));
}
}

View 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(())
}