fix(admin): 修复 usage 观测全表扫描并下推聚合查询

- 收紧 admin usage/stats 默认时间范围和 active 轮询查询
- 将 summary/records/aggregation/time-series/leaderboard 下推到 SQL 侧
- 统一前端时间参数并补齐 admin usage/stats 回归测试
This commit is contained in:
fawney19
2026-04-16 17:41:32 +08:00
parent 10605d9fb0
commit 65cd9dc3e5
28 changed files with 3085 additions and 381 deletions

View File

@@ -1,6 +1,7 @@
use aether_data::repository::auth::StoredAuthApiKeySnapshot;
use aether_data_contracts::repository::{
provider_catalog::StoredProviderCatalogProvider, usage::StoredRequestUsageAudit,
provider_catalog::StoredProviderCatalogProvider,
usage::{StoredRequestUsageAudit, StoredUsageLeaderboardSummary, StoredUsageTimeSeriesBucket},
};
use axum::{
body::Body,
@@ -811,7 +812,20 @@ pub fn build_admin_stats_comparison_response(
) -> Response<Body> {
let current = aggregate_usage_stats(current_usage);
let comparison = aggregate_usage_stats(comparison_usage);
build_admin_stats_comparison_response_from_aggregates(
&current,
&comparison,
current_range,
comparison_range,
)
}
pub fn build_admin_stats_comparison_response_from_aggregates(
current: &AdminStatsAggregate,
comparison: &AdminStatsAggregate,
current_range: &AdminStatsTimeRange,
comparison_range: &AdminStatsTimeRange,
) -> Response<Body> {
Json(json!({
"current": {
"total_requests": current.total_requests,
@@ -973,6 +987,171 @@ pub fn build_admin_stats_time_series_response(
.into_response()
}
fn admin_stats_time_series_bucket_from_summary(
bucket: &StoredUsageTimeSeriesBucket,
) -> AdminStatsTimeSeriesBucket {
AdminStatsTimeSeriesBucket {
total_requests: bucket.total_requests,
input_tokens: bucket.input_tokens,
output_tokens: bucket.output_tokens,
cache_creation_tokens: bucket.cache_creation_tokens,
cache_read_tokens: bucket.cache_read_tokens,
total_cost: bucket.total_cost_usd,
total_response_time_ms: bucket.total_response_time_ms,
}
}
fn build_daily_time_series_buckets_from_summaries(
time_range: &AdminStatsTimeRange,
buckets: &[StoredUsageTimeSeriesBucket],
) -> std::collections::BTreeMap<chrono::NaiveDate, AdminStatsTimeSeriesBucket> {
let mut values: std::collections::BTreeMap<chrono::NaiveDate, AdminStatsTimeSeriesBucket> =
time_range
.local_dates()
.into_iter()
.map(|date| (date, AdminStatsTimeSeriesBucket::default()))
.collect();
for bucket in buckets {
let Ok(date) = chrono::NaiveDate::parse_from_str(&bucket.bucket_key, "%Y-%m-%d") else {
continue;
};
let Some(value) = values.get_mut(&date) else {
continue;
};
value.merge(&admin_stats_time_series_bucket_from_summary(bucket));
}
values
}
fn build_daily_time_series_payload_from_summaries(
time_range: &AdminStatsTimeRange,
buckets: &[StoredUsageTimeSeriesBucket],
) -> Vec<serde_json::Value> {
build_daily_time_series_buckets_from_summaries(time_range, buckets)
.into_iter()
.map(|(date, bucket)| bucket.to_json_with_avg(date.to_string()))
.collect()
}
fn build_weekly_time_series_payload_from_summaries(
time_range: &AdminStatsTimeRange,
buckets: &[StoredUsageTimeSeriesBucket],
) -> Vec<serde_json::Value> {
let mut weekly: std::collections::BTreeMap<
(i32, u32),
(chrono::NaiveDate, AdminStatsTimeSeriesBucket),
> = std::collections::BTreeMap::new();
for (date, bucket) in build_daily_time_series_buckets_from_summaries(time_range, buckets) {
let iso = date.iso_week();
let entry = weekly
.entry((iso.year(), iso.week()))
.or_insert_with(|| (date, AdminStatsTimeSeriesBucket::default()));
entry.0 = entry.0.min(date);
entry.1.merge(&bucket);
}
weekly
.into_values()
.map(|(date, bucket)| bucket.to_json_with_avg(date.to_string()))
.collect()
}
fn build_monthly_time_series_payload_from_summaries(
time_range: &AdminStatsTimeRange,
buckets: &[StoredUsageTimeSeriesBucket],
) -> Vec<serde_json::Value> {
let mut monthly: std::collections::BTreeMap<
(i32, u32),
(chrono::NaiveDate, AdminStatsTimeSeriesBucket),
> = std::collections::BTreeMap::new();
for (date, bucket) in build_daily_time_series_buckets_from_summaries(time_range, buckets) {
let Some(month_start) = chrono::NaiveDate::from_ymd_opt(date.year(), date.month(), 1)
else {
continue;
};
let entry = monthly
.entry((date.year(), date.month()))
.or_insert_with(|| (month_start, AdminStatsTimeSeriesBucket::default()));
entry.1.merge(&bucket);
}
monthly
.into_values()
.map(|(date, bucket)| bucket.to_json_with_avg(date.to_string()))
.collect()
}
fn build_hourly_time_series_payload_from_summaries(
time_range: &AdminStatsTimeRange,
buckets: &[StoredUsageTimeSeriesBucket],
) -> Vec<serde_json::Value> {
let Some((mut current, end)) = time_range.to_utc_datetime_bounds() else {
return Vec::new();
};
let offset = chrono::Duration::minutes(i64::from(time_range.tz_offset_minutes));
let mut values: std::collections::BTreeMap<String, AdminStatsTimeSeriesBucket> =
std::collections::BTreeMap::new();
while current < end {
let label = (current + offset)
.format("%Y-%m-%dT%H:00:00+00:00")
.to_string();
values.insert(label, AdminStatsTimeSeriesBucket::default());
let Some(next) = current.checked_add_signed(chrono::Duration::hours(1)) else {
break;
};
current = next;
}
for bucket in buckets {
let Some(value) = values.get_mut(&bucket.bucket_key) else {
continue;
};
value.merge(&admin_stats_time_series_bucket_from_summary(bucket));
}
values
.into_iter()
.map(|(date, bucket)| bucket.to_json_without_avg(date))
.collect()
}
pub fn build_time_series_payload_from_summaries(
time_range: &AdminStatsTimeRange,
granularity: AdminStatsGranularity,
buckets: &[StoredUsageTimeSeriesBucket],
) -> Vec<serde_json::Value> {
match granularity {
AdminStatsGranularity::Hour => {
build_hourly_time_series_payload_from_summaries(time_range, buckets)
}
AdminStatsGranularity::Day => {
build_daily_time_series_payload_from_summaries(time_range, buckets)
}
AdminStatsGranularity::Week => {
build_weekly_time_series_payload_from_summaries(time_range, buckets)
}
AdminStatsGranularity::Month => {
build_monthly_time_series_payload_from_summaries(time_range, buckets)
}
}
}
pub fn build_admin_stats_time_series_response_from_summaries(
time_range: &AdminStatsTimeRange,
granularity: AdminStatsGranularity,
buckets: &[StoredUsageTimeSeriesBucket],
) -> Response<Body> {
Json(serde_json::Value::Array(
build_time_series_payload_from_summaries(time_range, granularity, buckets),
))
.into_response()
}
pub fn build_admin_stats_cost_forecast_response(
time_range: &AdminStatsTimeRange,
forecast_days: u32,
@@ -1293,6 +1472,21 @@ pub fn build_model_leaderboard_items(
grouped.into_values().collect()
}
pub fn build_model_leaderboard_items_from_summaries(
items: &[StoredUsageLeaderboardSummary],
) -> Vec<AdminStatsLeaderboardItem> {
items
.iter()
.map(|item| AdminStatsLeaderboardItem {
id: item.group_key.clone(),
name: item.group_key.clone(),
requests: item.request_count,
tokens: item.total_tokens,
cost: item.total_cost_usd,
})
.collect()
}
pub fn build_user_leaderboard_items(
items: &[StoredRequestUsageAudit],
users: &std::collections::BTreeMap<String, AdminStatsUserMetadata>,
@@ -1362,6 +1556,57 @@ pub fn build_user_leaderboard_items(
grouped.into_values().collect()
}
pub fn build_user_leaderboard_items_from_summaries(
items: &[StoredUsageLeaderboardSummary],
users: &std::collections::BTreeMap<String, AdminStatsUserMetadata>,
auth_user_reader_available: bool,
user_reader_available: bool,
include_inactive: bool,
exclude_admin: bool,
) -> Vec<AdminStatsLeaderboardItem> {
let mut grouped = Vec::new();
for item in items {
let user_id = item.group_key.as_str();
let entry_name = if let Some(user) = users.get(user_id) {
if user.is_deleted {
continue;
}
if !include_inactive && !user.is_active {
continue;
}
if exclude_admin && user.role.eq_ignore_ascii_case("admin") {
continue;
}
user.name.clone()
} else {
if exclude_admin {
continue;
}
if auth_user_reader_available || user_reader_available {
user_id.to_string()
} else {
item.legacy_name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| user_id.to_string())
}
};
grouped.push(AdminStatsLeaderboardItem {
id: user_id.to_string(),
name: entry_name,
requests: item.request_count,
tokens: item.total_tokens,
cost: item.total_cost_usd,
});
}
grouped
}
pub fn build_api_key_leaderboard_items(
items: &[StoredRequestUsageAudit],
snapshots: Option<&[StoredAuthApiKeySnapshot]>,
@@ -1436,6 +1681,60 @@ pub fn build_api_key_leaderboard_items(
grouped.into_values().collect()
}
pub fn build_api_key_leaderboard_items_from_summaries(
items: &[StoredUsageLeaderboardSummary],
snapshots: Option<&[StoredAuthApiKeySnapshot]>,
api_key_names: &std::collections::BTreeMap<String, String>,
include_inactive: bool,
exclude_admin: bool,
) -> Vec<AdminStatsLeaderboardItem> {
let snapshot_by_api_key_id: std::collections::BTreeMap<_, _> = snapshots
.unwrap_or(&[])
.iter()
.map(|snapshot| (snapshot.api_key_id.as_str(), snapshot))
.collect();
let snapshots_available = snapshots.is_some();
let mut grouped = Vec::new();
for item in items {
let api_key_id = item.group_key.as_str();
let entry_name = if let Some(snapshot) = snapshot_by_api_key_id.get(api_key_id) {
if snapshot.user_is_deleted {
continue;
}
if !include_inactive && !snapshot.api_key_is_active {
continue;
}
if exclude_admin && snapshot.user_role.eq_ignore_ascii_case("admin") {
continue;
}
api_key_names
.get(api_key_id)
.cloned()
.unwrap_or_else(|| api_key_id.to_string())
} else {
if snapshots_available {
continue;
}
api_key_names
.get(api_key_id)
.cloned()
.or_else(|| item.legacy_name.clone())
.unwrap_or_else(|| api_key_id.to_string())
};
grouped.push(AdminStatsLeaderboardItem {
id: api_key_id.to_string(),
name: entry_name,
requests: item.request_count,
tokens: item.total_tokens,
cost: item.total_cost_usd,
});
}
grouped
}
pub fn compare_leaderboard_items(
metric: AdminStatsLeaderboardMetric,
order: AdminStatsSortOrder,

View File

@@ -5,7 +5,7 @@ use aether_billing::{
use aether_data::repository::users::StoredUserSummary;
use aether_data_contracts::repository::{
provider_catalog::{StoredProviderCatalogEndpoint, StoredProviderCatalogProvider},
usage::{StoredRequestUsageAudit, UsageBodyField},
usage::{StoredRequestUsageAudit, StoredUsageAuditSummary, UsageBodyField},
};
use axum::{
body::Body,
@@ -1426,53 +1426,83 @@ pub fn admin_usage_build_curl_command(
parts.join(" \\\n ")
}
pub fn build_admin_usage_summary_stats_response(
usage: &[StoredRequestUsageAudit],
) -> Response<Body> {
fn summarize_admin_usage_stats(usage: &[StoredRequestUsageAudit]) -> StoredUsageAuditSummary {
let aggregate = aggregate_usage_stats(usage);
let cache_creation_tokens: u64 = usage.iter().map(admin_usage_cache_creation_tokens).sum();
let cache_creation_ephemeral_5m_tokens: u64 = usage
.iter()
.map(|item| item.cache_creation_ephemeral_5m_input_tokens)
.sum();
let cache_creation_ephemeral_1h_tokens: u64 = usage
.iter()
.map(|item| item.cache_creation_ephemeral_1h_input_tokens)
.sum();
let cache_read_tokens: u64 = usage.iter().map(|item| item.cache_read_input_tokens).sum();
let cache_creation_cost: f64 = usage.iter().map(|item| item.cache_creation_cost_usd).sum();
let cache_read_cost: f64 = usage.iter().map(|item| item.cache_read_cost_usd).sum();
let total_tokens: u64 = usage.iter().map(admin_usage_total_tokens).sum();
let avg_response_time = round_to(aggregate.avg_response_time_ms() / 1000.0, 2);
let error_rate = if aggregate.total_requests == 0 {
StoredUsageAuditSummary {
total_requests: aggregate.total_requests,
input_tokens: usage.iter().map(|item| item.input_tokens).sum(),
output_tokens: usage.iter().map(|item| item.output_tokens).sum(),
recorded_total_tokens: aggregate.total_tokens,
cache_creation_tokens: usage.iter().map(admin_usage_cache_creation_tokens).sum(),
cache_creation_ephemeral_5m_tokens: usage
.iter()
.map(|item| item.cache_creation_ephemeral_5m_input_tokens)
.sum(),
cache_creation_ephemeral_1h_tokens: usage
.iter()
.map(|item| item.cache_creation_ephemeral_1h_input_tokens)
.sum(),
cache_read_tokens: usage.iter().map(|item| item.cache_read_input_tokens).sum(),
total_cost_usd: aggregate.total_cost,
actual_total_cost_usd: aggregate.actual_total_cost,
cache_creation_cost_usd: usage.iter().map(|item| item.cache_creation_cost_usd).sum(),
cache_read_cost_usd: usage.iter().map(|item| item.cache_read_cost_usd).sum(),
total_response_time_ms: aggregate.total_response_time_ms,
error_requests: aggregate.error_requests,
}
}
pub fn build_admin_usage_summary_stats_response_from_summary(
summary: &StoredUsageAuditSummary,
) -> Response<Body> {
let total_tokens = summary
.input_tokens
.saturating_add(summary.output_tokens)
.saturating_add(summary.cache_creation_tokens)
.saturating_add(summary.cache_read_tokens);
let avg_response_time = if summary.total_requests == 0 {
0.0
} else {
round_to(
(aggregate.error_requests as f64 / aggregate.total_requests as f64) * 100.0,
summary.total_response_time_ms / summary.total_requests as f64 / 1000.0,
2,
)
};
let error_rate = if summary.total_requests == 0 {
0.0
} else {
round_to(
(summary.error_requests as f64 / summary.total_requests as f64) * 100.0,
2,
)
};
Json(json!({
"total_requests": aggregate.total_requests,
"total_requests": summary.total_requests,
"total_tokens": total_tokens,
"total_cost": round_to(aggregate.total_cost, 6),
"total_actual_cost": round_to(aggregate.actual_total_cost, 6),
"total_cost": round_to(summary.total_cost_usd, 6),
"total_actual_cost": round_to(summary.actual_total_cost_usd, 6),
"avg_response_time": avg_response_time,
"error_count": aggregate.error_requests,
"error_count": summary.error_requests,
"error_rate": error_rate,
"cache_stats": {
"cache_creation_tokens": cache_creation_tokens,
"cache_creation_ephemeral_5m_tokens": cache_creation_ephemeral_5m_tokens,
"cache_creation_ephemeral_1h_tokens": cache_creation_ephemeral_1h_tokens,
"cache_read_tokens": cache_read_tokens,
"cache_creation_cost": round_to(cache_creation_cost, 6),
"cache_read_cost": round_to(cache_read_cost, 6),
"cache_creation_tokens": summary.cache_creation_tokens,
"cache_creation_ephemeral_5m_tokens": summary.cache_creation_ephemeral_5m_tokens,
"cache_creation_ephemeral_1h_tokens": summary.cache_creation_ephemeral_1h_tokens,
"cache_read_tokens": summary.cache_read_tokens,
"cache_creation_cost": round_to(summary.cache_creation_cost_usd, 6),
"cache_read_cost": round_to(summary.cache_read_cost_usd, 6),
}
}))
.into_response()
}
pub fn build_admin_usage_summary_stats_response(
usage: &[StoredRequestUsageAudit],
) -> Response<Body> {
build_admin_usage_summary_stats_response_from_summary(&summarize_admin_usage_stats(usage))
}
pub fn build_admin_usage_active_requests_response(
items: &[StoredRequestUsageAudit],
api_key_names: &BTreeMap<String, String>,