use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, Default)]
pub struct MetricsSnapshot {
pub active_workers: usize,
pub in_flight_tasks: usize,
pub findings_total: u64,
pub tasks_dispatched_total: u64,
}
pub trait MetricsSource: Send + Sync + 'static {
fn snapshot(&self) -> MetricsSnapshot;
}
#[must_use]
pub fn render_prometheus(snap: MetricsSnapshot) -> String {
let mut out = String::with_capacity(512);
out.push_str("# HELP gossan_fleet_active_workers Currently connected fleet workers.\n");
out.push_str("# TYPE gossan_fleet_active_workers gauge\n");
out.push_str(&format!(
"gossan_fleet_active_workers {}\n",
snap.active_workers
));
out.push_str("# HELP gossan_fleet_in_flight_tasks Tasks currently in flight.\n");
out.push_str("# TYPE gossan_fleet_in_flight_tasks gauge\n");
out.push_str(&format!(
"gossan_fleet_in_flight_tasks {}\n",
snap.in_flight_tasks
));
out.push_str("# HELP gossan_fleet_findings_total Cumulative findings collected.\n");
out.push_str("# TYPE gossan_fleet_findings_total counter\n");
out.push_str(&format!(
"gossan_fleet_findings_total {}\n",
snap.findings_total
));
out.push_str("# HELP gossan_fleet_tasks_dispatched_total Cumulative tasks dispatched.\n");
out.push_str("# TYPE gossan_fleet_tasks_dispatched_total counter\n");
out.push_str(&format!(
"gossan_fleet_tasks_dispatched_total {}\n",
snap.tasks_dispatched_total
));
out
}
pub async fn serve(addr: &str, source: Arc<dyn MetricsSource>) -> std::io::Result<Infallible> {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let bind: SocketAddr = addr.parse().map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("bad addr: {e}"))
})?;
let listener = TcpListener::bind(bind).await?;
tracing::info!(addr = %bind, "observability listener ready (/healthz + /metrics)");
loop {
let (mut sock, _peer) = match listener.accept().await {
Ok(p) => p,
Err(e) => {
tracing::warn!(error = %e, "observability accept failed");
continue;
}
};
let source = Arc::clone(&source);
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let Ok(n) = sock.read(&mut buf).await else {
return;
};
let req = std::str::from_utf8(&buf[..n]).unwrap_or("");
let path = req
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.unwrap_or("/");
let (status, body, ctype) = match path {
"/healthz" => (200, "ok\n".to_string(), "text/plain"),
"/metrics" => (
200,
render_prometheus(source.snapshot()),
"text/plain; version=0.0.4",
),
_ => (404, "not found\n".to_string(), "text/plain"),
};
let resp = format!(
"HTTP/1.1 {status} OK\r\n\
Content-Type: {ctype}\r\n\
Content-Length: {}\r\n\
Connection: close\r\n\
\r\n{body}",
body.len()
);
if let Err(e) = sock.write_all(resp.as_bytes()).await {
tracing::warn!(error = %e, "metrics response write failed");
}
if let Err(e) = sock.shutdown().await {
tracing::debug!(error = %e, "metrics socket shutdown failed");
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
struct StaticSnap(MetricsSnapshot);
impl MetricsSource for StaticSnap {
fn snapshot(&self) -> MetricsSnapshot {
self.0
}
}
#[test]
fn render_prometheus_emits_all_four_metrics() {
let snap = MetricsSnapshot {
active_workers: 3,
in_flight_tasks: 12,
findings_total: 4321,
tasks_dispatched_total: 88,
};
let s = render_prometheus(snap);
for needle in [
"gossan_fleet_active_workers 3",
"gossan_fleet_in_flight_tasks 12",
"gossan_fleet_findings_total 4321",
"gossan_fleet_tasks_dispatched_total 88",
"# TYPE gossan_fleet_active_workers gauge",
"# TYPE gossan_fleet_findings_total counter",
] {
assert!(s.contains(needle), "metrics missing `{needle}`:\n{s}");
}
}
#[test]
fn render_prometheus_handles_zero_state() {
let s = render_prometheus(MetricsSnapshot::default());
assert!(s.contains("gossan_fleet_active_workers 0"));
assert!(s.contains("gossan_fleet_findings_total 0"));
}
#[tokio::test]
async fn observability_serves_healthz_and_metrics() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
let snap = StaticSnap(MetricsSnapshot {
active_workers: 2,
in_flight_tasks: 5,
findings_total: 100,
tasks_dispatched_total: 7,
});
let source: Arc<dyn MetricsSource> = Arc::new(snap);
let _ = source; }
}