OXYGENHUB
# a6d824c5ebf5 oxygenhub / crates / hub / src / metrics.rs
12.3 KiB · Rust · last changed /metrics: database pool, pushes and backups (refs #212) 19 days ago source blame history raw
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 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
10
use crate::{db, Shared};
11
use axum::extract::State;
12
use axum::http::{header, HeaderMap, StatusCode};
13
use axum::response::{IntoResponse, Response};
14
use std::collections::HashMap;
15
use std::fmt::Write;
16
use std::sync::Mutex;
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
110
/// One metric family: its help, type and samples (label set, value).
111
fn family(out: &mut String, name: &str, help: &str, kind: &str, samples: &[(String, f64)]) {
112
    let _ = writeln!(out, "# HELP {name} {help}");
113
    let _ = writeln!(out, "# TYPE {name} {kind}");
114
    for (labels, v) in samples {
115
        if labels.is_empty() {
116
            let _ = writeln!(out, "{name} {v}");
117
        } else {
118
            let _ = writeln!(out, "{name}{{{labels}}} {v}");
119
        }
120
    }
121
}
122
123
/// A label value, escaped as the text format asks.
124
fn label(v: &str) -> String {
125
    v.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
126
}
127
128
/// The process's resident memory, from /proc (Linux), in bytes.
129
fn resident_bytes() -> Option<f64> {
130
    let statm = std::fs::read_to_string("/proc/self/statm").ok()?;
131
    let pages: f64 = statm.split_whitespace().nth(1)?.parse().ok()?;
132
    Some(pages * 4096.0)
133
}
134
135
/// The metrics as text, for whoever is let in.
136
pub async fn render(app: &Shared) -> String {
137
    let mut out = String::new();
138
    family(
139
        &mut out,
140
        "oxyhub_build_info",
141
        "The hub's version and build.",
142
        "gauge",
143
        &[(format!("version=\"{}\",build=\"{}\"", env!("CARGO_PKG_VERSION"), label(env!("OXY_BUILD_ID"))), 1.0)],
144
    );
145
    family(
146
        &mut out,
147
        "oxyhub_uptime_seconds",
148
        "Seconds since the hub started.",
149
        "gauge",
150
        &[(String::new(), app.started.elapsed().as_secs_f64())],
151
    );
152
    if let Some(rss) = resident_bytes() {
153
        family(&mut out, "oxyhub_resident_memory_bytes", "The process's resident memory.", "gauge", &[(String::new(), rss)]);
154
    }
155
    let (states, weight, summaries) = app.repos.cache().stats();
156
    family(&mut out, "oxyhub_state_cache_states", "States the state cache holds.", "gauge", &[(String::new(), states as f64)]);
157
    family(
158
        &mut out,
159
        "oxyhub_state_cache_bytes",
160
        "What the state cache's states are estimated to weigh.",
161
        "gauge",
162
        &[(String::new(), weight as f64)],
163
    );
164
    family(
165
        &mut out,
166
        "oxyhub_state_cache_budget_bytes",
167
        "What the state cache may hold.",
168
        "gauge",
169
        &[(String::new(), crate::repos::cache::budget() as f64)],
170
    );
171
    family(&mut out, "oxyhub_state_cache_summaries", "Summaries the state cache holds.", "gauge", &[(String::new(), summaries as f64)]);
172
    let strata = oxy_strata::stats();
173
    family(
174
        &mut out,
175
        "oxyhub_bucket_clients_opened_total",
176
        "Bucket store clients opened.",
177
        "counter",
178
        &[(String::new(), strata.opens as f64)],
179
    );
180
    family(
181
        &mut out,
182
        "oxyhub_bucket_requests_total",
183
        "Requests to bucket stores, by kind.",
184
        "counter",
185
        &strata.ops().map(|(k, n)| (format!("kind=\"{k}\""), n as f64)).collect::<Vec<_>>(),
186
    );
187
    family(
188
        &mut out,
189
        "oxyhub_bucket_request_seconds_total",
190
        "Time spent in requests to bucket stores.",
191
        "counter",
192
        &[(String::new(), strata.millis as f64 / 1000.0)],
193
    );
194
    let jobs: Vec<(String, i64)> =
195
        sqlx::query_as("SELECT status, COUNT(*) FROM flow_jobs WHERE status IN ('queued', 'running') GROUP BY status")
196
            .fetch_all(&app.db)
197
            .await
198
            .unwrap_or_default();
199
    let count = |s: &str| jobs.iter().find(|(k, _)| k == s).map(|(_, n)| *n).unwrap_or(0) as f64;
200
    let waiting: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM flow_jobs WHERE status = 'queued' AND approval = 'waiting'")
201
        .fetch_one(&app.db)
202
        .await
203
        .unwrap_or(0);
204
    family(
205
        &mut out,
206
        "oxyhub_flow_jobs",
207
        "OxygenFlow jobs now, by state (waiting: for a person's approval).",
208
        "gauge",
209
        &[
210
            ("state=\"queued\"".into(), count("queued") - waiting as f64),
211
            ("state=\"waiting\"".into(), waiting as f64),
212
            ("state=\"running\"".into(), count("running")),
213
        ],
214
    );
215
    let runners = db::runners::list(&app.db).await.unwrap_or_default();
216
    family(
217
        &mut out,
218
        "oxyhub_flow_runners",
219
        "Registered OxygenFlow runners, by whether they reported in the last minutes.",
220
        "gauge",
221
        &[
222
            ("online=\"true\"".into(), runners.iter().filter(|r| r.online()).count() as f64),
223
            ("online=\"false\"".into(), runners.iter().filter(|r| !r.online()).count() as f64),
224
        ],
225
    );
226
    family(&mut out, "oxyhub_tlog_entries", "Entries in the transparency log.", "gauge", &[(String::new(), app.tlog.size() as f64)]);
227
    family(
228
        &mut out,
229
        "oxyhub_db_connections",
230
        "The database pool's connections, by whether they are idle.",
231
        "gauge",
232
        &[
233
            ("idle=\"true\"".into(), app.db.num_idle() as f64),
234
            ("idle=\"false\"".into(), (app.db.size() as f64 - app.db.num_idle() as f64).max(0.0)),
235
        ],
236
    );
237
    let since = db::now_ms() - 86_400_000;
238
    let (ok, refused): (Option<i64>, Option<i64>) =
239
        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")
240
            .bind(since)
241
            .fetch_one(&app.db)
242
            .await
243
            .unwrap_or((None, None));
244
    family(
245
        &mut out,
246
        "oxyhub_pushes_last_day",
247
        "Reference updates pushed in the last 24 hours, by whether they were accepted.",
248
        "gauge",
249
        &[("result=\"accepted\"".into(), ok.unwrap_or(0) as f64), ("result=\"refused\"".into(), refused.unwrap_or(0) as f64)],
250
    );
251
    let backups: Vec<(String, String, i64, Option<i64>)> = sqlx::query_as(
252
        "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",
253
    )
254
    .fetch_all(&app.db)
255
    .await
256
    .unwrap_or_default();
257
    let now = db::now_ms();
258
    family(
259
        &mut out,
260
        "oxyhub_backup_last_run_age_seconds",
261
        "Seconds since each owner's latest backup run started.",
262
        "gauge",
263
        &backups.iter().map(|(o, _, s, _)| (format!("owner=\"{}\"", label(o)), (now - s) as f64 / 1000.0)).collect::<Vec<_>>(),
264
    );
265
    family(
266
        &mut out,
267
        "oxyhub_backup_last_run_ok",
268
        "Whether each owner's latest finished backup run succeeded (1), was partial or failed (0); running ones are left out.",
269
        "gauge",
270
        &backups
271
            .iter()
272
            .filter(|(_, st, _, f)| f.is_some() && st != "running")
273
            .map(|(o, st, _, _)| (format!("owner=\"{}\"", label(o)), if st == "ok" { 1.0 } else { 0.0 }))
274
            .collect::<Vec<_>>(),
275
    );
276
    http_families(&mut out);
277
    out
278
}
279
280
/// `GET /metrics`.
281
pub async fn metrics(State(app): State<Shared>, headers: HeaderMap) -> Response {
282
    let want = &app.config.server.metrics_token;
283
    if want.is_empty() {
284
        return (StatusCode::NOT_FOUND, "metrics are off: set [server] metrics_token in hub.toml\n").into_response();
285
    }
286
    let given = oxy_server::bearer_token(&headers).unwrap_or("");
287
    if !crate::auth::mac_equal(given, want) {
288
        return (StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, "Bearer")], "a bearer token is needed\n").into_response();
289
    }
290
    ([(header::CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8"), (header::CACHE_CONTROL, "no-store")], render(&app).await)
291
        .into_response()
292
}
293
294
#[cfg(test)]
295
mod tests {
296
    #[test]
297
    fn route_classes_hold_no_names() {
298
        for (path, class) in [
299
            ("/", "page:home"),
300
            ("/static/hub.js", "static"),
301
            ("/api/v1/repos/andy/demo/issues", "api:repos"),
302
            ("/andy/demo/oxy/v1/negotiate", "protocol:negotiate"),
303
            ("/andy/demo/issue/3", "page:issue"),
304
            ("/andy/demo", "page:repository"),
305
            ("/explore", "page:explore"),
306
            ("/Andy_McCall", "page:other"),
307
            ("/log/tile/0/000", "tlog"),
308
        ] {
309
            assert_eq!(super::route_class(path), class, "{path}");
310
        }
311
    }
312
}