use axum::{Router, routing::get};
use metrics_exporter_prometheus::{Matcher, PrometheusBuilder, PrometheusHandle};
use std::net::SocketAddr;
use tokio::net::TcpListener;
const FINE_BUCKETS_SECONDS: &[f64] = &[
0.000_05, 0.000_1, 0.000_25, 0.000_5, 0.001, 0.002_5, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5,
1.0, 1.5, 2.5, 3.5, 5.0, 10.0, 30.0, 60.0,
];
const COARSE_BUCKETS_SECONDS: &[f64] = &[
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0,
];
const DWELL_BUCKETS_SECONDS: &[f64] = &[
0.000_5, 0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0,
300.0,
];
const DEPTH_BUCKETS: &[f64] = &[
0.0, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0, 512.0, 1024.0,
];
#[allow(clippy::cast_precision_loss)]
const FRAME_BYTES_BUCKETS: &[f64] = &[
64.0,
256.0,
1024.0,
4096.0,
16384.0,
65536.0,
262_144.0,
1_048_576.0,
4_194_304.0,
16_777_216.0,
33_554_432.0, 50_331_648.0, 52_428_800.0, 67_108_864.0, 104_857_600.0, 134_217_728.0, ];
#[must_use]
pub fn init_metrics() -> PrometheusHandle {
use subduction_core::metrics::names;
#[allow(clippy::expect_used)]
let handle = PrometheusBuilder::new()
.set_buckets_for_metric(
Matcher::Full(names::STORAGE_OPERATION_DURATION_SECONDS.to_owned()),
FINE_BUCKETS_SECONDS,
)
.expect("fine buckets are non-empty and sorted")
.set_buckets_for_metric(
Matcher::Full(names::DISPATCH_DURATION_SECONDS.to_owned()),
FINE_BUCKETS_SECONDS,
)
.expect("fine buckets are non-empty and sorted")
.set_buckets_for_metric(
Matcher::Full(names::OUTBOUND_QUEUE_DWELL_SECONDS.to_owned()),
DWELL_BUCKETS_SECONDS,
)
.expect("dwell buckets are non-empty and sorted")
.set_buckets_for_metric(
Matcher::Full(names::OUTBOUND_QUEUE_DEPTH.to_owned()),
DEPTH_BUCKETS,
)
.expect("depth buckets are non-empty and sorted")
.set_buckets_for_metric(
Matcher::Full(names::DISPATCH_PERMIT_WAIT_SECONDS.to_owned()),
FINE_BUCKETS_SECONDS,
)
.expect("fine buckets are non-empty and sorted")
.set_buckets_for_metric(
Matcher::Full(names::MSG_QUEUE_DWELL_SECONDS.to_owned()),
FINE_BUCKETS_SECONDS,
)
.expect("fine buckets are non-empty and sorted")
.set_buckets_for_metric(
Matcher::Full(names::STORAGE_BLOCKING_QUEUE_WAIT_SECONDS.to_owned()),
FINE_BUCKETS_SECONDS,
)
.expect("fine buckets are non-empty and sorted")
.set_buckets_for_metric(
Matcher::Full(names::REDB_DRAIN_BATCH_SIZE.to_owned()),
DEPTH_BUCKETS,
)
.expect("depth buckets are non-empty and sorted")
.set_buckets_for_metric(
Matcher::Full(names::NETWORK_FRAME_BYTES.to_owned()),
FRAME_BYTES_BUCKETS,
)
.expect("frame-bytes buckets are non-empty and sorted")
.set_buckets_for_metric(
Matcher::Full(names::HYDRATION_DURATION_SECONDS.to_owned()),
FINE_BUCKETS_SECONDS,
)
.expect("fine buckets are non-empty and sorted")
.set_buckets_for_metric(
Matcher::Suffix("_duration_seconds".to_owned()),
COARSE_BUCKETS_SECONDS,
)
.expect("coarse buckets are non-empty and sorted")
.install_recorder()
.expect("failed to install Prometheus recorder");
subduction_core::metrics::describe_all();
handle
}
pub async fn start_metrics_server(addr: SocketAddr, handle: PrometheusHandle) -> eyre::Result<()> {
let app = Router::new().route(
"/metrics",
get(move || {
let handle = handle.clone();
async move { handle.render() }
}),
);
let listener = TcpListener::bind(addr).await?;
tracing::info!(addr = %addr, "Metrics server listening");
tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
tracing::error!(error = %e, "Metrics server error");
}
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[allow(clippy::too_many_lines)]
#[test]
fn duration_histograms_render_as_buckets() {
let handle = init_metrics();
subduction_core::metrics::dispatch_duration("LooseCommit", 0.000_3);
subduction_core::metrics::storage_operation_duration("save_loose_commit", 0.000_8);
subduction_core::metrics::sync_duration(2.0);
subduction_core::metrics::outbound_queue_dwell("longpoll", 5.0, 3);
subduction_core::metrics::dispatch_permit_waited(0.001);
subduction_core::metrics::msg_queue_dwell(0.000_2);
subduction_core::metrics::mux_pending_duration(0.5);
subduction_core::metrics::sedimentree_cache_hit();
subduction_core::metrics::sedimentree_cache_hit();
subduction_core::metrics::sedimentree_cache_miss();
subduction_core::metrics::set_sedimentree_cache_resident(7);
subduction_core::metrics::storage_blocking_queue_wait(0.001);
subduction_core::metrics::redb_drain(3);
subduction_core::metrics::HydrationGuard::new().complete();
subduction_core::metrics::sync_verify_failure("commit");
subduction_core::metrics::requested_data_send_failure();
subduction_core::metrics::late_response();
subduction_core::metrics::keepalive_pong_missed();
subduction_core::metrics::keepalive_close();
subduction_core::metrics::set_top_requestors(&[5, 3], 12);
subduction_core::metrics::network_frame("websocket", "sent", 300);
subduction_core::metrics::handshake_duration("ok", 0.05);
subduction_core::metrics::subscription_pushes(2, 1);
subduction_core::metrics::subscription_propagation("established");
subduction_core::metrics::set_build_info("0.0.0-test", "deadbeef");
let rendered = handle.render();
for series in [
"subduction_dispatch_duration_seconds_bucket",
"subduction_storage_operation_duration_seconds_bucket",
"subduction_sync_duration_seconds_bucket",
] {
assert!(
rendered.contains(series),
"{series} should emit _bucket series:\n{rendered}"
);
}
assert!(
rendered.contains("subduction_dispatch_duration_seconds_bucket")
&& rendered.contains("type=\"LooseCommit\""),
"dispatch histogram should carry the `type` label:\n{rendered}"
);
let storage_lines: String = rendered
.lines()
.filter(|l| l.contains("subduction_storage_operation_duration_seconds_bucket"))
.collect::<Vec<_>>()
.join("\n");
assert!(
storage_lines.contains("le=\"0.00005\""),
"storage op histogram should use the fine 50us bucket:\n{storage_lines}"
);
assert!(
storage_lines.contains("le=\"2.5\"") && storage_lines.contains("le=\"60\""),
"storage op histogram should carry the >1s tail buckets:\n{storage_lines}"
);
assert!(
!storage_lines.contains("le=\"300\""),
"storage op histogram should NOT carry the coarse 300s bucket:\n{storage_lines}"
);
let sync_lines: String = rendered
.lines()
.filter(|l| l.contains("subduction_sync_duration_seconds_bucket"))
.collect::<Vec<_>>()
.join("\n");
assert!(
sync_lines.contains("le=\"300\""),
"sync duration histogram should use the coarse 300s bucket:\n{sync_lines}"
);
assert!(
rendered.contains("subduction_outbound_queue_dwell_seconds_bucket")
&& rendered.contains("transport=\"longpoll\""),
"dwell histogram should render with the transport label:\n{rendered}"
);
let depth_lines: String = rendered
.lines()
.filter(|l| l.contains("subduction_outbound_queue_depth_bucket"))
.collect::<Vec<_>>()
.join("\n");
assert!(
depth_lines.contains("le=\"1024\""),
"depth histogram should render as buckets up to the 1024 capacity:\n{depth_lines}"
);
assert!(
depth_lines.contains("le=\"0\""),
"depth histogram should carry the le=0 bucket so empty queues read 0:\n{depth_lines}"
);
assert!(
rendered.contains("subduction_dispatch_permit_wait_seconds_bucket"),
"permit-wait histogram should render as buckets:\n{rendered}"
);
assert!(
rendered.contains("subduction_msg_queue_dwell_seconds_bucket"),
"msg-queue dwell histogram should render as buckets:\n{rendered}"
);
assert!(
rendered.contains("subduction_mux_pending_duration_seconds_bucket"),
"mux pending-duration histogram should render as buckets:\n{rendered}"
);
assert!(
rendered.contains("subduction_storage_blocking_queue_wait_seconds_bucket"),
"storage queue-wait histogram should render as buckets:\n{rendered}"
);
let drain_lines: String = rendered
.lines()
.filter(|l| l.contains("subduction_redb_drain_batch_size_bucket"))
.collect::<Vec<_>>()
.join("\n");
assert!(
drain_lines.contains("le=\"1024\""),
"drain batch-size histogram should render count buckets up to the queue capacity:\n{drain_lines}"
);
assert!(
rendered.contains("subduction_redb_drains_total"),
"drain counter should render:\n{rendered}"
);
assert!(
rendered.contains("subduction_hydration_duration_seconds_bucket"),
"hydration duration histogram should render as buckets:\n{rendered}"
);
assert!(
rendered.contains("subduction_hydration_inflight 0"),
"hydration in-flight gauge should return to 0 after the guard drops:\n{rendered}"
);
assert!(
rendered.contains("subduction_sync_verify_failures_total{kind=\"commit\"} 1"),
"verify-failure counter should render with kind label:\n{rendered}"
);
for counter in [
"subduction_requested_data_send_failures_total 1",
"subduction_late_responses_total 1",
"subduction_keepalive_pongs_missed_total 1",
"subduction_keepalive_closes_total 1",
] {
assert!(
rendered.contains(counter),
"{counter} should render:\n{rendered}"
);
}
assert!(
rendered.contains("subduction_top_requestor_requests{rank=\"1\"} 5")
&& rendered.contains("subduction_top_requestor_requests{rank=\"2\"} 3")
&& rendered.contains("subduction_top_requestor_requests{rank=\"3\"} 0"),
"top-requestor gauges should rank and zero-fill:\n{rendered}"
);
assert!(
rendered.contains("subduction_requestor_window_requests 12"),
"window-total gauge should carry the full (untruncated) sum:\n{rendered}"
);
let frame_lines: String = rendered
.lines()
.filter(|l| l.contains("subduction_network_frame_bytes_bucket"))
.collect::<Vec<_>>()
.join("\n");
assert!(
frame_lines.contains("transport=\"websocket\"")
&& frame_lines.contains("direction=\"sent\"")
&& frame_lines.contains("le=\"134217728\""),
"frame-bytes histogram should carry transport/direction and the 128MiB top bucket:\n{frame_lines}"
);
let bucket_count = |le: &str| {
frame_lines
.lines()
.find(|l| l.contains(&format!("le=\"{le}\"")))
.and_then(|l| l.rsplit(' ').next())
};
assert_eq!(
bucket_count("256"),
Some("0"),
"300 B frame must not land in le=256:\n{frame_lines}"
);
assert_eq!(
bucket_count("1024"),
Some("1"),
"300 B frame must land in le=1024:\n{frame_lines}"
);
assert!(
rendered.contains("subduction_handshake_duration_seconds_bucket{outcome=\"ok\"")
|| rendered.contains("outcome=\"ok\",le="),
"handshake duration should render as an outcome-labeled histogram:\n{rendered}"
);
assert!(
rendered.contains("subduction_subscription_pushes_total{outcome=\"ok\"} 2")
&& rendered.contains("subduction_subscription_pushes_total{outcome=\"failed\"} 1"),
"subscription pushes should split by outcome:\n{rendered}"
);
assert!(
rendered
.contains("subduction_subscription_propagations_total{outcome=\"established\"} 1"),
"propagation counter should render with outcome:\n{rendered}"
);
assert!(
rendered.contains("subduction_build_info")
&& rendered.contains("version=\"0.0.0-test\"")
&& rendered.contains("git_sha=\"deadbeef\""),
"build info gauge should carry version and git_sha labels:\n{rendered}"
);
assert!(
rendered.contains("subduction_sedimentree_cache_hits_total 2"),
"cache hits counter should render a total of 2:\n{rendered}"
);
assert!(
rendered.contains("subduction_sedimentree_cache_misses_total 1"),
"cache misses counter should render a total of 1:\n{rendered}"
);
assert!(
rendered.contains("subduction_sedimentree_cache_resident 7"),
"cache resident gauge should render 7:\n{rendered}"
);
}
}