1use std::sync::Arc;
14
15use tokio::io::{AsyncReadExt, AsyncWriteExt};
16
17use crate::session::ServerContext;
18
19pub 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 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
70pub 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 {
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
193struct 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
230fn 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 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 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 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 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 assert_eq!(out.text.lines().filter(|l| !l.starts_with('#')).count(), 1);
389 }
390
391 #[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 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 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 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 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}