Skip to main content

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}