OXYGENHUB

/metrics: HTTP requests by route class with latency histograms (refs #212) b9b74d997cec

The router's timing layer (the one that logs slow requests) now counts
every request by route class, method and status class, with a latency
histogram from 5 ms to 30 s. Classes are page:<tab>, api:<area>,
protocol:<endpoint>, static, tlog and the like, never a user's or
repository's name, and at most 300. Tests: route classes for each kind
of path; a page request shows in the counters and the histogram. Hub
spec §13.5.5.

Refs #212
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 3d99f88ab70a
When
· 2026-10-18 03:22 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
b9b74d997cecc19ad363184695f1a78118182c4d774906efbee092d671bfc54f
Depends on
39442b000e6b 42fa95540468 8a48dd830178 93be9ce4653e d23464c0ac60
Depended on by
9e8cbfc3ad00 85ec74fd82d8 2c47eecfeab6 a6d824c5ebf5
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
12 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified crates/hub/src/lib.rs +1 −0
…
259 unchanged lines
260 260
    let start = std::time::Instant::now();
261 261
    let res = next.run(req).await;
262 262
    let took = start.elapsed();
263 +
    metrics::observe(method.as_str(), &path, res.status().as_u16(), took.as_secs_f64());
263 264
    if took >= SLOW {
264 265
        tracing::warn!("slow request: {method} {path} took {:.1} s ({})", took.as_secs_f64(), res.status().as_u16());
265 266
    }
…
89 unchanged lines
modified crates/hub/src/metrics.rs +118 −3
…
2 unchanged lines
3 3
//! Private: served only to a bearer of `[server] metrics_token`, and not
4 4
//! at all while that is unset.
5 5
//!
6 -
//! First slice: the process, the state cache, repositories kept in buckets,
7 -
//! OxygenFlow's jobs and runners, and the transparency log. Per-route
8 -
//! request histograms and the rest the issue lists come next.
6 +
//! The process, the state cache, repositories kept in buckets, OxygenFlow's
7 +
//! jobs and runners, the transparency log, and HTTP requests by route class
8 +
//! and status with latency histograms. The rest the issue lists comes next.
9 9
10 10
use crate::{db, Shared};
11 11
use axum::extract::State;
12 12
use axum::http::{header, HeaderMap, StatusCode};
13 13
use axum::response::{IntoResponse, Response};
14 +
use std::collections::HashMap;
14 15
use std::fmt::Write;
16 +
use std::sync::Mutex;
15 17
18 +
/// Latency buckets, in seconds.
19 +
const BUCKETS: [f64; 12] = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0];
20 +
/// At most this many route classes are kept; more go to `other`.
21 +
const MAX_ROUTES: usize = 300;
22 +
23 +
#[derive(Default)]
24 +
struct Hist {
25 +
    /// Requests by status class (2xx … 5xx).
26 +
    by_status: [u64; 4],
27 +
    buckets: [u64; BUCKETS.len()],
28 +
    sum: f64,
29 +
    count: u64,
30 +
}
31 +
32 +
static HTTP: Mutex<Option<HashMap<(String, String), Hist>>> = Mutex::new(None);
33 +
34 +
/// A request's route class: a few dozen at most, so a label never holds a
35 +
/// user's or repository's name.
36 +
pub fn route_class(path: &str) -> String {
37 +
    let segs: Vec<&str> = path.trim_start_matches('/').split('/').filter(|s| !s.is_empty()).collect();
38 +
    let tab = |s: &str| s.chars().all(|c| c.is_ascii_lowercase() || c == '-' || c == '.') && s.len() <= 24;
39 +
    match segs.as_slice() {
40 +
        [] => "page:home".into(),
41 +
        ["static", ..] => "static".into(),
42 +
        ["api", "v1", area, ..] if tab(area) => format!("api:{area}"),
43 +
        ["api", ..] => "api".into(),
44 +
        ["log", ..] => "tlog".into(),
45 +
        [".well-known", ..] => "well-known".into(),
46 +
        ["live", ..] => "live".into(),
47 +
        ["avatar", ..] => "avatar".into(),
48 +
        [_, _, "oxy", "v1", endpoint, ..] if tab(endpoint) => format!("protocol:{endpoint}"),
49 +
        [_, _, "oxy", ..] => "protocol".into(),
50 +
        [_, _, t, ..] if tab(t) => format!("page:{t}"),
51 +
        [_, _] => "page:repository".into(),
52 +
        [one] if tab(one) => format!("page:{one}"),
53 +
        _ => "page:other".into(),
54 +
    }
55 +
}
56 +
57 +
/// Count a request (from the router's outermost timing layer).
58 +
pub fn observe(method: &str, path: &str, status: u16, seconds: f64) {
59 +
    let mut g = HTTP.lock().unwrap_or_else(|p| p.into_inner());
60 +
    let map = g.get_or_insert_with(HashMap::new);
61 +
    let mut route = route_class(path);
62 +
    if !map.contains_key(&(route.clone(), method.to_string())) && map.len() >= MAX_ROUTES {
63 +
        route = "other".into();
64 +
    }
65 +
    let h = map.entry((route, method.to_string())).or_default();
66 +
    let class = (status / 100).clamp(2, 5) as usize - 2;
67 +
    h.by_status[class] += 1;
68 +
    for (i, b) in BUCKETS.iter().enumerate() {
69 +
        if seconds <= *b {
70 +
            h.buckets[i] += 1;
71 +
        }
72 +
    }
73 +
    h.sum += seconds;
74 +
    h.count += 1;
75 +
}
76 +
77 +
fn http_families(out: &mut String) {
78 +
    let g = HTTP.lock().unwrap_or_else(|p| p.into_inner());
79 +
    let Some(map) = g.as_ref() else { return };
80 +
    let mut keys: Vec<&(String, String)> = map.keys().collect();
81 +
    keys.sort();
82 +
    let _ = writeln!(out, "# HELP oxyhub_http_requests_total HTTP requests by route class, method and status class.");
83 +
    let _ = writeln!(out, "# TYPE oxyhub_http_requests_total counter");
84 +
    for k in &keys {
85 +
        let h = &map[*k];
86 +
        for (i, n) in h.by_status.iter().enumerate().filter(|(_, n)| **n > 0) {
87 +
            let _ = writeln!(
88 +
                out,
89 +
                "oxyhub_http_requests_total{{route=\"{}\",method=\"{}\",status=\"{}xx\"}} {n}",
90 +
                label(&k.0),
91 +
                label(&k.1),
92 +
                i + 2
93 +
            );
94 +
        }
95 +
    }
96 +
    let _ = writeln!(out, "# HELP oxyhub_http_request_duration_seconds How long requests took, by route class and method.");
97 +
    let _ = writeln!(out, "# TYPE oxyhub_http_request_duration_seconds histogram");
98 +
    for k in &keys {
99 +
        let h = &map[*k];
100 +
        let l = format!("route=\"{}\",method=\"{}\"", label(&k.0), label(&k.1));
101 +
        for (i, b) in BUCKETS.iter().enumerate() {
102 +
            let _ = writeln!(out, "oxyhub_http_request_duration_seconds_bucket{{{l},le=\"{b}\"}} {}", h.buckets[i]);
103 +
        }
104 +
        let _ = writeln!(out, "oxyhub_http_request_duration_seconds_bucket{{{l},le=\"+Inf\"}} {}", h.count);
105 +
        let _ = writeln!(out, "oxyhub_http_request_duration_seconds_sum{{{l}}} {}", h.sum);
106 +
        let _ = writeln!(out, "oxyhub_http_request_duration_seconds_count{{{l}}} {}", h.count);
107 +
    }
108 +
}
109 +
16 110
/// One metric family: its help, type and samples (label set, value).
17 111
fn family(out: &mut String, name: &str, help: &str, kind: &str, samples: &[(String, f64)]) {
18 112
    let _ = writeln!(out, "# HELP {name} {help}");
…
111 unchanged lines
130 224
        ],
