use std::io;
use std::net::ToSocketAddrs;
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use crate::metrics::{Metrics, render_prometheus};
const PROMETHEUS_CONTENT_TYPE: &[u8] = b"text/plain; version=0.0.4";
#[derive(Clone)]
pub struct MetricsHandle {
inner: Arc<Mutex<Metrics>>,
}
impl MetricsHandle {
pub fn publish(&self, metrics: &Metrics) {
let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
*guard = metrics.clone();
}
pub fn render(&self) -> String {
let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
render_prometheus(&guard)
}
}
pub struct MetricsServer {
handle: MetricsHandle,
server: Arc<tiny_http::Server>,
thread: Option<JoinHandle<()>>,
}
impl MetricsServer {
pub fn start(addr: impl ToSocketAddrs) -> io::Result<Self> {
Self::start_with(addr, Metrics::default())
}
pub fn start_with(addr: impl ToSocketAddrs, initial: Metrics) -> io::Result<Self> {
let sock = addr
.to_socket_addrs()?
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "no socket address"))?;
let server = tiny_http::Server::http(sock).map_err(|e| io::Error::other(e.to_string()))?;
let server = Arc::new(server);
let handle = MetricsHandle {
inner: Arc::new(Mutex::new(initial)),
};
let thread = std::thread::Builder::new()
.name("bifrost-metrics-http".into())
.spawn({
let server = Arc::clone(&server);
let handle = handle.clone();
move || serve_loop(&server, &handle)
})?;
Ok(Self {
handle,
server,
thread: Some(thread),
})
}
pub fn local_addr(&self) -> io::Result<std::net::SocketAddr> {
match self.server.server_addr() {
tiny_http::ListenAddr::IP(addr) => Ok(addr),
other => Err(io::Error::other(format!(
"non-IP listen address: {other:?}"
))),
}
}
pub fn handle(&self) -> MetricsHandle {
self.handle.clone()
}
}
impl Drop for MetricsServer {
fn drop(&mut self) {
self.server.unblock();
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
fn serve_loop(server: &tiny_http::Server, handle: &MetricsHandle) {
for request in server.incoming_requests() {
respond(request, handle);
}
}
fn respond(request: tiny_http::Request, handle: &MetricsHandle) {
if request.url() == "/metrics" {
let body = handle.render();
let header = tiny_http::Header::from_bytes(&b"Content-Type"[..], PROMETHEUS_CONTENT_TYPE)
.expect("static content-type header is valid");
let response = tiny_http::Response::from_string(body).with_header(header);
let _ = request.respond(response);
} else {
let response = tiny_http::Response::from_string("not found").with_status_code(404);
let _ = request.respond(response);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpStream;
fn http_get(addr: std::net::SocketAddr, path: &str) -> io::Result<String> {
let mut stream = TcpStream::connect(addr)?;
write!(stream, "GET {path} HTTP/1.0\r\nHost: localhost\r\n\r\n")?;
stream.flush()?;
let mut buf = String::new();
stream.read_to_string(&mut buf)?;
Ok(buf)
}
#[test]
fn get_metrics_serves_rendered_counters() {
let metrics = Metrics {
rounds_total: 7,
..Metrics::default()
};
let server = MetricsServer::start_with("127.0.0.1:0", metrics).expect("start server");
let addr = server.local_addr().expect("local addr");
let resp = http_get(addr, "/metrics").expect("GET /metrics");
let status = resp.lines().next().unwrap_or_default();
assert!(status.contains(" 200 "), "200 status, got:\n{resp}");
assert!(
resp.contains("text/plain; version=0.0.4"),
"prometheus content type, got:\n{resp}"
);
assert!(
resp.contains("bifrost_rounds_total 7"),
"live counter, got:\n{resp}"
);
assert!(
resp.contains("# TYPE bifrost_rounds_total counter"),
"typed, got:\n{resp}"
);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"metrics_http::get_metrics_serves_rendered_counters",
status.contains(" 200 ")
&& resp.contains("bifrost_rounds_total 7")
&& resp.contains("text/plain; version=0.0.4"),
"GET /metrics → 200, prometheus content-type, live counter=7",
);
}
#[test]
fn publish_updates_the_served_snapshot() {
let server = MetricsServer::start("127.0.0.1:0").expect("start server");
let addr = server.local_addr().expect("local addr");
let resp = http_get(addr, "/metrics").expect("GET");
assert!(
resp.contains("bifrost_rounds_total 0"),
"starts empty, got:\n{resp}"
);
server.handle().publish(&Metrics {
rounds_total: 3,
rows_ingested: 42,
..Default::default()
});
let resp = http_get(addr, "/metrics").expect("GET");
assert!(
resp.contains("bifrost_rounds_total 3"),
"published rounds, got:\n{resp}"
);
assert!(
resp.contains("bifrost_rows_ingested 42"),
"published rows, got:\n{resp}"
);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"metrics_http::publish_updates_the_served_snapshot",
resp.contains("bifrost_rounds_total 3") && resp.contains("bifrost_rows_ingested 42"),
"publish() reflected on next scrape: rounds=3, rows=42",
);
}
#[test]
fn unknown_path_is_404() {
let server = MetricsServer::start("127.0.0.1:0").expect("start server");
let addr = server.local_addr().expect("local addr");
let resp = http_get(addr, "/nope").expect("GET");
let status = resp.lines().next().unwrap_or_default();
assert!(
status.contains(" 404 "),
"404 for unknown path, got:\n{resp}"
);
crate::testmatrix::emit(
crate::testmatrix::COMPONENT,
"metrics_http::unknown_path_is_404",
status.contains(" 404 "),
"unknown path → 404",
);
}
}