diff --git a/backend/crates/gateway-admin/src/model/observability.rs b/backend/crates/gateway-admin/src/model/observability.rs index 3d6ee4e3b..1e0f48d61 100644 --- a/backend/crates/gateway-admin/src/model/observability.rs +++ b/backend/crates/gateway-admin/src/model/observability.rs @@ -172,9 +172,19 @@ pub struct UsageFilter { pub search: Option, } +/// 请求列表的结果筛选,不改变汇总和计量口径 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum UsageRecordStatus { + #[default] + Success, + Error, + All, +} + /// 用量记录分页查询 #[derive(Debug, Clone, PartialEq, Eq)] pub struct UsageQuery { + pub status: UsageRecordStatus, pub range: TimeRange, pub filter: UsageFilter, pub current_page: u32, @@ -653,6 +663,7 @@ pub struct DashboardObservation { /// 使用记录表格的窄读模型 #[derive(Debug, Clone, PartialEq)] pub struct UsageListRecord { + pub outcome: RequestOutcome, pub client_api_key_name: Option, pub id: String, pub endpoint: String, @@ -845,7 +856,7 @@ pub struct UsageAttempt { pub occurred_at: DateTime, } -/// 一条请求及其全部尝试 +/// 一条请求及其保留的尝试与诊断轨迹 #[derive(Debug, Clone, PartialEq)] pub struct UsageDetail { pub trace: Option, diff --git a/backend/crates/gateway-admin/src/use_case/key_usage.rs b/backend/crates/gateway-admin/src/use_case/key_usage.rs index 4b0dbf1d0..b99f6a12d 100644 --- a/backend/crates/gateway-admin/src/use_case/key_usage.rs +++ b/backend/crates/gateway-admin/src/use_case/key_usage.rs @@ -225,6 +225,7 @@ impl KeyUsageService for DefaultKeyUsageService { KeyUsageRecordKind::Success => KeyUsageRecords::Success( self.observations .list_usage_records(UsageQuery { + status: Default::default(), range: query.usage.range, filter: usage_filter(&id, query.usage.model), current_page: query.current_page, diff --git a/backend/crates/gateway-admin/tests/use_case/observability/mod.rs b/backend/crates/gateway-admin/tests/use_case/observability/mod.rs index 327ae943e..086203c8e 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability/mod.rs @@ -1202,6 +1202,7 @@ fn observation_range(end: DateTime) -> TimeRange { fn usage_query(now: DateTime) -> UsageQuery { UsageQuery { + status: Default::default(), range: observation_range(now), filter: UsageFilter::default(), current_page: 1, @@ -1216,6 +1217,7 @@ fn total_record( now: DateTime, ) -> UsageListRecord { UsageListRecord { + outcome: gateway_admin::model::observability::RequestOutcome::Succeeded, client_api_key_name: Some("Production".to_owned()), id: id.to_owned(), endpoint: "/v1/responses".to_owned(), diff --git a/backend/crates/gateway-api/src/admin/observability/presenter.rs b/backend/crates/gateway-api/src/admin/observability/presenter.rs index 0d0950752..11dcabef2 100644 --- a/backend/crates/gateway-api/src/admin/observability/presenter.rs +++ b/backend/crates/gateway-api/src/admin/observability/presenter.rs @@ -262,6 +262,7 @@ pub(crate) fn usage_list_record_view( .clone() .or_else(|| record.requested_model_id.clone()); UsageListRecordView { + logical_outcome: record.outcome.as_str().to_owned(), client_api_key_name: record.client_api_key_name, id: record.id, provider: record.provider_kind, diff --git a/backend/crates/gateway-api/src/admin/observability/query.rs b/backend/crates/gateway-api/src/admin/observability/query.rs index ed51154bd..065df1a89 100644 --- a/backend/crates/gateway-api/src/admin/observability/query.rs +++ b/backend/crates/gateway-api/src/admin/observability/query.rs @@ -29,6 +29,7 @@ impl DashboardQuery { #[derive(Clone, Default, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct UsageQuery { + pub status: Option, pub current_page: Option, pub page_size: Option, pub kind: Option, @@ -330,6 +331,12 @@ pub(crate) fn usage_command( let page_size_value = DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?; Ok(domain::UsageQuery { + status: match query.status.as_deref() { + None | Some("success") => domain::UsageRecordStatus::Success, + Some("error") => domain::UsageRecordStatus::Error, + Some("all") => domain::UsageRecordStatus::All, + Some(_) => return Err(WireValidationError::new("status")), + }, range: usage_range( query.start_time.as_deref(), query.end_time.as_deref(), diff --git a/backend/crates/gateway-api/src/admin/observability/wire.rs b/backend/crates/gateway-api/src/admin/observability/wire.rs index c36dae1e1..42e3afc05 100644 --- a/backend/crates/gateway-api/src/admin/observability/wire.rs +++ b/backend/crates/gateway-api/src/admin/observability/wire.rs @@ -86,6 +86,7 @@ pub struct ImageBillingView { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct UsageListRecordView { + pub logical_outcome: String, pub client_api_key_name: Option, pub id: String, pub provider: Option, diff --git a/backend/crates/gateway-api/tests/admin/observability/response.rs b/backend/crates/gateway-api/tests/admin/observability/response.rs index 2d777f268..218ba9a69 100644 --- a/backend/crates/gateway-api/tests/admin/observability/response.rs +++ b/backend/crates/gateway-api/tests/admin/observability/response.rs @@ -801,6 +801,7 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() { .lock() .expect("usage records") .push(UsageListRecord { + outcome: gateway_admin::model::observability::RequestOutcome::Succeeded, client_api_key_name: Some("Production".to_owned()), id: "request_endpoint".to_owned(), endpoint: "/v1/responses".to_owned(), @@ -1307,3 +1308,62 @@ pub(super) fn ops_error_record() -> gateway_admin::model::observability::OpsErro stable_sort_id: "model_request:req_err".to_owned(), } } + +#[tokio::test] +async fn usage_status_query_accepts_three_modes_and_rejects_unknown_values() { + use crate::admin::{AdminTestFixture, AdminTestState}; + use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, + }; + use gateway_api::admin::observability; + use tower::ServiceExt as _; + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + for (status, expected) in [ + ("success", StatusCode::OK), + ("error", StatusCode::OK), + ("all", StatusCode::OK), + ("failed", StatusCode::BAD_REQUEST), + ] { + let response = observability::router::() + .with_state(fixture.state()) + .oneshot( + Request::builder() + .uri(format!("/api/admin/usage/records?status={status}")) + .header(header::COOKIE, "cpr_session=valid-session") + .header("x-request-id", "usage_status_fixture") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let actual = response.status(); + let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + assert_eq!( + actual, + expected, + "{status}: {}", + String::from_utf8_lossy(&body) + ); + if expected == StatusCode::OK { + use gateway_admin::model::observability::UsageRecordStatus; + let status_value = match status { + "success" => UsageRecordStatus::Success, + "error" => UsageRecordStatus::Error, + _ => UsageRecordStatus::All, + }; + assert_eq!( + fixture + .observations + .lock() + .unwrap() + .records + .last() + .unwrap() + .status, + status_value + ); + } + } +} diff --git a/backend/crates/gateway-api/tests/key_usage/fixtures.rs b/backend/crates/gateway-api/tests/key_usage/fixtures.rs index ef14c7ecf..4f392544c 100644 --- a/backend/crates/gateway-api/tests/key_usage/fixtures.rs +++ b/backend/crates/gateway-api/tests/key_usage/fixtures.rs @@ -93,6 +93,7 @@ pub(super) async fn fixture() -> AdminTestFixture { fn usage_record() -> UsageListRecord { UsageListRecord { + outcome: gateway_admin::model::observability::RequestOutcome::Succeeded, client_api_key_name: Some("Production".to_owned()), id: "req-visible".to_owned(), endpoint: "/v1/responses".to_owned(), diff --git a/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs b/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs index 01cf2a049..e406e3caf 100644 --- a/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/observability/admin_adapter.rs @@ -173,6 +173,7 @@ impl PgObservabilityRepository { let account_usage_query = ProviderAccountUsageQuery::recent(range, query.account_limit)? .with_hourly_request_buckets()?; let recent_query = UsageRecordQuery { + status: Default::default(), range, filter: store_usage_filter(query.recent_request_filter), current_page: 1, diff --git a/backend/crates/gateway-store/src/postgres/observability/mapping.rs b/backend/crates/gateway-store/src/postgres/observability/mapping.rs index 8d847de88..6b581c89a 100644 --- a/backend/crates/gateway-store/src/postgres/observability/mapping.rs +++ b/backend/crates/gateway-store/src/postgres/observability/mapping.rs @@ -41,6 +41,7 @@ pub(crate) fn store_usage_query( query: admin_observability::UsageQuery, ) -> AdminStoreResult { Ok(UsageRecordQuery { + status: query.status, range: store_range(query.range).map_err(observability_error)?, filter: store_usage_filter(query.filter), current_page: query.current_page, @@ -302,6 +303,8 @@ pub(crate) fn usage_list_record_from_row( cost_currency.as_deref(), )?; Ok(UsageListRecord { + outcome: admin_observability::RequestOutcome::new(get::(row, "outcome")?) + .map_err(|_| invalid("invalid request outcome"))?, client_api_key_name: get(row, "client_api_key_name")?, billing, id: get(row, "id")?, diff --git a/backend/crates/gateway-store/src/postgres/observability/model.rs b/backend/crates/gateway-store/src/postgres/observability/model.rs index 4db2dd92a..cab02aca0 100644 --- a/backend/crates/gateway-store/src/postgres/observability/model.rs +++ b/backend/crates/gateway-store/src/postgres/observability/model.rs @@ -94,6 +94,7 @@ impl UsageRecordFilter { #[derive(Debug, Clone, PartialEq, Eq)] pub struct UsageRecordQuery { + pub status: admin_observability::UsageRecordStatus, pub range: ObservabilityRange, pub filter: UsageRecordFilter, pub current_page: u32, diff --git a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs index c19f9688c..fd2dade64 100644 --- a/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs +++ b/backend/crates/gateway-store/src/postgres/observability/queries/usage.rs @@ -113,7 +113,7 @@ pub(crate) fn literal_prefix_pattern(value: &str) -> String { } pub(crate) const USAGE_LIST_RECORD_SELECT: &str = - "select mr.id, mr.client_api_key_name_snapshot as client_api_key_name, mr.endpoint, mr.client_transport, mr.requested_model_id, + "select mr.id, mr.outcome, mr.client_api_key_name_snapshot as client_api_key_name, mr.endpoint, mr.client_transport, mr.requested_model_id, mr.provider_kind, mr.provider_account_ref, mr.provider_account_name_snapshot as provider_account_name, mr.provider_account_email_snapshot as provider_account_email, @@ -176,22 +176,24 @@ pub(crate) async fn list_usage_records( let items = list_usage_record_items(&mut *snapshot.tx, &query, None).await?; let total = if items.len() < usize::from(query.page_size.get()) { items.len() as u64 - } else if let Some(counts) = snapshot - .completed_request_counts(query.range, &query.filter) - .await? + } else if query.status == admin_observability::UsageRecordStatus::Success + && let Some(counts) = snapshot + .completed_request_counts(query.range, &query.filter) + .await? { counts.values().try_fold(0_u64, |sum, count| { sum.checked_add(*count) .ok_or_else(|| invalid("usage count overflows u64")) })? } else { - count_usage_records(&mut *snapshot.tx, query.range, &query.filter).await? + count_usage_records(&mut *snapshot.tx, query.range, &query.filter, query.status).await? }; (total, items) } else { - let (total, page) = if let Some(counts) = snapshot - .completed_request_counts(query.range, &query.filter) - .await? + let (total, page) = if query.status == admin_observability::UsageRecordStatus::Success + && let Some(counts) = snapshot + .completed_request_counts(query.range, &query.filter) + .await? { time_page_from_segments( counts.into_iter().rev(), @@ -260,7 +262,7 @@ async fn list_usage_record_items_at<'e, E: sqlx::Executor<'e, Database = Postgre statement.push_bind(page.range.start); statement.push(" and mr.started_at < "); statement.push_bind(page.range.end); - push_completed_usage_fact_filter(&mut statement, "mr"); + push_usage_record_status(&mut statement, query.status); push_usage_filter(&mut statement, &query.filter, "mr"); statement.push(if ascending { " order by mr.started_at, mr.id limit " @@ -363,7 +365,7 @@ async fn usage_time_page( statement.push_bind(query.range.start); statement.push(" and mr.started_at < "); statement.push_bind(query.range.end); - push_completed_usage_fact_filter(&mut statement, "mr"); + push_usage_record_status(&mut statement, query.status); push_usage_filter(&mut statement, &query.filter, "mr"); statement.push(" group by bucket_start order by bucket_start desc"); let rows = statement @@ -378,6 +380,7 @@ pub(crate) async fn count_usage_records<'e, E: sqlx::Executor<'e, Database = Pos executor: E, range: ObservabilityRange, filter: &UsageRecordFilter, + status: admin_observability::UsageRecordStatus, ) -> StoreResult { let mut statement = QueryBuilder::::new( "select count(*)::bigint from model_request_observations mr where mr.started_at >= ", @@ -385,7 +388,7 @@ pub(crate) async fn count_usage_records<'e, E: sqlx::Executor<'e, Database = Pos statement.push_bind(range.start); statement.push(" and mr.started_at < "); statement.push_bind(range.end); - push_completed_usage_fact_filter(&mut statement, "mr"); + push_usage_record_status(&mut statement, status); push_usage_filter(&mut statement, filter, "mr"); let total = statement .build_query_scalar::() @@ -558,3 +561,23 @@ pub(crate) fn push_diagnostic_dimension_filter( | DiagnosticDimension::Transport => {} } } + +fn push_usage_record_status( + statement: &mut QueryBuilder, + status: admin_observability::UsageRecordStatus, +) { + use admin_observability::UsageRecordStatus; + match status { + UsageRecordStatus::Success => push_completed_usage_fact_filter(statement, "mr"), + UsageRecordStatus::Error => { + statement.push(" and mr.outcome in ('failed', 'incomplete')"); + } + UsageRecordStatus::All => { + // 成功沿用用量边界,未成功记录保留真实生命周期,不把取消与执行中标为错误 + statement + .push(" and (") + .push(completed_usage_fact_predicate("mr")) + .push(" or mr.outcome <> 'succeeded')"); + } + } +} diff --git a/backend/crates/gateway-store/tests/postgres/execution/mod.rs b/backend/crates/gateway-store/tests/postgres/execution/mod.rs index 6e8221385..ac68ff817 100644 --- a/backend/crates/gateway-store/tests/postgres/execution/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/execution/mod.rs @@ -458,6 +458,7 @@ async fn core_adapter_should_persist_calculated_cost_exactly() { ); let page = observation .list_usage_records(UsageRecordQuery { + status: Default::default(), range: ObservabilityRange::new( Utc::now() - Duration::hours(1), Utc::now() + Duration::hours(1), @@ -2070,6 +2071,7 @@ async fn zero_attempt_failure_does_not_enter_successful_usage_or_cost_aggregates assert_eq!(failed.attempts, AttemptMetrics::default()); let page = repository .list_usage_records(UsageRecordQuery { + status: Default::default(), range: zero_attempt_range(&request), filter: filter.clone(), current_page: 1, diff --git a/backend/crates/gateway-store/tests/postgres/observability/mod.rs b/backend/crates/gateway-store/tests/postgres/observability/mod.rs index 83c6b39f0..1f3ce0c38 100644 --- a/backend/crates/gateway-store/tests/postgres/observability/mod.rs +++ b/backend/crates/gateway-store/tests/postgres/observability/mod.rs @@ -125,6 +125,7 @@ async fn output_throughput_uses_official_response_duration_without_local_fallbac super::drain_analytics(&database.pool).await; let records = repository .list_usage_records(UsageRecordQuery { + status: Default::default(), range, filter: UsageRecordFilter::default(), current_page: 1, @@ -235,6 +236,7 @@ async fn observability_preserves_and_filters_opaque_response_ids() { super::drain_analytics(&database.pool).await; let records = repository .list_usage_records(UsageRecordQuery { + status: Default::default(), range, filter: UsageRecordFilter { response_id: Some(response_id.clone()), @@ -279,6 +281,7 @@ async fn usage_page_should_always_return_total() { super::drain_analytics(&database.pool).await; let page = observability_repository(&database.pool) .list_usage_records(UsageRecordQuery { + status: Default::default(), range, filter: UsageRecordFilter::default(), current_page: 1, @@ -351,6 +354,7 @@ async fn usage_list_and_diagnostics_preserve_account_snapshots_by_id() { super::drain_analytics(&database.pool).await; let page = store .list_usage_records(admin_observability::UsageQuery { + status: Default::default(), range, filter: admin_observability::UsageFilter::default(), current_page: 1, @@ -440,6 +444,7 @@ async fn usage_list_and_diagnostics_preserve_account_snapshots_by_id() { super::drain_analytics(&database.pool).await; let page = store .list_usage_records(admin_observability::UsageQuery { + status: Default::default(), range, filter: admin_observability::UsageFilter { provider_account_ref: Some("acct_team".to_owned()), @@ -502,6 +507,7 @@ async fn usage_search_should_match_literal_prefix_instead_of_substring() { super::drain_analytics(&database.pool).await; let page = observability_repository(&database.pool) .list_usage_records(UsageRecordQuery { + status: Default::default(), range, filter: UsageRecordFilter { search: Some("rimary".to_owned()), @@ -549,6 +555,7 @@ async fn usage_search_should_ignore_request_ids_and_preserve_exact_filters() { ] { let page = observability_repository(&database.pool) .list_usage_records(UsageRecordQuery { + status: Default::default(), range, filter, current_page: 1, @@ -686,6 +693,7 @@ async fn assert_usage_search_ids( ) { let page = observability_repository(pool) .list_usage_records(UsageRecordQuery { + status: Default::default(), range, filter: UsageRecordFilter { search: Some(search.to_owned()), @@ -754,6 +762,7 @@ async fn usage_and_error_search_match_key_names_instead_of_credentials() { super::drain_analytics(&database.pool).await; let page = repository .list_usage_records(UsageRecordQuery { + status: Default::default(), range, filter: UsageRecordFilter { search: Some(search.to_owned()), @@ -813,6 +822,7 @@ async fn usage_and_error_search_match_key_names_instead_of_credentials() { super::drain_analytics(&database.pool).await; let page = repository .list_usage_records(UsageRecordQuery { + status: Default::default(), range, filter: UsageRecordFilter::default(), current_page: 1, @@ -1696,6 +1706,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det super::drain_analytics(&database.pool).await; let first_page = store .list_usage_records(admin_observability::UsageQuery { + status: Default::default(), range, filter: admin_observability::UsageFilter::default(), current_page: 1, @@ -1715,6 +1726,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det super::drain_analytics(&database.pool).await; let deep_page = store .list_usage_records(admin_observability::UsageQuery { + status: Default::default(), range, filter: admin_observability::UsageFilter::default(), current_page: 129, @@ -1730,6 +1742,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det super::drain_analytics(&database.pool).await; let filtered = store .list_usage_records(admin_observability::UsageQuery { + status: Default::default(), range, filter: admin_observability::UsageFilter { client_api_key_ref: Some("key_observe".to_owned()), @@ -1758,6 +1771,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det super::drain_analytics(&database.pool).await; let other_outcome = store .list_usage_records(admin_observability::UsageQuery { + status: Default::default(), range, filter: admin_observability::UsageFilter { outcome: Some( @@ -2182,6 +2196,7 @@ async fn observability_queries_preserve_request_account_cost_and_diagnostic_fact super::drain_analytics(&database.pool).await; let usage_page = repository .list_usage_records(UsageRecordQuery { + status: Default::default(), range, filter: UsageRecordFilter { provider_account_ref: Some("acct_observe".to_owned()), diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts/quota_forecast.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts/quota_forecast.rs index 025458f22..c58872da9 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts/quota_forecast.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts/quota_forecast.rs @@ -144,6 +144,7 @@ async fn quota_forecast_excludes_openai_prewarm_but_preserves_inference_and_audi assert_eq!(account_usage[0].cost_coverage.unavailable_count, 0); let records = repository .list_usage_records(UsageRecordQuery { + status: Default::default(), range, filter: UsageRecordFilter::default(), current_page: 1, diff --git a/backend/crates/gateway-store/tests/postgres/snapshots.rs b/backend/crates/gateway-store/tests/postgres/snapshots.rs index 8922a5f01..7c8276932 100644 --- a/backend/crates/gateway-store/tests/postgres/snapshots.rs +++ b/backend/crates/gateway-store/tests/postgres/snapshots.rs @@ -1288,6 +1288,7 @@ async fn account_snapshot( fn usage_query(started_at: DateTime, filter: UsageRecordFilter) -> UsageRecordQuery { UsageRecordQuery { + status: Default::default(), range: range_around(started_at), filter, current_page: 1, diff --git a/backend/crates/gateway-store/tests/postgres/usage_paging.rs b/backend/crates/gateway-store/tests/postgres/usage_paging.rs index 53425fdfb..f77881c1c 100644 --- a/backend/crates/gateway-store/tests/postgres/usage_paging.rs +++ b/backend/crates/gateway-store/tests/postgres/usage_paging.rs @@ -62,6 +62,7 @@ async fn projected_page_counts_correct_pending_moves_deletions_and_model_or() { for current_page in [1, 3, 6, 8] { let page = repository .list_usage_records(UsageRecordQuery { + status: Default::default(), range, filter: UsageRecordFilter { client_api_key_ref: Some("key_analytics".to_owned()), @@ -125,6 +126,7 @@ async fn usage_pages_preserve_order_and_exact_totals_from_both_ends() { for page in 1..=5 { let result = repository .list_usage_records(UsageRecordQuery { + status: Default::default(), range, filter: UsageRecordFilter { client_api_key_ref: key.map(str::to_owned), @@ -190,6 +192,7 @@ async fn usage_pages_cross_minutes_and_keep_exact_counts_after_source_changes() for current_page in 1..=6 { let page = repository .list_usage_records(UsageRecordQuery { + status: Default::default(), range, filter: UsageRecordFilter { client_api_key_ref: Some("key_analytics".to_owned()), @@ -280,3 +283,105 @@ async fn ops_pages_merge_sources_across_minutes_with_stable_ties() { } database.close().await; } + +#[tokio::test] +async fn request_status_pages_share_one_request_timeline() { + use gateway_admin::{ + model::{diagnostics::DiagnosticTarget, observability::UsageRecordStatus}, + ports::store::ObservabilityStore, + }; + + let Some(database) = TestDatabase::create("usage_status_pages").await else { + return; + }; + let start = chrono::DateTime::parse_from_rfc3339("2026-09-01T10:00:00Z") + .unwrap() + .with_timezone(&Utc); + // 同时刻请求按 ID 稳定排列;成功请求的中间错误不能额外占据列表行 + for (id, outcome) in [ + ("status_a", "succeeded"), + ("status_b", "failed"), + ("status_c", "incomplete"), + ("status_d", "cancelled"), + ("status_e", "running"), + ("status_f", "succeeded"), + ] { + seed(&database.pool, id, start, &[]).await; + sqlx::query("update model_requests set outcome=$2, provider_kind='openai', completed_at=case when $2='running' then null else completed_at end, input_tokens=case when $2='succeeded' then input_tokens else null end, output_tokens=case when $2='succeeded' then output_tokens else null end, total_tokens=case when $2='succeeded' then total_tokens else null end where id=$1") + .bind(id).bind(outcome).execute(&database.pool).await.unwrap(); + } + sqlx::query( + "update model_requests set requested_model_id='model_status_b' where id='status_b'", + ) + .execute(&database.pool) + .await + .unwrap(); + for id in ["event_a", "event_b"] { + sqlx::query("insert into ops_events(id,model_request_id,attempt_index,level,component,operation,failure_kind,message,created_at) values($1,'status_a',1,'error','provider','request','upstream','retry evidence',$2)") + .bind(id).bind(start).execute(&database.pool).await.unwrap(); + } + sqlx::query("insert into ops_events(id,level,component,operation,failure_kind,message,created_at) values('standalone','error','system','refresh','upstream','standalone error',$1)").bind(start).execute(&database.pool).await.unwrap(); + super::drain_analytics(&database.pool).await; + let repo = observability_repository(&database.pool); + let range = + ObservabilityRange::new(start - TimeDelta::minutes(1), start + TimeDelta::minutes(5)) + .unwrap(); + for (status, expected) in [ + (UsageRecordStatus::Success, vec!["status_f", "status_a"]), + (UsageRecordStatus::Error, vec!["status_c", "status_b"]), + ( + UsageRecordStatus::All, + vec![ + "status_f", "status_e", "status_d", "status_c", "status_b", "status_a", + ], + ), + ] { + let mut actual = Vec::new(); + for current_page in 1..=4 { + let page = repo + .list_usage_records(UsageRecordQuery { + status, + range, + filter: UsageRecordFilter::default(), + current_page, + page_size: ObservabilityPageSize::new(2).unwrap(), + }) + .await + .unwrap(); + assert_eq!(page.total, expected.len() as u64); + for item in page.items { + if matches!( + item.outcome.as_str(), + "failed" | "incomplete" | "running" | "cancelled" + ) { + assert_eq!(item.total_tokens, None); + } + actual.push(item.id); + } + } + assert_eq!(actual, expected, "{status:?}"); + } + let page = repo + .list_usage_records(UsageRecordQuery { + status: UsageRecordStatus::Error, + range, + filter: UsageRecordFilter { + search: Some("model_status_b".into()), + provider_kind: Some("openai".into()), + ..Default::default() + }, + current_page: 1, + page_size: ObservabilityPageSize::new(10).unwrap(), + }) + .await + .unwrap(); + assert_eq!(page.total, 1); + assert_eq!(page.items[0].id, "status_b"); + // 诊断包仍可按请求 ID 关联中间事件,不受列表时间范围或最终结果限制 + let errors = super::admin_observability_store(&database.pool) + .diagnostic_errors(&DiagnosticTarget::Request("status_a".into()), 100) + .await + .unwrap(); + assert_eq!(errors.len(), 2); + database.close().await; +} diff --git a/docs/api.md b/docs/api.md index 9d53b4e49..1e68ea312 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1890,6 +1890,12 @@ Dashboard 返回原始 `asOf` 及 `asOfDisplay`,作为当前数据的查询锚 request/response/upstream ID、outcome 与搜索文本。`search` 仅供明细及错误列表使用; 汇总、洞察和诊断不接受搜索参数,管理观测接口不接受路由分组筛选。页面搜索不改变顶部统计范围 +请求列表的 `status` 可取 `success`、`error`、`all`,默认 `success`;未知值返回 `400`。 +`success` 沿用成功用量记录范围,`error` 包含最终 `failed`、`incomplete` 的请求, +`all` 合并成功用量记录及全部非成功请求,包括执行中和已取消请求。每个请求只返回一行, +重试中间错误不改变请求最终分类;`logicalOutcome` 保留真实结果。状态筛选只影响明细列表, +不改变顶部统计,独立运维事件仍通过 `/api/admin/operations/errors` 查询 + 诊断 `dimension` 可取 `model`、`account`、 `apiKey`、`provider`、`transport`、`failureClass`、`status`。诊断按请求量降序返回最多 100 项, 同请求量按维度标识稳定排列 diff --git a/frontend/src/api/modules/usage.ts b/frontend/src/api/modules/usage.ts index acbf3e473..b8712199d 100644 --- a/frontend/src/api/modules/usage.ts +++ b/frontend/src/api/modules/usage.ts @@ -81,7 +81,10 @@ export interface UsageRecordMetadata { [key: string]: unknown } +export type UsageRecordStatus = 'success' | 'error' | 'all' + export interface UsageListRecord { + logicalOutcome: string clientApiKeyName: string | null id: string provider: string | null @@ -218,77 +221,6 @@ export interface UsageRecordsResponse { total: number } -export interface OpsErrorMetadata { - source: string - component: string - attemptId: string | null - accountLabel: string | null - continuationAffinityHash: string | null - continuationPreviousResponseIdHash: string | null - continuationUnavailableReason: string | null - upstreamConnectionId: string | null - upstreamConnectionExitReason: string | null - upstreamConnectionAgeMs: number | null - upstreamConnectionIdleMs: number | null - recoveryRequestId: string | null - recoveredAt: string | null - recoveryAttemptCount: number - recoveryRetryDelayMs: number | null - recoveryTotalLatencyMs: number | null -} - -export interface OpsError { - clientApiKeyName: string | null - id: string - requestId: string | null - clientApiKeyId: string | null - kind: string - operation: string - protocol: string | null - clientTransport: string | null - provider: string | null - authenticationKind: string | null - accountId: string | null - accountName: string | null - accountEmail: string | null - accountPlanType: string | null - accountPlanTypeDisplay: string | null - route: string - model: string | null - requestedModel: string | null - upstreamModel: string | null - serviceTier: string | null - clientStatusCode: number | null - upstreamStatusCode: number | null - transport: string | null - attemptIndex: number | null - failureClass: string - upstreamSendState: string | null - providerErrorCode: string | null - responseId: string | null - upstreamRequestId: string | null - latencyMs: number | null - clientIp: string | null - userAgent: string | null - reasoningEffort: string | null - reasoningPreset: string | null - requestKind: string | null - subagentKind: string | null - compact: boolean | null - message: string - errorDetails: string | null - metadata: OpsErrorMetadata - createdAt: string - createdAtDisplay: string -} - -export interface OpsErrorsResponse { - items: OpsError[] - currentPage: number - pageSize: number - total: number -} - export interface UsageSummaryResponse { analytics: AnalyticsMetadata totalRequests: string @@ -454,9 +386,7 @@ interface PageQuery { pageSize: number } -type UsagePageQuery = UsageRangeQuery & PageQuery & { search?: string } - -type OpsErrorPageQuery = UsagePageQuery +type UsagePageQuery = UsageRangeQuery & PageQuery & { search?: string, status?: UsageRecordStatus } interface UsageDetailQuery { id: string @@ -473,15 +403,6 @@ export function getUsageRecords(data: UsagePageQuery, options: RequestOptions = }) } -export function getOpsErrors(data: OpsErrorPageQuery, options: RequestOptions = {}) { - return request({ - url: '/api/admin/operations/errors', - method: 'GET', - params: data, - ...options, - }) -} - export function getUsageRecordDetail(data: UsageDetailQuery, options: RequestOptions = {}) { return request({ url: '/api/admin/usage/records/detail', diff --git a/frontend/src/views/theme/fixtures/dashboard.ts b/frontend/src/views/theme/fixtures/dashboard.ts index 3f5fad591..1687cf69e 100644 --- a/frontend/src/views/theme/fixtures/dashboard.ts +++ b/frontend/src/views/theme/fixtures/dashboard.ts @@ -112,6 +112,7 @@ function previewUsageRecord(options: PreviewUsageRecordOptions): UsageListRecord return { id: options.id, + logicalOutcome: 'succeeded', clientApiKeyName: '演示 Key', provider: options.provider, authenticationKind: options.authenticationKind, diff --git a/frontend/src/views/usage/components/OpsErrorDetailModal.vue b/frontend/src/views/usage/components/OpsErrorDetailModal.vue deleted file mode 100644 index 180da48dd..000000000 --- a/frontend/src/views/usage/components/OpsErrorDetailModal.vue +++ /dev/null @@ -1,188 +0,0 @@ - - - diff --git a/frontend/src/views/usage/components/OpsErrorPanel.vue b/frontend/src/views/usage/components/OpsErrorPanel.vue deleted file mode 100644 index cf87075e6..000000000 --- a/frontend/src/views/usage/components/OpsErrorPanel.vue +++ /dev/null @@ -1,237 +0,0 @@ - - - diff --git a/frontend/src/views/usage/components/UsageFilters.vue b/frontend/src/views/usage/components/UsageFilters.vue index 61c5903d6..2b64c4e13 100644 --- a/frontend/src/views/usage/components/UsageFilters.vue +++ b/frontend/src/views/usage/components/UsageFilters.vue @@ -16,8 +16,8 @@ const search = defineModel('search', { required: true })