use std::net::SocketAddr;
use std::sync::OnceLock;
use thiserror::Error;
use crate::config::MetricsConfig;
use tokio::sync::oneshot;
pub use http::{Method, StatusCode, Response, Request};
#[cfg(feature = "prometheus-metrics")]
pub use prometheus::Registry;
#[derive(Debug, Error)]
pub enum MetricsError {
#[error("Failed to create exporter: {0}")]
ExporterFailed(String),
#[error("Metrics setup error: {0}")]
SetupError(String),
#[error("Invalid configuration: {0}")]
InvalidConfig(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
pub struct MetricsHandle {
shutdown_tx: Option<oneshot::Sender<()>>,
pub metrics_addr: SocketAddr,
}
impl MetricsHandle {
pub async fn shutdown(mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
}
}
pub fn init_metrics(config: &MetricsConfig) -> Result<MetricsHandle, MetricsError> {
const DEFAULT_METRICS_ADDR: &str = "0.0.0.0:0";
if !config.enabled {
return Ok(MetricsHandle {
shutdown_tx: None,
metrics_addr: DEFAULT_METRICS_ADDR
.parse()
.map_err(|e| MetricsError::InvalidConfig(format!("Invalid default metrics address: {}", e)))?,
});
}
match config.exporter {
crate::config::MetricsExporterType::Prometheus => {
init_prometheus(config)
}
crate::config::MetricsExporterType::Stdout => {
tracing::info!("Metrics exporter: stdout (logging via tracing)");
Ok(MetricsHandle {
shutdown_tx: None,
metrics_addr: "0.0.0.0:0".parse().unwrap(),
})
}
}
}
#[cfg(feature = "prometheus-metrics")]
fn init_prometheus(config: &MetricsConfig) -> Result<MetricsHandle, MetricsError> {
use std::net::SocketAddr;
let registry = prometheus::default_registry().clone();
setup_default_metrics(®istry)?;
let port = config.port;
let (shutdown_tx, shutdown_rx) = oneshot::channel();
tokio::spawn(async move {
metrics_server_task(registry, port, shutdown_rx).await;
});
Ok(MetricsHandle {
shutdown_tx: Some(shutdown_tx),
metrics_addr: SocketAddr::from(([127, 0, 0, 1], port)),
})
}
#[cfg(feature = "prometheus-metrics")]
async fn metrics_server_task(
registry: Registry,
port: u16,
shutdown_rx: oneshot::Receiver<()>,
) {
use axum::{routing::get, Router};
use tokio::net::TcpListener;
let addr = SocketAddr::from(([0, 0, 0, 0], port));
match TcpListener::bind(addr).await {
Ok(listener) => {
let actual_addr = listener.local_addr().unwrap_or(addr);
tracing::info!("Prometheus metrics server listening on http://{}", actual_addr);
tracing::info!("Metrics endpoint: http://{}/metrics", actual_addr);
let app = Router::new()
.route("/metrics", get(metrics_handler))
.route("/health", get(health_handler))
.with_state(registry);
let _ = axum::serve(listener, app)
.with_graceful_shutdown(async {
shutdown_rx.await.ok();
tracing::info!("Metrics server shutting down gracefully");
})
.await
.map_err(|e| tracing::error!("Metrics server error: {}", e));
}
Err(e) => {
tracing::error!("Failed to bind metrics server: {}", e);
}
}
}
#[cfg(feature = "prometheus-metrics")]
fn setup_default_metrics(registry: &Registry) -> Result<(), MetricsError> {
use prometheus::{
IntCounterVec, Histogram, IntGaugeVec,
Opts, HistogramOpts,
};
let _db_queries_total = IntCounterVec::new(
Opts::new("db_queries_total", "Total number of database queries"),
&["operation", "table"]
)
.map_err(|e| MetricsError::SetupError(format!("Failed to create db_queries_total: {}", e)))?;
registry.register(Box::new(_db_queries_total.clone()))
.map_err(|e| MetricsError::SetupError(format!("Failed to register db_queries_total: {}", e)))?;
let _db_query_duration_seconds = Histogram::with_opts(
HistogramOpts::new("db_query_duration_seconds", "Database query latency in seconds")
.buckets(vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0])
.into(),
)
.map_err(|e| MetricsError::SetupError(format!("Failed to create db_query_duration_seconds: {}", e)))?;
registry.register(Box::new(_db_query_duration_seconds.clone()))
.map_err(|e| MetricsError::SetupError(format!("Failed to register db_query_duration_seconds: {}", e)))?;
let _http_active_connections = IntGaugeVec::new(
Opts::new("http_active_connections", "Number of active HTTP connections"),
&["state"]
)
.map_err(|e| MetricsError::SetupError(format!("Failed to create http_active_connections: {}", e)))?;
registry.register(Box::new(_http_active_connections.clone()))
.map_err(|e| MetricsError::SetupError(format!("Failed to register http_active_connections: {}", e)))?;
tracing::debug!("Default Prometheus metrics registered successfully");
Ok(())
}
#[cfg(feature = "prometheus-metrics")]
async fn metrics_handler(
axum::extract::State(registry): axum::extract::State<Registry>,
) -> Result<axum::response::Response, String> {
use prometheus::{Encoder, TextEncoder};
use http::header;
let metrics_families = registry.gather();
let encoder = TextEncoder::new();
let mut buffer = Vec::new();
encoder
.encode(&metrics_families, &mut buffer)
.map_err(|e| format!("Failed to encode metrics: {}", e))?;
Ok(axum::response::Response::builder()
.status(axum::http::StatusCode::OK)
.header(header::CONTENT_TYPE, encoder.format_type())
.body(axum::body::Body::from(buffer))
.unwrap())
}
#[cfg(feature = "prometheus-metrics")]
async fn health_handler() -> &'static str {
"OK"
}
#[cfg(not(feature = "prometheus-metrics"))]
fn init_prometheus(_config: &MetricsConfig) -> Result<MetricsHandle, MetricsError> {
tracing::warn!("Prometheus metrics requested but 'prometheus-metrics' feature is not enabled");
tracing::warn!("Enable with: cargo build --features prometheus-metrics");
Ok(MetricsHandle {
shutdown_tx: None,
metrics_addr: "0.0.0.0:0".parse().unwrap(),
})
}
#[derive(Debug, Clone, Copy)]
pub struct PoolStats {
pub total: u32,
pub active: u32,
pub idle: u32,
pub max: u32,
}
#[cfg(feature = "prometheus-metrics")]
static DB_POOL_TOTAL: OnceLock<prometheus::IntGaugeVec> = OnceLock::new();
#[cfg(feature = "prometheus-metrics")]
static DB_POOL_ACTIVE: OnceLock<prometheus::IntGaugeVec> = OnceLock::new();
#[cfg(feature = "prometheus-metrics")]
static DB_POOL_IDLE: OnceLock<prometheus::IntGaugeVec> = OnceLock::new();
#[cfg(feature = "prometheus-metrics")]
static DB_POOL_MAX: OnceLock<prometheus::IntGaugeVec> = OnceLock::new();
#[cfg(feature = "prometheus-metrics")]
pub fn record_pool_metrics(stats: &PoolStats) {
record_pool_metrics_named("default", stats);
}
#[cfg(feature = "prometheus-metrics")]
pub fn record_pool_metrics_named(pool_name: &str, stats: &PoolStats) {
use prometheus::{IntGaugeVec, Opts};
let total = DB_POOL_TOTAL.get_or_init(|| {
let metric = IntGaugeVec::new(
Opts::new(common::DB_POOL_CONNECTIONS_TOTAL, "Total connections in database pool"),
&[common::LABEL_POOL],
).expect("Failed to create db_pool_connections_total metric");
let _ = prometheus::register(Box::new(metric.clone()));
metric
});
let active = DB_POOL_ACTIVE.get_or_init(|| {
let metric = IntGaugeVec::new(
Opts::new(common::DB_POOL_CONNECTIONS_ACTIVE, "Active connections in database pool"),
&[common::LABEL_POOL],
).expect("Failed to create db_pool_connections_active metric");
let _ = prometheus::register(Box::new(metric.clone()));
metric
});
let idle = DB_POOL_IDLE.get_or_init(|| {
let metric = IntGaugeVec::new(
Opts::new(common::DB_POOL_CONNECTIONS_IDLE, "Idle connections in database pool"),
&[common::LABEL_POOL],
).expect("Failed to create db_pool_connections_idle metric");
let _ = prometheus::register(Box::new(metric.clone()));
metric
});
let max = DB_POOL_MAX.get_or_init(|| {
let metric = IntGaugeVec::new(
Opts::new(common::DB_POOL_MAX_CONNECTIONS, "Max configured connections for database pool"),
&[common::LABEL_POOL],
).expect("Failed to create db_pool_max_connections metric");
let _ = prometheus::register(Box::new(metric.clone()));
metric
});
total.with_label_values(&[pool_name]).set(stats.total as i64);
active.with_label_values(&[pool_name]).set(stats.active as i64);
idle.with_label_values(&[pool_name]).set(stats.idle as i64);
max.with_label_values(&[pool_name]).set(stats.max as i64);
}
#[cfg(not(feature = "prometheus-metrics"))]
pub fn record_pool_metrics(_stats: &PoolStats) {}
#[cfg(not(feature = "prometheus-metrics"))]
pub fn record_pool_metrics_named(_pool_name: &str, _stats: &PoolStats) {}
pub mod common {
pub const HTTP_REQUESTS_TOTAL: &str = "http_requests_total";
pub const HTTP_REQUEST_DURATION_SECONDS: &str = "http_request_duration_seconds";
pub const HTTP_ACTIVE_CONNECTIONS: &str = "http_active_connections";
pub const DB_QUERIES_TOTAL: &str = "db_queries_total";
pub const DB_QUERY_DURATION_SECONDS: &str = "db_query_duration_seconds";
pub const LABEL_METHOD: &str = "method";
pub const LABEL_PATH: &str = "path";
pub const LABEL_STATUS: &str = "status";
pub const LABEL_OPERATION: &str = "operation";
pub const LABEL_TABLE: &str = "table";
pub const LABEL_STATE: &str = "state";
pub const DB_POOL_CONNECTIONS_TOTAL: &str = "db_pool_connections_total";
pub const DB_POOL_CONNECTIONS_ACTIVE: &str = "db_pool_connections_active";
pub const DB_POOL_CONNECTIONS_IDLE: &str = "db_pool_connections_idle";
pub const DB_POOL_MAX_CONNECTIONS: &str = "db_pool_max_connections";
pub const LABEL_POOL: &str = "pool";
}