OXYGENHUB

Coverage / crates/hub/src/metrics.rs 93.1%

367 of 394 lines run, 33 of 78 functions · run #483 on api-unknown-fields · run missed

1
//! `/metrics` (oxygenhub#212): the hub's numbers in the Prometheus text
2
//! format, for a monitoring stack (oxygenhub-gcp's Prometheus and Grafana).
3
//! Private: served only to a bearer of `[server] metrics_token`, and not
4
//! at all while that is unset.
5
//!
6
//! The process, the state cache, repositories kept in buckets, OxygenFlow's
7
//! jobs, runs and runners, the transparency log, the database pool, pushes,
8
//! backups, mail, and HTTP requests by route class and status with latency
9
//! histograms. The rest the issue lists comes next.
10
11
use crate::{db, Shared};
12
use axum::extract::State;
13
use axum::http::{header, HeaderMap, StatusCode};
14
use axum::response::{IntoResponse, Response};
15
use std::collections::HashMap;
16
use std::fmt::Write;
17
use std::sync::atomic::{AtomicU64, Ordering};
18
use std::sync::Mutex;
19
20
/// Messages the hub sent, and failed to send, since it started.
21
pub static MAIL_SENT: AtomicU64 = AtomicU64::new(0);
22
pub static MAIL_FAILED: AtomicU64 = AtomicU64::new(0);
23
24
/// Latency buckets, in seconds.
25
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];
26
/// At most this many route classes are kept; more go to `other`.
27
const MAX_ROUTES: usize = 300;
28
29
#[derive(Default)]
30
struct Hist {
31
    /// Requests by status class (2xx … 5xx).
32
    by_status: [u64; 4],
33
    buckets: [u64; BUCKETS.len()],
34
    sum: f64,
35
    count: u64,
36
}
37
38
static HTTP: Mutex<Option<HashMap<(String, String), Hist>>> = Mutex::new(None);
39
40
/// Bytes the protocol received and sent, by endpoint, counted before the
41
/// answer is compressed: what pushes and fetches carry.
42
static PROTOCOL: Mutex<std::collections::BTreeMap<&'static str, (u64, u64)>> = Mutex::new(std::collections::BTreeMap::new());
43
44
/// Count a protocol call's request and response bodies.
451081×
pub fn protocol_bytes(endpoint: &'static str, received: u64, sent: u64) {
461081×
    let mut g = PROTOCOL.lock().unwrap_or_else(|p| p.into_inner());
471081×
    let e = g.entry(endpoint).or_default();
481081×
    e.0 += received;
491081×
    e.1 += sent;
501081×
}
51
52
/// A request's route class: a few dozen at most, so a label never holds a
53
/// user's or repository's name.
543660×
pub fn route_class(path: &str) -> String {
5515290×
    let segs: Vec<&str> = path.trim_start_matches('/').split('/').filter(|s| !s.is_empty()).collect();
5619823×
    let tab = |s: &str| s.chars().all(|c| c.is_ascii_lowercase() || c == '-' || c == '.') && s.len() <= 24;
573660×
    match segs.as_slice() {
583660×
        [] => "page:home".into(),
593648×
        ["static", ..] => "static".into(),
602926×
        ["api", "v1", area, ..] if tab(area) => format!("api:{area}"),
612738×
        ["api", ..] => "api".into(),
622738×
        ["log", ..] => "tlog".into(),
632721×
        [".well-known", ..] => "well-known".into(),
642687×
        ["live", ..] => "live".into(),
652680×
        ["avatar", ..] => "avatar".into(),
661250×
        [_, _, "oxy", "v1", endpoint, ..] if tab(endpoint) => format!("protocol:{endpoint}"),
67946×
        [_, _, "oxy", ..] => "protocol".into(),
68946×
        [_, _, t, ..] if tab(t) => format!("page:{t}"),
69663×
        [_, _] => "page:repository".into(),
70358×
        [one] if tab(one) => format!("page:{one}"),
71156×
        _ => "page:other".into(),
72
    }
733660×
}
74
75
/// Count a request (from the router's outermost timing layer).
763651×
pub fn observe(method: &str, path: &str, status: u16, seconds: f64) {
773651×
    let mut g = HTTP.lock().unwrap_or_else(|p| p.into_inner());
783651×
    let map = g.get_or_insert_with(HashMap::new);
793651×
    let mut route = route_class(path);
803651×
    if !map.contains_key(&(route.clone(), method.to_string())) && map.len() >= MAX_ROUTES {
810×
        route = "other".into();
823651×
    }
833651×
    let h = map.entry((route, method.to_string())).or_default();
843651×
    let class = (status / 100).clamp(2, 5) as usize - 2;
853651×
    h.by_status[class] += 1;
8643812×
    for (i, b) in BUCKETS.iter().enumerate() {
8743812×
        if seconds <= *b {
8838808×
            h.buckets[i] += 1;
8938808×
        }
90
    }
913651×
    h.sum += seconds;
923651×
    h.count += 1;
933651×
}
94
954×
fn http_families(out: &mut String) {
964×
    let g = HTTP.lock().unwrap_or_else(|p| p.into_inner());
974×
    let Some(map) = g.as_ref() else { return };
983×
    let mut keys: Vec<&(String, String)> = map.keys().collect();
993×
    keys.sort();
1003×
    let _ = writeln!(out, "# HELP oxyhub_http_requests_total HTTP requests by route class, method and status class.");
1013×
    let _ = writeln!(out, "# TYPE oxyhub_http_requests_total counter");
10210×
    for k in &keys {
10310×
        let h = &map[*k];
10440×
        for (i, n) in h.by_status.iter().enumerate().filter(|(_, n)| **n > 0) {
10510×
            let _ = writeln!(
10610×
                out,
10710×
                "oxyhub_http_requests_total{{route=\"{}\",method=\"{}\",status=\"{}xx\"}} {n}",
10810×
                label(&k.0),
10910×
                label(&k.1),
11010×
                i + 2
11110×
            );
11210×
        }
113
    }
1143×
    let _ = writeln!(out, "# HELP oxyhub_http_request_duration_seconds How long requests took, by route class and method.");
1153×
    let _ = writeln!(out, "# TYPE oxyhub_http_request_duration_seconds histogram");
11610×
    for k in &keys {
11710×
        let h = &map[*k];
11810×
        let l = format!("route=\"{}\",method=\"{}\"", label(&k.0), label(&k.1));
119120×
        for (i, b) in BUCKETS.iter().enumerate() {
120120×
            let _ = writeln!(out, "oxyhub_http_request_duration_seconds_bucket{{{l},le=\"{b}\"}} {}", h.buckets[i]);
121120×
        }
12210×
        let _ = writeln!(out, "oxyhub_http_request_duration_seconds_bucket{{{l},le=\"+Inf\"}} {}", h.count);
12310×
        let _ = writeln!(out, "oxyhub_http_request_duration_seconds_sum{{{l}}} {}", h.sum);
12410×
        let _ = writeln!(out, "oxyhub_http_request_duration_seconds_count{{{l}}} {}", h.count);
125
    }
1264×
}
127
128
/// One metric family: its help, type and samples (label set, value).
12996×
fn family(out: &mut String, name: &str, help: &str, kind: &str, samples: &[(String, f64)]) {
13096×
    let _ = writeln!(out, "# HELP {name} {help}");
13196×
    let _ = writeln!(out, "# TYPE {name} {kind}");
132168×
    for (labels, v) in samples {
133168×
        if labels.is_empty() {
13440×
            let _ = writeln!(out, "{name} {v}");
135128×
        } else {
136128×
            let _ = writeln!(out, "{name}{{{labels}}} {v}");
137128×
        }
138
    }
13996×
}
140
141
/// A label value, escaped as the text format asks.
14245×
fn label(v: &str) -> String {
14345×
    v.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
14445×
}
145
146
/// The process's resident memory, from /proc (Linux), in bytes.
1474×
fn resident_bytes() -> Option<f64> {
1484×
    let statm = std::fs::read_to_string("/proc/self/statm").ok()?;
1494×
    let pages: f64 = statm.split_whitespace().nth(1)?.parse().ok()?;
1504×
    Some(pages * 4096.0)
1514×
}
152
153
/// The metrics as text, for whoever is let in.
1544×
pub async fn render(app: &Shared) -> String {
1554×
    let mut out = String::new();
1564×
    family(
1574×
        &mut out,
1584×
        "oxyhub_build_info",
1594×
        "The hub's version and build.",
1604×
        "gauge",
1614×
        &[(format!("version=\"{}\",build=\"{}\"", env!("CARGO_PKG_VERSION"), label(env!("OXY_BUILD_ID"))), 1.0)],
162
    );
1634×
    family(
1644×
        &mut out,
1654×
        "oxyhub_uptime_seconds",
1664×
        "Seconds since the hub started.",
1674×
        "gauge",
1684×
        &[(String::new(), app.started.elapsed().as_secs_f64())],
169
    );
1704×
    if let Some(rss) = resident_bytes() {
1714×
        family(&mut out, "oxyhub_resident_memory_bytes", "The process's resident memory.", "gauge", &[(String::new(), rss)]);
1724×
    }
1734×
    let (states, weight, summaries) = app.repos.cache().stats();
1744×
    family(&mut out, "oxyhub_state_cache_states", "States the state cache holds.", "gauge", &[(String::new(), states as f64)]);
1754×
    family(
1764×
        &mut out,
1774×
        "oxyhub_state_cache_bytes",
1784×
        "What the state cache's states are estimated to weigh.",
1794×
        "gauge",
1804×
        &[(String::new(), weight as f64)],
181
    );
1824×
    family(
1834×
        &mut out,
1844×
        "oxyhub_state_cache_budget_bytes",
1854×
        "What the state cache may hold.",
1864×
        "gauge",
1874×
        &[(String::new(), crate::repos::cache::budget() as f64)],
188
    );
1894×
    family(&mut out, "oxyhub_state_cache_summaries", "Summaries the state cache holds.", "gauge", &[(String::new(), summaries as f64)]);
1904×
    let strata = oxy_strata::stats();
1914×
    family(
1924×
        &mut out,
1934×
        "oxyhub_bucket_clients_opened_total",
1944×
        "Bucket store clients opened.",
1954×
        "counter",
1964×
        &[(String::new(), strata.opens as f64)],
197
    );
1984×
    family(
1994×
        &mut out,
2004×
        "oxyhub_bucket_requests_total",
2014×
        "Requests to bucket stores, by kind.",
2024×
        "counter",
20348×
        &strata.ops().map(|(k, n)| (format!("kind=\"{k}\""), n as f64)).collect::<Vec<_>>(),
204
    );
2054×
    family(
2064×
        &mut out,
2074×
        "oxyhub_bucket_request_seconds_total",
2084×
        "Time spent in requests to bucket stores.",
2094×
        "counter",
2104×
        &[(String::new(), strata.millis as f64 / 1000.0)],
211
    );
2124×
    let jobs: Vec<(String, i64)> =
2134×
        sqlx::query_as("SELECT status, COUNT(*) FROM flow_jobs WHERE status IN ('queued', 'running') GROUP BY status")
2144×
            .fetch_all(&app.db)
2154×
            .await
2164×
            .unwrap_or_default();
2178×
    let count = |s: &str| jobs.iter().find(|(k, _)| k == s).map(|(_, n)| *n).unwrap_or(0) as f64;
2184×
    let waiting: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM flow_jobs WHERE status = 'queued' AND approval = 'waiting'")
2194×
        .fetch_one(&app.db)
2204×
        .await
2214×
        .unwrap_or(0);
2224×
    family(
2234×
        &mut out,
2244×
        "oxyhub_flow_jobs",
2254×
        "OxygenFlow jobs now, by state (waiting: for a person's approval).",
2264×
        "gauge",
2274×
        &[
2284×
            ("state=\"queued\"".into(), count("queued") - waiting as f64),
2294×
            ("state=\"waiting\"".into(), waiting as f64),
2304×
            ("state=\"running\"".into(), count("running")),
2314×
        ],
232
    );
2334×
    let runners = db::runners::list(&app.db).await.unwrap_or_default();
2344×
    family(
2354×
        &mut out,
2364×
        "oxyhub_flow_runners",
2374×
        "Registered OxygenFlow runners, by whether they reported in the last minutes.",
2384×
        "gauge",
239
        &[
2404×
            ("online=\"true\"".into(), runners.iter().filter(|r| r.online()).count() as f64),
2414×
            ("online=\"false\"".into(), runners.iter().filter(|r| !r.online()).count() as f64),
242
        ],
243
    );
2444×
    let now = db::now_ms();
2454×
    family(
2464×
        &mut out,
2474×
        "oxyhub_flow_runner_last_seen_seconds",
2484×
        "Seconds since each registered runner last called the hub.",
2494×
        "gauge",
2504×
        &runners
2514×
            .iter()
2524×
            .filter_map(|r| r.last_seen_ms.map(|t| (format!("runner=\"{}\"", label(&r.name)), (now - t).max(0) as f64 / 1000.0)))
2534×
            .collect::<Vec<_>>(),
254
    );
2554×
    let since = now - 86_400_000;
2564×
    let runs: Vec<(String, i64)> = sqlx::query_as("SELECT status, COUNT(*) FROM flow_runs WHERE finished_ms > $1 GROUP BY status")
2574×
        .bind(since)
2584×
        .fetch_all(&app.db)
2594×
        .await
2604×
        .unwrap_or_default();
2614×
    family(
2624×
        &mut out,
2634×
        "oxyhub_flow_runs_last_day",
2644×
        "OxygenFlow runs finished in the last 24 hours, by result.",
2654×
        "gauge",
2664×
        &["passed", "failed", "error"]
2674×
            .iter()
26812×
            .map(|r| (format!("result=\"{r}\""), runs.iter().find(|(s, _)| s == r).map(|(_, n)| *n).unwrap_or(0) as f64))
2694×
            .collect::<Vec<_>>(),
270
    );
2714×
    family(&mut out, "oxyhub_tlog_entries", "Entries in the transparency log.", "gauge", &[(String::new(), app.tlog.size() as f64)]);
2724×
    let protocol: Vec<(&'static str, (u64, u64))> =
2736×
        PROTOCOL.lock().unwrap_or_else(|p| p.into_inner()).iter().map(|(k, v)| (*k, *v)).collect();
2744×
    family(
2754×
        &mut out,
2764×
        "oxyhub_protocol_bytes_total",
2774×
        "Bytes the Oxygen protocol received and sent since the hub started, by endpoint, before compression: push carries pushes, fetch and objects carry fetches.",
2784×
        "counter",
2794×
        &protocol
2804×
            .iter()
2816×
            .flat_map(|(e, (rx, tx))| {
2826×
                [(format!("endpoint=\"{e}\",direction=\"received\""), *rx as f64), (format!("endpoint=\"{e}\",direction=\"sent\""), *tx as f64)]
2836×
            })
2844×
            .collect::<Vec<_>>(),
285
    );
2864×
    family(
2874×
        &mut out,
2884×
        "oxyhub_mail_total",
2894×
        "Messages the hub sent and failed to send since it started.",
2904×
        "counter",
2914×
        &[
2924×
            ("result=\"sent\"".into(), MAIL_SENT.load(Ordering::Relaxed) as f64),
2934×
            ("result=\"failed\"".into(), MAIL_FAILED.load(Ordering::Relaxed) as f64),
2944×
        ],
295
    );
2964×
    family(
2974×
        &mut out,
2984×
        "oxyhub_db_connections",
2994×
        "The database pool's connections, by whether they are idle.",
3004×
        "gauge",
3014×
        &[
3024×
            ("idle=\"true\"".into(), app.db.num_idle() as f64),
3034×
            ("idle=\"false\"".into(), (app.db.size() as f64 - app.db.num_idle() as f64).max(0.0)),
3044×
        ],
305
    );
3064×
    let (ok, refused): (Option<i64>, Option<i64>) =
3074×
        sqlx::query_as("SELECT SUM(CASE WHEN ok THEN 1 ELSE 0 END), SUM(CASE WHEN ok THEN 0 ELSE 1 END) FROM pushes WHERE at_ms > $1")
3084×
            .bind(since)
3094×
            .fetch_one(&app.db)
3104×
            .await
3114×
            .unwrap_or((None, None));
3124×
    family(
3134×
        &mut out,
3144×
        "oxyhub_pushes_last_day",
3154×
        "Reference updates pushed in the last 24 hours, by whether they were accepted.",
3164×
        "gauge",
3174×
        &[("result=\"accepted\"".into(), ok.unwrap_or(0) as f64), ("result=\"refused\"".into(), refused.unwrap_or(0) as f64)],
318
    );
3194×
    let backups: Vec<(String, String, i64, Option<i64>)> = sqlx::query_as(
3204×
        "SELECT owner, status, started_ms, finished_ms FROM backup_runs WHERE id IN (SELECT MAX(id) FROM backup_runs GROUP BY owner) ORDER BY owner",
3214×
    )
3224×
    .fetch_all(&app.db)
3234×
    .await
3244×
    .unwrap_or_default();
3254×
    family(
3264×
        &mut out,
3274×
        "oxyhub_backup_last_run_age_seconds",
3284×
        "Seconds since each owner's latest backup run started.",
3294×
        "gauge",
3304×
        &backups.iter().map(|(o, _, s, _)| (format!("owner=\"{}\"", label(o)), (now - s) as f64 / 1000.0)).collect::<Vec<_>>(),
331
    );
3324×
    family(
3334×
        &mut out,
3344×
        "oxyhub_backup_last_run_ok",
3354×
        "Whether each owner's latest finished backup run succeeded (1), was partial or failed (0); running ones are left out.",
3364×
        "gauge",
3374×
        &backups
3384×
            .iter()
3394×
            .filter(|(_, st, _, f)| f.is_some() && st != "running")
3404×
            .map(|(o, st, _, _)| (format!("owner=\"{}\"", label(o)), if st == "ok" { 1.0 } else { 0.0 }))
3414×
            .collect::<Vec<_>>(),
342
    );
3434×
    flow_timings(app, &mut out, now).await;
3444×
    if let Ok((waiting, dead)) = db::queue::counts(&app.db).await {
3454×
        family(
3464×
            &mut out,
3474×
            "oxyhub_queue_items",
3484×
            "Items in the durable work queue (#276): waiting (due, backing off or claimed) and dead (failed every attempt).",
3494×
            "gauge",
3504×
            &[("state=\"waiting\"".into(), waiting as f64), ("state=\"dead\"".into(), dead as f64)],
3514×
        );
3524×
    }
3534×
    http_families(&mut out);
3544×
    out
3554×
}
356
357
/// A finished job's runner, result, note, and when it was ready, started
358
/// and finished.
359
type JobTimes = (String, String, String, Option<i64>, Option<i64>, Option<i64>);
360
361
/// Bucket bounds for job times, in seconds.
362
const JOB_SECONDS: [f64; 11] = [1.0, 5.0, 15.0, 30.0, 60.0, 120.0, 300.0, 600.0, 1200.0, 1800.0, 3600.0];
363
364
/// Flow's job times over the last day (oxygenflow#35), by runner: how long
365
/// jobs waited from ready to started (queue time) and ran (run time), and
366
/// how many finished, by result, and were reused (#265).
3674×
async fn flow_timings(app: &Shared, out: &mut String, now: i64) {
3684×
    let rows: Vec<JobTimes> = sqlx::query_as(
3694×
        "SELECT runner, status, error, COALESCE(ready_ms, queued_ms), started_ms, finished_ms FROM flow_jobs \
3704×
         WHERE finished_ms >= $1 AND status IN ('passed', 'failed', 'error', 'cancelled')",
3714×
    )
3724×
    .bind(now - 86_400_000)
3734×
    .fetch_all(&app.db)
3744×
    .await
3754×
    .unwrap_or_default();
3764×
    let mut queue: std::collections::BTreeMap<String, Vec<f64>> = Default::default();
3774×
    let mut run: std::collections::BTreeMap<String, Vec<f64>> = Default::default();
3784×
    let mut finished: std::collections::BTreeMap<(String, String), f64> = Default::default();
3794×
    let mut reused = 0.0;
3804×
    for (runner, status, error, ready, started, finished_ms) in &rows {
3810×
        if error.starts_with("reused from run #") {
3820×
            reused += 1.0;
3830×
            continue;
3840×
        }
3850×
        let r = if runner.is_empty() { "none".to_string() } else { runner.clone() };
3860×
        *finished.entry((r.clone(), status.clone())).or_default() += 1.0;
3870×
        if let (Some(ready), Some(started)) = (ready, started) {
3880×
            queue.entry(r.clone()).or_default().push((started - ready).max(0) as f64 / 1000.0);
3890×
        }
3900×
        if let (Some(started), Some(f)) = (started, finished_ms) {
3910×
            run.entry(r).or_default().push((f - started).max(0) as f64 / 1000.0);
3920×
        }
393
    }
3944×
    histogram(
3954×
        out,
3964×
        "oxyhub_flow_job_queue_seconds",
3974×
        "How long finished jobs of the last day waited from ready to started, by runner.",
3984×
        &queue,
399
    );
4004×
    histogram(out, "oxyhub_flow_job_run_seconds", "How long finished jobs of the last day ran, by runner.", &run);
4014×
    family(
4024×
        out,
4034×
        "oxyhub_flow_jobs_finished_last_day",
4044×
        "Jobs that finished in the last day, by runner and result (none: never placed).",
4054×
        "gauge",
4064×
        &finished.iter().map(|((r, st), n)| (format!("runner=\"{}\",status=\"{}\"", label(r), label(st)), *n)).collect::<Vec<_>>(),
407
    );
4084×
    family(
4094×
        out,
4104×
        "oxyhub_flow_jobs_reused_last_day",
4114×
        "Jobs of the last day that reused a pass on the same state instead of running (#265).",
4124×
        "gauge",
4134×
        &[(String::new(), reused)],
414
    );
4154×
}
416
417
/// A histogram family from samples (seconds) per runner.
4189×
fn histogram(out: &mut String, name: &str, help: &str, by_runner: &std::collections::BTreeMap<String, Vec<f64>>) {
4199×
    let _ = writeln!(out, "# HELP {name} {help}");
4209×
    let _ = writeln!(out, "# TYPE {name} histogram");
4219×
    for (runner, samples) in by_runner {
4221×
        let l = format!("runner=\"{}\"", label(runner));
42311×
        for b in JOB_SECONDS {
42433×
            let n = samples.iter().filter(|s| **s <= b).count();
42511×
            let _ = writeln!(out, "{name}_bucket{{{l},le=\"{b}\"}} {n}");
426
        }
4271×
        let _ = writeln!(out, "{name}_bucket{{{l},le=\"+Inf\"}} {}", samples.len());
4281×
        let _ = writeln!(out, "{name}_sum{{{l}}} {}", samples.iter().sum::<f64>());
4291×
        let _ = writeln!(out, "{name}_count{{{l}}} {}", samples.len());
430
    }
4319×
}
432
433
/// `GET /metrics`.
4342×
pub async fn metrics(State(app): State<Shared>, headers: HeaderMap) -> Response {
4352×
    let want = &app.config.server.metrics_token;
4362×
    if want.is_empty() {
4371×
        return (StatusCode::NOT_FOUND, "metrics are off: set [server] metrics_token in hub.toml\n").into_response();
4381×
    }
4391×
    let given = oxy_server::bearer_token(&headers).unwrap_or("");
4401×
    if !crate::auth::mac_equal(given, want) {
4410×
        return (StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, "Bearer")], "a bearer token is needed\n").into_response();
4421×
    }
