use std::io;
use std::net::SocketAddr;
use std::sync::Arc;
use oxidelake_core::telemetry::TelemetryHub;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
pub const METRICS_PATH: &str = "/metrics";
const MAX_REQUEST_LINE: u64 = 8 * 1024;
pub async fn serve(
addr: SocketAddr,
hub: Arc<TelemetryHub>,
) -> io::Result<(SocketAddr, impl Future<Output = ()>)> {
let listener = TcpListener::bind(addr).await?;
let local = listener.local_addr()?;
Ok((local, async move {
loop {
match listener.accept().await {
Ok((stream, peer)) => {
let hub = Arc::clone(&hub);
tokio::spawn(async move {
if let Err(e) = handle(stream, &hub).await {
tracing::debug!(%peer, error = %e, "metrics request failed");
}
});
}
Err(e) => tracing::warn!(error = %e, "metrics listener accept failed"),
}
}
}))
}
async fn handle(stream: TcpStream, hub: &TelemetryHub) -> io::Result<()> {
let (read, mut write) = stream.into_split();
let mut line = String::new();
BufReader::new(read.take(MAX_REQUEST_LINE))
.read_line(&mut line)
.await?;
let mut parts = line.split_whitespace();
let response = match (parts.next(), parts.next()) {
(Some("GET"), Some(path)) if path.split('?').next() == Some(METRICS_PATH) => {
let body = hub.snapshot().to_prometheus();
format!(
"HTTP/1.1 200 OK\r\n\
Content-Type: text/plain; version=0.0.4; charset=utf-8\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\r\n{body}",
body.len()
)
}
(Some("GET"), Some(_)) => status(404, "Not Found", "only /metrics is served\n"),
(Some(_), Some(_)) => status(405, "Method Not Allowed", "only GET is served\n"),
_ => status(400, "Bad Request", "expected a request line\n"),
};
write.write_all(response.as_bytes()).await?;
write.flush().await
}
fn status(code: u16, reason: &str, body: &str) -> String {
format!(
"HTTP/1.1 {code} {reason}\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\r\n{body}",
body.len()
)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::net::{IpAddr, Ipv4Addr};
use oxidelake_core::BackendKind;
use super::*;
async fn request(addr: SocketAddr, line: &str) -> String {
let mut stream = TcpStream::connect(addr).await.unwrap();
stream.write_all(line.as_bytes()).await.unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).await.unwrap();
response
}
#[tokio::test]
async fn metrics_are_served_and_other_paths_are_not() {
let hub = TelemetryHub::new();
let op = hub.register_operator("GpuFilterExec", BackendKind::Cuda);
op.record_batch(100, 40, std::time::Duration::from_millis(1));
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
let (bound, server) = serve(addr, Arc::clone(&hub)).await.unwrap();
let handle = tokio::spawn(server);
let ok = request(bound, "GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n").await;
assert!(ok.starts_with("HTTP/1.1 200 OK"), "{ok}");
assert!(ok.contains("text/plain; version=0.0.4"), "{ok}");
assert!(
ok.contains("oxide_operator_rows_in_total{operator=\"GpuFilterExec\""),
"{ok}"
);
let query = request(bound, "GET /metrics?x=1 HTTP/1.1\r\n\r\n").await;
assert!(query.starts_with("HTTP/1.1 200 OK"), "{query}");
let missing = request(bound, "GET /health HTTP/1.1\r\n\r\n").await;
assert!(missing.starts_with("HTTP/1.1 404"), "{missing}");
let post = request(bound, "POST /metrics HTTP/1.1\r\n\r\n").await;
assert!(post.starts_with("HTTP/1.1 405"), "{post}");
handle.abort();
}
#[tokio::test]
async fn a_second_scrape_sees_new_counters() {
let hub = TelemetryHub::new();
let op = hub.register_operator("GpuAggregateExec", BackendKind::Metal);
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
let (bound, server) = serve(addr, Arc::clone(&hub)).await.unwrap();
let handle = tokio::spawn(server);
let before = request(bound, "GET /metrics HTTP/1.1\r\n\r\n").await;
assert!(
before.contains(
"oxide_operator_batches_total{operator=\"GpuAggregateExec\",backend=\"metal\"} 0"
),
"{before}"
);
op.record_batch(10, 2, std::time::Duration::from_millis(1));
op.record_fallback();
let after = request(bound, "GET /metrics HTTP/1.1\r\n\r\n").await;
assert!(
after.contains(
"oxide_operator_batches_total{operator=\"GpuAggregateExec\",backend=\"metal\"} 1"
),
"{after}"
);
assert!(after.contains("oxide_operator_fallback_batches_total{operator=\"GpuAggregateExec\",backend=\"metal\"} 1"), "{after}");
handle.abort();
}
}