Files
Oxicloud/src/interfaces/metrics.rs
T

98 lines
4.1 KiB
Rust
Raw Normal View History

//! Prometheus `/metrics` exporter — opt-in, isolated listener.
//!
//! Enabled iff `OXICLOUD_METRICS_LISTEN` is set (see
//! [`crate::common::config::AppConfig::metrics_listen`]). When unset,
//! no recorder is installed and every `metrics::counter!(…)` call
//! across the codebase compiles to a no-op — no runtime cost, no
//! endpoint bound. When set, this module:
//!
//! 1. Installs the process-global Prometheus recorder (once — panics
//! if called twice, so [`spawn`] MUST be a single-call site).
//! 2. Binds a fresh `axum` `Router` on the configured address exposing
//! only `GET /metrics`. Deliberately **not merged** into the main
//! API router — operators bind to loopback / a private interface
//! (typical: `127.0.0.1:9090` for a node_exporter-adjacent scrape)
//! without any auth, CSRF, or DPoP layer in front. Public exposure
//! is an operator choice via the bind address, not an app default.
//! 3. Spawns the listener on a detached tokio task — the metrics
//! endpoint's lifetime tracks the runtime, and a listener error
//! logs but doesn't take the main server down.
//!
//! Counter naming follows Prometheus conventions:
//! `oxicloud_<subsystem>_<verb>_total{label=…}`. Emission is
//! **duplicated** with existing audit `tracing::info!(target: "audit", …)`
//! lines — logs stay authoritative for incident forensics; counters
//! are for rate / rollup dashboards. Never remove one when adding the
//! other.
//!
//! Starter counter surface (extend as needed):
//! * `oxicloud_dpop_verify_failed_total{reason}`
//! * `oxicloud_dpop_proof_missing_total`
//! * `oxicloud_dpop_header_missing_on_bound_session_total`
//! * `oxicloud_dpop_replay_detected_total`
//! * `oxicloud_dpop_nonce_challenges_issued_total`
use axum::{Router, extract::State, http::header, response::IntoResponse, routing::get};
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
use std::net::SocketAddr;
/// Error type returned by [`spawn`]. Uses the same `Box<dyn Error>`
/// shape `main` already threads for setup failures — one less crate
/// dep (`anyhow`) and no coupling to a specific error framework.
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
/// Install the Prometheus recorder and spawn the `/metrics` listener.
///
/// Idempotent-unsafe: MUST be called at most once per process (the
/// recorder is a process-global singleton). Caller (main.rs) checks
/// `config.metrics_listen.is_some()` — no runtime guard here.
///
/// Returns immediately after `bind` succeeds; the listener runs on a
/// detached tokio task. A bind failure returns the error so main can
/// decide whether to abort (recommended) or continue without metrics.
pub async fn spawn(bind: SocketAddr) -> Result<(), BoxError> {
let handle: PrometheusHandle =
PrometheusBuilder::new()
.install_recorder()
.map_err(|err| -> BoxError {
format!("failed to install Prometheus recorder: {err}").into()
})?;
let app = Router::new()
.route("/metrics", get(scrape))
.with_state(handle);
let listener = tokio::net::TcpListener::bind(bind)
.await
.map_err(|err| -> BoxError {
format!("failed to bind metrics listener on {bind}: {err}").into()
})?;
let actual = listener.local_addr()?;
tracing::info!(
target: "oxicloud::metrics",
"📊 Prometheus /metrics listening on http://{actual}/metrics",
);
tokio::spawn(async move {
if let Err(err) = axum::serve(listener, app).await {
tracing::error!(
target: "oxicloud::metrics",
"metrics listener terminated with error: {err}",
);
}
});
Ok(())
}
/// Render the current Prometheus text-format snapshot. Content-type
/// per spec: `text/plain; version=0.0.4`; scrapers parse strictly.
async fn scrape(State(handle): State<PrometheusHandle>) -> impl IntoResponse {
(
[(
header::CONTENT_TYPE,
"text/plain; version=0.0.4; charset=utf-8",
)],
handle.render(),
)
}