crates/gunnar-server/src/scrape.rs
//! **The counters, over HTTP**: one opt-in read-only listener that
//! answers *"is it slow or is it stuck"* without `perf` and without a restart.
//!
//! # What was missing
//!
//! [`ServerMetrics`](gunnar_core::observe::ServerMetrics) has nineteen numbers
//! in it and exactly one reader: the heartbeat, which writes a line to the log
//! every sixty seconds *when something changed*. That is the right default and
//! it is not enough on its own. A heartbeat cannot be asked a question — an
//! operator watching a transfer that has not finished waits a minute to find
//! out whether `bytes_out` moved, and a graph over an hour of an incident has
//! to be reconstructed by hand out of journal lines. `transfers_in_flight`, the
//! one number that distinguishes *slow* from *stuck*, is the one most worth
//! sampling at a rate a human chooses.
//!
//! # It does not open a port
//!
//! [`ScrapeConfig::bind`] is an `Option` and it is `None`. Nothing listens
//! unless `gunnar serve --metrics-bind <addr>` says so, which is the same shape
//! `--http-bind` already uses (smart HTTP) and for the same reason: a second
//! listening port is a decision an operator makes, and a metrics surface that
//! bound itself by default would be a worse defect than the missing metrics
//! were.
//!
//! **There is no authentication on it, and that is stated rather than
//! discovered.** The body is nineteen integers about this process — no
//! repository names, no principals, no references, no paths — so the surface is
//! deliberately not worth a credential, and a credential is exactly what an
//! operator cannot supply from a scraper's config half the time. What that
//! costs is that anyone who can reach the port learns how busy the server is,
//! which is why [`Bound::serve`] writes a `warn` when the address is not
//! loopback: it must be a decision that was noticed.
//!
//! # It costs no dependency
//!
//! hyper 1, hyper-util and http-body-util, all of which tonic already resolves
//! for gRPC. `cargo xtask guard` measures the appliance graph before and after:
//! **281 crates / 267 third-party, unchanged** (MEASURED 2026-08-05, the reference desktop). It
//! is deliberately not behind the `http` feature — that feature is off in the
//! shipped `gunnar-server` atom, and an appliance is precisely where an operator
//! has the least ability to attach a debugger, so it is the build that must be
//! able to answer this question, not the one that may.
//!
//! # It is not `gunnar-http`
//!
//! `gunnar-http` is the git smart-HTTP *transport*: four fixed git URLs, an
//! authorisation decision per request, gzip, pkt-line. This shares its accept
//! loop's shape and none of its body, and it lives here because putting it
//! there would mean an operator who wants counters compiles a git transport to
//! get them. The two loops are written once each; what one implementation forbids is a
//! second copy of a *decision*, and there is no decision in an accept loop.
use std::net::SocketAddr;
use std::sync::Arc;
use gunnar_core::observe::{ServerMetrics, SCRAPE_CONTENT_TYPE, TARGET_METRICS};
/// The one path that answers. Anything else is a `404`.
///
/// `/metrics` because that is what every scraper's default configuration
/// already asks for, and a forge is not the place to be interesting about a
/// URL.
pub const SCRAPE_PATH: &str = "/metrics";
/// What `--metrics-bind` was given, if anything.
#[derive(Debug, Clone, Copy, Default)]
pub struct ScrapeConfig {
/// Where the scrape listener binds. `None` — the default — starts nothing:
/// no socket, no task, no port.
pub bind: Option<SocketAddr>,
}
/// A bound scrape listener that has not started accepting yet.
///
/// Split from serving for the same reason [`gunnar_http::Bound`] is: a caller
/// that asked for port 0 has no other way to learn what it got, and "address
/// already in use" has to surface as a failed `start` rather than as a task
/// that quietly ended.
pub struct Bound {
listener: tokio::net::TcpListener,
local: SocketAddr,
metrics: Arc<ServerMetrics>,
}
/// Bind the scrape listener.
pub async fn bind(addr: SocketAddr, metrics: Arc<ServerMetrics>) -> std::io::Result<Bound> {
let listener = tokio::net::TcpListener::bind(addr).await?;
let local = listener.local_addr()?;
Ok(Bound {
listener,
local,
metrics,
})
}
impl Bound {
/// The address actually bound.
pub fn local_addr(&self) -> SocketAddr {
self.local
}
/// Accept and answer until the task is dropped.
pub async fn serve(self) -> std::io::Result<()> {
tracing::info!(
target: TARGET_METRICS,
event = "metrics.listening",
addr = %self.local,
path = SCRAPE_PATH,
"the counters are readable over HTTP"
);
if let Some(warning) = public_warning(self.local) {
tracing::warn!(
target: TARGET_METRICS,
event = "metrics.public",
addr = %self.local,
"{warning}"
);
}
loop {
let (socket, peer) = match self.listener.accept().await {
Ok(accepted) => accepted,
Err(err) => {
tracing::warn!(target: TARGET_METRICS, %err, "accept failed");
continue;
}
};
// TCP_NODELAY for the reason `gunnar_http::Bound::serve` records at
// its own call: a small response written without it waits on Nagle
// against the peer's delayed ACK, and a scrape body is nothing but
// one small write.
if let Err(err) = socket.set_nodelay(true) {
tracing::debug!(target: TARGET_METRICS, %peer, %err, "TCP_NODELAY refused");
}
let metrics = Arc::clone(&self.metrics);
// A32-14, stays a task: one task per metrics scrape connection.
tokio::spawn(async move {
let io = hyper_util::rt::TokioIo::new(socket);
let handler = hyper::service::service_fn(move |request| {
let metrics = Arc::clone(&metrics);
async move { Ok::<_, std::convert::Infallible>(answer(&request, &metrics)) }
});
// HTTP/1.1, like the git transport and for the same reason: h2
// for a client is negotiated by ALPN, which needs TLS, which
// gunnar does not terminate. Every scraper speaks 1.1.
if let Err(err) = hyper::server::conn::http1::Builder::new()
.serve_connection(io, handler)
.await
{
tracing::debug!(target: TARGET_METRICS, %peer, %err, "connection ended");
}
});
}
}
}
/// The line to write when the bound address is reachable from off this host,
/// or `None` when it is not.
///
/// **Pure, and given the address rather than reading it**, so both directions
/// can be seen from a unit test without this process binding `0.0.0.0`. A
/// warning nobody has watched fire on a public address is a warning that may
/// well be an `if` written backwards, and a warning that fires on *every*
/// server is one operators learn to skip — the pair is the claim, so the pair
/// is what is asserted.
///
/// The unspecified address (`0.0.0.0`, `[::]`) counts as public, and it is the
/// case this exists for: an operator who types it meaning "localhost" gets a
/// line at start-up rather than a message from someone else's dashboard.
fn public_warning(addr: SocketAddr) -> Option<String> {
(!addr.ip().is_loopback()).then(|| {
format!(
"the scrape endpoint on {addr} is NOT on loopback and has no authentication: \
anyone who can reach this address can read this server's counters"
)
})
}
/// Answer one request.
///
/// Pure but for the counter read: given a request and the counters it returns
/// the whole response, so every status this endpoint can produce is reachable
/// from a test without a socket. It is `fn` and not `async fn` because there is
/// nothing here to await — reading nineteen relaxed atomics and formatting them
/// is not I/O, and making it look like I/O would invite someone to put some in.
fn answer(
request: &hyper::Request<hyper::body::Incoming>,
metrics: &ServerMetrics,
) -> hyper::Response<http_body_util::Full<bytes::Bytes>> {
// `HEAD` as well as `GET`: a scraper's health check and a human's `curl -I`
// both send it, and hyper drops the body itself. Refusing it would produce
// a 405 for a request this endpoint can obviously answer.
if request.method() != hyper::Method::GET && request.method() != hyper::Method::HEAD {
return text(
hyper::StatusCode::METHOD_NOT_ALLOWED,
"this endpoint reads counters; it accepts GET and HEAD\n",
);
}
if request.uri().path() != SCRAPE_PATH {
// The 404 names the one path that works. An operator who typed
// `/metrics/` or pointed a scraper at `/` gets the answer in the body
// rather than a bare status they have to go and look up.
//
// Built from `SCRAPE_PATH` and not from a second literal: this sentence
// is the only documentation of the URL a lost operator will read, and a
// hard-coded copy of it is the one that would still say `/metrics`
// after the constant moved.
return text(
hyper::StatusCode::NOT_FOUND,
format!("no such path; the counters are at {SCRAPE_PATH}\n"),
);
}
let body = metrics.snapshot().render_prometheus();
hyper::Response::builder()
.status(hyper::StatusCode::OK)
.header(hyper::header::CONTENT_TYPE, SCRAPE_CONTENT_TYPE)
// No caching, ever. A scraper that got a cached body would draw a flat
// line through an incident, which is the one wrong answer this whole
// module exists to prevent.
.header(hyper::header::CACHE_CONTROL, "no-store")
.body(http_body_util::Full::new(bytes::Bytes::from(body)))
.expect("a response built from constant headers")
}
/// A plain-text response, so the error bodies are built one way.
fn text(
status: hyper::StatusCode,
body: impl Into<bytes::Bytes>,
) -> hyper::Response<http_body_util::Full<bytes::Bytes>> {
hyper::Response::builder()
.status(status)
.header(hyper::header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(http_body_util::Full::new(body.into()))
.expect("a response built from constant headers")
}
#[cfg(test)]
mod tests {
use super::*;
// `hyper::body::Incoming` cannot be constructed outside hyper, so `answer`
// is not reachable from a unit test at all. That is the right way round:
// this endpoint's claim is about what an HTTP CLIENT receives, and
// `tests/metrics_endpoint.rs` asserts it over a real socket against a real
// `gunnar serve`. What is left here is what needs no wire.
#[test]
fn the_scrape_path_is_the_one_every_scraper_already_asks_for() {
assert_eq!(SCRAPE_PATH, "/metrics");
}
#[test]
fn nothing_binds_unless_an_address_was_given() {
assert!(
ScrapeConfig::default().bind.is_none(),
"the default must not open a port"
);
}
/// Both directions of the exposure warning, which is the only way to know
/// the `if` is not written backwards.
#[test]
fn the_exposure_warning_fires_off_loopback_and_only_off_loopback() {
let quiet = ["127.0.0.1:9100", "127.5.6.7:1", "[::1]:9100"];
for addr in quiet {
let addr: SocketAddr = addr.parse().expect("a literal address");
assert!(
public_warning(addr).is_none(),
"{addr} is loopback and was warned about; a warning on every server is one \
nobody reads"
);
}
let loud = [
"0.0.0.0:9100",
"[::]:9100",
"10.0.0.4:9100",
"192.168.1.9:80",
];
for addr in loud {
let addr: SocketAddr = addr.parse().expect("a literal address");
let warning = public_warning(addr).unwrap_or_else(|| {
panic!("{addr} is reachable from off this host and was NOT warned about")
});
assert!(
warning.contains(&addr.to_string()),
"the warning for {addr} does not name the address it is about: {warning}"
);
assert!(
warning.contains("no authentication"),
"the warning for {addr} does not say what the exposure IS: {warning}"
);
}
}
}