use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
use crate::audit::AuditAction;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verb {
Get,
Put,
Delete,
Promote,
Search,
Sbom,
}
const ALL_VERBS: [Verb; 6] =
[Verb::Get, Verb::Put, Verb::Delete, Verb::Promote, Verb::Search, Verb::Sbom];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProxyOutcome {
PrimaryHit,
UpstreamHit,
NegcacheHit,
Miss,
}
const ALL_PROXY_OUTCOMES: [ProxyOutcome; 4] = [
ProxyOutcome::PrimaryHit,
ProxyOutcome::UpstreamHit,
ProxyOutcome::NegcacheHit,
ProxyOutcome::Miss,
];
impl ProxyOutcome {
pub fn label(self) -> &'static str {
match self {
ProxyOutcome::PrimaryHit => "primary_hit",
ProxyOutcome::UpstreamHit => "upstream_hit",
ProxyOutcome::NegcacheHit => "negcache_hit",
ProxyOutcome::Miss => "miss",
}
}
}
impl Verb {
pub fn label(self) -> &'static str {
match self {
Verb::Get => "get",
Verb::Put => "put",
Verb::Delete => "delete",
Verb::Promote => "promote",
Verb::Search => "search",
Verb::Sbom => "sbom",
}
}
pub fn from_audit_action(action: AuditAction) -> Option<Verb> {
match action {
AuditAction::Download | AuditAction::List => Some(Verb::Get),
AuditAction::Upload => Some(Verb::Put),
AuditAction::Delete => Some(Verb::Delete),
AuditAction::Promote => Some(Verb::Promote),
AuditAction::Other => None,
}
}
}
const DURATION_BUCKETS: [f64; 12] =
[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0];
#[derive(Debug)]
struct DurationHistogram {
buckets: [AtomicU64; DURATION_BUCKETS.len() + 1],
count: AtomicU64,
sum_nanos: AtomicU64,
}
impl Default for DurationHistogram {
fn default() -> Self {
Self {
buckets: std::array::from_fn(|_| AtomicU64::new(0)),
count: AtomicU64::new(0),
sum_nanos: AtomicU64::new(0),
}
}
}
impl DurationHistogram {
fn observe(&self, seconds: f64) {
if !seconds.is_finite() || seconds < 0.0 {
return;
}
let idx = DURATION_BUCKETS
.iter()
.position(|&b| seconds <= b)
.unwrap_or(DURATION_BUCKETS.len());
self.buckets[idx].fetch_add(1, Ordering::Relaxed);
self.count.fetch_add(1, Ordering::Relaxed);
self.sum_nanos.fetch_add((seconds * 1e9) as u64, Ordering::Relaxed);
}
}
#[derive(Debug, Default)]
pub struct Metrics {
get: AtomicU64,
put: AtomicU64,
delete: AtomicU64,
promote: AtomicU64,
search: AtomicU64,
sbom: AtomicU64,
bytes_served: AtomicU64,
errors: AtomicU64,
retention_runs: AtomicU64,
proxy_primary_hit: AtomicU64,
proxy_upstream_hit: AtomicU64,
proxy_negcache_hit: AtomicU64,
proxy_miss: AtomicU64,
request_duration: DurationHistogram,
}
impl Metrics {
pub fn new() -> Self {
Self::default()
}
pub fn record_request(&self, verb: Verb, status: u16, bytes: u64) {
let slot = self.slot(verb);
slot.fetch_add(1, Ordering::Relaxed);
if bytes > 0 {
self.bytes_served.fetch_add(bytes, Ordering::Relaxed);
}
if status >= 500 {
self.errors.fetch_add(1, Ordering::Relaxed);
}
}
pub fn record_retention_run(&self) {
self.retention_runs.fetch_add(1, Ordering::Relaxed);
}
pub fn record_proxy(&self, outcome: ProxyOutcome) {
self.proxy_slot(outcome).fetch_add(1, Ordering::Relaxed);
}
fn proxy_slot(&self, outcome: ProxyOutcome) -> &AtomicU64 {
match outcome {
ProxyOutcome::PrimaryHit => &self.proxy_primary_hit,
ProxyOutcome::UpstreamHit => &self.proxy_upstream_hit,
ProxyOutcome::NegcacheHit => &self.proxy_negcache_hit,
ProxyOutcome::Miss => &self.proxy_miss,
}
}
pub fn proxy_count(&self, outcome: ProxyOutcome) -> u64 {
self.proxy_slot(outcome).load(Ordering::Relaxed)
}
pub fn observe_request_duration(&self, seconds: f64) {
self.request_duration.observe(seconds);
}
pub fn request_duration_count(&self) -> u64 {
self.request_duration.count.load(Ordering::Relaxed)
}
pub fn request_duration_sum_seconds(&self) -> f64 {
self.request_duration.sum_nanos.load(Ordering::Relaxed) as f64 / 1e9
}
fn slot(&self, verb: Verb) -> &AtomicU64 {
match verb {
Verb::Get => &self.get,
Verb::Put => &self.put,
Verb::Delete => &self.delete,
Verb::Promote => &self.promote,
Verb::Search => &self.search,
Verb::Sbom => &self.sbom,
}
}
pub fn request_count(&self, verb: Verb) -> u64 {
self.slot(verb).load(Ordering::Relaxed)
}
pub fn bytes_served(&self) -> u64 {
self.bytes_served.load(Ordering::Relaxed)
}
pub fn errors(&self) -> u64 {
self.errors.load(Ordering::Relaxed)
}
pub fn retention_runs(&self) -> u64 {
self.retention_runs.load(Ordering::Relaxed)
}
pub fn render_prometheus(&self, repo_counts: &[(String, usize)]) -> String {
let mut out = String::with_capacity(1024);
out.push_str(
"# HELP holger_requests_total Total requests handled (gRPC + HTTP), by verb.\n",
);
out.push_str("# TYPE holger_requests_total counter\n");
for verb in ALL_VERBS {
out.push_str(&format!(
"holger_requests_total{{verb=\"{}\"}} {}\n",
verb.label(),
self.request_count(verb),
));
}
out.push_str("# HELP holger_bytes_served_total Total response bytes served over HTTP.\n");
out.push_str("# TYPE holger_bytes_served_total counter\n");
out.push_str(&format!("holger_bytes_served_total {}\n", self.bytes_served()));
out.push_str("# HELP holger_errors_total Total 5xx HTTP responses.\n");
out.push_str("# TYPE holger_errors_total counter\n");
out.push_str(&format!("holger_errors_total {}\n", self.errors()));
out.push_str("# HELP holger_retention_runs_total Total retention-executor runs.\n");
out.push_str("# TYPE holger_retention_runs_total counter\n");
out.push_str(&format!("holger_retention_runs_total {}\n", self.retention_runs()));
out.push_str(
"# HELP holger_proxy_requests_total Total proxy/group-repo reads, by outcome.\n",
);
out.push_str("# TYPE holger_proxy_requests_total counter\n");
for outcome in ALL_PROXY_OUTCOMES {
out.push_str(&format!(
"holger_proxy_requests_total{{result=\"{}\"}} {}\n",
outcome.label(),
self.proxy_count(outcome),
));
}
out.push_str(
"# HELP holger_request_duration_seconds HTTP request handling duration in seconds.\n",
);
out.push_str("# TYPE holger_request_duration_seconds histogram\n");
let h = &self.request_duration;
let mut cumulative = 0u64;
for (i, bound) in DURATION_BUCKETS.iter().enumerate() {
cumulative += h.buckets[i].load(Ordering::Relaxed);
out.push_str(&format!(
"holger_request_duration_seconds_bucket{{le=\"{}\"}} {}\n",
fmt_bucket_bound(*bound),
cumulative,
));
}
cumulative += h.buckets[DURATION_BUCKETS.len()].load(Ordering::Relaxed);
out.push_str(&format!(
"holger_request_duration_seconds_bucket{{le=\"+Inf\"}} {}\n",
cumulative,
));
out.push_str(&format!(
"holger_request_duration_seconds_sum {}\n",
self.request_duration_sum_seconds(),
));
out.push_str(&format!(
"holger_request_duration_seconds_count {}\n",
self.request_duration_count(),
));
out.push_str("# HELP holger_repo_artifacts Current artifact count per repository.\n");
out.push_str("# TYPE holger_repo_artifacts gauge\n");
for (repo, count) in repo_counts {
out.push_str(&format!(
"holger_repo_artifacts{{repo=\"{}\"}} {}\n",
escape_label(repo),
count,
));
}
out
}
}
fn fmt_bucket_bound(bound: f64) -> String {
format!("{}", bound)
}
fn escape_label(v: &str) -> String {
let mut out = String::with_capacity(v.len());
for c in v.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
_ => out.push(c),
}
}
out
}
pub fn global() -> &'static Metrics {
static GLOBAL: OnceLock<Metrics> = OnceLock::new();
GLOBAL.get_or_init(Metrics::new)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_counter_counts_exactly_n_per_verb() {
let m = Metrics::new();
for _ in 0..7 {
m.record_request(Verb::Get, 200, 100);
}
for _ in 0..3 {
m.record_request(Verb::Put, 201, 0);
}
assert_eq!(m.request_count(Verb::Get), 7, "7 GETs must read 7");
assert_eq!(m.request_count(Verb::Put), 3, "3 PUTs must read 3");
assert_eq!(m.request_count(Verb::Delete), 0, "an untouched verb stays 0");
assert_eq!(m.bytes_served(), 700, "7×100 bytes served");
}
#[test]
fn errors_count_only_5xx() {
let m = Metrics::new();
m.record_request(Verb::Get, 200, 10);
m.record_request(Verb::Get, 404, 0);
m.record_request(Verb::Get, 500, 0);
m.record_request(Verb::Sbom, 503, 0);
assert_eq!(m.errors(), 2, "only the 500 and 503 are errors");
}
#[test]
fn retention_runs_increment() {
let m = Metrics::new();
assert_eq!(m.retention_runs(), 0);
m.record_retention_run();
m.record_retention_run();
assert_eq!(m.retention_runs(), 2);
}
#[test]
fn render_is_parseable_prometheus_reflecting_state() {
let m = Metrics::new();
m.record_request(Verb::Get, 200, 512);
m.record_request(Verb::Get, 200, 512);
m.record_request(Verb::Search, 200, 30);
m.record_retention_run();
let text = m.render_prometheus(&[
("rust-dev".to_string(), 12),
("maven-dev".to_string(), 3),
]);
assert!(text.contains("# TYPE holger_requests_total counter"));
assert!(text.contains("# TYPE holger_repo_artifacts gauge"));
for line in text.lines() {
if line.is_empty() || line.starts_with('#') {
continue;
}
let value = line.rsplit(' ').next().expect("a value token");
assert!(
value.parse::<f64>().is_ok(),
"sample line must end in a number: {line:?}",
);
}
assert!(
text.contains("holger_requests_total{verb=\"get\"} 2"),
"2 GETs must render as 2:\n{text}",
);
assert!(text.contains("holger_requests_total{verb=\"search\"} 1"));
assert!(text.contains("holger_bytes_served_total 1054"), "512+512+30");
assert!(text.contains("holger_retention_runs_total 1"));
assert!(text.contains("holger_repo_artifacts{repo=\"rust-dev\"} 12"));
assert!(text.contains("holger_repo_artifacts{repo=\"maven-dev\"} 3"));
assert!(text.contains("holger_requests_total{verb=\"put\"} 0"));
}
#[test]
fn audit_action_maps_to_the_expected_verb() {
assert_eq!(Verb::from_audit_action(AuditAction::Download), Some(Verb::Get));
assert_eq!(Verb::from_audit_action(AuditAction::List), Some(Verb::Get), "a listing is a read");
assert_eq!(Verb::from_audit_action(AuditAction::Upload), Some(Verb::Put));
assert_eq!(Verb::from_audit_action(AuditAction::Delete), Some(Verb::Delete));
assert_eq!(
Verb::from_audit_action(AuditAction::Promote),
Some(Verb::Promote),
"promote is its own verb, not folded into put",
);
assert_eq!(
Verb::from_audit_action(AuditAction::Other),
None,
"a non-request action must not be counted",
);
}
#[test]
fn recording_via_audit_action_counts_the_grpc_plane_by_verb() {
let m = Metrics::new();
for action in [
AuditAction::Download, AuditAction::List, AuditAction::Upload, AuditAction::Promote, AuditAction::Other, ] {
if let Some(v) = Verb::from_audit_action(action) {
m.record_request(v, 200, 4);
}
}
assert_eq!(m.request_count(Verb::Get), 2, "download + listing both count as get");
assert_eq!(m.request_count(Verb::Put), 1);
assert_eq!(m.request_count(Verb::Promote), 1, "a promotion is counted under promote");
assert_eq!(m.request_count(Verb::Delete), 0, "no delete happened");
let text = m.render_prometheus(&[]);
assert!(
text.contains("holger_requests_total{verb=\"promote\"} 1"),
"the promote verb must render as its own series:\n{text}",
);
assert!(text.contains("holger_requests_total{verb=\"delete\"} 0"));
}
#[test]
fn proxy_outcomes_count_and_render_by_result_label() {
let m = Metrics::new();
for _ in 0..4 {
m.record_proxy(ProxyOutcome::PrimaryHit);
}
for _ in 0..3 {
m.record_proxy(ProxyOutcome::UpstreamHit);
}
for _ in 0..2 {
m.record_proxy(ProxyOutcome::NegcacheHit);
}
m.record_proxy(ProxyOutcome::Miss);
assert_eq!(m.proxy_count(ProxyOutcome::PrimaryHit), 4, "4 primary hits");
assert_eq!(m.proxy_count(ProxyOutcome::UpstreamHit), 3, "3 upstream hits");
assert_eq!(m.proxy_count(ProxyOutcome::NegcacheHit), 2, "2 negcache hits");
assert_eq!(m.proxy_count(ProxyOutcome::Miss), 1, "1 miss");
assert_ne!(
m.proxy_count(ProxyOutcome::Miss),
m.proxy_count(ProxyOutcome::PrimaryHit),
"a hit must not be counted as a miss (mis-wired slot)",
);
let text = m.render_prometheus(&[]);
assert!(text.contains("# TYPE holger_proxy_requests_total counter"));
assert!(
text.contains("holger_proxy_requests_total{result=\"primary_hit\"} 4"),
"primary_hit renders as its own series:\n{text}",
);
assert!(text.contains("holger_proxy_requests_total{result=\"upstream_hit\"} 3"));
assert!(text.contains("holger_proxy_requests_total{result=\"negcache_hit\"} 2"));
assert!(text.contains("holger_proxy_requests_total{result=\"miss\"} 1"));
}
#[test]
fn duration_histogram_buckets_sum_and_count() {
let m = Metrics::new();
m.observe_request_duration(0.002);
m.observe_request_duration(0.030);
m.observe_request_duration(0.300);
m.observe_request_duration(12.0);
assert_eq!(m.request_duration_count(), 4, "4 observations counted");
let sum = m.request_duration_sum_seconds();
assert!((sum - 12.332).abs() < 1e-6, "sum of durations must be 12.332, got {sum}");
let text = m.render_prometheus(&[]);
assert!(text.contains("# TYPE holger_request_duration_seconds histogram"));
assert!(
text.contains("holger_request_duration_seconds_bucket{le=\"0.001\"} 0"),
"nothing ≤1ms:\n{text}",
);
assert!(
text.contains("holger_request_duration_seconds_bucket{le=\"0.005\"} 1"),
"the 2ms obs falls in ≤5ms:\n{text}",
);
assert!(text.contains("holger_request_duration_seconds_bucket{le=\"0.05\"} 2"));
assert!(text.contains("holger_request_duration_seconds_bucket{le=\"0.5\"} 3"));
assert!(
text.contains("holger_request_duration_seconds_bucket{le=\"10\"} 3"),
"the 12s obs is above the 10s bound:\n{text}",
);
assert!(
text.contains("holger_request_duration_seconds_bucket{le=\"+Inf\"} 4"),
"+Inf must equal the total count:\n{text}",
);
assert!(text.contains("holger_request_duration_seconds_count 4"));
m.observe_request_duration(f64::NAN);
m.observe_request_duration(-1.0);
assert_eq!(m.request_duration_count(), 4, "NaN/negative are not counted");
}
#[test]
fn label_values_are_escaped() {
assert_eq!(escape_label("plain"), "plain");
assert_eq!(escape_label("a\"b\\c"), "a\\\"b\\\\c");
let m = Metrics::new();
let text = m.render_prometheus(&[("we\"ird".to_string(), 1)]);
assert!(text.contains("holger_repo_artifacts{repo=\"we\\\"ird\"} 1"));
}
}