mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
fix(admin): 修复 usage 观测全表扫描并下推聚合查询
- 收紧 admin usage/stats 默认时间范围和 active 轮询查询 - 将 summary/records/aggregation/time-series/leaderboard 下推到 SQL 侧 - 统一前端时间参数并补齐 admin usage/stats 回归测试
This commit is contained in:
@@ -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(
|
||||
¤t,
|
||||
&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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user