feat(usage): add request concurrency trends (#537)

* feat(usage): add request concurrency trends

* perf(usage)!: unify load insights and optimize concurrency

Aggregate concurrency on a single event timeline and reuse lifecycle
indexes instead of joining every request against every time bucket.

Align load summaries and trend points with the four chart metrics,
combine concurrency and scheduling in one view, and right-align the
statistics card selectors. Update the API contract and regression tests.

BREAKING CHANGE: Usage insights concurrency and scheduling P95 fields
move under performance.load and performance.points[].load. Unused
scheduling P50, capacity, and performance coverage fields are removed.

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
mike zhuang
2026-10-28 17:22:28 +08:00
committed by GitHub
co-authored by Codex Zyy
parent 005466bcb0
commit 7cd17aff42
21 changed files with 739 additions and 412 deletions
+1
View File
@@ -34,6 +34,7 @@ Thumbs.db
# Local tools and worktrees
/.codegraph/
/.playwright-cli/
/.codex-ws-audit/
/.reasonix/
/.claude
@@ -2,7 +2,9 @@
use chrono::{DateTime, Utc};
use super::observability::{DecimalAmount, RequestMetricPoint, UsageOverview};
use super::observability::{
DecimalAmount, RequestMetricPoint, UsageConcurrencyPoint, UsageOverview,
};
/// 数据可见性状态,不以缺失投影冒充零统计
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
@@ -57,9 +59,10 @@ pub struct UsageProjectedCostPoint {
}
/// 同一数据库快照内的完整洞察输入
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct UsageInsightsObservation {
pub overview: UsageOverview,
pub trend: Vec<RequestMetricPoint>,
pub costs: Vec<UsageProjectedCostPoint>,
pub concurrency: Vec<UsageConcurrencyPoint>,
}
@@ -1260,38 +1260,44 @@ pub struct UsageInsightsHealth {
pub points: Vec<UsageInsightsHealthPoint>,
}
/// 一个时间桶内按重叠时长加权的逻辑请求并发
#[derive(Debug, Clone, PartialEq)]
pub struct UsageConcurrencyPoint {
pub bucket_start: DateTime<Utc>,
pub average_concurrency: f64,
pub peak_concurrency: u64,
}
/// 请求并发与调度等待的负载指标
#[derive(Debug, Clone, PartialEq)]
pub struct UsageInsightsLoad {
pub average_concurrency: f64,
pub peak_concurrency: u64,
pub admission_decision_p95_ms: Option<PercentileMilliseconds>,
pub account_selection_wait_p95_ms: Option<PercentileMilliseconds>,
}
/// 洞察性能时间点
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub struct UsageInsightsPerformancePoint {
pub bucket_start: DateTime<Utc>,
pub latency_percentiles: LatencyPercentiles,
pub first_token_latency_percentiles: LatencyPercentiles,
pub admission_decision_percentiles: LatencyPercentiles,
pub account_selection_wait_percentiles: LatencyPercentiles,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization_avg_basis_points: Option<u64>,
pub capacity_utilization_p95_basis_points: Option<u64>,
pub load: UsageInsightsLoad,
}
/// 洞察性能汇总与可观测覆盖率
/// 洞察性能汇总
#[derive(Debug, Clone, PartialEq)]
pub struct UsageInsightsPerformance {
pub latency_percentiles: LatencyPercentiles,
pub first_token_latency_percentiles: LatencyPercentiles,
pub latency_coverage: f64,
pub first_token_coverage: f64,
pub admission_decision_percentiles: LatencyPercentiles,
pub account_selection_wait_percentiles: LatencyPercentiles,
pub admission_decision_coverage: f64,
pub account_selection_wait_coverage: f64,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization_avg_basis_points: Option<u64>,
pub capacity_utilization_p95_basis_points: Option<u64>,
pub capacity_coverage: f64,
pub load: UsageInsightsLoad,
pub points: Vec<UsageInsightsPerformancePoint>,
}
@@ -20,9 +20,10 @@ use crate::{
HealthTimeline, HealthTimelinePoint, OpsErrorPage, OpsErrorQuery, ProviderBillingInput,
RequestMetricPoint, RequestMetrics, TimeRange, Trend, TrendKind, TrendPoint,
TrendSummary, UsageBilling, UsageBillingProjection, UsageCalculatedBillingFact,
UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost, UsageInsightsCostPoint,
UsageInsightsHealth, UsageInsightsHealthPoint, UsageInsightsPerformance,
UsageInsightsPerformancePoint, UsageOverview, UsagePage, UsageQuery, UsageSummary,
UsageConcurrencyPoint, UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost,
UsageInsightsCostPoint, UsageInsightsHealth, UsageInsightsHealthPoint,
UsageInsightsLoad, UsageInsightsPerformance, UsageInsightsPerformancePoint,
UsageOverview, UsagePage, UsageQuery, UsageSummary,
},
provider_credentials::ProviderQuotaRequest,
},
@@ -304,7 +305,7 @@ impl ObservabilityService for DefaultObservabilityService {
}
let overview = observation.overview;
let trend = observation.trend;
build_usage_insights(overview, trend, standard_costs)
build_usage_insights(overview, trend, standard_costs, observation.concurrency)
}
async fn diagnostics(
@@ -385,6 +386,7 @@ fn build_usage_insights(
overview: UsageOverview,
trend: Vec<RequestMetricPoint>,
standard_costs: UsageCostScenarios,
concurrency: Vec<UsageConcurrencyPoint>,
) -> Result<UsageInsights, AdminError> {
let granularity = trend.first().map_or(
crate::model::observability::Granularity::FifteenMinutes,
@@ -432,49 +434,69 @@ fn build_usage_insights(
})
.collect(),
};
let concurrency_by_bucket = concurrency
.iter()
.map(|point| (point.bucket_start, point))
.collect::<BTreeMap<_, _>>();
let mut weighted_concurrency_micros = 0.0;
let mut effective_micros = 0_i64;
for (index, point) in concurrency.iter().enumerate() {
let start = point.bucket_start.max(overview.range.start);
let end = concurrency
.get(index + 1)
.map_or(overview.range.end, |next| next.bucket_start)
.min(overview.range.end);
let micros = (end - start).num_microseconds().unwrap_or(0).max(0);
effective_micros = effective_micros.saturating_add(micros);
weighted_concurrency_micros += point.average_concurrency * micros as f64;
}
let average_concurrency = if effective_micros > 0 {
weighted_concurrency_micros / effective_micros as f64
} else {
0.0
};
let peak_concurrency = concurrency
.iter()
.map(|point| point.peak_concurrency)
.max()
.unwrap_or(0);
let performance = UsageInsightsPerformance {
latency_percentiles: requests.latency_percentiles,
first_token_latency_percentiles: requests.first_token_latency_percentiles,
latency_coverage: rate_or_zero(requests.latency_count, requests.request_count),
first_token_coverage: rate_or_zero(
requests.first_token_latency_count,
requests.request_count,
),
admission_decision_percentiles: requests.admission_decision_percentiles,
account_selection_wait_percentiles: requests.account_selection_wait_percentiles,
admission_decision_coverage: rate_or_zero(
requests.admission_decision_count,
requests.request_count,
),
account_selection_wait_coverage: rate_or_zero(
requests.account_selection_wait_count,
requests.request_count,
),
output_throughput_p10: requests.output_throughput_p10,
output_throughput_p50: requests.output_throughput_p50,
output_throughput_p90: requests.output_throughput_p90,
capacity_utilization_avg_basis_points: requests.capacity_utilization_avg_basis_points,
capacity_utilization_p95_basis_points: requests.capacity_utilization_p95_basis_points,
capacity_coverage: rate_or_zero(requests.capacity_sample_count, requests.request_count),
load: UsageInsightsLoad {
average_concurrency,
peak_concurrency,
admission_decision_p95_ms: requests.admission_decision_percentiles.p95_ms,
account_selection_wait_p95_ms: requests.account_selection_wait_percentiles.p95_ms,
},
points: trend
.iter()
.map(|point| UsageInsightsPerformancePoint {
bucket_start: point.bucket_start,
latency_percentiles: point.metrics.latency_percentiles,
first_token_latency_percentiles: point.metrics.first_token_latency_percentiles,
admission_decision_percentiles: point.metrics.admission_decision_percentiles,
account_selection_wait_percentiles: point
.metrics
.account_selection_wait_percentiles,
output_throughput_p10: point.metrics.output_throughput_p10,
output_throughput_p50: point.metrics.output_throughput_p50,
output_throughput_p90: point.metrics.output_throughput_p90,
capacity_utilization_avg_basis_points: point
.metrics
.capacity_utilization_avg_basis_points,
capacity_utilization_p95_basis_points: point
.metrics
.capacity_utilization_p95_basis_points,
.map(|point| {
let concurrency = concurrency_by_bucket.get(&point.bucket_start);
UsageInsightsPerformancePoint {
bucket_start: point.bucket_start,
latency_percentiles: point.metrics.latency_percentiles,
first_token_latency_percentiles: point.metrics.first_token_latency_percentiles,
output_throughput_p10: point.metrics.output_throughput_p10,
output_throughput_p50: point.metrics.output_throughput_p50,
output_throughput_p90: point.metrics.output_throughput_p90,
load: UsageInsightsLoad {
average_concurrency: concurrency
.map_or(0.0, |point| point.average_concurrency),
peak_concurrency: concurrency.map_or(0, |point| point.peak_concurrency),
admission_decision_p95_ms: point
.metrics
.admission_decision_percentiles
.p95_ms,
account_selection_wait_p95_ms: point
.metrics
.account_selection_wait_percentiles
.p95_ms,
},
}
})
.collect(),
};
@@ -22,8 +22,9 @@ use gateway_admin::{
DiagnosticDimension, DiagnosticObservation, DiagnosticsObservation, Granularity,
HealthStatus, LatencyPercentiles, ObservabilityPageSize, OpsErrorPage, OpsErrorQuery,
PercentileMilliseconds, RequestMetricPoint, RequestMetrics, TimeRange, TrendKind,
UsageBilling, UsageDetail, UsageFilter, UsageInsightsObservation, UsageListRecord,
UsageOverview, UsagePage, UsageProjectedCostPoint, UsageQuery, UsageSummaryObservation,
UsageBilling, UsageConcurrencyPoint, UsageDetail, UsageFilter,
UsageInsightsObservation, UsageListRecord, UsageOverview, UsagePage,
UsageProjectedCostPoint, UsageQuery, UsageSummaryObservation,
},
settings::{
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
@@ -678,10 +679,26 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
assert_eq!(insights.health.success_rate, 0.75);
assert_eq!(insights.health.completion_rate, 1.0);
assert_eq!(insights.granularity, Granularity::FifteenMinutes);
assert_eq!(insights.performance.latency_coverage, 0.5);
assert_eq!(insights.performance.admission_decision_coverage, 1.0);
assert_eq!(insights.performance.account_selection_wait_coverage, 0.8);
assert_eq!(insights.performance.capacity_coverage, 0.8);
assert_eq!(
insights
.performance
.load
.admission_decision_p95_ms
.map(|value| value.as_f64()),
Some(5.0)
);
assert_eq!(
insights
.performance
.load
.account_selection_wait_p95_ms
.map(|value| value.as_f64()),
Some(40.0)
);
assert_eq!(
insights.performance.points[0].load,
insights.performance.load
);
assert_eq!(insights.performance.output_throughput_p50, Some(50));
assert_eq!(insights.cost.tokens_per_request, 100.0);
assert_eq!(
@@ -742,6 +759,52 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
assert_eq!(diagnostics.items[1].request_share, 0.25);
}
#[tokio::test]
async fn usage_insights_should_weight_concurrency_by_effective_bucket_duration() {
let bucket_start = DateTime::parse_from_rfc3339("2026-09-01T10:00:00Z")
.unwrap()
.with_timezone(&Utc);
let range = TimeRange::new(
bucket_start + Duration::minutes(5),
bucket_start + Duration::minutes(20),
)
.unwrap();
let store = Arc::new(FixtureObservabilityStore::new(range));
store.replace_trend(vec![
health_metric_point(bucket_start, RequestMetrics::default()),
health_metric_point(
bucket_start + Duration::minutes(15),
RequestMetrics::default(),
),
]);
*store.concurrency.lock().unwrap() = vec![
UsageConcurrencyPoint {
bucket_start,
average_concurrency: 1.0,
peak_concurrency: 1,
},
UsageConcurrencyPoint {
bucket_start: bucket_start + Duration::minutes(15),
average_concurrency: 3.0,
peak_concurrency: 4,
},
];
let insights = observability_services(store)
.await
.observability()
.usage_insights(range, UsageFilter::default())
.await
.unwrap();
assert!((insights.performance.load.average_concurrency - 5.0 / 3.0).abs() < 1e-10);
assert_eq!(insights.performance.load.peak_concurrency, 4);
assert_eq!(insights.performance.points[0].load.average_concurrency, 1.0);
assert_eq!(insights.performance.points[0].load.peak_concurrency, 1);
assert_eq!(insights.performance.points[1].load.average_concurrency, 3.0);
assert_eq!(insights.performance.points[1].load.peak_concurrency, 4);
}
#[tokio::test]
async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
let now = Utc::now();
@@ -987,6 +1050,7 @@ struct FixtureObservabilityStore {
trend: Mutex<Vec<RequestMetricPoint>>,
overview: Mutex<UsageOverview>,
projected_costs: Mutex<Vec<UsageProjectedCostPoint>>,
concurrency: Mutex<Vec<UsageConcurrencyPoint>>,
insights_fail: AtomicBool,
summary_fail: AtomicBool,
diagnostics: Mutex<DiagnosticsObservation>,
@@ -1014,6 +1078,7 @@ impl FixtureObservabilityStore {
providers: Vec::new(),
}),
projected_costs: Mutex::new(Vec::new()),
concurrency: Mutex::new(Vec::new()),
insights_fail: AtomicBool::new(false),
summary_fail: AtomicBool::new(false),
diagnostics: Mutex::new(DiagnosticsObservation::default()),
@@ -1144,6 +1209,7 @@ impl ObservabilityStore for FixtureObservabilityStore {
.lock()
.expect("projected costs")
.clone(),
concurrency: self.concurrency.lock().expect("concurrency").clone(),
})
}
@@ -41,10 +41,6 @@ pub(crate) fn display_rate(value: f64) -> String {
}
}
fn basis_points_rate(value: u64) -> f64 {
value as f64 / 10_000.0
}
pub(crate) fn outcome_name(outcome: &domain::RequestOutcome) -> &str {
outcome.as_str()
}
@@ -1050,6 +1046,17 @@ pub(crate) fn usage_summary_view(
}
}
fn overview_load_view(load: &domain::UsageInsightsLoad) -> OverviewLoadView {
OverviewLoadView {
average_concurrency: load.average_concurrency,
peak_concurrency: load.peak_concurrency,
admission_decision_p95_ms: load.admission_decision_p95_ms.map(|value| value.as_f64()),
account_selection_wait_p95_ms: load
.account_selection_wait_p95_ms
.map(|value| value.as_f64()),
}
}
pub(crate) fn usage_insights_view(
insights: domain::UsageInsights,
time: crate::time::TimePresenter,
@@ -1092,31 +1099,10 @@ pub(crate) fn usage_insights_view(
.first_token_latency_percentiles
.p99_ms
.map(|value| value.as_f64()),
admission_decision_p50_ms: point
.admission_decision_percentiles
.p50_ms
.map(|value| value.as_f64()),
admission_decision_p95_ms: point
.admission_decision_percentiles
.p95_ms
.map(|value| value.as_f64()),
account_selection_wait_p50_ms: point
.account_selection_wait_percentiles
.p50_ms
.map(|value| value.as_f64()),
account_selection_wait_p95_ms: point
.account_selection_wait_percentiles
.p95_ms
.map(|value| value.as_f64()),
output_throughput_p10: point.output_throughput_p10,
output_throughput_p50: point.output_throughput_p50,
output_throughput_p90: point.output_throughput_p90,
capacity_utilization: point
.capacity_utilization_avg_basis_points
.map(basis_points_rate),
capacity_utilization_p95: point
.capacity_utilization_p95_basis_points
.map(basis_points_rate),
load: overview_load_view(&point.load),
})
.collect();
let cost_points = insights
@@ -1185,42 +1171,10 @@ pub(crate) fn usage_insights_view(
.first_token_latency_percentiles
.p99_ms
.map(|value| value.as_f64()),
admission_decision_p50_ms: insights
.performance
.admission_decision_percentiles
.p50_ms
.map(|value| value.as_f64()),
admission_decision_p95_ms: insights
.performance
.admission_decision_percentiles
.p95_ms
.map(|value| value.as_f64()),
account_selection_wait_p50_ms: insights
.performance
.account_selection_wait_percentiles
.p50_ms
.map(|value| value.as_f64()),
account_selection_wait_p95_ms: insights
.performance
.account_selection_wait_percentiles
.p95_ms
.map(|value| value.as_f64()),
output_throughput_p10: insights.performance.output_throughput_p10,
output_throughput_p50: insights.performance.output_throughput_p50,
output_throughput_p90: insights.performance.output_throughput_p90,
capacity_utilization: insights
.performance
.capacity_utilization_avg_basis_points
.map(basis_points_rate),
capacity_utilization_p95: insights
.performance
.capacity_utilization_p95_basis_points
.map(basis_points_rate),
latency_coverage: insights.performance.latency_coverage,
first_token_coverage: insights.performance.first_token_coverage,
admission_decision_coverage: insights.performance.admission_decision_coverage,
account_selection_wait_coverage: insights.performance.account_selection_wait_coverage,
capacity_coverage: insights.performance.capacity_coverage,
load: overview_load_view(&insights.performance.load),
points: performance_points,
},
cost: OverviewCostView {
@@ -648,6 +648,16 @@ pub struct OverviewHealthView {
pub points: Vec<OverviewHealthPointView>,
}
/// 并发与调度等待的负载指标
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OverviewLoadView {
pub average_concurrency: f64,
pub peak_concurrency: u64,
pub admission_decision_p95_ms: Option<f64>,
pub account_selection_wait_p95_ms: Option<f64>,
}
/// 洞察性能趋势点
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -660,15 +670,10 @@ pub struct OverviewPerformancePointView {
pub first_token_p50_ms: Option<f64>,
pub first_token_p95_ms: Option<f64>,
pub first_token_p99_ms: Option<f64>,
pub admission_decision_p50_ms: Option<f64>,
pub admission_decision_p95_ms: Option<f64>,
pub account_selection_wait_p50_ms: Option<f64>,
pub account_selection_wait_p95_ms: Option<f64>,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization: Option<f64>,
pub capacity_utilization_p95: Option<f64>,
pub load: OverviewLoadView,
}
/// 洞察性能摘要
@@ -681,20 +686,10 @@ pub struct OverviewPerformanceView {
pub first_token_p50_ms: Option<f64>,
pub first_token_p95_ms: Option<f64>,
pub first_token_p99_ms: Option<f64>,
pub admission_decision_p50_ms: Option<f64>,
pub admission_decision_p95_ms: Option<f64>,
pub account_selection_wait_p50_ms: Option<f64>,
pub account_selection_wait_p95_ms: Option<f64>,
pub output_throughput_p10: Option<u64>,
pub output_throughput_p50: Option<u64>,
pub output_throughput_p90: Option<u64>,
pub capacity_utilization: Option<f64>,
pub capacity_utilization_p95: Option<f64>,
pub latency_coverage: f64,
pub first_token_coverage: f64,
pub admission_decision_coverage: f64,
pub account_selection_wait_coverage: f64,
pub capacity_coverage: f64,
pub load: OverviewLoadView,
pub points: Vec<OverviewPerformancePointView>,
}
@@ -697,6 +697,7 @@ pub(super) struct MemoryClientKeyStore(Arc<Mutex<Option<ClientKeyRecord>>>);
pub(super) struct MemoryObservations {
pub summary: Option<UsageOverview>,
pub trend: Option<Vec<RequestMetricPoint>>,
pub concurrency: Vec<gateway_admin::model::observability::UsageConcurrencyPoint>,
pub summaries: Vec<(TimeRange, UsageFilter)>,
pub trends: Vec<(TimeRange, UsageFilter)>,
pub records: Vec<UsageQuery>,
@@ -1526,6 +1527,7 @@ impl ObservabilityStore for UnusedStore {
.clone()
.ok_or_else(|| unavailable("usage trend"))?,
costs: Vec::new(),
concurrency: data.concurrency.clone(),
},
)
}
@@ -1286,6 +1286,102 @@ async fn calendar_query_rejects_an_anchor_outside_the_local_calendar() {
assert!(fixture.dashboard_summary_range.lock().unwrap().is_none());
}
#[tokio::test]
async fn usage_insights_maps_load_summary_and_points_to_camel_case() {
use crate::admin::{AdminTestFixture, AdminTestState};
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use chrono::Duration;
use gateway_admin::model::observability::{
Granularity, LatencyPercentiles, PercentileMilliseconds, RequestMetricPoint,
RequestMetrics, TimeRange, UsageConcurrencyPoint, UsageOverview,
};
use gateway_api::admin::observability;
use tower::ServiceExt as _;
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let now = Utc::now();
let range = TimeRange::new(now - Duration::minutes(15), now).unwrap();
let bucket = range.start;
{
let mut observations = fixture.observations.lock().unwrap();
observations.summary = Some(UsageOverview {
analytics: Default::default(),
range,
requests: RequestMetrics {
admission_decision_percentiles: LatencyPercentiles {
p95_ms: Some(PercentileMilliseconds::new(12.5).unwrap()),
..Default::default()
},
account_selection_wait_percentiles: LatencyPercentiles {
p95_ms: Some(PercentileMilliseconds::new(128.75).unwrap()),
..Default::default()
},
..Default::default()
},
attempts: Default::default(),
providers: Vec::new(),
});
observations.trend = Some(vec![RequestMetricPoint {
bucket_start: bucket,
granularity: Granularity::FifteenMinutes,
metrics: RequestMetrics {
admission_decision_percentiles: LatencyPercentiles {
p95_ms: Some(PercentileMilliseconds::new(2.5).unwrap()),
..Default::default()
},
..Default::default()
},
cost_coverage: Default::default(),
costs: Vec::new(),
}]);
observations.concurrency = vec![UsageConcurrencyPoint {
bucket_start: bucket,
average_concurrency: 1.25,
peak_concurrency: 3,
}];
}
let response = observability::router::<AdminTestState>()
.with_state(fixture.state())
.oneshot(
Request::builder()
.uri("/api/admin/usage/insights/overview")
.header(header::COOKIE, "cpr_session=valid-session")
.header("x-request-id", "req_usage_insights_load")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(status, StatusCode::OK, "{value}");
let performance = &value["data"]["performance"];
assert_eq!(
performance["load"],
json!({
"averageConcurrency": 1.25,
"peakConcurrency": 3,
"admissionDecisionP95Ms": 12.5,
"accountSelectionWaitP95Ms": 128.75,
})
);
assert_eq!(
performance["points"][0]["load"],
json!({
"averageConcurrency": 1.25,
"peakConcurrency": 3,
"admissionDecisionP95Ms": 2.5,
"accountSelectionWaitP95Ms": null,
})
);
}
#[tokio::test]
async fn observability_queries_reject_group_filters_and_analytics_search() {
use crate::admin::{AdminTestFixture, AdminTestState};
@@ -294,7 +294,7 @@ impl PgObservabilityRepository {
"load usage insights snapshot",
|mut connection| async move {
let mut snapshot = analytics::AnalyticsSnapshot::new(&mut connection).await?;
let (mut overview, trend, costs) = snapshot
let (mut overview, trend, costs, concurrency) = snapshot
.insights(range, &filter, granularity, self.timezone)
.await?;
overview.analytics = snapshot.metadata();
@@ -303,6 +303,7 @@ impl PgObservabilityRepository {
overview: admin_usage_overview(overview),
trend: trend.into_iter().map(admin_request_metric_point).collect(),
costs,
concurrency,
})
},
)
@@ -339,6 +339,7 @@ impl<'a> AnalyticsSnapshot<'a> {
UsageOverview,
Vec<RequestMetricPoint>,
Vec<admin_observability::UsageProjectedCostPoint>,
Vec<admin_observability::UsageConcurrencyPoint>,
)> {
let states = self
.load(range, filter, None, Some(granularity), timezone, true)
@@ -354,6 +355,9 @@ impl<'a> AnalyticsSnapshot<'a> {
})
})
.collect::<StoreResult<Vec<_>>>()?;
let concurrency = self
.concurrency(range, filter, granularity, timezone)
.await?;
// 趋势已包含可合并的完整指标和情景费用,概览直接复用同一份状态
// 精确分位数不可跨桶合并,明细回退时仍按完整区间单独计算概览
let overview = if states.values().any(MetricState::has_exact_percentiles) {
@@ -362,7 +366,98 @@ impl<'a> AnalyticsSnapshot<'a> {
self.overview_from_state(range, filter, merge_states(states.into_values())?)
.await?
};
Ok((overview, trend, costs))
Ok((overview, trend, costs, concurrency))
}
async fn concurrency(
&mut self,
range: ObservabilityRange,
filter: &UsageRecordFilter,
granularity: ObservationGranularity,
timezone: gateway_core::time::DeploymentTimeZone,
) -> StoreResult<Vec<admin_observability::UsageConcurrencyPoint>> {
filter.validate()?;
self.require_visible(Some(range))?;
let boundaries = if granularity == ObservationGranularity::Day {
calendar_buckets(range, timezone)?
} else {
let seconds = granularity.seconds();
let mut bucket =
DateTime::from_timestamp(range.start.timestamp().div_euclid(seconds) * seconds, 0)
.ok_or_else(|| invalid("invalid concurrency bucket"))?;
let step = TimeDelta::seconds(seconds);
let mut boundaries = Vec::new();
while bucket < range.end {
boundaries.push(bucket);
bucket = bucket
.checked_add_signed(step)
.ok_or_else(|| invalid("invalid next concurrency bucket"))?;
}
boundaries
};
// 按生命周期分开筛选,使终态请求复用 completed_at 索引,运行中请求沿用独立索引
let mut query = QueryBuilder::<Postgres>::new(
"with buckets as materialized (select greatest(bucket_start,",
);
query
.push_bind(range.start)
.push("::timestamptz) as effective_start,least(coalesce(lead(bucket_start) over(order by bucket_start),")
.push_bind(range.end)
.push("::timestamptz),")
.push_bind(range.end)
.push("::timestamptz) as bucket_end,bucket_start from unnest(")
.push_bind(boundaries)
.push("::timestamptz[]) input(bucket_start)), matching as (select mr.started_at,mr.completed_at from model_request_observations mr where mr.outcome <> 'running' and mr.completed_at > ")
.push_bind(range.start)
.push(" and mr.started_at < ")
.push_bind(range.end);
push_analytics_filter(&mut query, filter, "mr");
query
.push(" union all select mr.started_at,")
.push_bind(range.end)
.push("::timestamptz as completed_at from model_request_observations mr where mr.outcome = 'running' and mr.started_at < ")
.push_bind(range.end);
push_analytics_filter(&mut query, filter, "mr");
// 每个请求只产生两个事件,桶边界只切分时间轴,避免请求与所有桶逐一连接
// 同时刻先合并增减,再计算下一段并发,遵循左闭右开的执行区间
query
.push("), events as (select e.occurred_at,e.delta,null::timestamptz as bucket_start from matching m cross join lateral (values (greatest(m.started_at,")
.push_bind(range.start)
.push("::timestamptz),1::bigint),(least(m.completed_at,")
.push_bind(range.end)
.push("::timestamptz),-1::bigint)) e(occurred_at,delta) union all select effective_start,0::bigint,bucket_start from buckets union all select ")
.push_bind(range.end)
.push("::timestamptz,0::bigint,null::timestamptz),
event_points as (
select occurred_at,sum(delta)::bigint as delta,max(bucket_start) as bucket_start
from events group by occurred_at
), timeline as (
select occurred_at,sum(delta) over timeline_order::bigint as concurrency,
max(bucket_start) over timeline_order as bucket_start,
lead(occurred_at) over(order by occurred_at) as next_occurred_at
from event_points
window timeline_order as (order by occurred_at rows between unbounded preceding and current row)
)
select b.bucket_start,
coalesce(sum(t.concurrency*extract(epoch from t.next_occurred_at-t.occurred_at))
/nullif(extract(epoch from b.bucket_end-b.effective_start),0),0)::float8 as average_concurrency,
coalesce(max(t.concurrency),0)::bigint as peak_concurrency
from buckets b left join timeline t on t.bucket_start=b.bucket_start and t.next_occurred_at>t.occurred_at
group by b.bucket_start,b.effective_start,b.bucket_end order by b.bucket_start");
let rows = query
.build()
.fetch_all(&mut *self.tx)
.await
.map_err(|error| postgres_unavailable("aggregate request concurrency", error))?;
rows.iter()
.map(|row| {
Ok(admin_observability::UsageConcurrencyPoint {
bucket_start: get(row, "bucket_start")?,
average_concurrency: get(row, "average_concurrency")?,
peak_concurrency: unsigned(row, "peak_concurrency")?,
})
})
.collect()
}
pub async fn diagnostics(
@@ -1691,6 +1691,156 @@ async fn daily_rank_counts_follow_rebuilds_rollbacks_deletions_and_dirty_days()
database.close().await;
}
#[tokio::test]
async fn insights_concurrency_counts_logical_request_overlap_once_per_bucket() {
let Some(database) = TestDatabase::create("analytics_concurrency").await else {
return;
};
let start = DateTime::parse_from_rfc3339("2026-09-01T10:00:00Z")
.unwrap()
.with_timezone(&Utc);
for (id, from, to) in [
("concurrency_serial_a", 1, Some(4)),
("concurrency_serial_b", 4, Some(6)),
("concurrency_cross_bucket", 10, Some(25)),
("concurrency_overlap", 12, Some(14)),
("concurrency_running", 30, None),
("concurrency_xai", 5, Some(20)),
("concurrency_instant", 7, Some(7)),
] {
seed(&database.pool, id, start, &[]).await;
sqlx::query("update model_requests set started_at=$2,completed_at=$3,recovery_lease_expires_at=$2+interval '1 hour',provider_kind=case when id='concurrency_xai' then 'xai' else 'openai' end,provider_account_ref='account_concurrency',upstream_transport='http_sse',upstream_send_state='sent',attempt_count=case when id='concurrency_serial_a' then 3 else 1 end,outcome=case when $3 is null then 'running' else 'succeeded' end where id=$1")
.bind(id)
.bind(start + TimeDelta::minutes(from))
.bind(to.map(|minute| start + TimeDelta::minutes(minute)))
.execute(&database.pool)
.await
.unwrap();
}
drain_analytics(&database.pool).await;
let repo = observability_repository(&database.pool);
use gateway_admin::model::observability::Granularity::{FifteenMinutes, Hour};
for (provider, from, to, granularity, expected) in [
(
Some("openai"),
0,
60,
FifteenMinutes,
vec![(0.8, 2), (2.0 / 3.0, 1), (1.0, 1), (1.0, 1)],
),
(Some("openai"), 0, 10, FifteenMinutes, vec![(0.5, 1)]),
(Some("openai"), 4, 6, FifteenMinutes, vec![(1.0, 1)]),
(
Some("openai"),
3,
17,
FifteenMinutes,
vec![(5.0 / 6.0, 2), (1.0, 1)],
),
(Some("openai"), 35, 40, FifteenMinutes, vec![(1.0, 1)]),
(Some("openai"), -15, 0, FifteenMinutes, vec![(0.0, 0)]),
(Some("openai"), 0, 60, Hour, vec![(13.0 / 15.0, 2)]),
(
Some("xai"),
0,
60,
FifteenMinutes,
vec![(2.0 / 3.0, 1), (1.0 / 3.0, 1), (0.0, 0), (0.0, 0)],
),
(
Some("xai"),
10,
17,
FifteenMinutes,
vec![(1.0, 1), (1.0, 1)],
),
(
None,
0,
60,
FifteenMinutes,
vec![(22.0 / 15.0, 3), (1.0, 2), (1.0, 1), (1.0, 1)],
),
] {
let insights = repo
.usage_insights_observation(
ObservabilityRange::new(
start + TimeDelta::minutes(from),
start + TimeDelta::minutes(to),
)
.unwrap(),
UsageRecordFilter {
provider_kind: provider.map(str::to_owned),
..Default::default()
},
granularity,
)
.await
.unwrap();
assert_eq!(insights.concurrency.len(), expected.len());
for (point, (average, peak)) in insights.concurrency.iter().zip(expected) {
assert!(
(point.average_concurrency - average).abs() < 1e-10,
"{provider:?} {from}..{to}: {point:?}"
);
assert_eq!(point.peak_concurrency, peak, "{provider:?} {from}..{to}");
}
}
database.close().await;
}
#[tokio::test]
async fn insights_concurrency_weights_calendar_days_across_dst() {
let Some(database) = TestDatabase::create("analytics_concurrency_days").await else {
return;
};
let repo =
observability_repository(&database.pool).with_timezone("America/New_York".parse().unwrap());
for (date, hours) in [("2026-03-08T05:00:00Z", 23), ("2026-11-01T04:00:00Z", 25)] {
let start = DateTime::parse_from_rfc3339(date)
.unwrap()
.with_timezone(&Utc);
let next_day = start + TimeDelta::hours(hours);
for (suffix, from, to) in [("completed", -1, Some(1)), ("running", 12, None)] {
let id = format!("concurrency_{date}_{suffix}");
seed(&database.pool, &id, start, &[]).await;
sqlx::query("update model_requests set started_at=$2,completed_at=$3,recovery_lease_expires_at=$2+interval '2 days',outcome=case when $3 is null then 'running' else 'succeeded' end,requested_model_id=$4 where id=$1")
.bind(id)
.bind(start + TimeDelta::hours(from))
.bind(to.map(|hour| start + TimeDelta::hours(hour)))
.bind(date)
.execute(&database.pool)
.await
.unwrap();
}
drain_analytics(&database.pool).await;
let insights = repo
.usage_insights_observation(
ObservabilityRange::new(start, next_day + TimeDelta::hours(6)).unwrap(),
UsageRecordFilter {
model: Some(date.to_owned()),
..Default::default()
},
gateway_admin::model::observability::Granularity::Day,
)
.await
.unwrap();
assert_eq!(insights.concurrency.len(), 2);
assert_eq!(insights.concurrency[0].bucket_start, start);
assert!(
(insights.concurrency[0].average_concurrency - (hours - 11) as f64 / hours as f64)
.abs()
< 1e-10
);
assert_eq!(insights.concurrency[0].peak_concurrency, 1);
assert_eq!(insights.concurrency[1].bucket_start, next_day);
assert_eq!(insights.concurrency[1].average_concurrency, 1.0);
assert_eq!(insights.concurrency[1].peak_concurrency, 1);
}
database.close().await;
}
#[tokio::test]
async fn sql_edge_histograms_preserve_repeated_samples_and_power_of_two_boundaries() {
let Some(database) = TestDatabase::create("analytics_sql_histogram_boundaries").await else {
+14 -1
View File
@@ -2124,7 +2124,7 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt
`points[]` 提供趋势图与首页卡片使用的请求数、Tokens、缓存比例、错误数、成功率、
`latencyValue`、首 Token P95、总延迟 P95 和输出吞吐 P50,以及时间标签与展示值
内部调度分位数和容量分布通过用量洞察查询
请求并发与内部调度等待通过用量洞察的 `performance.load` 查询
### Dashboard 客户端身份
@@ -2222,6 +2222,19 @@ OpenAI 推理强度优先取 `input` 中最后一条 `type: "configuration_updat
已登记的尝试即使尚未收到首个上游事件,也会保留真实 attempt。
鉴权、解析、路由和准入等入口拒绝不属于该范围;请求观测仍是可能延迟或丢弃的异步投影
性能洞察的 `performance.load` 和 `performance.points[].load` 使用相同的负载结构,分别表示查询范围汇总与时间桶指标:
| 字段 | 含义 |
| --- | --- |
| `averageConcurrency` | 按有效时长加权的平均请求并发 |
| `peakConcurrency` | 最大重叠请求数 |
| `admissionDecisionP95Ms` | 准入判定耗时 P95,单位毫秒,无样本为 `null` |
| `accountSelectionWaitP95Ms` | 账号选择等待耗时 P95,单位毫秒,无样本为 `null` |
并发按逻辑请求的执行区间计算,不因重试 attempt 重复计数。
区间从 `started_at` 到 `completed_at`,执行中请求按查询范围末端仍在处理计算;与时间桶重叠的请求参与该桶,
平均值按重叠时长加权,峰值取桶内最大重叠请求数,无请求的时间桶为零。全范围平均按有效查询时长加权,峰值取各桶最大值
汇总与洞察中的请求数与 outcome 分布覆盖筛选范围内全部请求;token、缓存、延迟与成本聚合仅统计
已完整交付客户端的成功推理响应。OpenAI 的 `generate: false` 连接与上下文准备记录归类为
`requestKind: "prewarm"`,不进入用量列表、账号用量或额度预测的 Token / 费用覆盖统计,但仍可按
+13 -28
View File
@@ -256,47 +256,32 @@ export interface UsageOverviewHealth {
points: UsageOverviewHealthPoint[]
}
export interface UsageOverviewPerformancePoint {
bucket: string
label: string
latencyP50Ms: number | null
latencyP95Ms: number | null
latencyP99Ms: number | null
firstTokenP50Ms: number | null
firstTokenP95Ms: number | null
firstTokenP99Ms: number | null
admissionDecisionP50Ms: number | null
export interface UsageOverviewLoad {
averageConcurrency: number
peakConcurrency: number
admissionDecisionP95Ms: number | null
accountSelectionWaitP50Ms: number | null
accountSelectionWaitP95Ms: number | null
outputThroughputP10: number | null
outputThroughputP50: number | null
outputThroughputP90: number | null
capacityUtilization: number | null
capacityUtilizationP95: number | null
}
export interface UsageOverviewPerformance {
export interface UsageOverviewPerformanceMetrics {
latencyP50Ms: number | null
latencyP95Ms: number | null
latencyP99Ms: number | null
firstTokenP50Ms: number | null
firstTokenP95Ms: number | null
firstTokenP99Ms: number | null
admissionDecisionP50Ms: number | null
admissionDecisionP95Ms: number | null
accountSelectionWaitP50Ms: number | null
accountSelectionWaitP95Ms: number | null
outputThroughputP10: number | null
outputThroughputP50: number | null
outputThroughputP90: number | null
capacityUtilization: number | null
capacityUtilizationP95: number | null
latencyCoverage: number
firstTokenCoverage: number
admissionDecisionCoverage: number
accountSelectionWaitCoverage: number
capacityCoverage: number
load: UsageOverviewLoad
}
export interface UsageOverviewPerformancePoint extends UsageOverviewPerformanceMetrics {
bucket: string
label: string
}
export interface UsageOverviewPerformance extends UsageOverviewPerformanceMetrics {
points: UsageOverviewPerformancePoint[]
}
+8 -8
View File
@@ -61,21 +61,21 @@
},
"performance": {
"ttftHelp": "TTFT: time to first token",
"scheduling": "Scheduling",
"concurrency": "Concurrency",
"averageConcurrency": "Average concurrency",
"peakConcurrency": "Peak concurrency",
"load": "Load",
"waitTime": "Duration",
"admissionP95": "Admission P95",
"accountSelectionP95": "Selection P95",
"capacityP95": "Capacity P95",
"decisionCoverage": "Decision coverage",
"accountSelectionCoverage": "Selection coverage",
"capacityCoverage": "Capacity coverage",
"title": "Performance",
"description": "Latency, throughput, scheduling",
"description": "Total latency, TTFT, rate, and load",
"performanceIndicators": "Performance indicators",
"loading": "Loading performance",
"noTtftSamples": "No TTFT samples in this range",
"noThroughput": "No throughput samples in this range",
"noScheduling": "No scheduling or capacity samples in this range",
"noLatency": "No latency samples in this range"
"noLatency": "No latency samples in this range",
"noLoad": "No load data in this range"
},
"reasoning": {
"subagentRequest": "Subagent request",
+10 -10
View File
@@ -61,21 +61,21 @@
},
"performance": {
"ttftHelp": "TTFT:首个 Token 耗时",
"scheduling": "调度",
"concurrency": "并发",
"averageConcurrency": "平均并发",
"peakConcurrency": "峰值并发",
"load": "负载",
"waitTime": "耗时",
"admissionP95": "准入 P95",
"accountSelectionP95": "选号 P95",
"capacityP95": "容量 P95",
"decisionCoverage": "判定覆盖",
"accountSelectionCoverage": "选号覆盖",
"capacityCoverage": "容量覆盖",
"accountSelectionP95": "选号等待 P95",
"title": "响应性能",
"description": "延迟、速率与调度分位",
"description": "总耗时、首个 Token 耗时、速率与负载",
"performanceIndicators": "性能指标",
"loading": "正在加载性能数据",
"noTtftSamples": "当前范围暂无 TTFT 样本",
"noThroughput": "当前范围暂无速率样本",
"noScheduling": "当前范围暂无调度或容量样本",
"noLatency": "当前范围暂无延迟样本"
"noLatency": "当前范围暂无延迟样本",
"noLoad": "当前范围暂无负载数据"
},
"reasoning": {
"subagentRequest": "子代理请求",
@@ -119,7 +119,7 @@
"subscription": "订阅",
"platformType": "平台"
},
"outputThroughput": "输出速率",
"outputThroughput": "速率",
"noValidSample": "无有效样本",
"notReachable": "不可达",
"unstable": "不稳定",
+10 -10
View File
@@ -61,21 +61,21 @@
},
"performance": {
"ttftHelp": "TTFT:首個 Token 耗時",
"scheduling": "調度",
"concurrency": "並發",
"averageConcurrency": "平均並發",
"peakConcurrency": "峰值並發",
"load": "負載",
"waitTime": "耗時",
"admissionP95": "准入 P95",
"accountSelectionP95": "選號 P95",
"capacityP95": "容量 P95",
"decisionCoverage": "判定覆蓋",
"accountSelectionCoverage": "選號覆蓋",
"capacityCoverage": "容量覆蓋",
"accountSelectionP95": "選號等待 P95",
"title": "響應性能",
"description": "延遲、速率與調度分位",
"description": "總耗時、首個 Token 耗時、速率與負載",
"performanceIndicators": "性能指標",
"loading": "正在加載性能數據",
"noTtftSamples": "當前範圍暫無 TTFT 樣本",
"noThroughput": "當前範圍暫無速率樣本",
"noScheduling": "當前範圍暫無調度或容量樣本",
"noLatency": "當前範圍暫無延遲樣本"
"noLatency": "當前範圍暫無延遲樣本",
"noLoad": "當前範圍暫無負載資料"
},
"reasoning": {
"subagentRequest": "子代理請求",
@@ -119,7 +119,7 @@
"subscription": "訂閱",
"platformType": "平臺"
},
"outputThroughput": "輸出速率",
"outputThroughput": "速率",
"noValidSample": "無有效樣本",
"notReachable": "不可達",
"unstable": "不穩定",
@@ -265,7 +265,9 @@ function costAmount(value: string | null, pointIndex: number) {
class="@container/cost min-h-90 xl:h-full"
>
<template #actions>
<ZSegmented v-model="activeView" :aria-label="t('usage.cost.costView')" :options="viewOptions" :disabled="loading" />
<div class="flex justify-end">
<ZSegmented v-model="activeView" :aria-label="t('usage.cost.costView')" :options="viewOptions" :disabled="loading" />
</div>
</template>
<template #body>
@@ -150,12 +150,14 @@ function diagnosticNameDisplay(name: string) {
class="max-h-105 min-w-0 w-full xl:h-full"
>
<template #actions>
<ZSegmented
v-model="dimension"
:aria-label="t('usage.diagnostics.diagnosticDimension')"
:options="dimensionOptions"
:disabled="loading"
/>
<div class="flex justify-end">
<ZSegmented
v-model="dimension"
:aria-label="t('usage.diagnostics.diagnosticDimension')"
:options="dimensionOptions"
:disabled="loading"
/>
</div>
</template>
<template #body>
@@ -10,7 +10,7 @@ import BaseChart from '@/components/charts/BaseChart.vue'
import { requestActivityByBucket, zeroInactiveValues } from '@/components/charts/timeSeriesGap'
import { chartTooltipContent, tooltipIndex, tooltipRows } from '@/components/charts/tooltip'
import { useChartPalette } from '@/composables/useChartPalette'
import { formatLocalizedCompactNumber as formatCompactNumber, formatDuration, formatPercent } from '@/utils/format'
import { formatLocalizedCompactNumber as formatCompactNumber, formatDuration } from '@/utils/format'
import {
formatDurationAxis,
@@ -37,6 +37,17 @@ const { t } = useI18n({ useScope: 'global' })
type Performance = Awaited<ReturnType<typeof getUsageRecordInsightsOverview>>['performance']
type PerformancePoint = Performance['points'][number]
type PerformanceMetric = Exclude<keyof PerformancePoint, 'bucket' | 'label' | 'load'>
interface PerformanceSeries {
field: PerformanceMetric | keyof PerformancePoint['load']
value: (point: Omit<Performance, 'points'>) => number | null
label: string
color: string
yAxisIndex: number
format: (value: number | null) => string
}
type Activity = Awaited<ReturnType<typeof getUsageRecordInsightsOverview>>['health']['points']
const activeView = shallowRef('total')
@@ -61,164 +72,128 @@ const viewOptions = computed(() => ([
value: 'throughput',
},
{
label: t('usage.performance.scheduling'),
value: 'scheduling',
label: t('usage.performance.load'),
value: 'load',
},
]))
const seriesLabels = computed(() => {
if (activeView.value === 'throughput')
return ['P10', 'P50', 'P90'] as const
if (activeView.value === 'scheduling') {
const seriesDefinitions = computed<PerformanceSeries[]>(() => {
const theme = palette.value
if (activeView.value === 'load') {
return [
t('usage.performance.admissionP95'),
t('usage.performance.accountSelectionP95'),
t('usage.performance.capacityP95'),
] as const
{
field: 'averageConcurrency',
value: point => point.load.averageConcurrency,
label: t('usage.performance.averageConcurrency'),
color: theme.info,
yAxisIndex: 0,
format: formatConcurrency,
},
{
field: 'peakConcurrency',
value: point => point.load.peakConcurrency,
label: t('usage.performance.peakConcurrency'),
color: theme.warning,
yAxisIndex: 0,
format: formatConcurrency,
},
{
field: 'admissionDecisionP95Ms',
value: point => point.load.admissionDecisionP95Ms,
label: t('usage.performance.admissionP95'),
color: theme.success,
yAxisIndex: 1,
format: formatDuration,
},
{
field: 'accountSelectionWaitP95Ms',
value: point => point.load.accountSelectionWaitP95Ms,
label: t('usage.performance.accountSelectionP95'),
color: theme.reasoning,
yAxisIndex: 1,
format: formatDuration,
},
]
}
return ['P50', 'P95', 'P99'] as const
})
const selectedPoints = computed(() => {
const points = performancePoints.value
const active = requestActivity.value
const firstValues = zeroInactiveValues(
points.map(point => seriesValue(point, 'first')),
active,
)
const secondValues = zeroInactiveValues(
points.map(point => seriesValue(point, 'second')),
active,
)
const thirdValues = zeroInactiveValues(
points.map(point => seriesValue(point, 'third')),
active,
)
return points.map((point, index) => ({
point,
first: firstValues[index] ?? null,
second: secondValues[index] ?? null,
third: thirdValues[index] ?? null,
const throughput = activeView.value === 'throughput'
const fields: PerformanceMetric[] = throughput
? ['outputThroughputP10', 'outputThroughputP50', 'outputThroughputP90']
: activeView.value === 'firstToken'
? ['firstTokenP50Ms', 'firstTokenP95Ms', 'firstTokenP99Ms']
: ['latencyP50Ms', 'latencyP95Ms', 'latencyP99Ms']
const labels = throughput
? ['P10', 'P50', 'P90']
: ['P50', 'P95', 'P99']
const colors = [theme.info, theme.warning, theme.danger]
return fields.map((field, index) => ({
field,
value: point => point[field],
label: labels[index]!,
color: colors[index]!,
yAxisIndex: 0,
format: throughput ? formatThroughput : formatDuration,
}))
})
const summaryMetrics = computed(() => {
const performance = props.performance
if (activeView.value === 'throughput') {
return [
{
label: 'P10',
value: formatThroughput(performance.outputThroughputP10),
},
{
label: 'P50',
value: formatThroughput(performance.outputThroughputP50),
},
{
label: 'P90',
value: formatThroughput(performance.outputThroughputP90),
},
]
const selectedSeries = computed(() => seriesDefinitions.value.map((series) => {
const values = performancePoints.value.map(series.value)
return {
...series,
// 并发包含之前时间桶开始的请求,不按本桶新请求数补零
values: series.field === 'averageConcurrency' || series.field === 'peakConcurrency'
? values
: zeroInactiveValues(values, requestActivity.value),
}
if (activeView.value === 'scheduling') {
return [
{
label: t('usage.performance.decisionCoverage'),
value: formatPercent(performance.admissionDecisionCoverage),
},
{
label: t('usage.performance.accountSelectionCoverage'),
value: formatPercent(performance.accountSelectionWaitCoverage),
},
{
label: t('usage.performance.capacityCoverage'),
value: formatPercent(performance.capacityCoverage),
},
]
}
const prefix = activeView.value === 'firstToken'
? 'firstToken'
: 'latency'
return [
{
label: 'P50',
value: formatDuration(performance[`${prefix}P50Ms`]),
},
{
label: 'P95',
value: formatDuration(performance[`${prefix}P95Ms`]),
},
{
label: 'P99',
value: formatDuration(performance[`${prefix}P99Ms`]),
},
]
})
}))
const hasData = computed(
() =>
!props.loading
&& performancePoints.value.some(
point =>
seriesValue(point, 'first') != null
|| seriesValue(point, 'second') != null
|| seriesValue(point, 'third') != null,
),
)
const summaryMetrics = computed(() => seriesDefinitions.value.map(series => ({
label: series.label,
value: series.format(series.value(props.performance)),
})))
const hasData = computed(() => !props.loading
&& seriesDefinitions.value.some(series => performancePoints.value.some(point => series.value(point) != null)))
const chartOption = computed<EChartsOption>(() => {
const theme = palette.value
const points = selectedPoints.value
const series = [
...usageGapAwareLineSeries(
seriesLabels.value[0],
points.map(point => point.first),
theme.info,
{ area: 'subtle' },
),
...usageGapAwareLineSeries(
seriesLabels.value[1],
points.map(point => point.second),
theme.warning,
),
...usageGapAwareLineSeries(
seriesLabels.value[2],
points.map(point => point.third),
activeView.value === 'scheduling'
? theme.success
: theme.danger,
{
yAxisIndex: activeView.value === 'scheduling'
? 1
: 0,
},
),
]
const load = activeView.value === 'load'
const series = selectedSeries.value.flatMap((item, index) => usageGapAwareLineSeries(
item.label,
item.values,
item.color,
{
yAxisIndex: item.yAxisIndex,
area: index === 0
? 'subtle'
: false,
},
))
return {
animationDuration: 240,
grid: {
left: 0,
right: 0,
top: 40,
top: load ? 60 : 40,
bottom: 0,
outerBoundsMode: 'same',
outerBoundsContain: 'axisLabel',
},
legend: usageLegend(theme, [...seriesLabels.value]),
legend: usageLegend(theme, selectedSeries.value.map(series => series.label)),
tooltip: usageTooltip(theme, formatTooltip),
xAxis: usageCategoryAxis(
points.map(({ point }) => point.label),
theme,
),
yAxis: activeView.value === 'scheduling'
xAxis: usageCategoryAxis(performancePoints.value.map(point => point.label), theme),
yAxis: load
? [
usageValueAxis(theme, formatDurationAxis),
usageValueAxis(theme, value => formatPercent(value), {
min: 0,
max: 1,
splitLine: false,
}),
{
...usageValueAxis(theme, formatCompactNumber, { min: 0 }),
name: t('usage.performance.concurrency'),
nameTextStyle: { color: theme.textMuted, fontSize: 10, align: 'left' },
},
{
...usageValueAxis(theme, formatDurationAxis, { min: 0, splitLine: false }),
name: t('usage.performance.waitTime'),
nameTextStyle: { color: theme.textMuted, fontSize: 10, align: 'right' },
},
]
: usageValueAxis(
theme,
@@ -230,66 +205,24 @@ const chartOption = computed<EChartsOption>(() => {
}
})
function seriesValue(point: PerformancePoint, position: 'first' | 'second' | 'third') {
if (activeView.value === 'throughput') {
if (position === 'first')
return point.outputThroughputP10
if (position === 'second')
return point.outputThroughputP50
return point.outputThroughputP90
}
if (activeView.value === 'scheduling') {
if (position === 'first')
return point.admissionDecisionP95Ms
if (position === 'second')
return point.accountSelectionWaitP95Ms
return point.capacityUtilizationP95
}
const prefix = activeView.value === 'firstToken'
? 'firstToken'
: 'latency'
const suffix = position === 'first'
? 'P50Ms'
: position === 'second'
? 'P95Ms'
: 'P99Ms'
return point[`${prefix}${suffix}`]
}
function formatTooltip(params: unknown) {
const rows = tooltipRows(params)
const selected = selectedPoints.value[tooltipIndex(rows[0])]
if (!selected)
const index = tooltipIndex(rows[0])
const point = performancePoints.value[index]
if (!point)
return ''
const theme = palette.value
return chartTooltipContent(selected.point.label, [
usageTooltipItem(
seriesLabels.value[0],
formatSeriesValue(selected.first, 'first'),
theme.info,
),
usageTooltipItem(
seriesLabels.value[1],
formatSeriesValue(selected.second, 'second'),
theme.warning,
),
usageTooltipItem(
seriesLabels.value[2],
formatSeriesValue(selected.third, 'third'),
activeView.value === 'scheduling'
? theme.success
: theme.danger,
),
])
return chartTooltipContent(point.label, selectedSeries.value.map(series => usageTooltipItem(
series.label,
series.format(series.values[index] ?? null),
series.color,
)))
}
function formatSeriesValue(value: number | null, position: 'first' | 'second' | 'third') {
if (activeView.value === 'throughput')
return formatThroughput(value)
if (activeView.value === 'scheduling' && position === 'third')
return formatPercent(value)
return formatDuration(value)
function formatConcurrency(value: number | null) {
return value == null
? '—'
: formatCompactNumber(value)
}
function formatThroughput(value: number | null) {
@@ -307,14 +240,20 @@ function formatThroughput(value: number | null) {
class="min-h-90 xl:h-full"
>
<template #actions>
<ZSegmented v-model="activeView" :aria-label="t('usage.performance.performanceIndicators')" :options="viewOptions" :disabled="loading" />
<div class="flex justify-end">
<ZSegmented v-model="activeView" :aria-label="t('usage.performance.performanceIndicators')" :options="viewOptions" :disabled="loading" />
</div>
</template>
<template #body>
<div class="grid min-h-66 gap-3">
<div v-if="hasData" class="grid grid-cols-3 gap-2 rounded-xl bg-cp-fill-quaternary/45 p-2">
<div
v-if="hasData"
class="grid gap-2 rounded-xl bg-cp-fill-quaternary/45 p-2"
:class="activeView === 'load' ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-3'"
>
<div v-for="metric in summaryMetrics" :key="metric.label" class="grid min-w-0 gap-1 px-2">
<span class="truncate text-[10px] font-bold text-cp-text-quaternary">{{ metric.label }}</span>
<span class="truncate text-[10px] font-bold text-cp-text-quaternary" :title="metric.label">{{ metric.label }}</span>
<strong class="truncate font-mono text-cp-base font-heavy tabular-nums text-cp-text" :title="metric.value">
{{ metric.value }}
</strong>
@@ -328,12 +267,12 @@ function formatThroughput(value: number | null) {
:title="
loading
? t('usage.performance.loading')
: activeView === 'firstToken'
? t('usage.performance.noTtftSamples')
: activeView === 'throughput'
? t('usage.performance.noThroughput')
: activeView === 'scheduling'
? t('usage.performance.noScheduling')
: activeView === 'load'
? t('usage.performance.noLoad')
: activeView === 'firstToken'
? t('usage.performance.noTtftSamples')
: activeView === 'throughput'
? t('usage.performance.noThroughput')
: t('usage.performance.noLatency')
"
class="h-52.5 place-content-center"
@@ -318,20 +318,15 @@ function emptyOverview() {
firstTokenP50Ms: null,
firstTokenP95Ms: null,
firstTokenP99Ms: null,
admissionDecisionP50Ms: null,
admissionDecisionP95Ms: null,
accountSelectionWaitP50Ms: null,
accountSelectionWaitP95Ms: null,
outputThroughputP10: null,
outputThroughputP50: null,
outputThroughputP90: null,
capacityUtilization: null,
capacityUtilizationP95: null,
latencyCoverage: 0,
firstTokenCoverage: 0,
admissionDecisionCoverage: 0,
accountSelectionWaitCoverage: 0,
capacityCoverage: 0,
load: {
averageConcurrency: 0,
peakConcurrency: 0,
admissionDecisionP95Ms: null,
accountSelectionWaitP95Ms: null,
},
points: [],
},
cost: {