use std::net::SocketAddr;
use std::time::Duration;
use axum::Router;
use axum::extract::State;
use axum::http::header::CONTENT_TYPE;
use axum::http::{HeaderValue, StatusCode};
use axum::response::IntoResponse;
use axum::routing::get;
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
use tokio::net::TcpListener;
use crate::error::AppError;
const PROMETHEUS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8";
pub(crate) struct MetricsBind {
listener: TcpListener,
handle: PrometheusHandle,
}
impl std::fmt::Debug for MetricsBind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MetricsBind")
.field("listener", &self.listener)
.finish_non_exhaustive()
}
}
pub(crate) async fn bind(addr: Option<SocketAddr>) -> Result<Option<MetricsBind>, AppError> {
let Some(addr) = addr else {
return Ok(None);
};
let handle = install()?;
let listener = TcpListener::bind(addr)
.await
.map_err(|source| AppError::Bind { addr, source })?;
let bound = listener.local_addr().unwrap_or(addr);
tracing::info!(%bound, "metrics listening");
Ok(Some(MetricsBind { listener, handle }))
}
pub(crate) fn spawn(bound: Option<MetricsBind>) -> Option<tokio::task::JoinHandle<()>> {
let MetricsBind { listener, handle } = bound?;
Some(tokio::spawn(async move {
if let Err(error) = axum::serve(listener, router(handle))
.with_graceful_shutdown(crate::shutdown::signal())
.await
{
tracing::error!(%error, "metrics server");
}
}))
}
fn install() -> Result<PrometheusHandle, AppError> {
let handle = PrometheusBuilder::new()
.install_recorder()
.map_err(|error| AppError::Metrics(error.to_string()))?;
metrics::gauge!("o402_up").set(1.0);
let upkeep = handle.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(5));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
upkeep.run_upkeep();
}
});
Ok(handle)
}
fn router(handle: PrometheusHandle) -> Router {
Router::new()
.route("/metrics", get(scrape))
.with_state(handle)
}
#[allow(clippy::unused_async, reason = "axum handler")]
async fn scrape(State(handle): State<PrometheusHandle>) -> impl IntoResponse {
(
StatusCode::OK,
[(
CONTENT_TYPE,
HeaderValue::from_static(PROMETHEUS_CONTENT_TYPE),
)],
handle.render(),
)
}
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::{Request, StatusCode};
use metrics_exporter_prometheus::PrometheusBuilder;
use tower::ServiceExt;
use super::router;
#[tokio::test]
async fn metrics_route_ok() {
let handle = PrometheusBuilder::new().build_recorder().handle();
let response = router(handle)
.oneshot(
Request::get("/metrics")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK, "metrics status");
}
}