4431×
    ([(header::CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8"), (header::CACHE_CONTROL, "no-store")], render(&app).await)
4441×
        .into_response()
4452×
}
446
447
#[cfg(test)]
448
mod tests {
449
450
    #[test]
4511×
    fn job_times_are_cumulative_buckets_per_runner() {
4521×
        let mut by: std::collections::BTreeMap<String, Vec<f64>> = Default::default();
4531×
        by.insert("earth-1".into(), vec![0.5, 20.0, 4000.0]);
4541×
        let mut out = String::new();
4551×
        super::histogram(&mut out, "x_seconds", "help", &by);
4561×
        assert!(out.contains("x_seconds_bucket{runner=\"earth-1\",le=\"1\"} 1"), "{out}");
4571×
        assert!(out.contains("x_seconds_bucket{runner=\"earth-1\",le=\"30\"} 2"), "{out}");
4581×
        assert!(out.contains("x_seconds_bucket{runner=\"earth-1\",le=\"3600\"} 2"), "{out}");
4591×
        assert!(
4601×
            out.contains("x_seconds_bucket{runner=\"earth-1\",le=\"+Inf\"} 3") && out.contains("x_seconds_count{runner=\"earth-1\"} 3"),
461
            "{out}"
462
        );
4631×
    }
464
465
    #[test]
4661×
    fn route_classes_hold_no_names() {
4679×
        for (path, class) in [
4681×
            ("/", "page:home"),
4691×
            ("/static/hub.js", "static"),
4701×
            ("/api/v1/repos/andy/demo/issues", "api:repos"),
4711×
            ("/andy/demo/oxy/v1/negotiate", "protocol:negotiate"),
4721×
            ("/andy/demo/issue/3", "page:issue"),
4731×
            ("/andy/demo", "page:repository"),
4741×
            ("/explore", "page:explore"),
4751×
            ("/Andy_McCall", "page:other"),
4761×
            ("/log/tile/0/000", "tlog"),
4771×
        ] {
4789×
            assert_eq!(super::route_class(path), class, "{path}");
479
        }
4801×
    }
481
}