OXYGENHUB
# 2c47eecfeab6 oxygenhub / crates / hub / src / metrics.rs
13.9 KiB · Rust · last changed Metrics: OxygenFlow runs and runners' heartbeats, and mail 18 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, 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
/// A request's route class: a few dozen at most, so a label never holds a
41
/// user's or repository's name.
42
pub fn route_class(path: &str) -> String {
43
    let segs: Vec<&str> = path.trim_start_matches('/').split('/').filter(|s| !s.is_empty()).collect();
44
    let tab = |s: &str| s.chars().all(|c| c.is_ascii_lowercase() || c == '-' || c == '.') && s.len() <= 24;
45
    match segs.as_slice() {
46
        [] => "page:home".into(),
47
        ["static", ..] => "static".into(),
48
        ["api", "v1", area, ..] if tab(area) => format!("api:{area}"),
49
        ["api", ..] => "api".into(),
50
        ["log", ..] => "tlog".into(),
51
        [".well-known", ..] => "well-known".into(),
52
        ["live", ..] => "live".into(),
53
        ["avatar", ..] => "avatar".into(),
54
        [_, _, "oxy", "v1", endpoint, ..] if tab(endpoint) => format!("protocol:{endpoint}"),
55
        [_, _, "oxy", ..] => "protocol".into(),
56
        [_, _, t, ..] if tab(t) => format!("page:{t}"),
57
        [_, _] => "page:repository".into(),
58
        [one] if tab(one) => format!("page:{one}"),
59
        _ => "page:other".into(),
60
    }
61
}
62
63
/// Count a request (from the router's outermost timing layer).
64
pub fn observe(method: &str, path: &str, status: u16, seconds: f64) {
65
    let mut g = HTTP.lock().unwrap_or_else(|p| p.into_inner());
66
    let map = g.get_or_insert_with(HashMap::new);
67
    let mut route = route_class(path);
68
    if !map.contains_key(&(route.clone(), method.to_string())) && map.len() >= MAX_ROUTES {
69
        route = "other".into();
70
    }
71
    let h = map.entry((route, method.to_string())).or_default();
72
    let class = (status / 100).clamp(2, 5) as usize - 2;
73
    h.by_status[class] += 1;
74
    for (i, b) in BUCKETS.iter().enumerate() {
75
        if seconds <= *b {
76
            h.buckets[i] += 1;
77
        }
78
    }
79
    h.sum += seconds;
80
    h.count += 1;
81
}
82
83
fn http_families(out: &mut String) {
84
    let g = HTTP.lock().unwrap_or_else(|p| p.into_inner());
85
    let Some(map) = g.as_ref() else { return };
86
    let mut keys: Vec<&(String, String)> = map.keys().collect();
87
    keys.sort();
88
    let _ = writeln!(out, "# HELP oxyhub_http_requests_total HTTP requests by route class, method and status class.");
89
    let _ = writeln!(out, "# TYPE oxyhub_http_requests_total counter");
90
    for k in &keys {
91
        let h = &map[*k];
92
        for (i, n) in h.by_status.iter().enumerate().filter(|(_, n)| **n > 0) {
93
            let _ = writeln!(
94
                out,
95
                "oxyhub_http_requests_total{{route=\"{}\",method=\"{}\",status=\"{}xx\"}} {n}",
96
                label(&k.0),
97
                label(&k.1),
98
                i + 2
99
            );
100
        }
101
    }
102
    let _ = writeln!(out, "# HELP oxyhub_http_request_duration_seconds How long requests took, by route class and method.");
103
    let _ = writeln!(out, "# TYPE oxyhub_http_request_duration_seconds histogram");
104
    for k in &keys {
105
        let h = &map[*k];
106
        let l = format!("route=\"{}\",method=\"{}\"", label(&k.0), label(&k.1));
107
        for (i, b) in BUCKETS.iter().enumerate() {
108
            let _ = writeln!(out, "oxyhub_http_request_duration_seconds_bucket{{{l},le=\"{b}\"}} {}", h.buckets[i]);
109
        }
110
        let _ = writeln!(out, "oxyhub_http_request_duration_seconds_bucket{{{l},le=\"+Inf\"}} {}", h.count);
111
        let _ = writeln!(out, "oxyhub_http_request_duration_seconds_sum{{{l}}} {}", h.sum);
112
        let _ = writeln!(out, "oxyhub_http_request_duration_seconds_count{{{l}}} {}", h.count);
113
    }
114
}
115
116
/// One metric family: its help, type and samples (label set, value).
117
fn family(out: &mut String, name: &str, help: &str, kind: &str, samples: &[(String, f64)]) {
118
    let _ = writeln!(out, "# HELP {name} {help}");
119
    let _ = writeln!(out, "# TYPE {name} {kind}");
120
    for (labels, v) in samples {
121
        if labels.is_empty() {
122
            let _ = writeln!(out, "{name} {v}");
123
        } else {
124
            let _ = writeln!(out, "{name}{{{labels}}} {v}");
125
        }
126
    }
127
}
128
129
/// A label value, escaped as the text format asks.
130
fn label(v: &str) -> String {
131
    v.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
132
}
133
134
/// The process's resident memory, from /proc (Linux), in bytes.
135
fn resident_bytes() -> Option<f64> {
136
    let statm = std::fs::read_to_string("/proc/self/statm").ok()?;
137
    let pages: f64 = statm.split_whitespace().nth(1)?.parse().ok()?;
138
    Some(pages * 4096.0)
139
}
140
141
/// The metrics as text, for whoever is let in.
142
pub async fn render(app: &Shared) -> String {
143
    let mut out = String::new();
144
    family(
145
        &mut out,
146
        "oxyhub_build_info",
147
        "The hub's version and build.",
148
        "gauge",
149
        &[(format!("version=\"{}\",build=\"{}\"", env!("CARGO_PKG_VERSION"), label(env!("OXY_BUILD_ID"))), 1.0)],
150
    );
151
    family(
152
        &mut out,
153
        "oxyhub_uptime_seconds",
154
        "Seconds since the hub started.",
155
        "gauge",
156
        &[(String::new(), app.started.elapsed().as_secs_f64())],
157
    );
158
    if let Some(rss) = resident_bytes() {
159
        family(&mut out, "oxyhub_resident_memory_bytes", "The process's resident memory.", "gauge", &[(String::new(), rss)]);
160
    }
161
    let (states, weight, summaries) = app.repos.cache().stats();
162
    family(&mut out, "oxyhub_state_cache_states", "States the state cache holds.", "gauge", &[(String::new(), states as f64)]);
163
    family(
164
        &mut out,
165
        "oxyhub_state_cache_bytes",
166
        "What the state cache's states are estimated to weigh.",
167
        "gauge",
168
        &[(String::new(), weight as f64)],
169
    );
170
    family(
171
        &mut out,
172
        "oxyhub_state_cache_budget_bytes",
173
        "What the state cache may hold.",
174
        "gauge",
175
        &[(String::new(), crate::repos::cache::budget() as f64)],
176
    );
177
    family(&mut out, "oxyhub_state_cache_summaries", "Summaries the state cache holds.", "gauge", &[(String::new(), summaries as f64)]);
178
    let strata = oxy_strata::stats();
179
    family(
180
        &mut out,
181
        "oxyhub_bucket_clients_opened_total",
182
        "Bucket store clients opened.",
183
        "counter",
184
        &[(String::new(), strata.opens as f64)],
185
    );
186
    family(
187
        &mut out,
188
        "oxyhub_bucket_requests_total",
189
        "Requests to bucket stores, by kind.",
190
        "counter",
191
        &strata.ops().map(|(k, n)| (format!("kind=\"{k}\""), n as f64)).collect::<Vec<_>>(),
192
    );
193
    family(
194
        &mut out,
195
        "oxyhub_bucket_request_seconds_total",
196
        "Time spent in requests to bucket stores.",
197
        "counter",
198
        &[(String::new(), strata.millis as f64 / 1000.0)],
199
    );
200
    let jobs: Vec<(String, i64)> =
201
        sqlx::query_as("SELECT status, COUNT(*) FROM flow_jobs WHERE status IN ('queued', 'running') GROUP BY status")
202
            .fetch_all(&app.db)
203
            .await
204
            .unwrap_or_default();
205
    let count = |s: &str| jobs.iter().find(|(k, _)| k == s).map(|(_, n)| *n).unwrap_or(0) as f64;
206
    let waiting: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM flow_jobs WHERE status = 'queued' AND approval = 'waiting'")
207
        .fetch_one(&app.db)
208
        .await
209
        .unwrap_or(0);
210
    family(
211
        &mut out,
212
        "oxyhub_flow_jobs",
213
        "OxygenFlow jobs now, by state (waiting: for a person's approval).",
214
        "gauge",
215
        &[
216
            ("state=\"queued\"".into(), count("queued") - waiting as f64),
217
            ("state=\"waiting\"".into(), waiting as f64),
218
            ("state=\"running\"".into(), count("running")),
219
        ],
220
    );
221
    let runners = db::runners::list(&app.db).await.unwrap_or_default();
222
    family(
223
        &mut out,
224
        "oxyhub_flow_runners",
225
        "Registered OxygenFlow runners, by whether they reported in the last minutes.",
226
        "gauge",
227
        &[
228
            ("online=\"true\"".into(), runners.iter().filter(|r| r.online()).count() as f64),
229
            ("online=\"false\"".into(), runners.iter().filter(|r| !r.online()).count() as f64),
230
        ],
231
    );
232
    let now = db::now_ms();
233
    family(
234
        &mut out,
235
        "oxyhub_flow_runner_last_seen_seconds",
236
        "Seconds since each registered runner last called the hub.",
237
        "gauge",
238
        &runners
239
            .iter()
240
            .filter_map(|r| r.last_seen_ms.map(|t| (format!("runner=\"{}\"", label(&r.name)), (now - t).max(0) as f64 / 1000.0)))
241
            .collect::<Vec<_>>(),
242
    );
243
    let since = now - 86_400_000;
244
    let runs: Vec<(String, i64)> = sqlx::query_as("SELECT status, COUNT(*) FROM flow_runs WHERE finished_ms > $1 GROUP BY status")
245
        .bind(since)
246
        .fetch_all(&app.db)
247
        .await
248
        .unwrap_or_default();
249
    family(
250
        &mut out,
251
        "oxyhub_flow_runs_last_day",
252
        "OxygenFlow runs finished in the last 24 hours, by result.",
253
        "gauge",
254
        &["passed", "failed", "error"]
255
            .iter()
256
            .map(|r| (format!("result=\"{r}\""), runs.iter().find(|(s, _)| s == r).map(|(_, n)| *n).unwrap_or(0) as f64))
257
            .collect::<Vec<_>>(),
258
    );
259
    family(&mut out, "oxyhub_tlog_entries", "Entries in the transparency log.", "gauge", &[(String::new(), app.tlog.size() as f64)]);
260
    family(
261
        &mut out,
262
        "oxyhub_mail_total",
263
        "Messages the hub sent and failed to send since it started.",
264
        "counter",
265
        &[
266
            ("result=\"sent\"".into(), MAIL_SENT.load(Ordering::Relaxed) as f64),
267
            ("result=\"failed\"".into(), MAIL_FAILED.load(Ordering::Relaxed) as f64),
268
        ],
269
    );
270
    family(
271
        &mut out,
272
        "oxyhub_db_connections",
273
        "The database pool's connections, by whether they are idle.",
274
        "gauge",
275
        &[
276
            ("idle=\"true\"".into(), app.db.num_idle() as f64),
277
            ("idle=\"false\"".into(), (app.db.size() as f64 - app.db.num_idle() as f64).max(0.0)),
278
        ],
279
    );
280
    let (ok, refused): (Option<i64>, Option<i64>) =
281
        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")
282
            .bind(since)
283
            .fetch_one(&app.db)
284
            .await
285
            .unwrap_or((None, None));
286
    family(
287
        &mut out,
288
        "oxyhub_pushes_last_day",
289
        "Reference updates pushed in the last 24 hours, by whether they were accepted.",
290
        "gauge",
291
        &[("result=\"accepted\"".into(), ok.unwrap_or(0) as f64), ("result=\"refused\"".into(), refused.unwrap_or(0) as f64)],
292
    );
293
    let backups: Vec<(String, String, i64, Option<i64>)> = sqlx::query_as(
294
        "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",
295
    )
296
    .fetch_all(&app.db)
297
    .await
298
    .unwrap_or_default();
299
    family(
300
        &mut out,
301
        "oxyhub_backup_last_run_age_seconds",
302
        "Seconds since each owner's latest backup run started.",
303
        "gauge",
304
        &backups.iter().map(|(o, _, s, _)| (format!("owner=\"{}\"", label(o)), (now - s) as f64 / 1000.0)).collect::<Vec<_>>(),
305
    );
306
    family(
307
        &mut out,
308
        "oxyhub_backup_last_run_ok",
309
        "Whether each owner's latest finished backup run succeeded (1), was partial or failed (0); running ones are left out.",
310
        "gauge",
311
        &backups
312
            .iter()
313
            .filter(|(_, st, _, f)| f.is_some() && st != "running")
314
            .map(|(o, st, _, _)| (format!("owner=\"{}\"", label(o)), if st == "ok" { 1.0 } else { 0.0 }))
315
            .collect::<Vec<_>>(),
316
    );
317
    http_families(&mut out);
318
    out
319
}
320
321
/// `GET /metrics`.
322
pub async fn metrics(State(app): State<Shared>, headers: HeaderMap) -> Response {
323
    let want = &app.config.server.metrics_token;
324
    if want.is_empty() {
325
        return (StatusCode::NOT_FOUND, "metrics are off: set [server] metrics_token in hub.toml\n").into_response();
326
    }
327
    let given = oxy_server::bearer_token(&headers).unwrap_or("");
328
    if !crate::auth::mac_equal(given, want) {
329
        return (StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, "Bearer")], "a bearer token is needed\n").into_response();
330
    }
331
    ([(header::CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8"), (header::CACHE_CONTROL, "no-store")], render(&app).await)
332
        .into_response()
333
}
334
335
#[cfg(test)]
336
mod tests {
337
    #[test]
338
    fn route_classes_hold_no_names() {
339
        for (path, class) in [
340
            ("/", "page:home"),
341
            ("/static/hub.js", "static"),
342
            ("/api/v1/repos/andy/demo/issues", "api:repos"),
343
            ("/andy/demo/oxy/v1/negotiate", "protocol:negotiate"),
344
            ("/andy/demo/issue/3", "page:issue"),
345
            ("/andy/demo", "page:repository"),
346
            ("/explore", "page:explore"),
347
            ("/Andy_McCall", "page:other"),
348
            ("/log/tile/0/000", "tlog"),
349
        ] {
350
            assert_eq!(super::route_class(path), class, "{path}");
351
        }
352
    }
353
}