gunnar_server/scrape.rs
1//! **The counters, over HTTP**: one opt-in read-only listener that
2//! answers *"is it slow or is it stuck"* without `perf` and without a restart.
3//!
4//! # What was missing
5//!
6//! [`ServerMetrics`](gunnar_core::observe::ServerMetrics) has nineteen numbers
7//! in it and exactly one reader: the heartbeat, which writes a line to the log
8//! every sixty seconds *when something changed*. That is the right default and
9//! it is not enough on its own. A heartbeat cannot be asked a question — an
10//! operator watching a transfer that has not finished waits a minute to find
11//! out whether `bytes_out` moved, and a graph over an hour of an incident has
12//! to be reconstructed by hand out of journal lines. `transfers_in_flight`, the
13//! one number that distinguishes *slow* from *stuck*, is the one most worth
14//! sampling at a rate a human chooses.
15//!
16//! # It does not open a port
17//!
18//! [`ScrapeConfig::bind`] is an `Option` and it is `None`. Nothing listens
19//! unless `gunnar serve --metrics-bind <addr>` says so, which is the same shape
20//! `--http-bind` already uses (smart HTTP) and for the same reason: a second
21//! listening port is a decision an operator makes, and a metrics surface that
22//! bound itself by default would be a worse defect than the missing metrics
23//! were.
24//!
25//! **There is no authentication on it, and that is stated rather than
26//! discovered.** The body is nineteen integers about this process — no
27//! repository names, no principals, no references, no paths — so the surface is
28//! deliberately not worth a credential, and a credential is exactly what an
29//! operator cannot supply from a scraper's config half the time. What that
30//! costs is that anyone who can reach the port learns how busy the server is,
31//! which is why [`Bound::serve`] writes a `warn` when the address is not
32//! loopback: it must be a decision that was noticed.
33//!
34//! # It costs no dependency
35//!
36//! hyper 1, hyper-util and http-body-util, all of which tonic already resolves
37//! for gRPC. `cargo xtask guard` measures the appliance graph before and after:
38//! **281 crates / 267 third-party, unchanged** (MEASURED 2026-08-05, the reference desktop). It
39//! is deliberately not behind the `http` feature — that feature is off in the
40//! shipped `gunnar-server` atom, and an appliance is precisely where an operator
41//! has the least ability to attach a debugger, so it is the build that must be
42//! able to answer this question, not the one that may.
43//!
44//! # It is not `gunnar-http`
45//!
46//! `gunnar-http` is the git smart-HTTP *transport*: four fixed git URLs, an
47//! authorisation decision per request, gzip, pkt-line. This shares its accept
48//! loop's shape and none of its body, and it lives here because putting it
49//! there would mean an operator who wants counters compiles a git transport to
50//! get them. The two loops are written once each; what one implementation forbids is a
51//! second copy of a *decision*, and there is no decision in an accept loop.
52
53use std::net::SocketAddr;
54use std::sync::Arc;
55
56use gunnar_core::observe::{ServerMetrics, SCRAPE_CONTENT_TYPE, TARGET_METRICS};
57
58/// The one path that answers. Anything else is a `404`.
59///
60/// `/metrics` because that is what every scraper's default configuration
61/// already asks for, and a forge is not the place to be interesting about a
62/// URL.
63pub const SCRAPE_PATH: &str = "/metrics";
64
65/// What `--metrics-bind` was given, if anything.
66#[derive(Debug, Clone, Copy, Default)]
67pub struct ScrapeConfig {
68 /// Where the scrape listener binds. `None` — the default — starts nothing:
69 /// no socket, no task, no port.
70 pub bind: Option<SocketAddr>,
71}
72
73/// A bound scrape listener that has not started accepting yet.
74///
75/// Split from serving for the same reason [`gunnar_http::Bound`] is: a caller
76/// that asked for port 0 has no other way to learn what it got, and "address
77/// already in use" has to surface as a failed `start` rather than as a task
78/// that quietly ended.
79pub struct Bound {
80 listener: tokio::net::TcpListener,
81 local: SocketAddr,
82 metrics: Arc<ServerMetrics>,
83}
84
85/// Bind the scrape listener.
86pub async fn bind(addr: SocketAddr, metrics: Arc<ServerMetrics>) -> std::io::Result<Bound> {
87 let listener = tokio::net::TcpListener::bind(addr).await?;
88 let local = listener.local_addr()?;
89 Ok(Bound {
90 listener,
91 local,
92 metrics,
93 })
94}
95
96impl Bound {
97 /// The address actually bound.
98 pub fn local_addr(&self) -> SocketAddr {
99 self.local
100 }
101
102 /// Accept and answer until the task is dropped.
103 pub async fn serve(self) -> std::io::Result<()> {
104 tracing::info!(
105 target: TARGET_METRICS,
106 event = "metrics.listening",
107 addr = %self.local,
108 path = SCRAPE_PATH,
109 "the counters are readable over HTTP"
110 );
111 if let Some(warning) = public_warning(self.local) {
112 tracing::warn!(
113 target: TARGET_METRICS,
114 event = "metrics.public",
115 addr = %self.local,
116 "{warning}"
117 );
118 }
119 loop {
120 let (socket, peer) = match self.listener.accept().await {
121 Ok(accepted) => accepted,
122 Err(err) => {
123 tracing::warn!(target: TARGET_METRICS, %err, "accept failed");
124 continue;
125 }
126 };
127 // TCP_NODELAY for the reason `gunnar_http::Bound::serve` records at
128 // its own call: a small response written without it waits on Nagle
129 // against the peer's delayed ACK, and a scrape body is nothing but
130 // one small write.
131 if let Err(err) = socket.set_nodelay(true) {
132 tracing::debug!(target: TARGET_METRICS, %peer, %err, "TCP_NODELAY refused");
133 }
134 let metrics = Arc::clone(&self.metrics);
135 // A32-14, stays a task: one task per metrics scrape connection.
136 tokio::spawn(async move {
137 let io = hyper_util::rt::TokioIo::new(socket);
138 let handler = hyper::service::service_fn(move |request| {
139 let metrics = Arc::clone(&metrics);
140 async move { Ok::<_, std::convert::Infallible>(answer(&request, &metrics)) }
141 });
142 // HTTP/1.1, like the git transport and for the same reason: h2
143 // for a client is negotiated by ALPN, which needs TLS, which
144 // gunnar does not terminate. Every scraper speaks 1.1.
145 if let Err(err) = hyper::server::conn::http1::Builder::new()
146 .serve_connection(io, handler)
147 .await
148 {
149 tracing::debug!(target: TARGET_METRICS, %peer, %err, "connection ended");
150 }
151 });
152 }
153 }
154}
155
156/// The line to write when the bound address is reachable from off this host,
157/// or `None` when it is not.
158///
159/// **Pure, and given the address rather than reading it**, so both directions
160/// can be seen from a unit test without this process binding `0.0.0.0`. A
161/// warning nobody has watched fire on a public address is a warning that may
162/// well be an `if` written backwards, and a warning that fires on *every*
163/// server is one operators learn to skip — the pair is the claim, so the pair
164/// is what is asserted.
165///
166/// The unspecified address (`0.0.0.0`, `[::]`) counts as public, and it is the
167/// case this exists for: an operator who types it meaning "localhost" gets a
168/// line at start-up rather than a message from someone else's dashboard.
169fn public_warning(addr: SocketAddr) -> Option<String> {
170 (!addr.ip().is_loopback()).then(|| {
171 format!(
172 "the scrape endpoint on {addr} is NOT on loopback and has no authentication: \
173 anyone who can reach this address can read this server's counters"
174 )
175 })
176}
177
178/// Answer one request.
179///
180/// Pure but for the counter read: given a request and the counters it returns
181/// the whole response, so every status this endpoint can produce is reachable
182/// from a test without a socket. It is `fn` and not `async fn` because there is
183/// nothing here to await — reading nineteen relaxed atomics and formatting them
184/// is not I/O, and making it look like I/O would invite someone to put some in.
185fn answer(
186 request: &hyper::Request<hyper::body::Incoming>,
187 metrics: &ServerMetrics,
188) -> hyper::Response<http_body_util::Full<bytes::Bytes>> {
189 // `HEAD` as well as `GET`: a scraper's health check and a human's `curl -I`
190 // both send it, and hyper drops the body itself. Refusing it would produce
191 // a 405 for a request this endpoint can obviously answer.
192 if request.method() != hyper::Method::GET && request.method() != hyper::Method::HEAD {
193 return text(
194 hyper::StatusCode::METHOD_NOT_ALLOWED,
195 "this endpoint reads counters; it accepts GET and HEAD\n",
196 );
197 }
198 if request.uri().path() != SCRAPE_PATH {
199 // The 404 names the one path that works. An operator who typed
200 // `/metrics/` or pointed a scraper at `/` gets the answer in the body
201 // rather than a bare status they have to go and look up.
202 //
203 // Built from `SCRAPE_PATH` and not from a second literal: this sentence
204 // is the only documentation of the URL a lost operator will read, and a
205 // hard-coded copy of it is the one that would still say `/metrics`
206 // after the constant moved.
207 return text(
208 hyper::StatusCode::NOT_FOUND,
209 format!("no such path; the counters are at {SCRAPE_PATH}\n"),
210 );
211 }
212 let body = metrics.snapshot().render_prometheus();
213 hyper::Response::builder()
214 .status(hyper::StatusCode::OK)
215 .header(hyper::header::CONTENT_TYPE, SCRAPE_CONTENT_TYPE)
216 // No caching, ever. A scraper that got a cached body would draw a flat
217 // line through an incident, which is the one wrong answer this whole
218 // module exists to prevent.
219 .header(hyper::header::CACHE_CONTROL, "no-store")
220 .body(http_body_util::Full::new(bytes::Bytes::from(body)))
221 .expect("a response built from constant headers")
222}
223
224/// A plain-text response, so the error bodies are built one way.
225fn text(
226 status: hyper::StatusCode,
227 body: impl Into<bytes::Bytes>,
228) -> hyper::Response<http_body_util::Full<bytes::Bytes>> {
229 hyper::Response::builder()
230 .status(status)
231 .header(hyper::header::CONTENT_TYPE, "text/plain; charset=utf-8")
232 .body(http_body_util::Full::new(body.into()))
233 .expect("a response built from constant headers")
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 // `hyper::body::Incoming` cannot be constructed outside hyper, so `answer`
241 // is not reachable from a unit test at all. That is the right way round:
242 // this endpoint's claim is about what an HTTP CLIENT receives, and
243 // `tests/metrics_endpoint.rs` asserts it over a real socket against a real
244 // `gunnar serve`. What is left here is what needs no wire.
245
246 #[test]
247 fn the_scrape_path_is_the_one_every_scraper_already_asks_for() {
248 assert_eq!(SCRAPE_PATH, "/metrics");
249 }
250
251 #[test]
252 fn nothing_binds_unless_an_address_was_given() {
253 assert!(
254 ScrapeConfig::default().bind.is_none(),
255 "the default must not open a port"
256 );
257 }
258
259 /// Both directions of the exposure warning, which is the only way to know
260 /// the `if` is not written backwards.
261 #[test]
262 fn the_exposure_warning_fires_off_loopback_and_only_off_loopback() {
263 let quiet = ["127.0.0.1:9100", "127.5.6.7:1", "[::1]:9100"];
264 for addr in quiet {
265 let addr: SocketAddr = addr.parse().expect("a literal address");
266 assert!(
267 public_warning(addr).is_none(),
268 "{addr} is loopback and was warned about; a warning on every server is one \
269 nobody reads"
270 );
271 }
272 let loud = [
273 "0.0.0.0:9100",
274 "[::]:9100",
275 "10.0.0.4:9100",
276 "192.168.1.9:80",
277 ];
278 for addr in loud {
279 let addr: SocketAddr = addr.parse().expect("a literal address");
280 let warning = public_warning(addr).unwrap_or_else(|| {
281 panic!("{addr} is reachable from off this host and was NOT warned about")
282 });
283 assert!(
284 warning.contains(&addr.to_string()),
285 "the warning for {addr} does not name the address it is about: {warning}"
286 );
287 assert!(
288 warning.contains("no authentication"),
289 "the warning for {addr} does not say what the exposure IS: {warning}"
290 );
291 }
292 }
293}