131 225
    );
132 226
    family(&mut out, "oxyhub_tlog_entries", "Entries in the transparency log.", "gauge", &[(String::new(), app.tlog.size() as f64)]);
227 +
    http_families(&mut out);
133 228
    out
134 229
}
135 230
…
9 unchanged lines
145 240
    }
146 241
    ([(header::CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8"), (header::CACHE_CONTROL, "no-store")], render(&app).await)
147 242
        .into_response()
243 +
}
244 +
245 +
#[cfg(test)]
246 +
mod tests {
247 +
    #[test]
248 +
    fn route_classes_hold_no_names() {
249 +
        for (path, class) in [
250 +
            ("/", "page:home"),
251 +
            ("/static/hub.js", "static"),
252 +
            ("/api/v1/repos/andy/demo/issues", "api:repos"),
253 +
            ("/andy/demo/oxy/v1/negotiate", "protocol:negotiate"),
254 +
            ("/andy/demo/issue/3", "page:issue"),
255 +
            ("/andy/demo", "page:repository"),
256 +
            ("/explore", "page:explore"),
257 +
            ("/Andy_McCall", "page:other"),
258 +
            ("/log/tile/0/000", "tlog"),
259 +
        ] {
260 +
            assert_eq!(super::route_class(path), class, "{path}");
261 +
        }
262 +
    }
148 263
}
modified crates/hub/tests/flow.rs +5 −0
…
5317 unchanged lines
5318 5318
    assert_eq!(runs_of(&h.app, "andy", "demo").await.len(), 1);
5319 5319
    let text = oxygenhub::metrics::render(&h.app).await;
5320 5320
    assert!(!text.contains("oxyhub_flow_jobs{state=\"queued\"} 0"), "the run's jobs are queued:\n{text}");
5321 +
    // Requests through the router are counted by route class.
5322 +
    call(&h.app, "GET", "/andy/demo", None, None).await;
5323 +
    let text = oxygenhub::metrics::render(&h.app).await;
5324 +
    assert!(text.contains("oxyhub_http_requests_total{route=\"page:repository\",method=\"GET\",status=\"2xx\"}"), "{text}");
5325 +
    assert!(text.contains("oxyhub_http_request_duration_seconds_bucket{route=\"page:repository\",method=\"GET\",le=\"+Inf\"}"));
5321 5326
}
5322 5327
5323 5328
/// Keyless step 4c (oxygenhub#241): when a key the hub certified moves a
…
183 unchanged lines
modified docs/spec.md +3 −2
…
642 unchanged lines
643 643
- the state cache's states, estimated weight, budget and summaries;
644 644
- requests to bucket stores by kind (with pack listings sent and kept apart), and the time spent in them;
645 645
- OxygenFlow jobs queued, waiting for approval and running, and runners online and not;
646 -
- the transparency log's entries.
646 +
- the transparency log's entries;
647 +
- HTTP requests by route class (`page:<tab>`, `api:<area>`, `protocol:<endpoint>`, `static` and so on, never a user's or repository's name; at most 300 classes), method and status class, with latency histograms (5 ms to 30 s).
647 648
648 -
Per-route request counts and latency histograms, pushes and fetches, backups, Diffusion, mail and the database pool are the next slices; the runner's own metrics too.
649 +
Pushes and fetches, backups, Diffusion, mail and the database pool are the next slices; the runner's own metrics too.
649 650
650 651
### 13.6 Coverage reports (oxygenhub#113, done 2026-10-06)
651 652
…
168 unchanged lines