|
|
… |
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 |
|
} |