diff --git a/backend/crates/gateway-admin/src/use_case/observability/mod.rs b/backend/crates/gateway-admin/src/use_case/observability/mod.rs index 3c861f02b..513b127f2 100644 --- a/backend/crates/gateway-admin/src/use_case/observability/mod.rs +++ b/backend/crates/gateway-admin/src/use_case/observability/mod.rs @@ -37,7 +37,7 @@ use super::{map_provider_error, map_store_error}; const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15; const HEALTH_TIMELINE_MIN_SAMPLE_SIZE: u64 = 10; const HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD: u64 = 3; -const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 99.0; +const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 95.0; mod store_cache; pub(crate) use store_cache::CachedObservabilityStore; @@ -379,8 +379,9 @@ fn build_usage_insights( } let requests = &overview.requests; - let failed_requests = service_failure_count(requests); - let health_requests = requests.success_count.saturating_add(failed_requests); + let health_window = HealthWindow::from_metrics(requests); + let failed_requests = health_window.failed_requests; + let health_requests = health_window.eligible_requests(); let health = UsageInsightsHealth { total_requests: requests.request_count, success_requests: requests.success_count, @@ -398,17 +399,19 @@ fn build_usage_insights( points: trend .iter() .map(|point| { - let failed_requests = service_failure_count(&point.metrics); - let health_requests = point.metrics.success_count.saturating_add(failed_requests); + let health_window = HealthWindow::from_metrics(&point.metrics); UsageInsightsHealthPoint { total_requests: point.metrics.request_count, bucket_start: point.bucket_start, success_requests: point.metrics.success_count, - failed_requests, + failed_requests: health_window.failed_requests, cancelled_requests: point.metrics.cancelled_count, incomplete_requests: point.metrics.incomplete_count, caller_error_requests: point.metrics.caller_error_count, - error_rate: rate_or_zero(failed_requests, health_requests), + error_rate: rate_or_zero( + health_window.non_success_requests(), + health_window.eligible_requests(), + ), } }) .collect(), @@ -956,7 +959,7 @@ fn trend( let service_failure_count = service_failure_count(&metrics); let success_rate = ratio( metrics.success_count, - metrics.success_count.saturating_add(service_failure_count), + HealthWindow::from_metrics(&metrics).eligible_requests(), ); let cache_hit_request_rate = ratio( metrics.cache_hit_request_count, @@ -1023,7 +1026,10 @@ fn trend_point(point: RequestMetricPoint) -> TrendPoint { point.metrics.cache_hit_request_count, point.metrics.cache_eligible_request_count, ), - success_rate: ratio(point.metrics.success_count, point.metrics.request_count), + success_rate: ratio( + point.metrics.success_count, + HealthWindow::from_metrics(&point.metrics).eligible_requests(), + ), metrics: point.metrics, cost_coverage: point.cost_coverage, costs: point.costs, @@ -1123,20 +1129,29 @@ struct HealthWindow { } impl HealthWindow { + fn from_metrics(metrics: &RequestMetrics) -> Self { + Self { + success_requests: metrics.success_count, + failed_requests: service_failure_count(metrics), + cancelled_requests: metrics.cancelled_count, + incomplete_requests: metrics.incomplete_count, + caller_error_requests: metrics.caller_error_count, + } + } + + // 未完成保留交付终态,但上游失败、超时和进程中断都不能视为服务成功 + fn non_success_requests(self) -> u64 { + self.failed_requests + .saturating_add(self.incomplete_requests) + } + + fn eligible_requests(self) -> u64 { + self.success_requests + .saturating_add(self.non_success_requests()) + } + fn add_metrics(&mut self, metrics: &RequestMetrics) { - self.success_requests = self.success_requests.saturating_add(metrics.success_count); - self.failed_requests = self - .failed_requests - .saturating_add(service_failure_count(metrics)); - self.cancelled_requests = self - .cancelled_requests - .saturating_add(metrics.cancelled_count); - self.incomplete_requests = self - .incomplete_requests - .saturating_add(metrics.incomplete_count); - self.caller_error_requests = self - .caller_error_requests - .saturating_add(metrics.caller_error_count); + self.add_window(Self::from_metrics(metrics)); } fn add_window(&mut self, other: Self) { @@ -1155,15 +1170,13 @@ impl HealthWindow { } fn health_status(bucket: HealthWindow, is_future: bool) -> HealthStatus { - let eligible_requests = bucket - .success_requests - .saturating_add(bucket.failed_requests); + let eligible_requests = bucket.eligible_requests(); if is_future { HealthStatus::Future } else if eligible_requests == 0 { HealthStatus::NoData } else if bucket.success_requests == 0 - && bucket.failed_requests >= HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD + && bucket.non_success_requests() >= HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD { HealthStatus::Unavailable } else if eligible_requests < HEALTH_TIMELINE_MIN_SAMPLE_SIZE { @@ -1178,9 +1191,7 @@ fn health_status(bucket: HealthWindow, is_future: bool) -> HealthStatus { } fn health_reliability(bucket: HealthWindow) -> Option { - let eligible_requests = bucket - .success_requests - .saturating_add(bucket.failed_requests); + let eligible_requests = bucket.eligible_requests(); (eligible_requests > 0) .then(|| bucket.success_requests as f64 / eligible_requests as f64 * 100.0) } 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 d450261d7..59fb44959 100644 --- a/backend/crates/gateway-admin/tests/use_case/observability/mod.rs +++ b/backend/crates/gateway-admin/tests/use_case/observability/mod.rs @@ -148,7 +148,7 @@ async fn dashboard_summary_should_derive_first_token_average_from_trend() { } #[tokio::test] -async fn health_timeline_should_match_legacy_status_precedence_and_thresholds() { +async fn health_timeline_should_apply_status_precedence_and_thresholds() { let now = Utc::now(); let current_slot = quarter_hour_start(now); let store = Arc::new(FixtureObservabilityStore::new(observation_range(now))); @@ -158,13 +158,28 @@ async fn health_timeline_should_match_legacy_status_precedence_and_thresholds() RequestMetrics { failure_count: 1, cancelled_count: 1, - incomplete_count: 1, caller_error_count: 1, ..RequestMetrics::default() }, HealthStatus::NoData, None, ), + ( + RequestMetrics { + incomplete_count: 1, + ..RequestMetrics::default() + }, + HealthStatus::LowSample, + Some(0.0), + ), + ( + RequestMetrics { + incomplete_count: 3, + ..RequestMetrics::default() + }, + HealthStatus::Unavailable, + Some(0.0), + ), ( RequestMetrics { failure_count: 3, @@ -184,21 +199,21 @@ async fn health_timeline_should_match_legacy_status_precedence_and_thresholds() ), ( RequestMetrics { - success_count: 98, - failure_count: 2, + success_count: 94, + failure_count: 6, ..RequestMetrics::default() }, HealthStatus::Unstable, - Some(98.0), + Some(94.0), ), ( RequestMetrics { - success_count: 99, - failure_count: 1, + success_count: 95, + incomplete_count: 5, ..RequestMetrics::default() }, HealthStatus::Stable, - Some(99.0), + Some(95.0), ), ]; @@ -224,6 +239,92 @@ async fn health_timeline_should_match_legacy_status_precedence_and_thresholds() } } +#[tokio::test] +async fn health_views_should_include_incomplete_and_keep_final_outcomes_separate() { + let now = Utc::now(); + let current_slot = quarter_hour_start(now); + let store = Arc::new(FixtureObservabilityStore::new(observation_range(now))); + let services = observability_services(store.clone()).await; + let fixtures = [ + (162, 0, 2, 0, 0, 98.780_487_804_878_05), + (297, 0, 2, 5, 0, 99.331_103_678_929_76), + (467, 1, 5, 0, 0, 98.731_501_057_082_45), + (95, 2, 3, 4, 1, 95.0), + (100, 0, 0, 0, 0, 100.0), + ]; + + for (index, (success, failed, incomplete, cancelled, caller_error, expected_percent)) in + fixtures.into_iter().enumerate() + { + let range = observation_range( + now + Duration::seconds(i64::try_from(index).expect("fixture index fits i64") * 2), + ); + let metrics = RequestMetrics { + request_count: success + failed + incomplete + cancelled + caller_error, + success_count: success, + failure_count: failed + caller_error, + incomplete_count: incomplete, + cancelled_count: cancelled, + caller_error_count: caller_error, + ..RequestMetrics::default() + }; + store.replace_trend(vec![health_metric_point(current_slot, metrics.clone())]); + store.replace_overview(UsageOverview { + analytics: Default::default(), + range, + requests: metrics, + // 中间尝试失败不会增加最终失败请求数 + attempts: AttemptMetrics { + failure_count: 2, + ..AttemptMetrics::default() + }, + providers: Vec::new(), + }); + + let dashboard = services + .observability() + .dashboard_summary(range, TrendKind::Usage) + .await + .expect("dashboard summary"); + let timeline = dashboard.health_timeline; + let point = timeline + .points + .iter() + .find(|point| point.bucket_start == current_slot) + .expect("current health slot"); + assert_eq!(point.status, HealthStatus::Stable); + assert_eq!(point.failed_requests, failed); + assert_eq!(point.incomplete_requests, incomplete); + assert_eq!(point.cancelled_requests, cancelled); + assert_eq!(point.caller_error_requests, caller_error); + for reliability in [timeline.reliability_percent, point.reliability_percent] { + assert!((reliability.expect("health reliability") - expected_percent).abs() < 1e-10); + } + for success_rate in [ + dashboard.trend.summary.success_rate, + dashboard.trend.points[0].success_rate, + ] { + assert!( + (success_rate.expect("trend success rate") * 100.0 - expected_percent).abs() + < 1e-10 + ); + } + + let insights = services + .observability() + .usage_insights(range, UsageFilter::default()) + .await + .expect("usage insights"); + assert_eq!(insights.health.failed_requests, failed); + assert_eq!(insights.health.incomplete_requests, incomplete); + assert!((insights.health.success_rate * 100.0 - expected_percent).abs() < 1e-10); + assert!( + (insights.health.points[0].error_rate * 100.0 - (100.0 - expected_percent)).abs() + < 1e-10 + ); + } +} + #[tokio::test] async fn dashboard_summary_should_project_rebuildable_runtime_slots() { let now = Utc::now(); diff --git a/docs/api.md b/docs/api.md index 527a53905..839032515 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1866,6 +1866,12 @@ OpenAI 优先采用服务端 `openai-model` / `x-openai-model` 报告(流内 ### 记录范围与统计口径 +请求健康可用性与服务成功率按 `成功 /(成功 + 服务失败 + 未完成)` 计算 +未完成保留独立计数,客户端取消与调用方错误不计入该分母,中间尝试失败不重复计为最终失败请求 +健康时间线以 15 分钟分桶,可用性不低于 95% 且有效样本不少于 10 个时显示稳定 +没有成功且服务失败与未完成合计不少于 3 个时显示不可达,其他不足 10 个有效样本的时段显示低样本 +有效样本为零时显示无有效样本,尚未到达的时间格显示未来 + 已进入模型执行会话、但尚未登记执行尝试的失败请求也进入错误及详情查询, 包含无可用账号、准备失败、启动/准备超时与取消。此时 attempt 数为零,未确认的 Provider、账号及 上游传输为空;可用模型执行 ID 在“全部平台”下查询,不从路由候选推断实际调用平台。 diff --git a/frontend/src/components/usage/HealthTimelinePointPopover.vue b/frontend/src/components/usage/HealthTimelinePointPopover.vue index 5cbb6838a..33e33fadb 100644 --- a/frontend/src/components/usage/HealthTimelinePointPopover.vue +++ b/frontend/src/components/usage/HealthTimelinePointPopover.vue @@ -2,18 +2,16 @@ import type { DashboardHealthTimelinePoint } from '@/api' import { computed } from 'vue' -import { formatHealthCount, healthReliabilityValueClass, healthStatusMeta } from '@/components/usage/shared/health' +import { formatHealthCount, healthEligibleRequestCount, healthObservedRequestCount, healthServiceFailureCount, healthStatusMeta } from '@/components/usage/shared/health' const props = defineProps<{ point: DashboardHealthTimelinePoint }>() const status = computed(() => healthStatusMeta[props.point.status]) -const eligibleRequests = computed(() => props.point.successRequests + props.point.failedRequests) -const observedRequests = computed( - () => eligibleRequests.value + props.point.cancelledRequests + props.point.callerErrorRequests, -) -// 四类结果的标签/取值/配色单一来源;分段条与明细行都由此派生。 +const eligibleRequests = computed(() => healthEligibleRequestCount(props.point)) +const observedRequests = computed(() => healthObservedRequestCount(props.point)) +// 结果标签、取值与配色集中维护,分段条与明细行都由此派生 const outcomeMeta = computed(() => [ { label: '成功', @@ -23,7 +21,7 @@ const outcomeMeta = computed(() => [ }, { label: '服务失败', - value: props.point.failedRequests, + value: healthServiceFailureCount(props.point), dotClass: 'bg-cp-error', valueClass: 'text-cp-error-text', }, @@ -86,7 +84,7 @@ const metricItems = outcomeMeta

{{ point.reliabilityDisplay }} @@ -126,7 +124,7 @@ const metricItems = outcomeMeta

- 客户端取消与调用方错误单独记录,不计入可用性 + 取消与调用方错误不计入可用性

diff --git a/frontend/src/components/usage/RequestHealthTimelineCard.vue b/frontend/src/components/usage/RequestHealthTimelineCard.vue index cb26d362a..ee3e1f02e 100644 --- a/frontend/src/components/usage/RequestHealthTimelineCard.vue +++ b/frontend/src/components/usage/RequestHealthTimelineCard.vue @@ -6,7 +6,7 @@ import { useEventListener, usePreferredReducedMotion, useResizeObserver } from ' import { gsap } from 'gsap' import { computed, nextTick, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue' import HealthTimelinePointPopover from '@/components/usage/HealthTimelinePointPopover.vue' -import { formatHealthCount, healthLegend, healthReliabilityValueClass, healthStatusMeta } from '@/components/usage/shared/health' +import { formatHealthCount, healthEligibleRequestCount, healthLegend, healthObservedRequestCount, healthStatusMeta } from '@/components/usage/shared/health' const props = defineProps<{ timeline: DashboardHealthTimeline @@ -31,12 +31,8 @@ let wavedCellIndexes = new Set() let anchorXTo: ReturnType | undefined let anchorYTo: ReturnType | undefined -function observedRequests(point: DashboardHealthTimelinePoint) { - return point.successRequests + point.failedRequests + point.cancelledRequests + point.callerErrorRequests -} - function isInteractivePoint(point: DashboardHealthTimelinePoint) { - return point.status !== 'future' && observedRequests(point) > 0 + return point.status !== 'future' && healthObservedRequestCount(point) > 0 } function isActivePoint(point: DashboardHealthTimelinePoint) { @@ -222,7 +218,7 @@ function releasePointWave() { } function pointAccessibilityLabel(point: DashboardHealthTimelinePoint) { - const eligibleRequests = point.successRequests + point.failedRequests + const eligibleRequests = healthEligibleRequestCount(point) return `${point.time},${healthStatusMeta[point.status].label},有效请求 ${formatHealthCount(eligibleRequests)},可用性 ${point.reliabilityDisplay}` } @@ -270,7 +266,7 @@ onBeforeUnmount(() => {
{{ timeline.reliabilityDisplay }} diff --git a/frontend/src/components/usage/shared/health.ts b/frontend/src/components/usage/shared/health.ts index e5ef3bd01..c0cc88a3b 100644 --- a/frontend/src/components/usage/shared/health.ts +++ b/frontend/src/components/usage/shared/health.ts @@ -1,10 +1,11 @@ -import type { DashboardHealthStatus } from '@/api' +import type { DashboardHealthStatus, DashboardHealthTimelinePoint } from '@/api' import { formatInteger } from '@/utils/format' interface DashboardHealthStatusMeta { label: string cellClass: string badgeClass: string + valueClass: string } export const healthLegend = [ @@ -20,47 +21,50 @@ export const healthStatusMeta: Record) { + return point.failedRequests + point.incompleteRequests +} - const reliability = (Math.max(0, successRequests) / eligibleRequests) * 100 - if (reliability >= 99.5) - return 'text-cp-success-text' - if (reliability >= 98) - return 'text-cp-cyan-text' - if (reliability >= 95) - return 'text-cp-warning-text' - return 'text-cp-error-text' +export function healthEligibleRequestCount(point: Pick) { + return point.successRequests + healthServiceFailureCount(point) +} + +export function healthObservedRequestCount(point: DashboardHealthTimelinePoint) { + return healthEligibleRequestCount(point) + point.cancelledRequests + point.callerErrorRequests } export function formatHealthCount(value: number) { diff --git a/frontend/src/views/usage/components/UsageHealthCard.vue b/frontend/src/views/usage/components/UsageHealthCard.vue index f8148054f..358e49e24 100644 --- a/frontend/src/views/usage/components/UsageHealthCard.vue +++ b/frontend/src/views/usage/components/UsageHealthCard.vue @@ -8,6 +8,7 @@ import { use } from 'echarts/core' import { computed } from 'vue' import BaseChart from '@/components/charts/BaseChart.vue' import { tooltipIndex, tooltipRows } from '@/components/charts/tooltip' +import { healthEligibleRequestCount } from '@/components/usage/shared/health' import { useChartPalette } from '@/composables/useChartPalette' import { formatLocalizedCompactNumber as formatCompactNumber, formatPercent } from '@/utils/format' @@ -113,9 +114,9 @@ function requestCount(point: HealthPoint) { } function serviceSuccessRate(point: HealthPoint) { - const total = Math.max(0, point.successRequests) + Math.max(0, point.failedRequests) + const total = healthEligibleRequestCount(point) if (total > 0) - return Math.max(0, point.successRequests ?? 0) / total + return 1 - point.errorRate return requestCount(point) === 0 ? 0 : null }