use super::registry::MetricsRegistry;
use super::MetricsError;
use axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::net::TcpListener;
use tracing::{debug, error, info};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetricsServerConfig {
pub bind_address: String,
pub port: u16,
}
impl Default for MetricsServerConfig {
fn default() -> Self {
Self {
bind_address: "0.0.0.0".to_string(),
port: 9090,
}
}
}
impl MetricsServerConfig {
pub fn new(bind_address: impl Into<String>, port: u16) -> Self {
Self {
bind_address: bind_address.into(),
port,
}
}
pub fn socket_addr(&self) -> Result<SocketAddr, MetricsError> {
format!("{}:{}", self.bind_address, self.port)
.parse()
.map_err(|e| MetricsError::BindError {
address: format!("{}:{}", self.bind_address, self.port),
source: std::io::Error::new(std::io::ErrorKind::InvalidInput, e),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HealthStatus {
pub status: String,
pub version: String,
pub uptime_seconds: u64,
}
impl HealthStatus {
pub fn healthy() -> Self {
Self {
status: "healthy".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
uptime_seconds: 0,
}
}
pub fn healthy_with_uptime(uptime: Duration) -> Self {
Self {
status: "healthy".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
uptime_seconds: uptime.as_secs(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReadinessStatus {
pub ready: bool,
pub checks: Vec<ReadinessCheck>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReadinessCheck {
pub name: String,
pub ready: bool,
pub message: Option<String>,
}
impl ReadinessStatus {
pub fn ready() -> Self {
Self {
ready: true,
checks: vec![ReadinessCheck {
name: "processor".to_string(),
ready: true,
message: None,
}],
}
}
pub fn not_ready(message: impl Into<String>) -> Self {
Self {
ready: false,
checks: vec![ReadinessCheck {
name: "processor".to_string(),
ready: false,
message: Some(message.into()),
}],
}
}
}
#[derive(Clone)]
struct ServerState {
registry: Arc<MetricsRegistry>,
start_time: Instant,
}
pub struct MetricsServer {
config: MetricsServerConfig,
registry: Arc<MetricsRegistry>,
}
impl MetricsServer {
pub fn new(config: MetricsServerConfig) -> Self {
Self {
config,
registry: MetricsRegistry::global(),
}
}
pub fn with_registry(config: MetricsServerConfig, registry: Arc<MetricsRegistry>) -> Self {
Self { config, registry }
}
pub async fn start(self) -> Result<(), MetricsError> {
let addr = self.config.socket_addr()?;
info!(
"Starting metrics server on http://{}:{}",
self.config.bind_address, self.config.port
);
let state = ServerState {
registry: self.registry,
start_time: Instant::now(),
};
let app = Router::new()
.route("/metrics", get(metrics_handler))
.route("/health", get(health_handler))
.route("/ready", get(ready_handler))
.with_state(state);
let listener = TcpListener::bind(addr)
.await
.map_err(|e| MetricsError::BindError {
address: format!("{}:{}", self.config.bind_address, self.config.port),
source: e,
})?;
info!(
"Metrics server listening on http://{}",
listener.local_addr().map_err(|e| MetricsError::BindError {
address: format!("{}:{}", self.config.bind_address, self.config.port),
source: e,
})?
);
axum::serve(listener, app)
.await
.map_err(|e| MetricsError::ServerStartError(e.to_string()))?;
Ok(())
}
pub fn config(&self) -> &MetricsServerConfig {
&self.config
}
}
async fn metrics_handler(State(state): State<ServerState>) -> Response {
debug!("Metrics endpoint called");
match state.registry.encode() {
Ok(metrics) => {
debug!("Successfully encoded metrics");
(
StatusCode::OK,
[("content-type", "text/plain; version=0.0.4")],
metrics,
)
.into_response()
}
Err(e) => {
error!("Failed to encode metrics: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to encode metrics: {}", e),
)
.into_response()
}
}
}
async fn health_handler(State(state): State<ServerState>) -> Json<HealthStatus> {
debug!("Health check endpoint called");
let uptime = state.start_time.elapsed();
Json(HealthStatus::healthy_with_uptime(uptime))
}
async fn ready_handler() -> Response {
debug!("Readiness probe endpoint called");
let status = ReadinessStatus::ready();
if status.ready {
(StatusCode::OK, Json(status)).into_response()
} else {
(StatusCode::SERVICE_UNAVAILABLE, Json(status)).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_default() {
let config = MetricsServerConfig::default();
assert_eq!(config.bind_address, "0.0.0.0");
assert_eq!(config.port, 9090);
}
#[test]
fn test_config_custom() {
let config = MetricsServerConfig::new("127.0.0.1", 8080);
assert_eq!(config.bind_address, "127.0.0.1");
assert_eq!(config.port, 8080);
}
#[test]
fn test_socket_addr() {
let config = MetricsServerConfig::new("127.0.0.1", 8080);
let addr = config.socket_addr().unwrap();
assert_eq!(addr.port(), 8080);
}
#[test]
fn test_health_status() {
let status = HealthStatus::healthy();
assert_eq!(status.status, "healthy");
assert_eq!(status.version, env!("CARGO_PKG_VERSION"));
}
#[test]
fn test_readiness_status() {
let ready = ReadinessStatus::ready();
assert!(ready.ready);
assert_eq!(ready.checks.len(), 1);
let not_ready = ReadinessStatus::not_ready("Not initialized");
assert!(!not_ready.ready);
assert!(not_ready.checks[0].message.is_some());
}
#[tokio::test]
async fn test_metrics_server_creation() {
let config = MetricsServerConfig::new("127.0.0.1", 9091);
let server = MetricsServer::new(config);
assert_eq!(server.config().port, 9091);
}
}