OXYGENHUB

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

383 of 414 lines run, 35 of 80 functions · run #521 on main · 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.
451105×
pub fn protocol_bytes(endpoint: &'static str, received: u64, sent: u64) {
461105×
    let mut g = PROTOCOL.lock().unwrap_or_else(|p| p.into_inner());
471105×
    let e = g.entry(endpoint).or_default();
481105×
    e.0 += received;
491105×
    e.1 += sent;
501105×
}
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.
543824×
pub fn route_class(path: &str) -> String {
5516097×
    let segs: Vec<&str> = path.trim_start_matches('/').split('/').filter(|s| !s.is_empty()).collect();
5620699×
    let tab = |s: &str| s.chars().all(|c| c.is_ascii_lowercase() || c == '-' || c == '.') && s.len() <= 24;
573824×
    match segs.as_slice() {
583824×
        [] => "page:home".into(),
593812×
        ["static", ..] => "static".into(),
603081×
        ["api", "v1", area, ..] if tab(area) => format!("api:{area}"),
612817×
        ["api", ..] => "api".into(),
622817×
        ["log", ..] => "tlog".into(),
632800×
        [".well-known", ..] => "well-known".into(),
642766×
        ["live", ..] => "live".into(),
652759×
        ["avatar", ..] => "avatar".into(),
661281×
        [_, _, "oxy", "v1", endpoint, ..] if tab(endpoint) => format!("protocol:{endpoint}"),
67992×
        [_, _, "oxy", ..] => "protocol".into(),
68992×
        [_, _, t, ..] if tab(t) => format!("page:{t}"),
69672×
        [_, _] => "page:repository".into(),
70364×
        [one] if tab(one) => format!("page:{one}"),
71169×
        _ => "page:other".into(),
72
    }
733824×
}
74
75
/// Count a request (from the router's outermost timing layer).
763815×
pub fn observe(method: &str, path: &str, status: u16, seconds: f64) {
773815×
    let mut g = HTTP.lock().unwrap_or_else(|p| p.into_inner());
783815×
    let map = g.get_or_insert_with(HashMap::new);
793815×
    let mut route = route_class(path);
803815×
    if !map.contains_key(&(route.clone(), method.to_string())) && map.len() >= MAX_ROUTES {
810×
        route = "other".into();
823815×
    }
833815×
    let h = map.entry((route, method.to_string())).or_default();
843815×
    let class = (status / 100).clamp(2, 5) as usize - 2;
853815×
    h.by_status[class] += 1;
8645780×
    for (i, b) in BUCKETS.iter().enumerate() {
8745780×
        if seconds <= *b {
8840347×
            h.buckets[i] += 1;
8940347×
        }
90
    }
913815×
    h.sum += seconds;
923815×
    h.count += 1;
933815×
}
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);
401
    // Where their time went, as their runners reported (oxygenflow#35).
4024×
    let phases: Vec<String> = sqlx::query_scalar("SELECT phases FROM flow_jobs WHERE finished_ms >= $1 AND phases != ''")
4034×
        .bind(now - 86_400_000)
4044×
        .fetch_all(&app.db)
4054×
        .await
4064×
        .unwrap_or_default();
4074×
    let mut by_phase: std::collections::BTreeMap<String, Vec<f64>> = Default::default();
4084×
    for json in &phases {
4090×
        let m: std::collections::BTreeMap<String, u64> = serde_json::from_str(json).unwrap_or_default();
4100×
        for (phase, ms) in m {
4110×
            by_phase.entry(phase).or_default().push(ms as f64 / 1000.0);
4120×
        }
413
    }
4144×
    histogram_by(
4154×
        out,
4164×
        "oxyhub_flow_job_phase_seconds",
4174×
        "Where finished jobs of the last day spent their time, by phase: image (fetching it), prepare, run, finish.",
4184×
        "phase",
4194×
        &by_phase,
420
    );
4214×
    family(
4224×
        out,
4234×
        "oxyhub_flow_jobs_finished_last_day",
4244×
        "Jobs that finished in the last day, by runner and result (none: never placed).",
4254×
        "gauge",
4264×
        &finished.iter().map(|((r, st), n)| (format!("runner=\"{}\",status=\"{}\"", label(r), label(st)), *n)).collect::<Vec<_>>(),
427
    );
