o402 0.1.2

OpenAI-compatible gateway, paid with x402.
//! Optional Prometheus scrape on `observability.metrics_bind`. Never on `:8080`.

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;

/// Prometheus text exposition format.
const PROMETHEUS_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8";

/// Listener plus scrape handle for the metrics bind.
pub(crate) struct MetricsBind {
    /// Bound metrics socket.
    listener: TcpListener,
    /// Prometheus scrape handle.
    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()
    }
}

/// Install the recorder and bind `addr` when `Some`.
///
/// # Errors
///
/// Returns [`AppError::Metrics`] if the recorder cannot be installed, or
/// [`AppError::Bind`] if the listen socket fails.
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 }))
}

/// Run `/metrics` in the background. Payment drain is independent of this task.
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");
    }
}