Skip to main content

fhd/
metrics_server.rs

1//! An opt-in Prometheus endpoint.
2//!
3//! Deliberately hand-rolled over a `TcpListener`: this serves exactly one
4//! static text endpoint, and adding a web framework to the daemon's dependency
5//! tree to do that would be a poor trade. Nothing here accepts a request body,
6//! runs a handler, or takes a header — the request line is read and answered.
7//!
8//! Everything exported already exists in [`ServerContext`] and the metrics
9//! probes; this only formats it. The endpoint binds separately from the agent
10//! port so it can be firewalled independently, and it is off unless
11//! `--metrics-port` is passed.
12
13use std::sync::Arc;
14
15use tokio::io::{AsyncReadExt, AsyncWriteExt};
16
17use crate::session::ServerContext;
18
19/// Start serving `/metrics` on `listener` until the process exits.
20pub async fn serve_metrics(listener: tokio::net::TcpListener, ctx: Arc<ServerContext>) {
21    let addr = listener
22        .local_addr()
23        .map(|a| a.to_string())
24        .unwrap_or_else(|_| "?".into());
25    tracing::info!("Prometheus metrics listening on http://{addr}/metrics");
26    loop {
27        let (mut socket, _peer) = match listener.accept().await {
28            Ok(pair) => pair,
29            Err(e) => {
30                tracing::warn!("metrics: accept failed: {e}");
31                continue;
32            }
33        };
34        let ctx = Arc::clone(&ctx);
35        // One short-lived task per scrape; a scrape is cheap and this must
36        // never be able to stall the accept loop.
37        tokio::spawn(async move {
38            let mut buf = [0u8; 1024];
39            let read = match socket.read(&mut buf).await {
40                Ok(n) => n,
41                Err(_) => return,
42            };
43            let request = String::from_utf8_lossy(&buf[..read]);
44            let path = request
45                .lines()
46                .next()
47                .and_then(|line| line.split_whitespace().nth(1))
48                .unwrap_or("/");
49
50            let (status, content_type, body) = match path {
51                "/metrics" => ("200 OK", "text/plain; version=0.0.4", render(&ctx)),
52                "/healthz" => ("200 OK", "text/plain; charset=utf-8", "ok\n".to_string()),
53                _ => (
54                    "404 Not Found",
55                    "text/plain; charset=utf-8",
56                    "try /metrics or /healthz\n".to_string(),
57                ),
58            };
59
60            let response = format!(
61                "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
62                body.len()
63            );
64            let _ = socket.write_all(response.as_bytes()).await;
65            let _ = socket.flush().await;
66        });
67    }
68}
69
70/// Render the whole exposition in Prometheus text format.
71pub fn render(ctx: &ServerContext) -> String {
72    let mut out = Exposition::new();
73    let active_runs = ctx
74        .max_runs
75        .saturating_sub(ctx.semaphore.available_permits());
76    let queued = ctx.queue_depth.load(std::sync::atomic::Ordering::Relaxed);
77
78    out.gauge("farhand_up", "1 if the agent is serving metrics", &[], 1.0);
79    out.gauge(
80        "farhand_uptime_seconds",
81        "Seconds since the agent started",
82        &[],
83        ctx.start_time.elapsed().as_secs_f64(),
84    );
85    out.gauge(
86        "farhand_active_runs",
87        "Runs executing right now",
88        &[],
89        active_runs as f64,
90    );
91    out.gauge(
92        "farhand_max_runs",
93        "Configured concurrency limit",
94        &[],
95        ctx.max_runs as f64,
96    );
97    out.gauge(
98        "farhand_queue_depth",
99        "Runs waiting on a project lock or a run slot",
100        &[],
101        queued as f64,
102    );
103    out.gauge(
104        "farhand_max_queued_runs",
105        "Configured queue limit",
106        &[],
107        ctx.max_queued_runs as f64,
108    );
109    out.gauge(
110        "farhand_workspaces",
111        "Persistent workspaces on this agent",
112        &[],
113        crate::metrics::get_workspaces_count(&ctx.workdir_root).unwrap_or(0) as f64,
114    );
115    out.gauge(
116        "farhand_cpu_count",
117        "Usable CPUs on the agent host",
118        &[],
119        crate::metrics::get_cpu_count() as f64,
120    );
121
122    if let Ok(space) = workspace::get_disk_space(&ctx.workdir_root) {
123        out.gauge(
124            "farhand_disk_free_bytes",
125            "Free space on the workspace volume",
126            &[],
127            space.available_bytes as f64,
128        );
129        out.gauge(
130            "farhand_disk_total_bytes",
131            "Total space on the workspace volume",
132            &[],
133            space.total_bytes as f64,
134        );
135    }
136
137    if let Some(loads) = crate::metrics::get_load_averages() {
138        for (interval, value) in [("1", loads[0]), ("5", loads[1]), ("15", loads[2])] {
139            out.gauge(
140                "farhand_load_average",
141                "Host load average",
142                &[("interval", interval)],
143                value,
144            );
145        }
146    }
147
148    let (used, total) = crate::metrics::get_memory_info();
149    if let Some(total) = total {
150        out.gauge(
151            "farhand_memory_total_bytes",
152            "Physical memory on the agent host",
153            &[],
154            total as f64,
155        );
156    }
157    if let Some(used) = used {
158        out.gauge(
159            "farhand_memory_used_bytes",
160            "Physical memory in use on the agent host",
161            &[],
162            used as f64,
163        );
164    }
165
166    // Active builds, one sample per run. The id is high-cardinality by nature
167    // (it is the run id), so it is deliberately NOT a label: a run that ends
168    // would leave a stale series. Project and duration are enough to alert on.
169    {
170        let map = ctx
171            .active_builds
172            .lock()
173            .unwrap_or_else(std::sync::PoisonError::into_inner);
174        let by_project: std::collections::BTreeMap<&str, usize> = map.values().fold(
175            std::collections::BTreeMap::new(),
176            |mut acc, (project, ..)| {
177                *acc.entry(project.as_str()).or_insert(0) += 1;
178                acc
179            },
180        );
181        for (project, count) in by_project {
182            out.gauge(
183                "farhand_active_builds",
184                "Runs in flight, by project",
185                &[("project", project)],
186                count as f64,
187            );
188        }
189    }
190    out.text
191}
192
193/// Accumulates the exposition, declaring each metric family exactly once.
194///
195/// Prometheus requires a single `# HELP`/`# TYPE` pair per metric *name*: a
196/// family exposed with several label values (load averages per interval, for
197/// example) declares the type once and then emits several samples. Emitting
198/// the pair again per sample is a scrape-time parse error, not a cosmetic one.
199struct Exposition {
200    text: String,
201    declared: std::collections::HashSet<String>,
202}
203
204impl Exposition {
205    fn new() -> Self {
206        Exposition {
207            text: String::with_capacity(2048),
208            declared: std::collections::HashSet::new(),
209        }
210    }
211
212    fn gauge(&mut self, name: &str, help: &str, labels: &[(&str, &str)], value: f64) {
213        if self.declared.insert(name.to_string()) {
214            self.text
215                .push_str(&format!("# HELP {name} {help}\n# TYPE {name} gauge\n"));
216        }
217        if labels.is_empty() {
218            self.text.push_str(&format!("{name} {value}\n"));
219        } else {
220            let rendered: Vec<String> = labels
221                .iter()
222                .map(|(k, v)| format!("{k}=\"{}\"", escape_label_value(v)))
223                .collect();
224            self.text
225                .push_str(&format!("{name}{{{}}} {value}\n", rendered.join(",")));
226        }
227    }
228}
229
230/// Escape a label value for the exposition format.
231///
232/// A project name comes from a directory name, and Unix allows newlines in
233/// those: an unescaped one would split the sample and corrupt every line after
234/// it.
235fn escape_label_value(value: &str) -> String {
236    let mut out = String::with_capacity(value.len());
237    for ch in value.chars() {
238        match ch {
239            '\\' => out.push_str("\\\\"),
240            '"' => out.push_str("\\\""),
241            '\n' => out.push_str("\\n"),
242            '\r' => out.push_str("\\r"),
243            '\t' => out.push_str("\\t"),
244            c => out.push(c),
245        }
246    }
247    out
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use std::collections::HashMap;
254
255    #[test]
256    fn exposition_is_well_formed_for_every_series() {
257        let ctx = crate::session::ServerContext {
258            expected_token: None,
259            workdir_root: std::env::temp_dir(),
260            custom_shell: None,
261            semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
262            lock_manager: workspace::WorkspaceLockManager::new(),
263            tags: vec![],
264            queue_depth: Arc::new(std::sync::atomic::AtomicUsize::new(2)),
265            max_runs: 4,
266            min_disk_bytes: 0,
267            cas_store: None,
268            start_time: std::time::Instant::now(),
269            active_builds: Arc::new(std::sync::Mutex::new(HashMap::new())),
270            connection_limiter: Arc::new(tokio::sync::Semaphore::new(32)),
271            max_queued_runs: 16,
272        };
273
274        let text = render(&ctx);
275
276        // A gauge is a bare number, so a float is always fine, but the labels
277        // and the sample must line up: `name{a="b"} value`, never a dangling
278        // brace or a missing value (which Prometheus silently drops).
279        for line in text.lines().filter(|l| !l.starts_with('#')) {
280            assert!(
281                !line.contains('{') || line.contains("} "),
282                "malformed: {line}"
283            );
284            let value = line.rsplit(' ').next().expect("a sample has a value");
285            assert!(
286                value.parse::<f64>().is_ok(),
287                "sample value is not a number: {line}"
288            );
289        }
290
291        // Every series must be declared, and each name declared only once —
292        // a duplicate HELP/TYPE block is a parse error for strict scrapers.
293        let mut declared: Vec<&str> = Vec::new();
294        for line in text.lines().filter(|l| l.starts_with("# TYPE ")) {
295            let name = line
296                .split_whitespace()
297                .nth(2)
298                .expect("TYPE line has a name");
299            assert!(!declared.contains(&name), "duplicate TYPE for {name}");
300            declared.push(name);
301        }
302        for line in text.lines().filter(|l| !l.starts_with('#')) {
303            let name = line.split(['{', ' ']).next().expect("sample has a name");
304            assert!(
305                declared.contains(&name),
306                "sample `{name}` has no # TYPE declaration"
307            );
308        }
309
310        assert!(text.contains("farhand_up 1"));
311        assert!(text.contains("farhand_queue_depth 2"));
312        assert!(text.contains("farhand_max_runs 4"));
313    }
314
315    #[test]
316    fn active_builds_are_reported_per_project() {
317        let ctx = crate::session::ServerContext {
318            expected_token: None,
319            workdir_root: std::env::temp_dir(),
320            custom_shell: None,
321            semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
322            lock_manager: workspace::WorkspaceLockManager::new(),
323            tags: vec![],
324            queue_depth: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
325            max_runs: 4,
326            min_disk_bytes: 0,
327            cas_store: None,
328            start_time: std::time::Instant::now(),
329            active_builds: Arc::new(std::sync::Mutex::new(HashMap::from([
330                (
331                    "run-1".to_string(),
332                    (
333                        "api".to_string(),
334                        vec!["make".to_string()],
335                        std::time::Instant::now(),
336                        "c".to_string(),
337                    ),
338                ),
339                (
340                    "run-2".to_string(),
341                    (
342                        "api".to_string(),
343                        vec!["make".to_string()],
344                        std::time::Instant::now(),
345                        "c".to_string(),
346                    ),
347                ),
348                (
349                    "run-3".to_string(),
350                    (
351                        "web".to_string(),
352                        vec!["npm".to_string()],
353                        std::time::Instant::now(),
354                        "c".to_string(),
355                    ),
356                ),
357            ]))),
358            connection_limiter: Arc::new(tokio::sync::Semaphore::new(32)),
359            max_queued_runs: 16,
360        };
361
362        let text = render(&ctx);
363        assert!(
364            text.contains(r#"farhand_active_builds{project="api"} 2"#),
365            "{text}"
366        );
367        assert!(
368            text.contains(r#"farhand_active_builds{project="web"} 1"#),
369            "{text}"
370        );
371        // Run ids are deliberately not labels: a finished run would leave a
372        // stale series behind forever.
373        assert!(!text.contains("run-1"), "run ids must not become labels");
374    }
375
376    #[test]
377    fn label_values_are_escaped() {
378        let mut out = Exposition::new();
379        // A project name comes from a directory name, and Unix allows quotes,
380        // backslashes, and newlines in those.
381        out.gauge("x", "h", &[("project", "we\"ird\nname")], 1.0);
382        assert!(
383            out.text.contains(r#"project="we\"ird\nname""#),
384            "{}",
385            out.text
386        );
387        // Every sample must stay on one line, or everything after it is lost.
388        assert_eq!(out.text.lines().filter(|l| !l.starts_with('#')).count(), 1);
389    }
390
391    /// Guards the metric reference in `docs/observability.md` against the code.
392    ///
393    /// Every metric this endpoint can emit is named in that table, and every
394    /// metric in the table is emitted here. Documentation drift on a metrics
395    /// endpoint is not cosmetic: someone alerts on a name that does not exist,
396    /// or assumes a gauge is missing when it is deliberately absent while idle.
397    ///
398    /// Skipped when the documentation is not present (a packaged crate does not
399    /// ship the repository's `docs/`), so a published crate's tests do not fail
400    /// over a file they were never given.
401    #[test]
402    fn documented_metrics_match_the_emitted_set() {
403        let doc_path =
404            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/observability.md");
405        let Ok(doc) = std::fs::read_to_string(&doc_path) else {
406            eprintln!("skipping: {} is not available", doc_path.display());
407            return;
408        };
409
410        // Table rows look like: | `farhand_up` | gauge | | ... |
411        let documented: std::collections::BTreeSet<String> = doc
412            .lines()
413            .filter_map(|line| line.strip_prefix("| `"))
414            .filter_map(|rest| rest.split('`').next())
415            .filter(|name| name.starts_with("farhand_"))
416            .map(str::to_string)
417            .collect();
418        assert!(
419            !documented.is_empty(),
420            "found no metric rows in {} — the table format probably changed",
421            doc_path.display()
422        );
423
424        // Render against a live context so the emitted set is what a scrape
425        // would actually contain, not a scan of the source.
426        let ctx = test_context();
427        let rendered = render(&ctx);
428        let emitted: std::collections::BTreeSet<String> = rendered
429            .lines()
430            .filter(|l| !l.starts_with('#'))
431            .filter_map(|l| l.split(['{', ' ']).next())
432            .filter(|n| n.starts_with("farhand_"))
433            .map(str::to_string)
434            .collect();
435
436        let undocumented: Vec<&String> = emitted.difference(&documented).collect();
437        assert!(
438            undocumented.is_empty(),
439            "emitted but absent from docs/observability.md: {undocumented:?}"
440        );
441
442        // `farhand_active_builds` only has samples while a run is in flight, so
443        // an idle render cannot prove it is emitted. Check the documented-but-idle
444        // case against the source, and say so rather than pretending otherwise.
445        let source = include_str!("metrics_server.rs");
446        let missing: Vec<&String> = documented
447            .iter()
448            .filter(|name| !emitted.contains(*name))
449            .filter(|name| !source.contains(&format!("\"{name}\"")))
450            .collect();
451        assert!(
452            missing.is_empty(),
453            "documented in docs/observability.md but never emitted: {missing:?}"
454        );
455    }
456
457    /// A minimal context for rendering the exposition without touching the disk.
458    fn test_context() -> crate::session::ServerContext {
459        use std::collections::HashMap;
460        crate::session::ServerContext {
461            expected_token: None,
462            workdir_root: std::env::temp_dir(),
463            custom_shell: None,
464            semaphore: Arc::new(tokio::sync::Semaphore::new(4)),
465            lock_manager: workspace::WorkspaceLockManager::new(),
466            tags: vec![],
467            queue_depth: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
468            max_runs: 4,
469            min_disk_bytes: 0,
470            cas_store: None,
471            start_time: std::time::Instant::now(),
472            active_builds: Arc::new(std::sync::Mutex::new(HashMap::new())),
473            connection_limiter: Arc::new(tokio::sync::Semaphore::new(32)),
474            max_queued_runs: 16,
475        }
476    }
477}