mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: 提前记录 pending 用量、优化流遥测时序与前端活跃请求发现机制
- 将 record_pending 调用移至执行开始前(sync/stream 两路),确保请求在执行前即有 pending 记录 - stream_pump 在收到第一个数据块前优先 yield 遥测帧,保证 ttfb 早于 data 帧到达 - stream execution 增加 should_refresh_stream_usage_telemetry,在遥测帧携带新 ttfb/elapsed 时及时更新 record_stream_started - access_log 对高频轮询路径(usage/active、usage/records 等)降级为 TRACE 日志,减少日志噪音 - 前端新增 reconcileActiveRequestDiscovery 工具函数及 discoverActiveRequests 逻辑,活跃请求发现与全局自动刷新解耦,空闲时降频为 5 秒扫描 - RequestDetailDrawer 调整:进行中请求不再自动开启轮询,由用户手动触发;刷新按钮 title 动态适配状态
This commit is contained in:
@@ -70,6 +70,10 @@ pub(crate) async fn execute_execution_runtime_stream(
|
|||||||
mut report_context: Option<serde_json::Value>,
|
mut report_context: Option<serde_json::Value>,
|
||||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||||
ensure_execution_request_candidate_slot(state, &mut plan, &mut report_context).await;
|
ensure_execution_request_candidate_slot(state, &mut plan, &mut report_context).await;
|
||||||
|
state
|
||||||
|
.usage_runtime
|
||||||
|
.record_pending(state.data.as_ref(), &plan, report_context.as_ref())
|
||||||
|
.await;
|
||||||
let plan_request_id_for_log = short_request_id(plan.request_id.as_str());
|
let plan_request_id_for_log = short_request_id(plan.request_id.as_str());
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
{
|
{
|
||||||
@@ -235,6 +239,19 @@ where
|
|||||||
read_next_frame(lines).await
|
read_next_frame(lines).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn should_refresh_stream_usage_telemetry(
|
||||||
|
previous: Option<&ExecutionTelemetry>,
|
||||||
|
next: &ExecutionTelemetry,
|
||||||
|
) -> bool {
|
||||||
|
let previous_ttfb = previous.and_then(|telemetry| telemetry.ttfb_ms);
|
||||||
|
let previous_elapsed = previous.and_then(|telemetry| telemetry.elapsed_ms);
|
||||||
|
let next_ttfb = next.ttfb_ms;
|
||||||
|
let next_elapsed = next.elapsed_ms;
|
||||||
|
|
||||||
|
(next_ttfb.is_some() && next_ttfb != previous_ttfb)
|
||||||
|
|| (next_elapsed.is_some() && next_elapsed != previous_elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
async fn probe_local_stream_success_failover_text<R>(
|
async fn probe_local_stream_success_failover_text<R>(
|
||||||
buffered_frames: &mut VecDeque<StreamFrame>,
|
buffered_frames: &mut VecDeque<StreamFrame>,
|
||||||
lines: &mut FramedRead<R, LinesCodec>,
|
lines: &mut FramedRead<R, LinesCodec>,
|
||||||
@@ -740,10 +757,6 @@ async fn execute_stream_from_frame_stream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let candidate_started_unix_secs = current_request_candidate_unix_ms();
|
let candidate_started_unix_secs = current_request_candidate_unix_ms();
|
||||||
state
|
|
||||||
.usage_runtime
|
|
||||||
.record_pending(state.data.as_ref(), &plan, report_context.as_ref())
|
|
||||||
.await;
|
|
||||||
state
|
state
|
||||||
.usage_runtime
|
.usage_runtime
|
||||||
.record_stream_started(
|
.record_stream_started(
|
||||||
@@ -794,7 +807,8 @@ async fn execute_stream_from_frame_stream(
|
|||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut provider_buffered_body = provider_prefetched_body_for_report;
|
let mut provider_buffered_body = provider_prefetched_body_for_report;
|
||||||
let mut buffered_body = prefetched_body_for_report;
|
let mut buffered_body = prefetched_body_for_report;
|
||||||
let mut telemetry: Option<ExecutionTelemetry> = initial_telemetry;
|
let mut telemetry: Option<ExecutionTelemetry> = initial_telemetry.clone();
|
||||||
|
let mut usage_stream_telemetry: Option<ExecutionTelemetry> = initial_telemetry;
|
||||||
let reached_eof = initial_reached_eof;
|
let reached_eof = initial_reached_eof;
|
||||||
let mut downstream_dropped = false;
|
let mut downstream_dropped = false;
|
||||||
let mut terminal_failure: Option<StreamFailureReport> = None;
|
let mut terminal_failure: Option<StreamFailureReport> = None;
|
||||||
@@ -928,7 +942,25 @@ async fn execute_stream_from_frame_stream(
|
|||||||
StreamFramePayload::Telemetry {
|
StreamFramePayload::Telemetry {
|
||||||
telemetry: frame_telemetry,
|
telemetry: frame_telemetry,
|
||||||
} => {
|
} => {
|
||||||
telemetry = Some(frame_telemetry);
|
let should_refresh_stream_usage = should_refresh_stream_usage_telemetry(
|
||||||
|
usage_stream_telemetry.as_ref(),
|
||||||
|
&frame_telemetry,
|
||||||
|
);
|
||||||
|
telemetry = Some(frame_telemetry.clone());
|
||||||
|
if should_refresh_stream_usage {
|
||||||
|
state_for_report
|
||||||
|
.usage_runtime
|
||||||
|
.record_stream_started(
|
||||||
|
state_for_report.data.as_ref(),
|
||||||
|
&plan_for_report,
|
||||||
|
report_context_owned.as_ref(),
|
||||||
|
status_code,
|
||||||
|
&headers_for_report,
|
||||||
|
Some(&frame_telemetry),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
usage_stream_telemetry = Some(frame_telemetry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
StreamFramePayload::Eof { .. } => {
|
StreamFramePayload::Eof { .. } => {
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ pub(crate) fn build_direct_execution_frame_stream(
|
|||||||
|
|
||||||
let mut upstream_bytes = 0u64;
|
let mut upstream_bytes = 0u64;
|
||||||
let mut ttfb_ms = None;
|
let mut ttfb_ms = None;
|
||||||
|
let mut first_chunk_telemetry_emitted = false;
|
||||||
let mut bytes_stream = response.bytes_stream();
|
let mut bytes_stream = response.bytes_stream();
|
||||||
while let Some(item) = bytes_stream.next().await {
|
while let Some(item) = bytes_stream.next().await {
|
||||||
match item {
|
match item {
|
||||||
@@ -49,6 +50,26 @@ pub(crate) fn build_direct_execution_frame_stream(
|
|||||||
if ttfb_ms.is_none() {
|
if ttfb_ms.is_none() {
|
||||||
ttfb_ms = Some(started_at.elapsed().as_millis() as u64);
|
ttfb_ms = Some(started_at.elapsed().as_millis() as u64);
|
||||||
}
|
}
|
||||||
|
if !first_chunk_telemetry_emitted {
|
||||||
|
let telemetry_frame = StreamFrame {
|
||||||
|
frame_type: StreamFrameType::Telemetry,
|
||||||
|
payload: StreamFramePayload::Telemetry {
|
||||||
|
telemetry: ExecutionTelemetry {
|
||||||
|
ttfb_ms,
|
||||||
|
elapsed_ms: ttfb_ms,
|
||||||
|
upstream_bytes: Some(upstream_bytes),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
match encode_stream_frame_ndjson(&telemetry_frame) {
|
||||||
|
Ok(frame) => yield Ok(frame),
|
||||||
|
Err(err) => {
|
||||||
|
yield Err(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
first_chunk_telemetry_emitted = true;
|
||||||
|
}
|
||||||
upstream_bytes += chunk.len() as u64;
|
upstream_bytes += chunk.len() as u64;
|
||||||
let frame = StreamFrame {
|
let frame = StreamFrame {
|
||||||
frame_type: StreamFrameType::Data,
|
frame_type: StreamFrameType::Data,
|
||||||
@@ -217,4 +238,103 @@ mod tests {
|
|||||||
"telemetry frame should include a measured ttfb"
|
"telemetry frame should include a measured ttfb"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn direct_execution_frame_stream_emits_telemetry_before_first_data_frame() {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("listener should bind");
|
||||||
|
let addr = listener.local_addr().expect("local addr should resolve");
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let app = Router::new().route(
|
||||||
|
"/stream",
|
||||||
|
post(|| async {
|
||||||
|
let body_stream = stream! {
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
yield Ok::<Bytes, Infallible>(Bytes::from_static(b"data: hello\n\n"));
|
||||||
|
};
|
||||||
|
(
|
||||||
|
[(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("text/event-stream"),
|
||||||
|
)],
|
||||||
|
Body::from_stream(body_stream),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.await
|
||||||
|
.expect("server should start");
|
||||||
|
});
|
||||||
|
|
||||||
|
let runtime = DirectSyncExecutionRuntime::new();
|
||||||
|
let execution = runtime
|
||||||
|
.execute_stream(ExecutionPlan {
|
||||||
|
request_id: "req-telemetry-order".to_string(),
|
||||||
|
candidate_id: Some("cand-telemetry-order".to_string()),
|
||||||
|
provider_name: Some("OpenAI".to_string()),
|
||||||
|
provider_id: "provider-1".to_string(),
|
||||||
|
endpoint_id: "endpoint-1".to_string(),
|
||||||
|
key_id: "key-1".to_string(),
|
||||||
|
method: "POST".to_string(),
|
||||||
|
url: format!("http://{addr}/stream"),
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
content_type: None,
|
||||||
|
content_encoding: None,
|
||||||
|
body: RequestBody {
|
||||||
|
json_body: None,
|
||||||
|
body_bytes_b64: None,
|
||||||
|
body_ref: None,
|
||||||
|
},
|
||||||
|
stream: true,
|
||||||
|
client_api_format: "openai:chat".to_string(),
|
||||||
|
provider_api_format: "openai:chat".to_string(),
|
||||||
|
model_name: Some("gpt-5".into()),
|
||||||
|
proxy: None,
|
||||||
|
tls_profile: None,
|
||||||
|
timeouts: Some(ExecutionTimeouts {
|
||||||
|
connect_ms: Some(5_000),
|
||||||
|
total_ms: Some(5_000),
|
||||||
|
..ExecutionTimeouts::default()
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("stream execution should succeed");
|
||||||
|
|
||||||
|
let frames = build_direct_execution_frame_stream(execution)
|
||||||
|
.map(|item| item.expect("frame should encode"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.map(|bytes| String::from_utf8(bytes.to_vec()).expect("frame should be utf8"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
server.abort();
|
||||||
|
|
||||||
|
let frame_types = frames
|
||||||
|
.iter()
|
||||||
|
.map(|line| {
|
||||||
|
serde_json::from_str::<Value>(line)
|
||||||
|
.expect("frame should parse")
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let first_data_idx = frame_types
|
||||||
|
.iter()
|
||||||
|
.position(|kind| kind == "data")
|
||||||
|
.expect("data frame should exist");
|
||||||
|
let first_telemetry_idx = frame_types
|
||||||
|
.iter()
|
||||||
|
.position(|kind| kind == "telemetry")
|
||||||
|
.expect("telemetry frame should exist");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
first_telemetry_idx < first_data_idx,
|
||||||
|
"first telemetry frame should be emitted before the first data frame"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ pub(crate) async fn execute_execution_runtime_sync(
|
|||||||
let plan_request_id_for_log = short_request_id(plan_request_id);
|
let plan_request_id_for_log = short_request_id(plan_request_id);
|
||||||
let plan_candidate_id = plan.candidate_id.as_deref();
|
let plan_candidate_id = plan.candidate_id.as_deref();
|
||||||
let candidate_started_unix_secs = current_request_candidate_unix_ms();
|
let candidate_started_unix_secs = current_request_candidate_unix_ms();
|
||||||
|
state
|
||||||
|
.usage_runtime
|
||||||
|
.record_pending(state.data.as_ref(), &plan, report_context.as_ref())
|
||||||
|
.await;
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
let result = {
|
let result = {
|
||||||
match DirectSyncExecutionRuntime::new()
|
match DirectSyncExecutionRuntime::new()
|
||||||
@@ -276,10 +280,6 @@ pub(crate) async fn execute_execution_runtime_sync(
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
state
|
|
||||||
.usage_runtime
|
|
||||||
.record_pending(state.data.as_ref(), &plan, report_context.as_ref())
|
|
||||||
.await;
|
|
||||||
let terminal_unix_secs = current_request_candidate_unix_ms();
|
let terminal_unix_secs = current_request_candidate_unix_ms();
|
||||||
record_local_request_candidate_status(
|
record_local_request_candidate_status(
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ use crate::constants::{
|
|||||||
};
|
};
|
||||||
use crate::control::GatewayControlDecision;
|
use crate::control::GatewayControlDecision;
|
||||||
use crate::control::GatewayPublicRequestContext;
|
use crate::control::GatewayPublicRequestContext;
|
||||||
use crate::middleware::RequestLogEmitted;
|
use crate::middleware::{should_downgrade_access_log, RequestLogEmitted};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use aether_runtime::{maybe_hold_axum_response_permit, AdmissionPermit};
|
use aether_runtime::{maybe_hold_axum_response_permit, AdmissionPermit};
|
||||||
use axum::body::{Body, Bytes};
|
use axum::body::{Body, Bytes};
|
||||||
use axum::http::{self, header::HeaderName, header::HeaderValue, Response};
|
use axum::http::{self, header::HeaderName, header::HeaderValue, Response};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, trace, warn};
|
||||||
|
|
||||||
pub(super) fn request_wants_stream(
|
pub(super) fn request_wants_stream(
|
||||||
request_context: &GatewayPublicRequestContext,
|
request_context: &GatewayPublicRequestContext,
|
||||||
@@ -113,6 +113,24 @@ pub(super) fn finalize_gateway_response(
|
|||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
"gateway request failed"
|
"gateway request failed"
|
||||||
);
|
);
|
||||||
|
} else if should_downgrade_access_log(method, path_and_query) {
|
||||||
|
trace!(
|
||||||
|
event_name = "http_request_completed",
|
||||||
|
log_type = "access",
|
||||||
|
status = "completed",
|
||||||
|
status_code,
|
||||||
|
trace_id = %trace_id,
|
||||||
|
request_id,
|
||||||
|
remote_addr = %remote_addr,
|
||||||
|
method = %method,
|
||||||
|
path = %path_and_query,
|
||||||
|
route_class,
|
||||||
|
execution_path,
|
||||||
|
dependency_reason = dependency_reason.as_str(),
|
||||||
|
local_execution_runtime_miss_reason = local_execution_runtime_miss_reason.as_str(),
|
||||||
|
elapsed_ms,
|
||||||
|
"gateway completed request"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
info!(
|
info!(
|
||||||
event_name = "http_request_completed",
|
event_name = "http_request_completed",
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ use std::time::Instant;
|
|||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::Request;
|
use axum::extract::Request;
|
||||||
use axum::http::header::{HeaderName, HeaderValue};
|
use axum::http::header::{HeaderName, HeaderValue};
|
||||||
|
use axum::http::Method;
|
||||||
use axum::middleware::Next;
|
use axum::middleware::Next;
|
||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, trace, warn};
|
||||||
|
|
||||||
use crate::constants::{
|
use crate::constants::{
|
||||||
CONTROL_REQUEST_ID_HEADER, CONTROL_ROUTE_CLASS_HEADER, EXECUTION_PATH_HEADER, TRACE_ID_HEADER,
|
CONTROL_REQUEST_ID_HEADER, CONTROL_ROUTE_CLASS_HEADER, EXECUTION_PATH_HEADER, TRACE_ID_HEADER,
|
||||||
@@ -16,6 +17,41 @@ use crate::log_ids::short_request_id;
|
|||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub(crate) struct RequestLogEmitted;
|
pub(crate) struct RequestLogEmitted;
|
||||||
|
|
||||||
|
fn is_usage_detail_path(path: &str) -> bool {
|
||||||
|
let Some(detail_id) = path.strip_prefix("/api/admin/usage/") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
!detail_id.is_empty()
|
||||||
|
&& !detail_id.contains('/')
|
||||||
|
&& !matches!(detail_id, "active" | "records" | "stats" | "heatmap")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn should_downgrade_access_log(method: &Method, path: &str) -> bool {
|
||||||
|
if method != Method::GET {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let normalized_path = path.split('?').next().unwrap_or(path);
|
||||||
|
matches!(
|
||||||
|
normalized_path,
|
||||||
|
"/api/admin/usage/active"
|
||||||
|
| "/api/users/me/usage/active"
|
||||||
|
| "/api/admin/usage/records"
|
||||||
|
| "/api/admin/usage/stats"
|
||||||
|
| "/api/admin/usage/aggregation/stats"
|
||||||
|
| "/api/admin/usage/heatmap"
|
||||||
|
| "/api/admin/usage/cache-affinity/interval-timeline"
|
||||||
|
| "/api/admin/usage/cache-affinity/ttl-analysis"
|
||||||
|
| "/api/admin/usage/cache-affinity/hit-analysis"
|
||||||
|
| "/api/admin/users"
|
||||||
|
| "/api/admin/monitoring/cache/stats"
|
||||||
|
| "/api/admin/monitoring/cache/model-mapping/stats"
|
||||||
|
| "/api/admin/monitoring/cache/config"
|
||||||
|
| "/api/admin/monitoring/cache/redis-keys"
|
||||||
|
| "/api/admin/monitoring/cache/affinities"
|
||||||
|
) || is_usage_detail_path(normalized_path)
|
||||||
|
|| normalized_path.starts_with("/api/admin/monitoring/trace/")
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn access_log_middleware(request: Request<Body>, next: Next) -> Response {
|
pub(crate) async fn access_log_middleware(request: Request<Body>, next: Next) -> Response {
|
||||||
let started_at = Instant::now();
|
let started_at = Instant::now();
|
||||||
let method = request.method().clone();
|
let method = request.method().clone();
|
||||||
@@ -25,18 +61,33 @@ pub(crate) async fn access_log_middleware(request: Request<Body>, next: Next) ->
|
|||||||
.map(|value| value.as_str().to_string())
|
.map(|value| value.as_str().to_string())
|
||||||
.unwrap_or_else(|| "/".to_string());
|
.unwrap_or_else(|| "/".to_string());
|
||||||
let trace_id = extract_or_generate_trace_id(request.headers());
|
let trace_id = extract_or_generate_trace_id(request.headers());
|
||||||
info!(
|
if should_downgrade_access_log(&method, &path) {
|
||||||
event_name = "http_request_started",
|
trace!(
|
||||||
log_type = "access",
|
event_name = "http_request_started",
|
||||||
status = "started",
|
log_type = "access",
|
||||||
trace_id = %trace_id,
|
status = "started",
|
||||||
request_id = "-",
|
trace_id = %trace_id,
|
||||||
method = %method,
|
request_id = "-",
|
||||||
path = %path,
|
method = %method,
|
||||||
route_class = "pending",
|
path = %path,
|
||||||
execution_path = "pending",
|
route_class = "pending",
|
||||||
"gateway request started"
|
execution_path = "pending",
|
||||||
);
|
"gateway request started"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
info!(
|
||||||
|
event_name = "http_request_started",
|
||||||
|
log_type = "access",
|
||||||
|
status = "started",
|
||||||
|
trace_id = %trace_id,
|
||||||
|
request_id = "-",
|
||||||
|
method = %method,
|
||||||
|
path = %path,
|
||||||
|
route_class = "pending",
|
||||||
|
execution_path = "pending",
|
||||||
|
"gateway request started"
|
||||||
|
);
|
||||||
|
}
|
||||||
let mut response = next.run(request).await;
|
let mut response = next.run(request).await;
|
||||||
if !response.headers().contains_key(TRACE_ID_HEADER) {
|
if !response.headers().contains_key(TRACE_ID_HEADER) {
|
||||||
response.headers_mut().insert(
|
response.headers_mut().insert(
|
||||||
@@ -79,6 +130,21 @@ pub(crate) async fn access_log_middleware(request: Request<Body>, next: Next) ->
|
|||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
"gateway request failed"
|
"gateway request failed"
|
||||||
);
|
);
|
||||||
|
} else if should_downgrade_access_log(&method, &path) {
|
||||||
|
trace!(
|
||||||
|
event_name = "http_request_completed",
|
||||||
|
log_type = "access",
|
||||||
|
status = "completed",
|
||||||
|
status_code,
|
||||||
|
trace_id = %trace_id,
|
||||||
|
request_id,
|
||||||
|
method = %method,
|
||||||
|
path = %path,
|
||||||
|
route_class,
|
||||||
|
execution_path,
|
||||||
|
elapsed_ms,
|
||||||
|
"gateway completed request"
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
info!(
|
info!(
|
||||||
event_name = "http_request_completed",
|
event_name = "http_request_completed",
|
||||||
@@ -101,19 +167,20 @@ pub(crate) async fn access_log_middleware(request: Request<Body>, next: Next) ->
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::access_log_middleware;
|
use super::{access_log_middleware, should_downgrade_access_log};
|
||||||
use crate::constants::{
|
use crate::constants::{
|
||||||
CONTROL_REQUEST_ID_HEADER, CONTROL_ROUTE_CLASS_HEADER, EXECUTION_PATH_HEADER,
|
CONTROL_REQUEST_ID_HEADER, CONTROL_ROUTE_CLASS_HEADER, EXECUTION_PATH_HEADER,
|
||||||
TRACE_ID_HEADER,
|
TRACE_ID_HEADER,
|
||||||
};
|
};
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::http::{Request, Response, StatusCode};
|
use axum::http::{Method, Request, Response, StatusCode};
|
||||||
use axum::routing::get;
|
use axum::routing::get;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures_util::stream;
|
use futures_util::stream;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
use tracing_subscriber::filter::LevelFilter;
|
||||||
use tracing_subscriber::prelude::*;
|
use tracing_subscriber::prelude::*;
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
@@ -416,4 +483,88 @@ mod tests {
|
|||||||
assert_eq!(logs[1]["route_class"], "ai_public");
|
assert_eq!(logs[1]["route_class"], "ai_public");
|
||||||
assert_eq!(logs[1]["execution_path"], "execution_runtime_stream");
|
assert_eq!(logs[1]["execution_path"], "execution_runtime_stream");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn access_log_downgrades_usage_active_polling_to_trace() {
|
||||||
|
let writer = SharedBuffer::default();
|
||||||
|
let subscriber = tracing_subscriber::registry().with(
|
||||||
|
tracing_subscriber::fmt::layer()
|
||||||
|
.json()
|
||||||
|
.flatten_event(true)
|
||||||
|
.with_current_span(false)
|
||||||
|
.with_span_list(false)
|
||||||
|
.with_writer(writer.clone())
|
||||||
|
.with_filter(LevelFilter::TRACE),
|
||||||
|
);
|
||||||
|
let dispatch = tracing::Dispatch::new(subscriber);
|
||||||
|
let _guard = tracing::dispatcher::set_default(&dispatch);
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route(
|
||||||
|
"/api/admin/usage/active",
|
||||||
|
get(|| async {
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(CONTROL_ROUTE_CLASS_HEADER, "admin_proxy")
|
||||||
|
.header(EXECUTION_PATH_HEADER, "public_proxy_passthrough")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("response should build")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.layer(axum::middleware::from_fn(access_log_middleware));
|
||||||
|
|
||||||
|
let _response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/api/admin/usage/active?ids=req-1")
|
||||||
|
.body(Body::empty())
|
||||||
|
.expect("request should build"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
|
||||||
|
let logs = writer.lines();
|
||||||
|
assert_eq!(logs.len(), 2);
|
||||||
|
assert_eq!(logs[0]["level"], "TRACE");
|
||||||
|
assert_eq!(logs[0]["event_name"], "http_request_started");
|
||||||
|
assert_eq!(logs[1]["level"], "TRACE");
|
||||||
|
assert_eq!(logs[1]["event_name"], "http_request_completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn access_log_marks_usage_active_paths_as_high_frequency() {
|
||||||
|
assert!(should_downgrade_access_log(
|
||||||
|
&Method::GET,
|
||||||
|
"/api/admin/usage/active"
|
||||||
|
));
|
||||||
|
assert!(should_downgrade_access_log(
|
||||||
|
&Method::GET,
|
||||||
|
"/api/admin/usage/active?ids=req-1"
|
||||||
|
));
|
||||||
|
assert!(should_downgrade_access_log(
|
||||||
|
&Method::GET,
|
||||||
|
"/api/users/me/usage/active"
|
||||||
|
));
|
||||||
|
assert!(should_downgrade_access_log(
|
||||||
|
&Method::GET,
|
||||||
|
"/api/admin/usage/records?limit=20"
|
||||||
|
));
|
||||||
|
assert!(should_downgrade_access_log(
|
||||||
|
&Method::GET,
|
||||||
|
"/api/admin/usage/123e4567-e89b-12d3-a456-426614174000?include_bodies=false"
|
||||||
|
));
|
||||||
|
assert!(should_downgrade_access_log(
|
||||||
|
&Method::GET,
|
||||||
|
"/api/admin/monitoring/trace/req-123?attempted_only=false"
|
||||||
|
));
|
||||||
|
assert!(should_downgrade_access_log(
|
||||||
|
&Method::GET,
|
||||||
|
"/api/admin/monitoring/cache/stats"
|
||||||
|
));
|
||||||
|
assert!(!should_downgrade_access_log(
|
||||||
|
&Method::DELETE,
|
||||||
|
"/api/admin/monitoring/cache/affinity/provider/key/model/openai:cli"
|
||||||
|
));
|
||||||
|
assert!(!should_downgrade_access_log(&Method::GET, "/v1/responses"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ mod access_log;
|
|||||||
mod frontdoor_cors;
|
mod frontdoor_cors;
|
||||||
mod strip_cf_headers;
|
mod strip_cf_headers;
|
||||||
|
|
||||||
pub(crate) use access_log::{access_log_middleware, RequestLogEmitted};
|
pub(crate) use access_log::{
|
||||||
|
access_log_middleware, should_downgrade_access_log, RequestLogEmitted,
|
||||||
|
};
|
||||||
pub(crate) use frontdoor_cors::frontdoor_cors_middleware;
|
pub(crate) use frontdoor_cors::frontdoor_cors_middleware;
|
||||||
pub use strip_cf_headers::strip_cf_headers_middleware;
|
pub use strip_cf_headers::strip_cf_headers_middleware;
|
||||||
|
|||||||
@@ -118,6 +118,159 @@ async fn gateway_records_usage_for_execution_runtime_sync_when_runtime_enabled()
|
|||||||
upstream_handle.abort();
|
upstream_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_records_pending_usage_before_execution_runtime_sync_result_arrives() {
|
||||||
|
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||||
|
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||||
|
let execution_request_started = Arc::new(tokio::sync::Notify::new());
|
||||||
|
let allow_execution_response = Arc::new(tokio::sync::Notify::new());
|
||||||
|
|
||||||
|
let upstream = Router::new().route(
|
||||||
|
"/api/internal/gateway/report-sync",
|
||||||
|
any(|_request: Request| async move { Json(json!({"ok": true})) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
let execution_runtime = Router::new().route(
|
||||||
|
"/v1/execute/sync",
|
||||||
|
any({
|
||||||
|
let execution_request_started = Arc::clone(&execution_request_started);
|
||||||
|
let allow_execution_response = Arc::clone(&allow_execution_response);
|
||||||
|
move |_request: Request| {
|
||||||
|
let execution_request_started = Arc::clone(&execution_request_started);
|
||||||
|
let allow_execution_response = Arc::clone(&allow_execution_response);
|
||||||
|
async move {
|
||||||
|
execution_request_started.notify_one();
|
||||||
|
allow_execution_response.notified().await;
|
||||||
|
Json(json!({
|
||||||
|
"request_id": "req-usage-sync-pending-123",
|
||||||
|
"status_code": 200,
|
||||||
|
"headers": {
|
||||||
|
"content-type": "application/json"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"json_body": {
|
||||||
|
"id": "chatcmpl-usage-sync-pending-123",
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 3,
|
||||||
|
"output_tokens": 5,
|
||||||
|
"total_tokens": 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"telemetry": {
|
||||||
|
"elapsed_ms": 45
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||||
|
Some(hash_api_key("sk-client-openai-usage-sync-pending")),
|
||||||
|
sample_local_openai_auth_snapshot(
|
||||||
|
"api-key-usage-sync-pending-123",
|
||||||
|
"user-usage-sync-pending-123",
|
||||||
|
),
|
||||||
|
)]));
|
||||||
|
let candidate_selection_repository =
|
||||||
|
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||||
|
sample_local_openai_candidate_row(),
|
||||||
|
]));
|
||||||
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![sample_local_openai_provider()],
|
||||||
|
vec![sample_local_openai_endpoint()],
|
||||||
|
vec![sample_local_openai_key()],
|
||||||
|
));
|
||||||
|
|
||||||
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||||
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||||
|
let gateway_state =
|
||||||
|
build_state_with_execution_runtime_override(execution_runtime_url)
|
||||||
|
.with_data_state_for_tests(
|
||||||
|
GatewayDataState::with_auth_candidate_selection_provider_catalog_request_candidates_and_usage_for_tests(
|
||||||
|
auth_repository,
|
||||||
|
candidate_selection_repository,
|
||||||
|
provider_catalog_repository,
|
||||||
|
Arc::clone(&request_candidate_repository),
|
||||||
|
Arc::clone(&usage_repository),
|
||||||
|
DEVELOPMENT_ENCRYPTION_KEY,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_usage_runtime_for_tests(UsageRuntimeConfig {
|
||||||
|
enabled: true,
|
||||||
|
..UsageRuntimeConfig::default()
|
||||||
|
});
|
||||||
|
let gateway = build_router_with_state(gateway_state);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let request_task = tokio::spawn({
|
||||||
|
let gateway_url = gateway_url.clone();
|
||||||
|
async move {
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/v1/chat/completions"))
|
||||||
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(
|
||||||
|
http::header::AUTHORIZATION,
|
||||||
|
"Bearer sk-client-openai-usage-sync-pending",
|
||||||
|
)
|
||||||
|
.header(TRACE_ID_HEADER, "req-usage-sync-pending-123")
|
||||||
|
.body("{\"model\":\"gpt-5\",\"messages\":[]}")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.expect("body should read");
|
||||||
|
(status, body)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
execution_request_started.notified().await;
|
||||||
|
|
||||||
|
let mut pending = None;
|
||||||
|
for _ in 0..50 {
|
||||||
|
pending = usage_repository
|
||||||
|
.find_by_request_id("req-usage-sync-pending-123")
|
||||||
|
.await
|
||||||
|
.expect("usage lookup should succeed");
|
||||||
|
if pending
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|stored| stored.status == "pending")
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
let pending = pending.expect("pending usage should be recorded before sync result resolves");
|
||||||
|
assert_eq!(pending.status, "pending");
|
||||||
|
assert_eq!(pending.billing_status, "pending");
|
||||||
|
assert_eq!(pending.response_time_ms, None);
|
||||||
|
|
||||||
|
allow_execution_response.notify_one();
|
||||||
|
|
||||||
|
let (status, _body) = request_task.await.expect("request task should join");
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
|
||||||
|
let mut stored = None;
|
||||||
|
for _ in 0..50 {
|
||||||
|
stored = usage_repository
|
||||||
|
.find_by_request_id("req-usage-sync-pending-123")
|
||||||
|
.await
|
||||||
|
.expect("usage lookup should succeed");
|
||||||
|
if stored.as_ref().is_some_and(|row| row.status == "completed") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
let stored = stored.expect("usage should be finalized");
|
||||||
|
assert_eq!(stored.status, "completed");
|
||||||
|
assert_eq!(stored.response_time_ms, Some(45));
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
execution_runtime_handle.abort();
|
||||||
|
upstream_handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
async fn gateway_records_usage_for_execution_runtime_stream_when_runtime_enabled() {
|
async fn gateway_records_usage_for_execution_runtime_stream_when_runtime_enabled() {
|
||||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||||
|
|
||||||
@@ -239,3 +392,151 @@ async fn gateway_records_usage_for_execution_runtime_stream_when_runtime_enabled
|
|||||||
execution_runtime_handle.abort();
|
execution_runtime_handle.abort();
|
||||||
upstream_handle.abort();
|
upstream_handle.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gateway_records_pending_usage_before_execution_runtime_stream_headers_arrive() {
|
||||||
|
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||||
|
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||||
|
let execution_request_started = Arc::new(tokio::sync::Notify::new());
|
||||||
|
let allow_execution_response = Arc::new(tokio::sync::Notify::new());
|
||||||
|
|
||||||
|
let upstream = Router::new().route(
|
||||||
|
"/api/internal/gateway/report-stream",
|
||||||
|
any(|_request: Request| async move { Json(json!({"ok": true})) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
let execution_runtime = Router::new().route(
|
||||||
|
"/v1/execute/stream",
|
||||||
|
any({
|
||||||
|
let execution_request_started = Arc::clone(&execution_request_started);
|
||||||
|
let allow_execution_response = Arc::clone(&allow_execution_response);
|
||||||
|
move |_request: Request| {
|
||||||
|
let execution_request_started = Arc::clone(&execution_request_started);
|
||||||
|
let allow_execution_response = Arc::clone(&allow_execution_response);
|
||||||
|
async move {
|
||||||
|
execution_request_started.notify_one();
|
||||||
|
allow_execution_response.notified().await;
|
||||||
|
let frames = concat!(
|
||||||
|
"{\"type\":\"headers\",\"payload\":{\"kind\":\"headers\",\"status_code\":200,\"headers\":{\"content-type\":\"text/event-stream\"}}}\n",
|
||||||
|
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"data: {\\\"id\\\":\\\"chatcmpl-usage-stream-pending-123\\\",\\\"usage\\\":{\\\"input_tokens\\\":2,\\\"output_tokens\\\":4,\\\"total_tokens\\\":6}}\\n\\n\"}}\n",
|
||||||
|
"{\"type\":\"data\",\"payload\":{\"kind\":\"data\",\"text\":\"data: [DONE]\\n\\n\"}}\n",
|
||||||
|
"{\"type\":\"telemetry\",\"payload\":{\"kind\":\"telemetry\",\"telemetry\":{\"elapsed_ms\":51,\"ttfb_ms\":19}}}\n",
|
||||||
|
"{\"type\":\"eof\",\"payload\":{\"kind\":\"eof\"}}\n"
|
||||||
|
);
|
||||||
|
let mut response = Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.body(Body::from(frames))
|
||||||
|
.expect("response should build");
|
||||||
|
response.headers_mut().insert(
|
||||||
|
http::header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("application/x-ndjson"),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||||
|
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||||
|
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||||
|
Some(hash_api_key("sk-client-openai-stream-pending")),
|
||||||
|
sample_local_openai_auth_snapshot(
|
||||||
|
"api-key-usage-stream-pending-123",
|
||||||
|
"user-usage-stream-pending-123",
|
||||||
|
),
|
||||||
|
)]));
|
||||||
|
let candidate_selection_repository =
|
||||||
|
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||||
|
sample_local_openai_candidate_row(),
|
||||||
|
]));
|
||||||
|
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||||
|
vec![sample_local_openai_provider()],
|
||||||
|
vec![sample_local_openai_endpoint()],
|
||||||
|
vec![sample_local_openai_key()],
|
||||||
|
));
|
||||||
|
let gateway_state = build_state_with_execution_runtime_override(execution_runtime_url)
|
||||||
|
.with_data_state_for_tests(
|
||||||
|
GatewayDataState::with_auth_candidate_selection_provider_catalog_request_candidates_and_usage_for_tests(
|
||||||
|
auth_repository,
|
||||||
|
candidate_selection_repository,
|
||||||
|
provider_catalog_repository,
|
||||||
|
Arc::clone(&request_candidate_repository),
|
||||||
|
usage_repository.clone(),
|
||||||
|
DEVELOPMENT_ENCRYPTION_KEY,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with_usage_runtime_for_tests(UsageRuntimeConfig {
|
||||||
|
enabled: true,
|
||||||
|
..UsageRuntimeConfig::default()
|
||||||
|
});
|
||||||
|
let gateway = build_router_with_state(gateway_state);
|
||||||
|
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||||
|
|
||||||
|
let request_task = tokio::spawn({
|
||||||
|
let gateway_url = gateway_url.clone();
|
||||||
|
async move {
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.post(format!("{gateway_url}/v1/chat/completions"))
|
||||||
|
.header(http::header::CONTENT_TYPE, "application/json")
|
||||||
|
.header(
|
||||||
|
http::header::AUTHORIZATION,
|
||||||
|
"Bearer sk-client-openai-stream-pending",
|
||||||
|
)
|
||||||
|
.header(TRACE_ID_HEADER, "req-usage-stream-pending-123")
|
||||||
|
.body("{\"model\":\"gpt-5\",\"messages\":[],\"stream\":true}")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("request should succeed");
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.expect("stream body should read");
|
||||||
|
(status, body)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
execution_request_started.notified().await;
|
||||||
|
|
||||||
|
let mut pending = None;
|
||||||
|
for _ in 0..50 {
|
||||||
|
pending = usage_repository
|
||||||
|
.find_by_request_id("req-usage-stream-pending-123")
|
||||||
|
.await
|
||||||
|
.expect("usage lookup should succeed");
|
||||||
|
if pending
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|stored| stored.status == "pending")
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
let pending = pending.expect("pending usage should be recorded before stream headers arrive");
|
||||||
|
assert_eq!(pending.status, "pending");
|
||||||
|
assert_eq!(pending.billing_status, "pending");
|
||||||
|
assert_eq!(pending.first_byte_time_ms, None);
|
||||||
|
assert_eq!(pending.response_time_ms, None);
|
||||||
|
|
||||||
|
allow_execution_response.notify_one();
|
||||||
|
|
||||||
|
let (status, _body) = request_task.await.expect("request task should join");
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
|
||||||
|
let mut stored = None;
|
||||||
|
for _ in 0..50 {
|
||||||
|
stored = usage_repository
|
||||||
|
.find_by_request_id("req-usage-stream-pending-123")
|
||||||
|
.await
|
||||||
|
.expect("usage lookup should succeed");
|
||||||
|
if stored.as_ref().is_some_and(|row| row.status == "completed") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
let stored = stored.expect("usage should be finalized");
|
||||||
|
assert_eq!(stored.status, "completed");
|
||||||
|
assert_eq!(stored.first_byte_time_ms, Some(19));
|
||||||
|
|
||||||
|
gateway_handle.abort();
|
||||||
|
execution_runtime_handle.abort();
|
||||||
|
upstream_handle.abort();
|
||||||
|
}
|
||||||
|
|||||||
@@ -387,7 +387,10 @@ fn resolve_tiered(
|
|||||||
.map(|value| value == cache_ttl_minutes)
|
.map(|value| value == cache_ttl_minutes)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}) {
|
}) {
|
||||||
if let Some(value) = ttl_entry.get(ttl_value_key).filter(|value| !value.is_null()) {
|
if let Some(value) = ttl_entry
|
||||||
|
.get(ttl_value_key)
|
||||||
|
.filter(|value| !value.is_null())
|
||||||
|
{
|
||||||
return Ok((
|
return Ok((
|
||||||
value.clone(),
|
value.clone(),
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8"
|
class="h-8 w-8"
|
||||||
:disabled="loading && !autoRefreshing"
|
:disabled="loading && !autoRefreshing"
|
||||||
:title="autoRefreshing ? '停止自动刷新' : '刷新'"
|
:title="refreshButtonTitle"
|
||||||
@click="refreshDetail"
|
@click="refreshDetail"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
@@ -813,6 +813,10 @@ let timelineMountTimer: ReturnType<typeof setTimeout> | null = null
|
|||||||
|
|
||||||
const fullRequestId = computed(() => detail.value?.request_id || detail.value?.id || '-')
|
const fullRequestId = computed(() => detail.value?.request_id || detail.value?.id || '-')
|
||||||
const displayRequestId = computed(() => formatShortRequestId(fullRequestId.value))
|
const displayRequestId = computed(() => formatShortRequestId(fullRequestId.value))
|
||||||
|
const refreshButtonTitle = computed(() => {
|
||||||
|
if (autoRefreshing.value) return '停止自动刷新'
|
||||||
|
return isRequestCompleted() ? '刷新' : '开启自动刷新'
|
||||||
|
})
|
||||||
const displayInputTokens = computed(() => {
|
const displayInputTokens = computed(() => {
|
||||||
if (!detail.value) return 0
|
if (!detail.value) return 0
|
||||||
return getEffectiveInputTokens({
|
return getEffectiveInputTokens({
|
||||||
@@ -1686,13 +1690,9 @@ async function loadDetail(id: string, silent = false) {
|
|||||||
timelineRef.value?.refresh()
|
timelineRef.value?.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 抽屉打开时,对进行中请求自动保持刷新,保证详情实时更新
|
// 已完成请求需要停止自动刷新;进行中的请求只在用户手动开启后才保持刷新
|
||||||
if (props.isOpen) {
|
if (props.isOpen && isRequestCompleted()) {
|
||||||
if (isRequestCompleted()) {
|
stopAutoRefresh()
|
||||||
stopAutoRefresh()
|
|
||||||
} else {
|
|
||||||
startAutoRefresh()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (requestId !== loadDetailRequestId) return
|
if (requestId !== loadDetailRequestId) return
|
||||||
@@ -1781,10 +1781,6 @@ function handleVisibilityChange() {
|
|||||||
isPageVisible.value = !document.hidden
|
isPageVisible.value = !document.hidden
|
||||||
if (!isPageVisible.value) {
|
if (!isPageVisible.value) {
|
||||||
stopAutoRefresh()
|
stopAutoRefresh()
|
||||||
return
|
|
||||||
}
|
|
||||||
if (props.isOpen && props.requestId && !isRequestCompleted()) {
|
|
||||||
startAutoRefresh()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,7 @@
|
|||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8"
|
class="h-8 w-8"
|
||||||
:class="autoRefresh ? 'text-primary' : ''"
|
:class="autoRefresh ? 'text-primary' : ''"
|
||||||
:title="autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新(每3秒刷新)'"
|
:title="autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新'"
|
||||||
@click="$emit('update:autoRefresh', !autoRefresh)"
|
@click="$emit('update:autoRefresh', !autoRefresh)"
|
||||||
>
|
>
|
||||||
<RefreshCcw
|
<RefreshCcw
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { reconcileActiveRequestDiscovery } from '../activeRequestDiscovery'
|
||||||
|
|
||||||
|
describe('reconcileActiveRequestDiscovery', () => {
|
||||||
|
it('returns unseen active request ids while retaining still-pending discoveries', () => {
|
||||||
|
const result = reconcileActiveRequestDiscovery({
|
||||||
|
activeRequestIds: ['req-new', 'req-retained', 'req-new'],
|
||||||
|
knownRecordIds: ['req-known'],
|
||||||
|
discoveredActiveRequestIds: ['req-retained', 'req-stale']
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
retainedDiscoveredActiveRequestIds: ['req-retained'],
|
||||||
|
unseenActiveRequestIds: ['req-new']
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops discovered ids once they are known in the table', () => {
|
||||||
|
const result = reconcileActiveRequestDiscovery({
|
||||||
|
activeRequestIds: ['req-known', 'req-fresh'],
|
||||||
|
knownRecordIds: ['req-known'],
|
||||||
|
discoveredActiveRequestIds: ['req-known']
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
retainedDiscoveredActiveRequestIds: [],
|
||||||
|
unseenActiveRequestIds: ['req-fresh']
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns no unseen ids when every active request is already known or retained', () => {
|
||||||
|
const result = reconcileActiveRequestDiscovery({
|
||||||
|
activeRequestIds: ['req-known', 'req-retained'],
|
||||||
|
knownRecordIds: ['req-known'],
|
||||||
|
discoveredActiveRequestIds: ['req-retained']
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
retainedDiscoveredActiveRequestIds: ['req-retained'],
|
||||||
|
unseenActiveRequestIds: []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
43
frontend/src/features/usage/utils/activeRequestDiscovery.ts
Normal file
43
frontend/src/features/usage/utils/activeRequestDiscovery.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export interface ActiveRequestDiscoverySnapshot {
|
||||||
|
activeRequestIds: Iterable<string>
|
||||||
|
knownRecordIds: Iterable<string>
|
||||||
|
discoveredActiveRequestIds: Iterable<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveRequestDiscoveryResult {
|
||||||
|
retainedDiscoveredActiveRequestIds: string[]
|
||||||
|
unseenActiveRequestIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcileActiveRequestDiscovery(
|
||||||
|
snapshot: ActiveRequestDiscoverySnapshot
|
||||||
|
): ActiveRequestDiscoveryResult {
|
||||||
|
const knownRecordIds = new Set(snapshot.knownRecordIds)
|
||||||
|
const activeRequestIds: string[] = []
|
||||||
|
const activeRequestIdSet = new Set<string>()
|
||||||
|
|
||||||
|
for (const id of snapshot.activeRequestIds) {
|
||||||
|
if (!id || activeRequestIdSet.has(id)) continue
|
||||||
|
activeRequestIdSet.add(id)
|
||||||
|
activeRequestIds.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const retainedDiscoveredActiveRequestIds: string[] = []
|
||||||
|
const retainedDiscoveredSet = new Set<string>()
|
||||||
|
|
||||||
|
for (const id of snapshot.discoveredActiveRequestIds) {
|
||||||
|
if (!id || retainedDiscoveredSet.has(id)) continue
|
||||||
|
if (knownRecordIds.has(id) || !activeRequestIdSet.has(id)) continue
|
||||||
|
retainedDiscoveredSet.add(id)
|
||||||
|
retainedDiscoveredActiveRequestIds.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unseenActiveRequestIds = activeRequestIds.filter(
|
||||||
|
id => !knownRecordIds.has(id) && !retainedDiscoveredSet.has(id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
retainedDiscoveredActiveRequestIds,
|
||||||
|
unseenActiveRequestIds
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,6 +143,7 @@ import {
|
|||||||
useUsageData,
|
useUsageData,
|
||||||
getDateRangeFromPeriod
|
getDateRangeFromPeriod
|
||||||
} from '@/features/usage/composables'
|
} from '@/features/usage/composables'
|
||||||
|
import { reconcileActiveRequestDiscovery } from '@/features/usage/utils/activeRequestDiscovery'
|
||||||
import type { DateRangeParams, FilterStatusValue } from '@/features/usage/types'
|
import type { DateRangeParams, FilterStatusValue } from '@/features/usage/types'
|
||||||
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
|
import type { UserOption } from '@/features/usage/components/UsageRecordsTable.vue'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
@@ -334,9 +335,12 @@ const hasActiveRequests = computed(() => activeRequestIds.value.length > 0)
|
|||||||
|
|
||||||
// 自动刷新定时器
|
// 自动刷新定时器
|
||||||
let autoRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
let autoRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let activeDiscoveryTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let globalAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
let globalAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
let refreshInFlight: Promise<void> | null = null
|
let refreshInFlight: Promise<void> | null = null
|
||||||
const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次(用于活跃请求)
|
const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次(用于活跃请求)
|
||||||
|
const ACTIVE_DISCOVERY_HOT_INTERVAL = 1000 // 有活跃请求时 1 秒扫描一次
|
||||||
|
const ACTIVE_DISCOVERY_IDLE_INTERVAL = 5000 // 空闲时降频,避免后台持续刷日志
|
||||||
const GLOBAL_AUTO_REFRESH_INTERVAL = 3000 // 3秒刷新一次(全局自动刷新)
|
const GLOBAL_AUTO_REFRESH_INTERVAL = 3000 // 3秒刷新一次(全局自动刷新)
|
||||||
const globalAutoRefresh = ref(false) // 全局自动刷新开关(默认关闭)
|
const globalAutoRefresh = ref(false) // 全局自动刷新开关(默认关闭)
|
||||||
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
|
||||||
@@ -344,6 +348,17 @@ const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hid
|
|||||||
// 轮询活跃请求状态(轻量级,只更新状态变化的记录)
|
// 轮询活跃请求状态(轻量级,只更新状态变化的记录)
|
||||||
|
|
||||||
let pollInFlight = false
|
let pollInFlight = false
|
||||||
|
let activeDiscoveryInFlight = false
|
||||||
|
const discoveredActiveRequestIds = new Set<string>()
|
||||||
|
|
||||||
|
async function loadActiveRequestUpdates(ids?: string[]) {
|
||||||
|
if (isAdminPage.value) {
|
||||||
|
return usageApi.getActiveRequests(ids)
|
||||||
|
}
|
||||||
|
const idsParam = ids?.length ? ids.join(',') : undefined
|
||||||
|
return meApi.getActiveRequests(idsParam)
|
||||||
|
}
|
||||||
|
|
||||||
async function pollActiveRequests() {
|
async function pollActiveRequests() {
|
||||||
if (!isPageVisible.value) return
|
if (!isPageVisible.value) return
|
||||||
if (!hasActiveRequests.value) return
|
if (!hasActiveRequests.value) return
|
||||||
@@ -351,11 +366,7 @@ async function pollActiveRequests() {
|
|||||||
pollInFlight = true
|
pollInFlight = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 根据页面类型选择不同的 API
|
const { requests } = await loadActiveRequestUpdates(activeRequestIds.value)
|
||||||
const idsParam = activeRequestIds.value.join(',')
|
|
||||||
const { requests } = isAdminPage.value
|
|
||||||
? await usageApi.getActiveRequests(activeRequestIds.value)
|
|
||||||
: await meApi.getActiveRequests(idsParam)
|
|
||||||
|
|
||||||
let shouldRefresh = false
|
let shouldRefresh = false
|
||||||
|
|
||||||
@@ -435,6 +446,37 @@ async function pollActiveRequests() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function discoverActiveRequests() {
|
||||||
|
if (!isPageVisible.value) return
|
||||||
|
if (activeDiscoveryInFlight) return
|
||||||
|
if (refreshInFlight || isLoadingRecords.value) return
|
||||||
|
activeDiscoveryInFlight = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { requests } = await loadActiveRequestUpdates()
|
||||||
|
const {
|
||||||
|
retainedDiscoveredActiveRequestIds,
|
||||||
|
unseenActiveRequestIds
|
||||||
|
} = reconcileActiveRequestDiscovery({
|
||||||
|
activeRequestIds: requests.map(request => request.id),
|
||||||
|
knownRecordIds: currentRecords.value.map(record => record.id),
|
||||||
|
discoveredActiveRequestIds
|
||||||
|
})
|
||||||
|
|
||||||
|
discoveredActiveRequestIds.clear()
|
||||||
|
retainedDiscoveredActiveRequestIds.forEach(id => discoveredActiveRequestIds.add(id))
|
||||||
|
|
||||||
|
if (unseenActiveRequestIds.length > 0) {
|
||||||
|
unseenActiveRequestIds.forEach(id => discoveredActiveRequestIds.add(id))
|
||||||
|
await refreshData()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('发现新活跃请求失败:', error)
|
||||||
|
} finally {
|
||||||
|
activeDiscoveryInFlight = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleNextAutoRefresh() {
|
function scheduleNextAutoRefresh() {
|
||||||
if (autoRefreshTimer) return
|
if (autoRefreshTimer) return
|
||||||
if (!isPageVisible.value || !hasActiveRequests.value) return
|
if (!isPageVisible.value || !hasActiveRequests.value) return
|
||||||
@@ -445,12 +487,34 @@ function scheduleNextAutoRefresh() {
|
|||||||
}, AUTO_REFRESH_INTERVAL)
|
}, AUTO_REFRESH_INTERVAL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleNextActiveDiscovery() {
|
||||||
|
if (activeDiscoveryTimer) return
|
||||||
|
if (!isPageVisible.value) return
|
||||||
|
const interval = hasActiveRequests.value || discoveredActiveRequestIds.size > 0
|
||||||
|
? ACTIVE_DISCOVERY_HOT_INTERVAL
|
||||||
|
: ACTIVE_DISCOVERY_IDLE_INTERVAL
|
||||||
|
activeDiscoveryTimer = setTimeout(async () => {
|
||||||
|
activeDiscoveryTimer = null
|
||||||
|
await discoverActiveRequests()
|
||||||
|
scheduleNextActiveDiscovery()
|
||||||
|
}, interval)
|
||||||
|
}
|
||||||
|
|
||||||
// 启动自动刷新
|
// 启动自动刷新
|
||||||
function startAutoRefresh() {
|
function startAutoRefresh() {
|
||||||
if (!isPageVisible.value) return
|
if (!isPageVisible.value) return
|
||||||
scheduleNextAutoRefresh()
|
scheduleNextAutoRefresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startActiveDiscovery() {
|
||||||
|
if (!isPageVisible.value) return
|
||||||
|
if (activeDiscoveryTimer || activeDiscoveryInFlight) return
|
||||||
|
void (async () => {
|
||||||
|
await discoverActiveRequests()
|
||||||
|
scheduleNextActiveDiscovery()
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
// 停止自动刷新
|
// 停止自动刷新
|
||||||
function stopAutoRefresh() {
|
function stopAutoRefresh() {
|
||||||
if (autoRefreshTimer) {
|
if (autoRefreshTimer) {
|
||||||
@@ -459,10 +523,17 @@ function stopAutoRefresh() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopActiveDiscovery() {
|
||||||
|
if (activeDiscoveryTimer) {
|
||||||
|
clearTimeout(activeDiscoveryTimer)
|
||||||
|
activeDiscoveryTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 监听活跃请求状态,自动启动/停止刷新
|
// 监听活跃请求状态,自动启动/停止刷新
|
||||||
// 1秒轮询始终用于活跃请求的实时更新,不受全局刷新影响
|
// 活跃请求的 1 秒轮询受“自动刷新”开关控制
|
||||||
watch(hasActiveRequests, (hasActive) => {
|
watch(hasActiveRequests, (hasActive) => {
|
||||||
if (hasActive && isPageVisible.value) {
|
if (globalAutoRefresh.value && hasActive && isPageVisible.value) {
|
||||||
startAutoRefresh()
|
startAutoRefresh()
|
||||||
} else {
|
} else {
|
||||||
stopAutoRefresh()
|
stopAutoRefresh()
|
||||||
@@ -490,9 +561,15 @@ function handleAutoRefreshChange(value: boolean) {
|
|||||||
if (value) {
|
if (value) {
|
||||||
if (isPageVisible.value) {
|
if (isPageVisible.value) {
|
||||||
refreshData() // 立即刷新一次
|
refreshData() // 立即刷新一次
|
||||||
|
startActiveDiscovery()
|
||||||
|
if (hasActiveRequests.value) {
|
||||||
|
startAutoRefresh()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
startGlobalAutoRefresh()
|
startGlobalAutoRefresh()
|
||||||
} else {
|
} else {
|
||||||
|
stopAutoRefresh()
|
||||||
|
stopActiveDiscovery()
|
||||||
stopGlobalAutoRefresh()
|
stopGlobalAutoRefresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -501,13 +578,15 @@ function handleVisibilityChange() {
|
|||||||
isPageVisible.value = !document.hidden
|
isPageVisible.value = !document.hidden
|
||||||
if (!isPageVisible.value) {
|
if (!isPageVisible.value) {
|
||||||
stopAutoRefresh()
|
stopAutoRefresh()
|
||||||
|
stopActiveDiscovery()
|
||||||
stopGlobalAutoRefresh()
|
stopGlobalAutoRefresh()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (hasActiveRequests.value) {
|
|
||||||
startAutoRefresh()
|
|
||||||
}
|
|
||||||
if (globalAutoRefresh.value) {
|
if (globalAutoRefresh.value) {
|
||||||
|
startActiveDiscovery()
|
||||||
|
if (hasActiveRequests.value) {
|
||||||
|
startAutoRefresh()
|
||||||
|
}
|
||||||
refreshData()
|
refreshData()
|
||||||
startGlobalAutoRefresh()
|
startGlobalAutoRefresh()
|
||||||
}
|
}
|
||||||
@@ -517,6 +596,7 @@ function handleVisibilityChange() {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||||
stopAutoRefresh()
|
stopAutoRefresh()
|
||||||
|
stopActiveDiscovery()
|
||||||
stopGlobalAutoRefresh()
|
stopGlobalAutoRefresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -574,6 +654,10 @@ onMounted(async () => {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (globalAutoRefresh.value && isPageVisible.value) {
|
||||||
|
startActiveDiscovery()
|
||||||
|
}
|
||||||
|
|
||||||
if (globalAutoRefresh.value && isPageVisible.value) {
|
if (globalAutoRefresh.value && isPageVisible.value) {
|
||||||
startGlobalAutoRefresh()
|
startGlobalAutoRefresh()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user