OXYGENHUB

A runner's own metrics (refs #35) 4be5e4cd0af6

metrics = ADDRESS under [runner] serves Prometheus text at /metrics from a
thread of its own: version and uptime, jobs claimed and declined, finished
jobs by result, the seconds they ran, the jobs running now, labelled with
the runner's name; metrics_token asks scrapes for a bearer token. Off
unless set.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key c8bd4bfdbb7f
When
· 2026-10-21 01:44 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
4be5e4cd0af6bd874d3df14b8a2a2a34797ecdc09d26d0ab1084f7c9fefc45e3
Depends on
280ef951d193 3007d8f011fa 35f7a849bcc5 47c1d64dec8a 4cf0c99a1486 8bf49b95b09e a083cc802892 b11e30f5031d bb643c252515 c3db750ed96e e0918b887850 ea81796bb757
Depended on by
1a50d4dae89c 1215f05ff8d0 32f54b393396 85275e1ef3b7 06a4c472b385 5cf1e8aefbb6 6a2d31568cf2
In bookmarks
main
In tags
v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
11 atoms, 1 tree operation, 6 files

Browse the tree at this change

added crates/flow/src/metrics.rs +149 −0
1 +
//! The runner's own metrics (oxygenflow#35, oxygenhub#212): a Prometheus
2 +
//! text page on an address of the runner's choosing, off unless
3 +
//! `metrics = "127.0.0.1:9464"` is set under `[runner]`. With
4 +
//! `metrics_token`, a scrape must send it as a bearer token.
5 +
6 +
use std::io::{BufRead, BufReader, Write};
7 +
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
8 +
use std::time::Instant;
9 +
10 +
/// Jobs this runner claimed.
11 +
pub static CLAIMS: AtomicU64 = AtomicU64::new(0);
12 +
/// Jobs declined before running (the preflight said no).
13 +
pub static DECLINED: AtomicU64 = AtomicU64::new(0);
14 +
/// Finished jobs by result.
15 +
pub static PASSED: AtomicU64 = AtomicU64::new(0);
16 +
pub static FAILED: AtomicU64 = AtomicU64::new(0);
17 +
pub static ERRORS: AtomicU64 = AtomicU64::new(0);
18 +
pub static CANCELLED: AtomicU64 = AtomicU64::new(0);
19 +
/// Milliseconds finished jobs ran, together.
20 +
pub static JOB_MS: AtomicU64 = AtomicU64::new(0);
21 +
/// Jobs running now.
22 +
pub static RUNNING: AtomicU64 = AtomicU64::new(0);
23 +
24 +
/// Count a job's end: by exit code, its error, and how long it ran.
25 +
pub fn finished(exit_code: Option<i32>, error: Option<&str>, duration_ms: u64, cancelled: bool) {
26 +
    let counter = if cancelled {
27 +
        &CANCELLED
28 +
    } else if error.is_some() {
29 +
        &ERRORS
30 +
    } else if exit_code == Some(0) {
31 +
        &PASSED
32 +
    } else {
33 +
        &FAILED
34 +
    };
35 +
    counter.fetch_add(1, Relaxed);
36 +
    JOB_MS.fetch_add(duration_ms, Relaxed);
37 +
}
38 +
39 +
/// The page.
40 +
pub fn render(name: &str, started: Instant) -> String {
41 +
    let n = name.replace('\\', "\\\\").replace('"', "\\\"");
42 +
    let mut out = String::new();
43 +
    let mut family = |metric: &str, help: &str, kind: &str, samples: &[(String, u64)]| {
44 +
        out.push_str(&format!("# HELP {metric} {help}\n# TYPE {metric} {kind}\n"));
45 +
        for (labels, v) in samples {
46 +
            let sep = if labels.is_empty() { "" } else { "," };
47 +
            out.push_str(&format!("{metric}{{runner=\"{n}\"{sep}{labels}}} {v}\n"));
48 +
        }
49 +
    };
50 +
    family("flow_runner_build_info", "This runner's version.", "gauge", &[(format!("version=\"{}\"", env!("CARGO_PKG_VERSION")), 1)]);
51 +
    family("flow_runner_uptime_seconds", "Seconds since the runner started.", "gauge", &[(String::new(), started.elapsed().as_secs())]);
52 +
    family("flow_runner_claims_total", "Jobs claimed from the hub.", "counter", &[(String::new(), CLAIMS.load(Relaxed))]);
53 +
    family("flow_runner_declined_total", "Claimed jobs declined before running.", "counter", &[(String::new(), DECLINED.load(Relaxed))]);
54 +
    family(
55 +
        "flow_runner_jobs_finished_total",
56 +
        "Finished jobs by result.",
57 +
        "counter",
58 +
        &[
59 +
            ("result=\"passed\"".into(), PASSED.load(Relaxed)),
60 +
            ("result=\"failed\"".into(), FAILED.load(Relaxed)),
61 +
            ("result=\"error\"".into(), ERRORS.load(Relaxed)),
62 +
            ("result=\"cancelled\"".into(), CANCELLED.load(Relaxed)),
63 +
        ],
64 +
    );
65 +
    family(
66 +
        "flow_runner_job_seconds_total",
67 +
        "Seconds finished jobs ran, together.",
68 +
        "counter",
69 +
        &[(String::new(), JOB_MS.load(Relaxed) / 1000)],
70 +
    );
71 +
    family("flow_runner_jobs_running", "Jobs running now.", "gauge", &[(String::new(), RUNNING.load(Relaxed))]);
72 +
    out
73 +
}
74 +
75 +
/// Serve the page at `addr` on a thread of its own; a failure to listen is
76 +
/// said and the runner goes on without it.
77 +
pub fn serve(addr: &str, token: Option<String>, name: String) {
78 +
    let listener = match std::net::TcpListener::bind(addr) {
79 +
        Ok(l) => l,
80 +
        Err(e) => {
81 +
            eprintln!("flow runner {name}: not serving metrics on {addr}: {e}");
82 +
            return;
83 +
        }
84 +
    };
85 +
    eprintln!("flow runner {name}: metrics on http://{addr}/metrics");
86 +
    let started = Instant::now();
87 +
    std::thread::spawn(move || {
88 +
        for stream in listener.incoming().flatten() {
89 +
            let _ = answer(stream, token.as_deref(), &name, started);
90 +
        }
91 +
    });
92 +
}
93 +
94 +
fn answer(mut stream: std::net::TcpStream, token: Option<&str>, name: &str, started: Instant) -> std::io::Result<()> {
95 +
    stream.set_read_timeout(Some(std::time::Duration::from_secs(5)))?;
96 +
    let mut reader = BufReader::new(stream.try_clone()?);
97 +
    let mut line = String::new();
98 +
    reader.read_line(&mut line)?;
99 +
    let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
100 +
    let mut authorized = token.is_none();
101 +
    loop {
102 +
        let mut h = String::new();
103 +
        if reader.read_line(&mut h)? == 0 || h.trim().is_empty() {
104 +
            break;
105 +
        }
106 +
        if let (Some(t), Some((k, v))) = (token, h.split_once(':')) {
107 +
            if k.trim().eq_ignore_ascii_case("authorization") && v.trim() == format!("Bearer {t}") {
108 +
                authorized = true;
109 +
            }
110 +
        }
111 +
    }
112 +
    let (status, body) = match (path.as_str(), authorized) {
113 +
        ("/metrics", true) => ("200 OK", render(name, started)),
114 +
        ("/metrics", false) => ("401 Unauthorized", "a bearer token is needed\n".to_string()),
115 +
        _ => ("404 Not Found", "not found\n".to_string()),
116 +
    };
117 +
    write!(
118 +
        stream,
119 +
        "HTTP/1.1 {status}\r\nContent-Type: text/plain; version=0.0.4\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
120 +
        body.len()
121 +
    )
122 +
}
123 +
124 +
#[cfg(test)]
125 +
mod tests {
126 +
    use super::*;
127 +
    use std::io::Read;
128 +
129 +
    #[test]
130 +
    fn a_scrape_needs_the_token_and_reads_the_counters() {
131 +
        let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
132 +
        let addr = l.local_addr().unwrap().to_string();
133 +
        drop(l);
134 +
        serve(&addr, Some("t0k".into()), "earth-1".into());
135 +
        CLAIMS.fetch_add(1, Relaxed);
136 +
        finished(Some(0), None, 2500, false);
137 +
        let get = |auth: &str| {
138 +
            let mut s = std::net::TcpStream::connect(&addr).unwrap();
139 +
            write!(s, "GET /metrics HTTP/1.1\r\nHost: x\r\n{auth}\r\n").unwrap();
140 +
            let mut body = String::new();
141 +
            s.read_to_string(&mut body).unwrap();
142 +
            body
143 +
        };
144 +
        assert!(get("").starts_with("HTTP/1.1 401"));
145 +
        let page = get("Authorization: Bearer t0k\r\n");
146 +
        assert!(page.starts_with("HTTP/1.1 200"), "{page}");
147 +
        assert!(page.contains("flow_runner_claims_total{runner=\"earth-1\"} ") && page.contains("result=\"passed\"}"), "{page}");
148 +
    }
149 +
}
modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A runner's own metrics (#35):** `metrics = "127.0.0.1:9464"` under `[runner]` serves Prometheus text at `/metrics`: claims, declines, finished jobs by result, the seconds they ran, the jobs running now, version and uptime, optionally behind `metrics_token`. Off unless set.
7 8
- **Built with oxy 0.5.1.** A runner reads repositories whose release tags a keyless agent made (oxygen#129), and declares `certified-tags` to the hub (oxygen#131).
8 9
- **Build caches are pruned before they are kept (#72, spec §5.4).** A kept cache no longer grows until the job's disk is full. When a passing job's cache holds more than `cache_prune_percent` of its mount's size (runner config, default 60), the files the job did not read, write or share a cargo unit hash with are removed, oldest first, until it is under; one still over is not kept, and the next run starts empty. A microVM's guest init prunes its disk before it powers off; the runner prunes the syscall tier's directories. The job's log says how much was kept and pruned, or why the cache was not kept.
9 10
…
77 unchanged lines
modified crates/flow/src/home.rs +7 −0
…
79 unchanged lines
80 80
    /// the runner's key signs, and the job's log says why.
81 81
    #[serde(default)]
82 82
    pub keyless: bool,
83 +
    /// Serve this runner's metrics at this address, e.g. `127.0.0.1:9464`
84 +
    /// (oxygenflow#35); off unless set.
85 +
    #[serde(default)]
86 +
    pub metrics: Option<String>,
87 +
    /// A bearer token a scrape of the metrics must send.
88 +
    #[serde(default)]
89 +
    pub metrics_token: Option<String>,
83 90
}
84 91
85 92
impl RunnerConfig {
…
196 unchanged lines
modified crates/flow/src/main.rs +1 −0
…
10 unchanged lines
11 11
mod guest;
12 12
mod home;
13 13
mod images;
14 +
mod metrics;
14 15
mod microvm;
15 16
mod outputs;
16 17
mod runner;
…
552 unchanged lines
modified crates/flow/src/runner.rs +10 −1
…
588 unchanged lines
589 589
        runtimes.join(", "),
590 590
        key.identity.hash().to_hex()
591 591
    );
592 +
    if let Some(addr) = &cfg.metrics {
593 +
        crate::metrics::serve(addr, cfg.metrics_token.clone(), name.clone());
594 +
    }
592 595
    let used = std::sync::Mutex::new(Used::default());
593 596
    let mut idle = Duration::from_secs(2);
594 597
    let pending = report_pending(home);
…
83 unchanged lines
678 681
            match hub.claim(&req) {
679 682
                Ok(Some(claim)) => {
680 683
                    idle = Duration::from_secs(2);
684 +
                    crate::metrics::CLAIMS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
681 685
                    let (mem, cpus) = (claim.sandbox.memory_mib, claim.sandbox.cpus);
682 686
                    {
683 687
                        let mut u = used.lock().unwrap();
…
8 unchanged lines
692 696
                        fetch_image(home, hub, cfg, swarm, &claim);
693 697
                        if let Some(why) = preflight(home, &claim) {
694 698
                            eprintln!("job {id}: declined: {why}");
699 +
                            crate::metrics::DECLINED.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
695 700
                            if let Err(e) = hub.decline(id, &why) {
696 701
                                eprintln!("job {id}: decline: {e:#}");
697 702
                            }
…
10 unchanged lines
708 713
                            claim.job.name,
709 714
                            claim.heads.first().map(|h| &h[..12]).unwrap_or("-")
710 715
                        );
716 +
                        crate::metrics::RUNNING.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
711 717
                        let finish = run_claim(home, hub, cfg, key, &claim).unwrap_or_else(|e| Finish {
712 718
                            exit_code: None,
713 719
                            duration_ms: 0,
…
8 unchanged lines
722 728
                            finish.exit_code,
723 729
                            finish.error.as_deref().map(|e| format!(" ({e})")).unwrap_or_default()
724 730
                        );
725 -
                        if finish.error.as_deref() != Some(CANCELLED) {
731 +
                        crate::metrics::RUNNING.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
732 +
                        let cancelled = finish.error.as_deref() == Some(CANCELLED);
733 +
                        crate::metrics::finished(finish.exit_code, finish.error.as_deref(), finish.duration_ms, cancelled);
734 +
                        if !cancelled {
726 735
                            report_finish(home, hub, id, &finish);
727 736
                        }
728 737
                        held.lock().unwrap().remove(&id);
…
1027 unchanged lines
modified docs/spec.md +10 −0
…
249 unchanged lines
250 250
hub cannot certify, the runner's own key signs and the job's log says
251 251
why. The runner's key then only authenticates it to the hub.
252 252
253 +
**Metrics** (oxygenflow#35). With `metrics = "127.0.0.1:9464"` under
254 +
`[runner]` (off unless set), a runner serves Prometheus text at
255 +
`/metrics` on that address, from a thread of its own: its version and
256 +
uptime, jobs claimed and declined, finished jobs by result (`passed`,
257 +
`failed`, `error`, `cancelled`), the seconds they ran together, and the
258 +
jobs running now, each labelled with the runner's name. With
259 +
`metrics_token`, a scrape must send it as a bearer token; without one,
260 +
bind it to an address only the scraper reaches. The hub's own `/metrics`
261 +
has the queue and run times of every runner's jobs.
262 +
253 263
## 5. Content-Addressable Storage (CAS) & Zero-Copy Mounting
254 264
255 265
OxygenFlow runners bypass traditional clone operations. Repositories are accessed
…
541 unchanged lines