4284×
    family(
4294×
        out,
4304×
        "oxyhub_flow_jobs_reused_last_day",
4314×
        "Jobs of the last day that reused a pass on the same state instead of running (#265).",
4324×
        "gauge",
4334×
        &[(String::new(), reused)],
434
    );
4354×
}
436
437
/// A histogram family from samples (seconds) per runner.
4389×
fn histogram(out: &mut String, name: &str, help: &str, by_runner: &std::collections::BTreeMap<String, Vec<f64>>) {
4399×
    histogram_by(out, name, help, "runner", by_runner)
4409×
}
441
442
/// A histogram of job seconds, one series per value of label `key`.
44313×
fn histogram_by(out: &mut String, name: &str, help: &str, key: &str, by: &std::collections::BTreeMap<String, Vec<f64>>) {
44413×
    let _ = writeln!(out, "# HELP {name} {help}");
44513×
    let _ = writeln!(out, "# TYPE {name} histogram");
44613×
    for (runner, samples) in by {
4471×
        let l = format!("{key}=\"{}\"", label(runner));
44811×
        for b in JOB_SECONDS {
44933×
            let n = samples.iter().filter(|s| **s <= b).count();
45011×
            let _ = writeln!(out, "{name}_bucket{{{l},le=\"{b}\"}} {n}");
451
        }
4521×
        let _ = writeln!(out, "{name}_bucket{{{l},le=\"+Inf\"}} {}", samples.len());
4531×
        let _ = writeln!(out, "{name}_sum{{{l}}} {}", samples.iter().sum::<f64>());
4541×
        let _ = writeln!(out, "{name}_count{{{l}}} {}", samples.len());
455
    }
45613×
}
457
458
/// `GET /metrics`.
4592×
pub async fn metrics(State(app): State<Shared>, headers: HeaderMap) -> Response {
4602×
    let want = &app.config.server.metrics_token;
4612×
    if want.is_empty() {
4621×
        return (StatusCode::NOT_FOUND, "metrics are off: set [server] metrics_token in hub.toml\n").into_response();
4631×
    }
4641×
    let given = oxy_server::bearer_token(&headers).unwrap_or("");
4651×
    if !crate::auth::mac_equal(given, want) {
4660×
        return (StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, "Bearer")], "a bearer token is needed\n").into_response();
4671×
    }
4681×
    ([(header::CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8"), (header::CACHE_CONTROL, "no-store")], render(&app).await)
4691×
        .into_response()
4702×
}
471
472
#[cfg(test)]
473
mod tests {
474
475
    #[test]
4761×
    fn job_times_are_cumulative_buckets_per_runner() {
4771×
        let mut by: std::collections::BTreeMap<String, Vec<f64>> = Default::default();
4781×
        by.insert("earth-1".into(), vec![0.5, 20.0, 4000.0]);
4791×
        let mut out = String::new();
4801×
        super::histogram(&mut out, "x_seconds", "help", &by);
4811×
        assert!(out.contains("x_seconds_bucket{runner=\"earth-1\",le=\"1\"} 1"), "{out}");
4821×
        assert!(out.contains("x_seconds_bucket{runner=\"earth-1\",le=\"30\"} 2"), "{out}");
4831×
        assert!(out.contains("x_seconds_bucket{runner=\"earth-1\",le=\"3600\"} 2"), "{out}");
4841×
        assert!(
4851×
            out.contains("x_seconds_bucket{runner=\"earth-1\",le=\"+Inf\"} 3") && out.contains("x_seconds_count{runner=\"earth-1\"} 3"),
486
            "{out}"
487
        );
4881×
    }
489
490
    #[test]
4911×
    fn route_classes_hold_no_names() {
4929×
        for (path, class) in [
4931×
            ("/", "page:home"),
4941×
            ("/static/hub.js", "static"),
4951×
            ("/api/v1/repos/andy/demo/issues", "api:repos"),
4961×
            ("/andy/demo/oxy/v1/negotiate", "protocol:negotiate"),
4971×
            ("/andy/demo/issue/3", "page:issue"),
4981×
            ("/andy/demo", "page:repository"),
4991×
            ("/explore", "page:explore"),
5001×
            ("/Andy_McCall", "page:other"),
5011×
            ("/log/tile/0/000", "tlog"),
5021×
        ] {
5039×
            assert_eq!(super::route_class(path), class, "{path}");
504
        }
5051×
    }
506
}