From a95f0d2488a211b04cf0b8e724d9b7363aab2124 Mon Sep 17 00:00:00 2001 From: wangpengxiang Date: Fri, 18 Sep 2026 16:03:46 +0800 Subject: [PATCH] feat(stats): add user group usage views --- .../route/admin/observability_families.rs | 14 + .../src/control/tests/admin_stats.rs | 22 + .../src/handlers/admin/observability/mod.rs | 28 + .../observability/monitoring/activity.rs | 1 + .../observability/stats/analytics_routes.rs | 13 + .../admin/observability/stats/cost_routes.rs | 1 + .../admin/observability/stats/leaderboard.rs | 13 +- .../observability/stats/leaderboard_routes.rs | 167 +++++- .../observability/usage/summary_routes.rs | 6 + .../src/handlers/admin/request/users.rs | 24 + .../src/tests/control/admin/stats.rs | 132 ++++- .../aether-admin/src/observability/stats.rs | 47 ++ .../adapters/postgres/src/usage/mod.rs | 249 ++++---- .../adapters/postgres/src/usage/tests.rs | 6 +- .../contracts/src/repository/usage/types.rs | 9 + .../runtime/src/lifecycle/migrate/tests.rs | 1 + .../runtime/src/repository/usage/memory.rs | 27 + .../src/repository/usage/memory/tests.rs | 61 +- .../__tests__/admin-analytics-cache.spec.ts | 31 + frontend/src/api/admin.ts | 34 ++ frontend/src/api/usage.ts | 1 + .../src/components/stats/LeaderboardTable.vue | 51 +- frontend/src/i18n/messages.ts | 76 +++ frontend/src/views/admin/UserStats.vue | 530 +++++++++++------- 24 files changed, 1198 insertions(+), 346 deletions(-) diff --git a/apps/aether-gateway/src/control/route/admin/observability_families.rs b/apps/aether-gateway/src/control/route/admin/observability_families.rs index 54eea3921..7a1baa2ef 100644 --- a/apps/aether-gateway/src/control/route/admin/observability_families.rs +++ b/apps/aether-gateway/src/control/route/admin/observability_families.rs @@ -596,6 +596,20 @@ pub(super) fn classify_admin_observability_family_route( "admin:stats", false, )) + } else if method == http::Method::GET + && matches!( + normalized_path, + "/api/admin/stats/leaderboard/user-groups" + | "/api/admin/stats/leaderboard/user-groups/" + ) + { + Some(classified( + "admin_proxy", + "stats_manage", + "leaderboard_user_groups", + "admin:stats", + false, + )) } else if method == http::Method::GET && matches!( normalized_path, diff --git a/apps/aether-gateway/src/control/tests/admin_stats.rs b/apps/aether-gateway/src/control/tests/admin_stats.rs index 63c78530c..04cb7b94c 100644 --- a/apps/aether-gateway/src/control/tests/admin_stats.rs +++ b/apps/aether-gateway/src/control/tests/admin_stats.rs @@ -157,6 +157,28 @@ fn classifies_admin_stats_leaderboard_models_as_admin_proxy_route() { assert!(!decision.is_execution_runtime_candidate()); } +#[test] +fn classifies_admin_stats_leaderboard_user_groups_as_admin_proxy_route() { + let headers = headers(&[]); + let uri: Uri = "/api/admin/stats/leaderboard/user-groups" + .parse() + .expect("uri should parse"); + let decision = + classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify"); + + assert_eq!(decision.route_class.as_deref(), Some("admin_proxy")); + assert_eq!(decision.route_family.as_deref(), Some("stats_manage")); + assert_eq!( + decision.route_kind.as_deref(), + Some("leaderboard_user_groups") + ); + assert_eq!( + decision.auth_endpoint_signature.as_deref(), + Some("admin:stats") + ); + assert!(!decision.is_execution_runtime_candidate()); +} + #[test] fn classifies_admin_stats_leaderboard_users_as_admin_proxy_route() { let headers = headers(&[]); diff --git a/apps/aether-gateway/src/handlers/admin/observability/mod.rs b/apps/aether-gateway/src/handlers/admin/observability/mod.rs index 27904b9ff..5ca53be1b 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/mod.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/mod.rs @@ -11,3 +11,31 @@ pub(crate) use self::stats::{ }; pub(crate) use self::stats::{AdminStatsTimeRange, AdminStatsUsageFilter}; pub(crate) use self::usage::maybe_build_local_admin_usage_response; + +pub(crate) async fn resolve_usage_user_group_scope( + state: &crate::handlers::admin::request::AdminAppState<'_>, + query: Option<&str>, + include_inactive: bool, + exclude_admin: bool, +) -> Result>, String>, crate::GatewayError> { + let group_id = crate::handlers::admin::shared::query_param_value(query, "user_group_id"); + let Some(group_id) = group_id else { + return Ok(Ok(None)); + }; + if crate::handlers::admin::shared::query_param_value(query, "user_id").is_some() { + return Ok(Err( + "user_id and user_group_id cannot be used together".to_string() + )); + } + if !state.has_user_data_reader() { + return Ok(Err("user group data is unavailable".to_string())); + } + + match state + .resolve_usage_user_group_member_ids(&group_id, include_inactive, exclude_admin) + .await? + { + Some(user_ids) => Ok(Ok(Some(user_ids))), + None => Ok(Err("user_group_id does not exist".to_string())), + } +} diff --git a/apps/aether-gateway/src/handlers/admin/observability/monitoring/activity.rs b/apps/aether-gateway/src/handlers/admin/observability/monitoring/activity.rs index 14ed524dd..a7504cb03 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/monitoring/activity.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/monitoring/activity.rs @@ -172,6 +172,7 @@ pub(super) async fn build_admin_monitoring_system_status_response( created_from_unix_secs: today_start.timestamp().max(0) as u64, created_until_unix_secs: now_unix_secs.saturating_add(1), user_id: None, + user_ids: None, provider_name: None, model: None, }) diff --git a/apps/aether-gateway/src/handlers/admin/observability/stats/analytics_routes.rs b/apps/aether-gateway/src/handlers/admin/observability/stats/analytics_routes.rs index 45b59a005..7f0af1fe5 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/stats/analytics_routes.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/stats/analytics_routes.rs @@ -1,3 +1,4 @@ +use super::super::resolve_usage_user_group_scope; use super::range::{build_comparison_range, parse_bounded_u32}; use super::resolve_admin_usage_time_range; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; @@ -294,6 +295,17 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response( } let filters = AdminStatsUsageFilter::from_query(request_context.query_string()); + let user_ids = match resolve_usage_user_group_scope( + state, + request_context.query_string(), + false, + false, + ) + .await? + { + Ok(value) => value, + Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))), + }; let query_granularity = match granularity { AdminStatsGranularity::Hour => UsageTimeSeriesGranularity::Hour, AdminStatsGranularity::Day @@ -311,6 +323,7 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response( granularity: query_granularity, tz_offset_minutes: time_range.tz_offset_minutes, user_id: filters.user_id, + user_ids, provider_name: filters.provider_name, model: filters.model, }) diff --git a/apps/aether-gateway/src/handlers/admin/observability/stats/cost_routes.rs b/apps/aether-gateway/src/handlers/admin/observability/stats/cost_routes.rs index f4c85089b..6e6c2f34a 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/stats/cost_routes.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/stats/cost_routes.rs @@ -77,6 +77,7 @@ pub(super) async fn maybe_build_local_admin_stats_cost_response( granularity: UsageTimeSeriesGranularity::Day, tz_offset_minutes: time_range.tz_offset_minutes, user_id: None, + user_ids: None, provider_name: None, model: None, }) diff --git a/apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard.rs b/apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard.rs index 6ad30f30e..6b7b28089 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard.rs @@ -3,12 +3,13 @@ use crate::GatewayError; use aether_data_contracts::repository::usage::StoredRequestUsageAudit; pub(super) use aether_admin::observability::stats::{ - build_admin_stats_leaderboard_response, build_api_key_leaderboard_items, - build_api_key_leaderboard_items_from_summaries, build_model_leaderboard_items, - build_model_leaderboard_items_from_summaries, build_user_leaderboard_items, - build_user_leaderboard_items_from_summaries, compare_leaderboard_items, compute_dense_rank, - AdminStatsLeaderboardItem, AdminStatsLeaderboardMetric, AdminStatsLeaderboardNameMode, - AdminStatsSortOrder, AdminStatsUserMetadata, + build_admin_stats_leaderboard_response, build_admin_stats_user_group_leaderboard_response, + build_api_key_leaderboard_items, build_api_key_leaderboard_items_from_summaries, + build_model_leaderboard_items, build_model_leaderboard_items_from_summaries, + build_user_leaderboard_items, build_user_leaderboard_items_from_summaries, + compare_leaderboard_items, compute_dense_rank, AdminStatsLeaderboardItem, + AdminStatsLeaderboardMetric, AdminStatsLeaderboardNameMode, AdminStatsSortOrder, + AdminStatsUserMetadata, }; pub(super) async fn load_user_leaderboard_metadata( diff --git a/apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard_routes.rs b/apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard_routes.rs index 41e5a3b9a..85b2cf4d3 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard_routes.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard_routes.rs @@ -1,7 +1,9 @@ +use super::super::resolve_usage_user_group_scope; use super::leaderboard::{ - build_admin_stats_leaderboard_response, build_api_key_leaderboard_items_from_summaries, - build_model_leaderboard_items_from_summaries, build_user_leaderboard_items_from_summaries, - compare_leaderboard_items, load_user_leaderboard_metadata, AdminStatsLeaderboardNameMode, + build_admin_stats_leaderboard_response, build_admin_stats_user_group_leaderboard_response, + build_api_key_leaderboard_items_from_summaries, build_model_leaderboard_items_from_summaries, + build_user_leaderboard_items_from_summaries, compare_leaderboard_items, + load_user_leaderboard_metadata, AdminStatsLeaderboardItem, AdminStatsLeaderboardNameMode, }; use super::range::{parse_bounded_u32, parse_nonnegative_usize}; use super::resolve_admin_usage_time_range; @@ -14,6 +16,7 @@ use aether_admin::observability::stats::{ }; use aether_data_contracts::repository::usage::{UsageLeaderboardGroupBy, UsageLeaderboardQuery}; use axum::{body::Body, http, response::Response}; +use std::collections::{BTreeMap, BTreeSet}; pub(super) async fn maybe_build_local_admin_stats_leaderboard_response( state: &AdminAppState<'_>, @@ -79,6 +82,7 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response( created_until_unix_secs, group_by: UsageLeaderboardGroupBy::Model, user_id: filters.user_id, + user_ids: None, provider_name: filters.provider_name, model: filters.model, }) @@ -156,6 +160,7 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response( created_until_unix_secs, group_by: UsageLeaderboardGroupBy::ApiKey, user_id: filters.user_id, + user_ids: None, provider_name: filters.provider_name, model: filters.model, }) @@ -206,6 +211,154 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response( ))); } + if request_context + .decision() + .and_then(|decision| decision.route_kind.as_deref()) + == Some("leaderboard_user_groups") + && request_context.method() == http::Method::GET + && matches!( + request_context.path(), + "/api/admin/stats/leaderboard/user-groups" + | "/api/admin/stats/leaderboard/user-groups/" + ) + { + let time_range = match resolve_admin_usage_time_range(query) { + Ok(value) => value, + Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))), + }; + let metric = match AdminStatsLeaderboardMetric::parse(query) { + Ok(value) => value, + Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))), + }; + let order = match AdminStatsSortOrder::parse(query) { + Ok(value) => value, + Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))), + }; + let limit = match query_param_value(query, "limit") + .map(|value| parse_bounded_u32("limit", &value, 1, 100)) + .transpose() + { + Ok(Some(value)) => value as usize, + Ok(None) => 10, + Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))), + }; + let offset = match query_param_value(query, "offset") + .map(|value| parse_nonnegative_usize("offset", &value)) + .transpose() + { + Ok(Some(value)) => value, + Ok(None) => 0, + Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))), + }; + let empty_counts = BTreeMap::new(); + if !state.has_usage_data_reader() || !state.has_user_data_reader() { + return Ok(Some(build_admin_stats_user_group_leaderboard_response( + metric, + Some(&time_range), + &[], + &empty_counts, + &empty_counts, + offset, + limit, + ))); + } + let include_inactive = query_param_bool(query, "include_inactive", false); + let exclude_admin = query_param_bool(query, "exclude_admin", false); + let filters = AdminStatsUsageFilter::from_query(query); + if filters.user_id.is_some() { + return Ok(Some(admin_stats_bad_request_response( + "user_id is not supported for the user group leaderboard".to_string(), + ))); + } + let Some((created_from_unix_secs, created_until_unix_secs)) = time_range.to_unix_bounds() + else { + return Ok(Some(build_admin_stats_user_group_leaderboard_response( + metric, + Some(&time_range), + &[], + &empty_counts, + &empty_counts, + offset, + limit, + ))); + }; + + let summaries = state + .summarize_usage_leaderboard(&UsageLeaderboardQuery { + created_from_unix_secs, + created_until_unix_secs, + group_by: UsageLeaderboardGroupBy::User, + user_id: None, + user_ids: None, + provider_name: filters.provider_name, + model: filters.model, + }) + .await?; + let user_ids = summaries + .iter() + .map(|item| item.group_key.clone()) + .collect::>(); + let user_metadata = load_user_leaderboard_metadata(state, &user_ids).await?; + let user_usage = build_user_leaderboard_items_from_summaries( + &summaries, + &user_metadata, + state.has_auth_user_data_reader(), + state.has_user_data_reader(), + include_inactive, + exclude_admin, + ) + .into_iter() + .map(|item| (item.id.clone(), item)) + .collect::>(); + + let mut leaderboard = Vec::new(); + let mut member_counts = BTreeMap::new(); + let mut active_member_counts = BTreeMap::new(); + for group in state.list_user_groups().await? { + let members = state.list_user_group_members(&group.id).await?; + let member_count = members.iter().filter(|member| !member.is_deleted).count(); + let active_member_count = members + .iter() + .filter(|member| !member.is_deleted && member.is_active) + .count(); + let scoped_user_ids = members + .iter() + .filter(|member| !member.is_deleted) + .filter(|member| include_inactive || member.is_active) + .filter(|member| !exclude_admin || !member.role.eq_ignore_ascii_case("admin")) + .map(|member| member.user_id.as_str()) + .collect::>(); + let mut item = AdminStatsLeaderboardItem { + id: group.id.clone(), + name: group.name, + requests: 0, + tokens: 0, + cost: 0.0, + }; + for user_id in scoped_user_ids { + if let Some(user) = user_usage.get(user_id) { + item.requests = item.requests.saturating_add(user.requests); + item.tokens = item.tokens.saturating_add(user.tokens); + item.cost += user.cost; + } + } + member_counts.insert(group.id.clone(), member_count); + active_member_counts.insert(group.id, active_member_count); + leaderboard.push(item); + } + leaderboard.sort_by(|left, right| compare_leaderboard_items(metric, order, left, right)); + + return Ok(Some(build_admin_stats_user_group_leaderboard_response( + metric, + Some(&time_range), + &leaderboard, + &member_counts, + &active_member_counts, + offset, + limit, + ))); + } + if request_context .decision() .and_then(|decision| decision.route_kind.as_deref()) @@ -253,6 +406,13 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response( let include_inactive = query_param_bool(query, "include_inactive", false); let exclude_admin = query_param_bool(query, "exclude_admin", false); let filters = AdminStatsUsageFilter::from_query(query); + let scoped_user_ids = + match resolve_usage_user_group_scope(state, query, include_inactive, exclude_admin) + .await? + { + Ok(value) => value, + Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))), + }; let Some((created_from_unix_secs, created_until_unix_secs)) = time_range.to_unix_bounds() else { return Ok(Some(admin_stats_leaderboard_empty_response( @@ -266,6 +426,7 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response( created_until_unix_secs, group_by: UsageLeaderboardGroupBy::User, user_id: filters.user_id, + user_ids: scoped_user_ids, provider_name: filters.provider_name, model: filters.model, }) diff --git a/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs b/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs index 14a3f0d95..e7541e523 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs @@ -1,3 +1,4 @@ +use super::super::resolve_usage_user_group_scope; use super::super::stats::resolve_admin_usage_time_range; use super::analytics::admin_usage_api_key_names; use super::analytics::admin_usage_provider_key_names; @@ -710,11 +711,16 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response( &Default::default(), ))); }; + let user_ids = match resolve_usage_user_group_scope(state, query, false, false).await? { + Ok(value) => value, + Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))), + }; let summary = state .summarize_usage_audits(&UsageAuditSummaryQuery { created_from_unix_secs, created_until_unix_secs, user_id: query_param_value(query, "user_id"), + user_ids, provider_name: query_param_value(query, "provider"), model: query_param_value(query, "model"), }) diff --git a/apps/aether-gateway/src/handlers/admin/request/users.rs b/apps/aether-gateway/src/handlers/admin/request/users.rs index 39d8554a3..d0b163be6 100644 --- a/apps/aether-gateway/src/handlers/admin/request/users.rs +++ b/apps/aether-gateway/src/handlers/admin/request/users.rs @@ -126,6 +126,30 @@ impl<'a> AdminAppState<'a> { self.app.list_user_group_members(group_id).await } + pub(crate) async fn resolve_usage_user_group_member_ids( + &self, + group_id: &str, + include_inactive: bool, + exclude_admin: bool, + ) -> Result>, GatewayError> { + if self.find_user_group_by_id(group_id).await?.is_none() { + return Ok(None); + } + + let mut user_ids = self + .list_user_group_members(group_id) + .await? + .into_iter() + .filter(|member| !member.is_deleted) + .filter(|member| include_inactive || member.is_active) + .filter(|member| !exclude_admin || !member.role.eq_ignore_ascii_case("admin")) + .map(|member| member.user_id) + .collect::>(); + user_ids.sort(); + user_ids.dedup(); + Ok(Some(user_ids)) + } + pub(crate) async fn replace_user_group_members( &self, group_id: &str, diff --git a/apps/aether-gateway/src/tests/control/admin/stats.rs b/apps/aether-gateway/src/tests/control/admin/stats.rs index 54d82d5e3..32fdb1e99 100644 --- a/apps/aether-gateway/src/tests/control/admin/stats.rs +++ b/apps/aether-gateway/src/tests/control/admin/stats.rs @@ -9,7 +9,8 @@ use aether_data::repository::auth::{ use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository; use aether_data::repository::usage::InMemoryUsageReadRepository; use aether_data::repository::users::{ - InMemoryUserReadRepository, StoredUserAuthRecord, StoredUserSummary, + InMemoryUserReadRepository, StoredUserAuthRecord, StoredUserSummary, UpsertUserGroupRecord, + UserReadRepository, }; use aether_data_contracts::repository::usage::StoredRequestUsageAudit; use async_trait::async_trait; @@ -1937,6 +1938,135 @@ async fn gateway_handles_admin_stats_leaderboard_users_without_legacy_username_f upstream_handle.abort(); } +#[tokio::test] +async fn gateway_aggregates_admin_stats_by_current_user_group_membership() { + let (_upstream_url, upstream_hits, upstream_handle) = + start_stats_upstream("/api/admin/stats/leaderboard/user-groups").await; + let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![ + sample_usage_row( + "usage-group-a", + "req-group-a", + Some("user-1"), + Some("key-1"), + Some("key-1"), + "OpenAI", + "gpt-5", + 60, + 20, + 0.4, + 0.4, + DAY_1_UNIX_SECS, + ), + sample_usage_row( + "usage-group-b", + "req-group-b", + Some("user-2"), + Some("key-2"), + Some("key-2"), + "OpenAI", + "gpt-5", + 40, + 10, + 0.35, + 0.35, + DAY_1_UNIX_SECS + 10, + ), + sample_usage_row( + "usage-group-outside", + "req-group-outside", + Some("user-3"), + Some("key-3"), + Some("key-3"), + "OpenAI", + "gpt-5", + 100, + 50, + 1.5, + 1.5, + DAY_1_UNIX_SECS + 20, + ), + ])); + let user_repository = InMemoryUserReadRepository::seed_auth_users([ + sample_auth_user("user-1", "alice", "user", true), + sample_auth_user("user-2", "bob", "user", true), + sample_auth_user("user-3", "carol", "user", true), + ]); + let group = user_repository + .create_user_group(UpsertUserGroupRecord { + name: "Engineering".to_string(), + description: None, + priority: 0, + allowed_providers: None, + allowed_providers_mode: "inherit".to_string(), + allowed_api_formats: None, + allowed_api_formats_mode: "inherit".to_string(), + allowed_models: None, + allowed_models_mode: "inherit".to_string(), + rate_limit: None, + rate_limit_mode: "inherit".to_string(), + }) + .await + .expect("group creation should succeed") + .expect("group should be created"); + user_repository + .replace_user_group_members(&group.id, &["user-1".to_string(), "user-2".to_string()]) + .await + .expect("group members should be replaced"); + let data_state = GatewayDataState::with_usage_reader_for_tests(usage_repository) + .with_user_reader(Arc::new(user_repository)); + let gateway = build_router_with_state( + AppState::new() + .expect("gateway should build") + .with_data_state_for_tests(data_state), + ); + let (gateway_url, gateway_handle) = start_server(gateway).await; + let client = reqwest::Client::new(); + + let response = admin_request(client.get(format!( + "{gateway_url}/api/admin/stats/leaderboard/user-groups?start_date=2024-03-21&end_date=2024-03-21&metric=cost&tz_offset_minutes=0" + ))) + .send() + .await + .expect("group leaderboard request should succeed"); + assert_eq!(response.status(), StatusCode::OK); + let payload: serde_json::Value = response.json().await.expect("json body should parse"); + assert_eq!(payload["attribution"], "current_membership"); + assert_eq!(payload["total"], 1); + assert_eq!(payload["items"][0]["id"], group.id); + assert_eq!(payload["items"][0]["name"], "Engineering"); + assert_eq!(payload["items"][0]["requests"], 2); + assert_eq!(payload["items"][0]["cost"], 0.75); + assert_eq!(payload["items"][0]["member_count"], 2); + assert_eq!(payload["items"][0]["active_member_count"], 2); + + let response = admin_request(client.get(format!( + "{gateway_url}/api/admin/stats/leaderboard/users?start_date=2024-03-21&end_date=2024-03-21&metric=cost&tz_offset_minutes=0&user_group_id={}", + group.id + ))) + .send() + .await + .expect("group member leaderboard request should succeed"); + assert_eq!(response.status(), StatusCode::OK); + let payload: serde_json::Value = response.json().await.expect("json body should parse"); + assert_eq!(payload["total"], 2); + assert!(payload["items"] + .as_array() + .is_some_and(|items| { items.iter().all(|item| item["id"] != "user-3") })); + + let response = admin_request(client.get(format!( + "{gateway_url}/api/admin/stats/leaderboard/users?start_date=2024-03-21&end_date=2024-03-21&user_id=user-1&user_group_id={}", + group.id + ))) + .send() + .await + .expect("conflicting scope request should complete"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); + + gateway_handle.abort(); + upstream_handle.abort(); +} + #[tokio::test] async fn gateway_handles_admin_stats_leaderboard_users_locally_without_usage_reader() { let (upstream_url, upstream_hits, upstream_handle) = diff --git a/crates/aether-admin/src/observability/stats.rs b/crates/aether-admin/src/observability/stats.rs index a041b6524..cc2ff7630 100644 --- a/crates/aether-admin/src/observability/stats.rs +++ b/crates/aether-admin/src/observability/stats.rs @@ -841,6 +841,53 @@ pub fn build_admin_stats_leaderboard_response( .into_response() } +pub fn build_admin_stats_user_group_leaderboard_response( + metric: AdminStatsLeaderboardMetric, + time_range: Option<&AdminStatsTimeRange>, + leaderboard: &[AdminStatsLeaderboardItem], + member_counts: &std::collections::BTreeMap, + active_member_counts: &std::collections::BTreeMap, + offset: usize, + limit: usize, +) -> Response { + let total = leaderboard.len(); + let items: Vec<_> = leaderboard + .iter() + .enumerate() + .skip(offset) + .take(limit) + .map(|(index, item)| { + let rank = compute_dense_rank(metric, leaderboard, index); + let value = match metric { + AdminStatsLeaderboardMetric::Requests => json!(item.requests), + AdminStatsLeaderboardMetric::Tokens => json!(item.tokens), + AdminStatsLeaderboardMetric::Cost => json!(round_to(item.cost, 6)), + }; + json!({ + "rank": rank, + "id": item.id, + "name": item.name, + "value": value, + "requests": item.requests, + "tokens": item.tokens, + "cost": round_to(item.cost, 6), + "member_count": member_counts.get(&item.id).copied().unwrap_or(0), + "active_member_count": active_member_counts.get(&item.id).copied().unwrap_or(0), + }) + }) + .collect(); + + Json(json!({ + "items": items, + "total": total, + "metric": metric.as_str(), + "start_date": time_range.map(|value| value.start_date.to_string()), + "end_date": time_range.map(|value| value.end_date.to_string()), + "attribution": "current_membership", + })) + .into_response() +} + pub fn build_admin_stats_comparison_response( current_usage: &[StoredRequestUsageAudit], comparison_usage: &[StoredRequestUsageAudit], diff --git a/crates/aether-data/adapters/postgres/src/usage/mod.rs b/crates/aether-data/adapters/postgres/src/usage/mod.rs index 23f7615d3..e9f81ea60 100644 --- a/crates/aether-data/adapters/postgres/src/usage/mod.rs +++ b/crates/aether-data/adapters/postgres/src/usage/mod.rs @@ -1857,6 +1857,33 @@ fn usage_leaderboard_sql_fragments( } } +fn push_usage_user_scope( + builder: &mut QueryBuilder<'_, Postgres>, + column: &str, + user_id: Option<&str>, + user_ids: Option<&[String]>, +) { + if let Some(user_id) = user_id { + builder + .push(" AND ") + .push(column) + .push(" = ") + .push_bind(user_id.to_string()); + } + if let Some(user_ids) = user_ids { + if user_ids.is_empty() { + builder.push(" AND FALSE"); + } else { + builder.push(" AND ").push(column).push(" IN ("); + let mut separated = builder.separated(", "); + for user_id in user_ids { + separated.push_bind(user_id.clone()); + } + separated.push_unseparated(")"); + } + } +} + const LIST_RECENT_USAGE_AUDITS_PREFIX: &str = include_str!("queries/list_recent_usage_audits_prefix.sql"); @@ -3625,14 +3652,15 @@ OR (\"usage\".error_message IS NOT NULL AND BTRIM(\"usage\".error_message) <> '' start_day_utc: DateTime, end_day_utc: DateTime, user_id: Option<&str>, + user_ids: Option<&[String]>, ) -> Result { if start_day_utc >= end_day_utc { return Ok(StoredUsageAuditSummary::default()); } - let row = if let Some(user_id) = user_id { - sqlx::query( - r#" + let scoped_to_users = user_id.is_some() || user_ids.is_some(); + let mut builder = QueryBuilder::::new( + r#" SELECT COALESCE(SUM(total_requests), 0)::BIGINT AS total_requests, COALESCE(SUM(input_tokens), 0)::BIGINT AS input_tokens, @@ -3657,57 +3685,27 @@ SELECT COALESCE(SUM(cache_read_cost), 0)::DOUBLE PRECISION AS cache_read_cost_usd, COALESCE(SUM(response_time_sum_ms), 0)::DOUBLE PRECISION AS total_response_time_ms, COALESCE(SUM(error_requests), 0)::BIGINT AS error_requests -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(&self.pool) - .await - .map_postgres_err()? +FROM "#, + ); + builder.push(if scoped_to_users { + "stats_user_daily" } 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(output_tokens), 0)::BIGINT AS output_tokens, - COALESCE(SUM( - CASE - WHEN effective_input_tokens = 0 AND total_input_context = 0 AND input_tokens > 0 - THEN input_tokens - ELSE effective_input_tokens - END - + output_tokens + cache_creation_tokens + cache_read_tokens - ), 0)::BIGINT AS recorded_total_tokens, - COALESCE(SUM(cache_creation_tokens), 0)::BIGINT AS cache_creation_tokens, - COALESCE(SUM(cache_creation_ephemeral_5m_tokens), 0)::BIGINT - AS cache_creation_ephemeral_5m_tokens, - COALESCE(SUM(cache_creation_ephemeral_1h_tokens), 0)::BIGINT - AS cache_creation_ephemeral_1h_tokens, - COALESCE(SUM(cache_read_tokens), 0)::BIGINT AS cache_read_tokens, - COALESCE(SUM(total_cost), 0)::DOUBLE PRECISION AS total_cost_usd, - COALESCE(SUM(actual_total_cost), 0)::DOUBLE PRECISION AS actual_total_cost_usd, - COALESCE(SUM(cache_creation_cost), 0)::DOUBLE PRECISION AS cache_creation_cost_usd, - COALESCE(SUM(cache_read_cost), 0)::DOUBLE PRECISION AS cache_read_cost_usd, - COALESCE(SUM(response_time_sum_ms), 0)::DOUBLE PRECISION AS total_response_time_ms, - COALESCE(SUM(error_requests), 0)::BIGINT AS error_requests -FROM stats_daily -WHERE date >= $1 - AND date < $2 -"#, - ) - .bind(start_day_utc) - .bind(end_day_utc) + "stats_daily" + }); + builder + .push(" WHERE date >= ") + .push_bind(start_day_utc) + .push(" AND date < ") + .push_bind(end_day_utc); + if scoped_to_users { + push_usage_user_scope(&mut builder, "user_id", user_id, user_ids); + } + + let row = builder + .build() .fetch_one(&self.pool) .await - .map_postgres_err()? - }; - + .map_postgres_err()?; decode_usage_audit_summary_row(&row) } @@ -3716,6 +3714,7 @@ WHERE date >= $1 created_from_unix_secs: u64, created_until_unix_secs: u64, user_id: Option<&str>, + user_ids: Option<&[String]>, provider_name: Option<&str>, model: Option<&str>, ) -> Result { @@ -3770,12 +3769,7 @@ FROM usage_billing_facts AS "usage" .push("\"usage\".created_at < TO_TIMESTAMP(") .push_bind(created_until_unix_secs as f64) .push("::double precision)"); - if let Some(user_id) = user_id { - builder.push(if has_where { " AND " } else { " WHERE " }); - builder - .push("\"usage\".user_id = ") - .push_bind(user_id.to_string()); - } + push_usage_user_scope(&mut builder, "\"usage\".user_id", user_id, user_ids); if let Some(provider_name) = provider_name { builder.push(if has_where { " AND " } else { " WHERE " }); has_where = true; @@ -3808,6 +3802,7 @@ FROM usage_billing_facts AS "usage" query.created_from_unix_secs, query.created_until_unix_secs, query.user_id.as_deref(), + query.user_ids.as_deref(), query.provider_name.as_deref(), query.model.as_deref(), ) @@ -3819,6 +3814,7 @@ FROM usage_billing_facts AS "usage" query.created_from_unix_secs, query.created_until_unix_secs, query.user_id.as_deref(), + query.user_ids.as_deref(), None, None, ) @@ -3834,6 +3830,7 @@ FROM usage_billing_facts AS "usage" query.created_from_unix_secs, query.created_until_unix_secs, query.user_id.as_deref(), + query.user_ids.as_deref(), None, None, ) @@ -3848,6 +3845,7 @@ FROM usage_billing_facts AS "usage" dashboard_utc_to_unix_secs(raw_start), dashboard_utc_to_unix_secs(raw_end), query.user_id.as_deref(), + query.user_ids.as_deref(), None, None, ) @@ -3861,6 +3859,7 @@ FROM usage_billing_facts AS "usage" aggregate_start, aggregate_end, query.user_id.as_deref(), + query.user_ids.as_deref(), ) .await?, ); @@ -3872,6 +3871,7 @@ FROM usage_billing_facts AS "usage" dashboard_utc_to_unix_secs(raw_start), dashboard_utc_to_unix_secs(raw_end), query.user_id.as_deref(), + query.user_ids.as_deref(), None, None, ) @@ -6815,13 +6815,12 @@ FROM usage_billing_facts AS "usage" .push("\"usage\".created_at < TO_TIMESTAMP(") .push_bind(query.created_until_unix_secs as f64) .push("::double precision)"); - if let Some(user_id) = query.user_id.as_deref() { - builder.push(if has_where { " AND " } else { " WHERE " }); - has_where = true; - builder - .push("\"usage\".user_id = ") - .push_bind(user_id.to_string()); - } + push_usage_user_scope( + &mut builder, + "\"usage\".user_id", + query.user_id.as_deref(), + query.user_ids.as_deref(), + ); if let Some(provider_name) = query.provider_name.as_deref() { builder.push(if has_where { " AND " } else { " WHERE " }); has_where = true; @@ -6861,62 +6860,45 @@ FROM usage_billing_facts AS "usage" start_day_utc: DateTime, end_day_utc: DateTime, user_id: Option<&str>, + user_ids: Option<&[String]>, ) -> Result, DataLayerError> { if start_day_utc >= end_day_utc { return Ok(Vec::new()); } - let rows = if let Some(user_id) = user_id { - sqlx::query( - r#" + let scoped_to_users = user_id.is_some() || user_ids.is_some(); + let mut builder = QueryBuilder::::new( + r#" SELECT TO_CHAR(date, 'YYYY-MM-DD') AS bucket_key, - total_requests::BIGINT AS total_requests, - input_tokens::BIGINT AS input_tokens, - output_tokens::BIGINT AS output_tokens, - cache_creation_tokens::BIGINT AS cache_creation_tokens, - cache_read_tokens::BIGINT AS cache_read_tokens, - CAST(total_cost AS DOUBLE PRECISION) AS total_cost_usd, - CAST(response_time_sum_ms AS DOUBLE PRECISION) AS total_response_time_ms -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_all(&self.pool) - .await - .map_postgres_err()? + COALESCE(SUM(total_requests), 0)::BIGINT AS total_requests, + COALESCE(SUM(input_tokens), 0)::BIGINT AS input_tokens, + COALESCE(SUM(output_tokens), 0)::BIGINT AS output_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(CAST(total_cost AS DOUBLE PRECISION)), 0) AS total_cost_usd, + COALESCE(SUM(CAST(response_time_sum_ms AS DOUBLE PRECISION)), 0) + AS total_response_time_ms +FROM "#, + ); + builder.push(if scoped_to_users { + "stats_user_daily" } else { - sqlx::query( - r#" -SELECT - TO_CHAR(date, 'YYYY-MM-DD') AS bucket_key, - total_requests::BIGINT AS total_requests, - input_tokens::BIGINT AS input_tokens, - output_tokens::BIGINT AS output_tokens, - cache_creation_tokens::BIGINT AS cache_creation_tokens, - cache_read_tokens::BIGINT AS cache_read_tokens, - CAST(total_cost AS DOUBLE PRECISION) AS total_cost_usd, - CAST(response_time_sum_ms AS DOUBLE PRECISION) AS total_response_time_ms -FROM stats_daily -WHERE date >= $1 - AND date < $2 -ORDER BY date ASC -"#, - ) - .bind(start_day_utc) - .bind(end_day_utc) - .fetch_all(&self.pool) - .await - .map_postgres_err()? - }; + "stats_daily" + }); + builder + .push(" WHERE date >= ") + .push_bind(start_day_utc) + .push(" AND date < ") + .push_bind(end_day_utc); + if scoped_to_users { + push_usage_user_scope(&mut builder, "user_id", user_id, user_ids); + } + builder.push(" GROUP BY date ORDER BY date ASC"); + + let mut rows = builder.build().fetch(&self.pool); let mut items = Vec::new(); - for row in rows { + while let Some(row) = rows.try_next().await.map_postgres_err()? { items.push(decode_usage_time_series_bucket_row(&row)?); } Ok(items) @@ -7002,6 +6984,7 @@ WHERE is_complete IS TRUE granularity: UsageTimeSeriesGranularity::Day, tz_offset_minutes: 0, user_id: query.user_id.clone(), + user_ids: query.user_ids.clone(), provider_name: None, model: None, }, @@ -7016,6 +6999,7 @@ WHERE is_complete IS TRUE aggregate_start, aggregate_end, query.user_id.as_deref(), + query.user_ids.as_deref(), ) .await?, ); @@ -7028,6 +7012,7 @@ WHERE is_complete IS TRUE granularity: UsageTimeSeriesGranularity::Day, tz_offset_minutes: 0, user_id: query.user_id.clone(), + user_ids: query.user_ids.clone(), provider_name: None, model: None, }, @@ -7045,6 +7030,7 @@ WHERE is_complete IS TRUE granularity: UsageTimeSeriesGranularity::Day, tz_offset_minutes: 0, user_id: query.user_id.clone(), + user_ids: query.user_ids.clone(), provider_name: None, model: None, }, @@ -7058,7 +7044,8 @@ WHERE is_complete IS TRUE } } - if query.user_id.is_none() && query.tz_offset_minutes % 60 == 0 { + if query.user_id.is_none() && query.user_ids.is_none() && query.tz_offset_minutes % 60 == 0 + { if let Some(cutoff_utc) = self.read_stats_hourly_cutoff().await? { let start_utc = dashboard_unix_secs_to_utc(query.created_from_unix_secs); let end_utc = dashboard_unix_secs_to_utc(query.created_until_unix_secs); @@ -7075,6 +7062,7 @@ WHERE is_complete IS TRUE granularity: query.granularity, tz_offset_minutes: query.tz_offset_minutes, user_id: None, + user_ids: None, provider_name: None, model: None, }, @@ -7102,6 +7090,7 @@ WHERE is_complete IS TRUE granularity: query.granularity, tz_offset_minutes: query.tz_offset_minutes, user_id: None, + user_ids: None, provider_name: None, model: None, }, @@ -7119,6 +7108,7 @@ WHERE is_complete IS TRUE granularity: query.granularity, tz_offset_minutes: query.tz_offset_minutes, user_id: None, + user_ids: None, provider_name: None, model: None, }, @@ -7158,6 +7148,7 @@ WHERE "usage".created_at >= TO_TIMESTAMP($1::double precision) AND ($3::varchar IS NULL OR "usage".user_id = $3) AND ($4::varchar IS NULL OR "usage".provider_name = $4) AND ($5::varchar IS NULL OR "usage".model = $5) + AND ($6::text[] IS NULL OR "usage".user_id::text = ANY($6)) GROUP BY group_key ORDER BY group_key ASC "#, @@ -7171,6 +7162,7 @@ ORDER BY group_key ASC .bind(query.user_id.as_deref()) .bind(query.provider_name.as_deref()) .bind(query.model.as_deref()) + .bind(query.user_ids.clone()) .fetch(&self.pool); let mut items = Vec::new(); while let Some(row) = rows.try_next().await.map_postgres_err()? { @@ -7314,11 +7306,12 @@ WHERE date >= .push_bind(end_day_utc) .push(" AND provider_name = ") .push_bind(provider_name.to_string()); - if let Some(user_id) = query.user_id.as_deref() { - builder - .push(" AND user_id = ") - .push_bind(user_id.to_string()); - } + push_usage_user_scope( + &mut builder, + "user_id", + query.user_id.as_deref(), + query.user_ids.as_deref(), + ); builder.push(" GROUP BY user_id ORDER BY user_id ASC"); builder } else if let Some(model) = query.model.as_deref() { @@ -7340,11 +7333,12 @@ WHERE date >= .push_bind(end_day_utc) .push(" AND model = ") .push_bind(model.to_string()); - if let Some(user_id) = query.user_id.as_deref() { - builder - .push(" AND user_id = ") - .push_bind(user_id.to_string()); - } + push_usage_user_scope( + &mut builder, + "user_id", + query.user_id.as_deref(), + query.user_ids.as_deref(), + ); builder.push(" GROUP BY user_id ORDER BY user_id ASC"); builder } else { @@ -7368,11 +7362,12 @@ WHERE date >= .push(" AND date < ") .push_bind(end_day_utc) .push(" AND user_id IS NOT NULL"); - if let Some(user_id) = query.user_id.as_deref() { - builder - .push(" AND user_id = ") - .push_bind(user_id.to_string()); - } + push_usage_user_scope( + &mut builder, + "user_id", + query.user_id.as_deref(), + query.user_ids.as_deref(), + ); builder.push(" GROUP BY user_id ORDER BY user_id ASC"); builder }; diff --git a/crates/aether-data/adapters/postgres/src/usage/tests.rs b/crates/aether-data/adapters/postgres/src/usage/tests.rs index a1cef6095..ee140363b 100644 --- a/crates/aether-data/adapters/postgres/src/usage/tests.rs +++ b/crates/aether-data/adapters/postgres/src/usage/tests.rs @@ -3251,13 +3251,15 @@ fn usage_sql_canonical_openai_cache_case_preserves_effective_and_total_tokens() aggregate_audit_summary .matches("WHEN effective_input_tokens = 0 AND total_input_context = 0") .count(), - 2 + 1, + "the shared daily aggregate query should define the legacy token fallback once" ); assert_eq!( aggregate_audit_summary .matches("+ output_tokens + cache_creation_tokens + cache_read_tokens") .count(), - 2 + 1, + "the shared daily aggregate query should define canonical total tokens once" ); assert!(!aggregate_audit_summary.contains("SUM(input_tokens + output_tokens)")); diff --git a/crates/aether-data/contracts/src/repository/usage/types.rs b/crates/aether-data/contracts/src/repository/usage/types.rs index a2b88639a..c266625e8 100644 --- a/crates/aether-data/contracts/src/repository/usage/types.rs +++ b/crates/aether-data/contracts/src/repository/usage/types.rs @@ -1063,6 +1063,9 @@ pub struct UsageAuditSummaryQuery { pub created_from_unix_secs: u64, pub created_until_unix_secs: u64, pub user_id: Option, + /// Optional bulk user scope used by current user-group reporting. + /// An empty list intentionally matches no usage rows. + pub user_ids: Option>, pub provider_name: Option, pub model: Option, } @@ -1455,6 +1458,9 @@ pub struct UsageTimeSeriesQuery { pub granularity: UsageTimeSeriesGranularity, pub tz_offset_minutes: i32, pub user_id: Option, + /// Optional bulk user scope used by current user-group reporting. + /// An empty list intentionally matches no usage rows. + pub user_ids: Option>, pub provider_name: Option, pub model: Option, } @@ -1485,6 +1491,9 @@ pub struct UsageLeaderboardQuery { pub created_until_unix_secs: u64, pub group_by: UsageLeaderboardGroupBy, pub user_id: Option, + /// Optional bulk user scope used by current user-group reporting. + /// An empty list intentionally matches no usage rows. + pub user_ids: Option>, pub provider_name: Option, pub model: Option, } diff --git a/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs b/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs index d1549c3d5..02fa33e24 100644 --- a/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs +++ b/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs @@ -2388,6 +2388,7 @@ INSERT INTO public.stats_daily_api_key ( .expect("historical stats end should be nonnegative"), group_by: UsageLeaderboardGroupBy::ApiKey, user_id: Some("leaderboard-owner".to_string()), + user_ids: None, provider_name: None, model: None, }; diff --git a/crates/aether-data/runtime/src/repository/usage/memory.rs b/crates/aether-data/runtime/src/repository/usage/memory.rs index af0fa9b63..da40b1b72 100644 --- a/crates/aether-data/runtime/src/repository/usage/memory.rs +++ b/crates/aether-data/runtime/src/repository/usage/memory.rs @@ -493,6 +493,15 @@ fn usage_matches_summary_query( return false; } } + if let Some(user_ids) = query.user_ids.as_deref() { + if !item + .user_id + .as_ref() + .is_some_and(|user_id| user_ids.contains(user_id)) + { + return false; + } + } if let Some(provider_name) = query.provider_name.as_deref() { if item.provider_name != provider_name { return false; @@ -520,6 +529,15 @@ fn usage_matches_time_series_query( return false; } } + if let Some(user_ids) = query.user_ids.as_deref() { + if !item + .user_id + .as_ref() + .is_some_and(|user_id| user_ids.contains(user_id)) + { + return false; + } + } if let Some(provider_name) = query.provider_name.as_deref() { if item.provider_name != provider_name { return false; @@ -891,6 +909,15 @@ fn usage_matches_leaderboard_query( return false; } } + if let Some(user_ids) = query.user_ids.as_deref() { + if !item + .user_id + .as_ref() + .is_some_and(|user_id| user_ids.contains(user_id)) + { + return false; + } + } if let Some(provider_name) = query.provider_name.as_deref() { if item.provider_name != provider_name { return false; diff --git a/crates/aether-data/runtime/src/repository/usage/memory/tests.rs b/crates/aether-data/runtime/src/repository/usage/memory/tests.rs index 17bd0a7e9..d15e6951b 100644 --- a/crates/aether-data/runtime/src/repository/usage/memory/tests.rs +++ b/crates/aether-data/runtime/src/repository/usage/memory/tests.rs @@ -18,7 +18,7 @@ use aether_data_contracts::repository::usage::{ UsageAuditAggregationQuery, UsageAuditKeywordSearchQuery, UsageAuditListQuery, UsageAuditSummaryQuery, UsageBodyCaptureState, UsageBodyField, UsageDashboardSummaryQuery, UsageLeaderboardGroupBy, UsageLeaderboardQuery, UsageProviderPerformanceQuery, - UsageTimeSeriesGranularity, + UsageTimeSeriesGranularity, UsageTimeSeriesQuery, }; use serde_json::json; @@ -2217,6 +2217,7 @@ async fn dashboard_and_leaderboard_total_tokens_use_effective_cache_aware_tokens created_until_unix_secs: 1_711_000_001, group_by: UsageLeaderboardGroupBy::User, user_id: None, + user_ids: None, provider_name: None, model: None, }) @@ -2226,6 +2227,64 @@ async fn dashboard_and_leaderboard_total_tokens_use_effective_cache_aware_tokens assert_eq!(leaderboard[0].total_tokens, 120); } +#[tokio::test] +async fn usage_analytics_filters_by_multiple_user_ids() { + let user_one = sample_usage("req-user-1", 1_711_000_000); + let mut user_two = sample_usage("req-user-2", 1_711_000_000); + user_two.user_id = Some("user-2".to_string()); + let mut user_three = sample_usage("req-user-3", 1_711_000_000); + user_three.user_id = Some("user-3".to_string()); + let repository = InMemoryUsageReadRepository::seed(vec![user_one, user_two, user_three]); + let scoped_user_ids = vec!["user-1".to_string(), "user-2".to_string()]; + + let summary = repository + .summarize_usage_audits(&UsageAuditSummaryQuery { + created_from_unix_secs: 1_711_000_000, + created_until_unix_secs: 1_711_000_001, + user_ids: Some(scoped_user_ids.clone()), + ..Default::default() + }) + .await + .expect("summary should filter by multiple users"); + assert_eq!(summary.total_requests, 2); + + let buckets = repository + .summarize_usage_time_series(&UsageTimeSeriesQuery { + created_from_unix_secs: 1_711_000_000, + created_until_unix_secs: 1_711_000_001, + granularity: UsageTimeSeriesGranularity::Day, + tz_offset_minutes: 0, + user_id: None, + user_ids: Some(scoped_user_ids.clone()), + provider_name: None, + model: None, + }) + .await + .expect("time series should filter by multiple users"); + assert_eq!( + buckets + .iter() + .map(|bucket| bucket.total_requests) + .sum::(), + 2 + ); + + let leaderboard = repository + .summarize_usage_leaderboard(&UsageLeaderboardQuery { + created_from_unix_secs: 1_711_000_000, + created_until_unix_secs: 1_711_000_001, + group_by: UsageLeaderboardGroupBy::User, + user_id: None, + user_ids: Some(scoped_user_ids), + provider_name: None, + model: None, + }) + .await + .expect("leaderboard should filter by multiple users"); + assert_eq!(leaderboard.len(), 2); + assert!(leaderboard.iter().all(|item| item.group_key != "user-3")); +} + #[tokio::test] async fn summarizes_provider_api_key_last_used_at_in_seconds() { let repository = InMemoryUsageReadRepository::seed(vec![ diff --git a/frontend/src/api/__tests__/admin-analytics-cache.spec.ts b/frontend/src/api/__tests__/admin-analytics-cache.spec.ts index 776812243..a4a14c82b 100644 --- a/frontend/src/api/__tests__/admin-analytics-cache.spec.ts +++ b/frontend/src/api/__tests__/admin-analytics-cache.spec.ts @@ -82,4 +82,35 @@ describe('adminApi analytics cache options', () => { }) expect(getMock).toHaveBeenNthCalledWith(4, '/api/admin/stats/errors/distribution', { params }) }) + + it('requests the user group leaderboard with scoped cache parameters', async () => { + const groupParams = { + ...params, + metric: 'cost' as const, + offset: 10, + limit: 10, + include_inactive: true, + } + getMock.mockResolvedValueOnce({ + data: { items: [], total: 0, metric: 'cost', attribution: 'current_membership' }, + }) + + await expect(adminApi.getLeaderboardUserGroups(groupParams)).resolves.toMatchObject({ + attribution: 'current_membership', + }) + + expect(buildCacheKeyMock).toHaveBeenCalledWith( + 'admin:stats:leaderboard:user-groups', + groupParams + ) + expect(cachedRequestMock).toHaveBeenCalledWith( + 'admin:stats:leaderboard:user-groups', + expect.any(Function), + 20 * 1000 + ) + expect(getMock).toHaveBeenCalledWith('/api/admin/stats/leaderboard/user-groups', { + params: groupParams, + }) + }) + }) diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 86e8dc382..1010f67c0 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -709,6 +709,8 @@ export interface LeaderboardItem { requests: number tokens: number cost: number + member_count?: number + active_member_count?: number } export interface LeaderboardResponse { @@ -717,6 +719,7 @@ export interface LeaderboardResponse { metric: string start_date?: string | null end_date?: string | null + attribution?: 'current_membership' } export interface CostForecastResponse { @@ -1298,6 +1301,7 @@ export const adminApi = { model?: string include_inactive?: boolean exclude_admin?: boolean + user_group_id?: string }): Promise { const cacheKey = buildCacheKey('admin:stats:leaderboard:users', params) return cachedRequest( @@ -1312,6 +1316,35 @@ export const adminApi = { ) }, + async getLeaderboardUserGroups(params?: { + start_date?: string + end_date?: string + preset?: string + timezone?: string + tz_offset_minutes?: number + metric?: 'requests' | 'tokens' | 'cost' + order?: 'asc' | 'desc' + limit?: number + offset?: number + provider_name?: string + model?: string + include_inactive?: boolean + exclude_admin?: boolean + }): Promise { + const cacheKey = buildCacheKey('admin:stats:leaderboard:user-groups', params) + return cachedRequest( + cacheKey, + async () => { + const response = await apiClient.get( + '/api/admin/stats/leaderboard/user-groups', + { params } + ) + return response.data + }, + 20 * 1000 + ) + }, + async getLeaderboardApiKeys(params?: { start_date?: string end_date?: string @@ -1595,6 +1628,7 @@ export const adminApi = { timezone?: string tz_offset_minutes?: number user_id?: string + user_group_id?: string model?: string provider_name?: string }, diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index 12c76cd9f..88f67a499 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -134,6 +134,7 @@ export interface UsageByApiFormat { export interface UsageFilters { user_id?: string // UUID + user_group_id?: string // UUID provider_id?: string // UUID model?: string search?: string diff --git a/frontend/src/components/stats/LeaderboardTable.vue b/frontend/src/components/stats/LeaderboardTable.vue index f7e90208a..d9838acf8 100644 --- a/frontend/src/components/stats/LeaderboardTable.vue +++ b/frontend/src/components/stats/LeaderboardTable.vue @@ -8,17 +8,17 @@ @update:model-value="emitMetric" > - + - 请求数 + {{ t('stats.metric.requests') }} - Tokens + {{ t('stats.metric.tokens') }} - 成本 + {{ t('stats.metric.cost') }} @@ -36,25 +36,31 @@ class="p-6" > - 排名 + {{ t('stats.column.rank') }} - 名称 - - 请求数 + {{ t('stats.column.name') }} + + {{ t('stats.column.members') }} - Tokens + {{ t('stats.metric.requests') }} - 成本 + {{ t('stats.metric.tokens') }} + + + {{ t('stats.metric.cost') }} @@ -62,11 +68,22 @@ {{ item.rank }} {{ item.name }} + + {{ item.active_member_count ?? 0 }} / {{ item.member_count ?? 0 }} + {{ item.requests }} @@ -102,6 +119,7 @@ import { TableRow } from '@/components/ui' import { formatCurrency, formatTokens } from '@/utils/format' +import { useI18n } from '@/i18n' import type { LeaderboardItem } from '@/api/admin' interface Props { @@ -110,17 +128,24 @@ interface Props { metric: 'requests' | 'tokens' | 'cost' loading?: boolean showMetricSelect?: boolean + showMemberCount?: boolean + selectable?: boolean } const props = withDefaults(defineProps(), { loading: false, - showMetricSelect: true + showMetricSelect: true, + showMemberCount: false, + selectable: false }) const emit = defineEmits<{ (e: 'update:metric', value: 'requests' | 'tokens' | 'cost'): void + (e: 'select', value: LeaderboardItem): void }>() +const { t } = useI18n() + const metric = computed(() => props.metric) function emitMetric(value: string) { diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index 2992c30b2..9aa571175 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -346,6 +346,44 @@ export const messages = { 'nav.usageStats': '使用统计', 'nav.operations': '运维总览', 'nav.userStats': '用户统计', + 'userStats.title': '用户统计', + 'userStats.description': '按用户或当前用户组成员查看排行榜与使用趋势', + 'userStats.scope.placeholder': '统计维度', + 'userStats.scope.user': '按用户', + 'userStats.scope.userGroup': '按用户组', + 'userStats.search.user': '搜索用户', + 'userStats.search.userGroup': '搜索用户组', + 'userStats.select.user': '选择用户', + 'userStats.select.userGroup': '选择用户组', + 'userStats.compare.placeholder': '对比对象(可选)', + 'userStats.compare.none': '不对比', + 'userStats.leaderboard.user': '用户排行榜', + 'userStats.leaderboard.userGroup': '用户组排行榜', + 'userStats.pagination.summary': '共 {total} 项 · 第 {page} 页', + 'userStats.pagination.previous': '上一页', + 'userStats.pagination.next': '下一页', + 'userStats.summary.user': '用户摘要', + 'userStats.summary.userGroup': '用户组摘要', + 'userStats.selectPrompt': '请选择统计对象', + 'userStats.members.current': '当前成员', + 'userStats.members.active': '活跃成员', + 'userStats.memberLeaderboard': '组内成员排行', + 'userStats.trend.user': '用户使用趋势', + 'userStats.trend.userGroup': '用户组使用趋势', + 'userStats.comparisonTrend.user': '用户对比趋势', + 'userStats.comparisonTrend.userGroup': '用户组对比趋势', + 'userStats.chart.current': '当前对象', + 'userStats.chart.comparison': '对比对象', + 'stats.metric.placeholder': '指标', + 'stats.metric.requests': '请求数', + 'stats.metric.tokens': 'Tokens', + 'stats.metric.cost': '成本', + 'stats.metric.errorRate': '错误率', + 'stats.empty.title': '暂无数据', + 'stats.empty.description': '当前时间范围内没有统计结果', + 'stats.column.rank': '排名', + 'stats.column.name': '名称', + 'stats.column.members': '成员', 'nav.costAnalysis': '成本分析', 'nav.performanceAnalysis': '性能分析', 'nav.userManagement': '用户管理', @@ -792,6 +830,44 @@ export const messages = { 'nav.usageStats': 'Usage stats', 'nav.operations': 'Operations', 'nav.userStats': 'User stats', + 'userStats.title': 'User statistics', + 'userStats.description': 'View leaderboards and usage trends by user or current user-group membership', + 'userStats.scope.placeholder': 'Statistics scope', + 'userStats.scope.user': 'By user', + 'userStats.scope.userGroup': 'By user group', + 'userStats.search.user': 'Search users', + 'userStats.search.userGroup': 'Search user groups', + 'userStats.select.user': 'Select a user', + 'userStats.select.userGroup': 'Select a user group', + 'userStats.compare.placeholder': 'Comparison (optional)', + 'userStats.compare.none': 'No comparison', + 'userStats.leaderboard.user': 'User leaderboard', + 'userStats.leaderboard.userGroup': 'User-group leaderboard', + 'userStats.pagination.summary': '{total} items · Page {page}', + 'userStats.pagination.previous': 'Previous', + 'userStats.pagination.next': 'Next', + 'userStats.summary.user': 'User summary', + 'userStats.summary.userGroup': 'User-group summary', + 'userStats.selectPrompt': 'Select a statistics entity', + 'userStats.members.current': 'Current members', + 'userStats.members.active': 'Active members', + 'userStats.memberLeaderboard': 'Group member leaderboard', + 'userStats.trend.user': 'User usage trend', + 'userStats.trend.userGroup': 'User-group usage trend', + 'userStats.comparisonTrend.user': 'User comparison trend', + 'userStats.comparisonTrend.userGroup': 'User-group comparison trend', + 'userStats.chart.current': 'Current entity', + 'userStats.chart.comparison': 'Comparison entity', + 'stats.metric.placeholder': 'Metric', + 'stats.metric.requests': 'Requests', + 'stats.metric.tokens': 'Tokens', + 'stats.metric.cost': 'Cost', + 'stats.metric.errorRate': 'Error rate', + 'stats.empty.title': 'No data', + 'stats.empty.description': 'No statistics are available for the current time range', + 'stats.column.rank': 'Rank', + 'stats.column.name': 'Name', + 'stats.column.members': 'Members', 'nav.costAnalysis': 'Cost analysis', 'nav.performanceAnalysis': 'Performance analysis', 'nav.userManagement': 'Users', diff --git a/frontend/src/views/admin/UserStats.vue b/frontend/src/views/admin/UserStats.vue index 71f6e5768..f4e2316dc 100644 --- a/frontend/src/views/admin/UserStats.vue +++ b/frontend/src/views/admin/UserStats.vue @@ -1,70 +1,117 @@