[model-gateway] Implement Layer 1 HTTP metrics instrumentation (#15121)
This commit is contained in:
@@ -26,7 +26,7 @@ use tracing::{debug, error, field::Empty, info, info_span, warn, Span};
|
|||||||
|
|
||||||
pub use crate::core::token_bucket::TokenBucket;
|
pub use crate::core::token_bucket::TokenBucket;
|
||||||
use crate::{
|
use crate::{
|
||||||
observability::metrics::RouterMetrics,
|
observability::metrics::{smg_labels, RouterMetrics, SmgMetrics},
|
||||||
routers::error::extract_error_code_from_response,
|
routers::error::extract_error_code_from_response,
|
||||||
server::AppState,
|
server::AppState,
|
||||||
wasm::{
|
wasm::{
|
||||||
@@ -343,6 +343,9 @@ impl<B> OnResponse<B> for ResponseLogger {
|
|||||||
RouterMetrics::record_http_status_code(status_code, error_code);
|
RouterMetrics::record_http_status_code(status_code, error_code);
|
||||||
RouterMetrics::record_request_duration(latency);
|
RouterMetrics::record_request_duration(latency);
|
||||||
|
|
||||||
|
// New SMG metrics (Layer 1: HTTP)
|
||||||
|
SmgMetrics::record_http_response(status_code, error_code);
|
||||||
|
|
||||||
// Record these in the span for structured logging/observability tools
|
// Record these in the span for structured logging/observability tools
|
||||||
span.record("status_code", status_code);
|
span.record("status_code", status_code);
|
||||||
// Use microseconds as integer to avoid format! string allocation
|
// Use microseconds as integer to avoid format! string allocation
|
||||||
@@ -517,6 +520,7 @@ pub async fn concurrency_limit_middleware(
|
|||||||
// Try to acquire token immediately
|
// Try to acquire token immediately
|
||||||
if token_bucket.try_acquire(1.0).await.is_ok() {
|
if token_bucket.try_acquire(1.0).await.is_ok() {
|
||||||
debug!("Acquired token immediately");
|
debug!("Acquired token immediately");
|
||||||
|
SmgMetrics::record_http_rate_limit(smg_labels::RATE_LIMIT_ALLOWED);
|
||||||
let response = next.run(request).await;
|
let response = next.run(request).await;
|
||||||
|
|
||||||
// Wrap the response body with TokenGuardBody to return token when stream ends
|
// Wrap the response body with TokenGuardBody to return token when stream ends
|
||||||
@@ -551,6 +555,7 @@ pub async fn concurrency_limit_middleware(
|
|||||||
match permit_rx.await {
|
match permit_rx.await {
|
||||||
Ok(Ok(())) => {
|
Ok(Ok(())) => {
|
||||||
debug!("Acquired token from queue");
|
debug!("Acquired token from queue");
|
||||||
|
SmgMetrics::record_http_rate_limit(smg_labels::RATE_LIMIT_ALLOWED);
|
||||||
// Dequeue for embeddings
|
// Dequeue for embeddings
|
||||||
if is_embeddings {
|
if is_embeddings {
|
||||||
let new_val =
|
let new_val =
|
||||||
@@ -567,6 +572,7 @@ pub async fn concurrency_limit_middleware(
|
|||||||
}
|
}
|
||||||
Ok(Err(status)) => {
|
Ok(Err(status)) => {
|
||||||
warn!("Queue returned error status: {}", status);
|
warn!("Queue returned error status: {}", status);
|
||||||
|
SmgMetrics::record_http_rate_limit(smg_labels::RATE_LIMIT_REJECTED);
|
||||||
// Dequeue for embeddings on error
|
// Dequeue for embeddings on error
|
||||||
if is_embeddings {
|
if is_embeddings {
|
||||||
let new_val =
|
let new_val =
|
||||||
@@ -577,6 +583,7 @@ pub async fn concurrency_limit_middleware(
|
|||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
error!("Queue response channel closed");
|
error!("Queue response channel closed");
|
||||||
|
SmgMetrics::record_http_rate_limit(smg_labels::RATE_LIMIT_REJECTED);
|
||||||
// Dequeue for embeddings on channel error
|
// Dequeue for embeddings on channel error
|
||||||
if is_embeddings {
|
if is_embeddings {
|
||||||
let new_val =
|
let new_val =
|
||||||
@@ -589,16 +596,165 @@ pub async fn concurrency_limit_middleware(
|
|||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
warn!("Request queue is full, returning 429");
|
warn!("Request queue is full, returning 429");
|
||||||
|
SmgMetrics::record_http_rate_limit(smg_labels::RATE_LIMIT_REJECTED);
|
||||||
StatusCode::TOO_MANY_REQUESTS.into_response()
|
StatusCode::TOO_MANY_REQUESTS.into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
warn!("No tokens available and queuing is disabled, returning 429");
|
warn!("No tokens available and queuing is disabled, returning 429");
|
||||||
|
SmgMetrics::record_http_rate_limit(smg_labels::RATE_LIMIT_REJECTED);
|
||||||
StatusCode::TOO_MANY_REQUESTS.into_response()
|
StatusCode::TOO_MANY_REQUESTS.into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HTTP Metrics Layer (Layer 1: SMG metrics)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Global counter for active HTTP connections
|
||||||
|
static ACTIVE_HTTP_CONNECTIONS: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
/// Tower Layer for HTTP metrics collection (SMG Layer 1 metrics)
|
||||||
|
#[derive(Clone, Copy, Default)]
|
||||||
|
pub struct HttpMetricsLayer;
|
||||||
|
|
||||||
|
impl HttpMetricsLayer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> Layer<S> for HttpMetricsLayer {
|
||||||
|
type Service = HttpMetricsMiddleware<S>;
|
||||||
|
|
||||||
|
fn layer(&self, inner: S) -> Self::Service {
|
||||||
|
HttpMetricsMiddleware { inner }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tower Service for HTTP metrics collection
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct HttpMetricsMiddleware<S> {
|
||||||
|
inner: S,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> Service<Request> for HttpMetricsMiddleware<S>
|
||||||
|
where
|
||||||
|
S: Service<Request, Response = Response> + Send + Clone + 'static,
|
||||||
|
S::Future: Send + 'static,
|
||||||
|
{
|
||||||
|
type Response = S::Response;
|
||||||
|
type Error = S::Error;
|
||||||
|
type Future =
|
||||||
|
Pin<Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>>;
|
||||||
|
|
||||||
|
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||||
|
self.inner.poll_ready(cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn call(&mut self, req: Request) -> Self::Future {
|
||||||
|
let method = req.method().as_str().to_owned();
|
||||||
|
let path = normalize_path_for_metrics(req.uri().path());
|
||||||
|
let start = Instant::now();
|
||||||
|
|
||||||
|
let mut inner = self.inner.clone();
|
||||||
|
|
||||||
|
Box::pin(async move {
|
||||||
|
// Increment inside async block - ensures no leak if future is dropped before polling
|
||||||
|
let active = ACTIVE_HTTP_CONNECTIONS.fetch_add(1, Ordering::Relaxed) + 1;
|
||||||
|
SmgMetrics::set_http_connections_active(active as usize);
|
||||||
|
|
||||||
|
// Capture result before decrementing to ensure decrement happens on error too
|
||||||
|
let result = inner.call(req).await;
|
||||||
|
|
||||||
|
// Always decrement, regardless of success or failure
|
||||||
|
let active = ACTIVE_HTTP_CONNECTIONS.fetch_sub(1, Ordering::Relaxed) - 1;
|
||||||
|
SmgMetrics::set_http_connections_active(active as usize);
|
||||||
|
|
||||||
|
let response = result?;
|
||||||
|
|
||||||
|
let duration = start.elapsed();
|
||||||
|
let status_class = status_to_class(response.status().as_u16());
|
||||||
|
|
||||||
|
SmgMetrics::record_http_request(&method, &path, status_class);
|
||||||
|
SmgMetrics::record_http_duration(&method, &path, duration);
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn status_to_class(status: u16) -> &'static str {
|
||||||
|
match status {
|
||||||
|
100..=199 => "1xx",
|
||||||
|
200..=299 => "2xx",
|
||||||
|
300..=399 => "3xx",
|
||||||
|
400..=499 => "4xx",
|
||||||
|
500..=599 => "5xx",
|
||||||
|
_ => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize path for metrics to avoid high cardinality.
|
||||||
|
/// Replaces dynamic segments (IDs, UUIDs) with `{id}` placeholder.
|
||||||
|
/// Only allocates when normalization is needed; uses single-pass with byte offsets.
|
||||||
|
fn normalize_path_for_metrics(path: &str) -> String {
|
||||||
|
let bytes = path.as_bytes();
|
||||||
|
let mut segment_start = 0;
|
||||||
|
let mut segment_idx = 0;
|
||||||
|
let mut result: Option<String> = None;
|
||||||
|
|
||||||
|
for (pos, &b) in bytes.iter().enumerate() {
|
||||||
|
if b == b'/' || pos == bytes.len() - 1 {
|
||||||
|
// Determine segment end (include last char if not a slash)
|
||||||
|
let segment_end = if b == b'/' { pos } else { pos + 1 };
|
||||||
|
let segment = &path[segment_start..segment_end];
|
||||||
|
|
||||||
|
// Check segments after index 2 for dynamic IDs
|
||||||
|
if segment_idx > 2 && !segment.is_empty() && is_dynamic_id(segment) {
|
||||||
|
// Initialize result with everything before this segment
|
||||||
|
let result = result.get_or_insert_with(|| {
|
||||||
|
let mut s = String::with_capacity(path.len());
|
||||||
|
s.push_str(&path[..segment_start]);
|
||||||
|
s
|
||||||
|
});
|
||||||
|
result.push_str("{id}");
|
||||||
|
} else if let Some(ref mut r) = result {
|
||||||
|
// Already normalizing, append this segment as-is
|
||||||
|
r.push_str(segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add slash after segment (except at end)
|
||||||
|
if b == b'/' {
|
||||||
|
if let Some(ref mut r) = result {
|
||||||
|
r.push('/');
|
||||||
|
}
|
||||||
|
segment_start = pos + 1;
|
||||||
|
segment_idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.unwrap_or_else(|| path.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if segment looks like a dynamic ID (prefixed ID, UUID, or numeric).
|
||||||
|
#[inline]
|
||||||
|
fn is_dynamic_id(s: &str) -> bool {
|
||||||
|
// Prefixed IDs: resp_xxx, chatcmpl_xxx (len > 10 with underscore)
|
||||||
|
if s.len() > 10 && s.contains('_') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// UUIDs: 32+ hex chars with dashes
|
||||||
|
if s.len() >= 32 && s.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Numeric IDs
|
||||||
|
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn wasm_middleware(
|
pub async fn wasm_middleware(
|
||||||
State(app_state): State<Arc<AppState>>,
|
State(app_state): State<Arc<AppState>>,
|
||||||
request: Request<Body>,
|
request: Request<Body>,
|
||||||
@@ -820,3 +976,72 @@ pub async fn wasm_middleware(
|
|||||||
*final_response.headers_mut() = headers;
|
*final_response.headers_mut() = headers;
|
||||||
Ok(final_response)
|
Ok(final_response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_path_no_ids() {
|
||||||
|
// Common API paths should pass through unchanged
|
||||||
|
assert_eq!(
|
||||||
|
normalize_path_for_metrics("/v1/chat/completions"),
|
||||||
|
"/v1/chat/completions"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_path_for_metrics("/v1/completions"),
|
||||||
|
"/v1/completions"
|
||||||
|
);
|
||||||
|
assert_eq!(normalize_path_for_metrics("/v1/models"), "/v1/models");
|
||||||
|
assert_eq!(normalize_path_for_metrics("/health"), "/health");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_path_with_prefixed_id() {
|
||||||
|
// Prefixed IDs (resp_xxx, chatcmpl_xxx) should be normalized
|
||||||
|
assert_eq!(
|
||||||
|
normalize_path_for_metrics("/v1/responses/resp_abc123def456"),
|
||||||
|
"/v1/responses/{id}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_path_for_metrics("/v1/chat/completions/chatcmpl_abc123xyz"),
|
||||||
|
"/v1/chat/completions/{id}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_path_with_uuid() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_path_for_metrics("/v1/responses/550e8400-e29b-41d4-a716-446655440000"),
|
||||||
|
"/v1/responses/{id}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_path_with_numeric_id() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_path_for_metrics("/v1/workers/12345"),
|
||||||
|
"/v1/workers/{id}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_dynamic_id() {
|
||||||
|
// Prefixed IDs
|
||||||
|
assert!(is_dynamic_id("resp_abc123def"));
|
||||||
|
assert!(is_dynamic_id("chatcmpl_xyz789"));
|
||||||
|
assert!(!is_dynamic_id("short_id")); // Too short
|
||||||
|
|
||||||
|
// UUIDs
|
||||||
|
assert!(is_dynamic_id("550e8400-e29b-41d4-a716-446655440000"));
|
||||||
|
assert!(is_dynamic_id("550e8400e29b41d4a716446655440000")); // No dashes
|
||||||
|
|
||||||
|
// Numeric
|
||||||
|
assert!(is_dynamic_id("12345"));
|
||||||
|
assert!(!is_dynamic_id("")); // Empty
|
||||||
|
|
||||||
|
// Regular words
|
||||||
|
assert!(!is_dynamic_id("completions"));
|
||||||
|
assert!(!is_dynamic_id("chat"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -734,6 +734,7 @@ pub fn build_app(
|
|||||||
max_payload_size,
|
max_payload_size,
|
||||||
))
|
))
|
||||||
.layer(middleware::create_logging_layer())
|
.layer(middleware::create_logging_layer())
|
||||||
|
.layer(middleware::HttpMetricsLayer::new())
|
||||||
.layer(middleware::RequestIdLayer::new(request_id_headers))
|
.layer(middleware::RequestIdLayer::new(request_id_headers))
|
||||||
.layer(create_cors_layer(cors_allowed_origins))
|
.layer(create_cors_layer(cors_allowed_origins))
|
||||||
.fallback(sink_handler)
|
.fallback(sink_handler)
|
||||||
|
|||||||
Reference in New Issue
Block a user