Ya tienes crustaceo-tasks corriendo en un contenedor Docker. Responde peticiones, hace CRUD, tiene autenticación. Funciona. Pero hay un problema: no sabes si funciona hasta que alguien se queja.
Tu API está ahí, en el servidor, recibiendo tráfico. O no. El proceso puede estar vivo pero la app puede estar congelada. O respondiendo 500 a todo. O tan saturada que las peticiones tardan 30 segundos. Sin métricas ni healthchecks, no tienes manera de saberlo hasta que un usuario te envía un mensaje diciendo que no funciona.
Eso es lo que arreglas en este capítulo. Le pones ojos y oídos a tu API.
Si vienes de Bash, piensa en esto como pasar de:
# Modo avestruz: si no miro, no pasa nada
docker ps # ¿está corriendo? sí. ¿funciona? ni idea.
A esto:
# Modo adulto responsable
curl http://localhost:3000/health
# {"status":"ok","liveness":true,"readiness":true,"startup":true}
curl http://localhost:3000/metrics
# http_requests_total{method="GET",path="/health"} 42
# http_request_duration_seconds{method="GET",path="/health"} 0.003
Y que Prometheus lo recolecte, lo grafique y te avise cuando algo huele mal.
Este capítulo cierras el círculo: construiste el servicio, lo empaquetaste en Docker, y ahora le pones monitoreo. Tu API ya no es un cacho de código corriendo en un servidor. Es un servicio observable que puedes vigilar, medir y diagnosticar sin abrir una terminal.
El problema: no sabes si tu API está bien
Tener el proceso levantado no significa que la API funcione. Puede pasar de todo:
- Deadlock en un Mutex: el hilo principal está bloqueado y no procesa peticiones, pero el proceso sigue vivo.
- Conexión a base de datos caída: la API responde, pero todas las operaciones de escritura fallan.
- Memoria agotada: el proceso responde, pero cada petición tarda 10 segundos.
- Dependencia externa caída: si tu API llama a otro servicio y ese servicio no responde, tu API está degradada.
Sin métricas ni healthchecks, no sabes nada de esto. Solo sabes que docker ps te dice «Up». Y eso no es suficiente.
La solución en dos partes
Vas a implementar dos sistemas que se complementan:
Healthchecks: endpoints que el orquestador (Docker, Kubernetes, Traefik) consulta para saber si tu API está viva y lista para recibir tráfico. Son checks binarios: sí/no, vivo/muerto.
Métricas: un endpoint que expone datos numéricos en formato Prometheus. Cuántas peticiones has recibido, cuánto tardan, cuántas fallan, desde cuándo está corriendo el servicio. Son datos continuos que alimentan dashboards y alertas.
Los dos juntos te dan una imagen completa del estado de tu servicio.
Healthchecks: liveness, readiness, startup
No todos los healthchecks son iguales. Kubernetes define tres tipos, y es buena práctica implementarlos aunque no uses Kubernetes. Docker y Docker Compose también los soportan. La idea es la misma:
Liveness probe — ¿está vivo?
Responde siempre con 200 OK si el proceso está funcionando. No comprueba dependencias externas. Solo confirma que el hilo principal del servidor no se ha colgado.
Si falla, el orquestador reinicia el contenedor.
async fn liveness() -> Json<serde_json::Value> {
Json(serde_json::json!({
"status": "ok",
"probe": "liveness",
}))
}
Readiness probe — ¿está listo para recibir tráfico?
Comprueba que las dependencias externas están disponibles: base de datos, caché, API externa, sistema de archivos. Si falla, el orquestador deja de enviar tráfico al contenedor (pero no lo reinicia).
Un contenedor puede estar vivo pero no listo. Por ejemplo, si la base de datos se cae, el contenedor sigue vivo pero no puede servir peticiones correctamente.
async fn readiness(state: AppState) -> Result<Json<serde_json::Value>, StatusCode> {
// Comprobar que el mutex no está envenenado
match state.db.lock() {
Ok(_) => Ok(Json(serde_json::json!({
"status": "ok",
"probe": "readiness",
}))),
Err(_) => Err(StatusCode::SERVICE_UNAVAILABLE),
}
}
Startup probe — ¿ha terminado de inicializarse?
Se ejecuta solo al arrancar. Indica si la aplicación ha completado su inicialización (cargar datos, conectar DB, preparar cachés). Durante el startup, las otras probes no se ejecutan.
Útil para aplicaciones que tardan en arrancar: cargar un modelo de ML, migrar la base de datos, descargar datos remotos.
En crustaceo-tasks el arranque es inmediato, pero implementas la probe para que el patrón esté ahí.
El endpoint /health completo
Agrupas las tres probes en un solo endpoint que devuelve el estado global y el de cada sonda:
use std::sync::{Arc, Mutex};
#[derive(Serialize)]
struct HealthResponse {
status: String,
service: String,
version: String,
liveness: bool,
readiness: bool,
startup: bool,
uptime_seconds: u64,
}
async fn health(
State(state): State<AppState>,
) -> Json<HealthResponse> {
let ready = state.db.lock().is_ok();
let uptime = state.start_time.elapsed().as_secs();
Json(HealthResponse {
status: if ready { "ok".into() } else { "degraded".into() },
service: "crustaceo-tasks".into(),
version: env!("CARGO_PKG_VERSION").into(),
liveness: true,
readiness: ready,
startup: state.startup_completed.load(Ordering::Relaxed),
uptime_seconds: uptime,
})
}
Fíjate en el campo status: es «ok» solo si todo está bien, «degraded» si readiness falla pero liveness sigue vivo. Esto permite que el orquestador decida qué hacer según el problema.
Métricas con la crate metrics
Rust tiene un ecosistema de métricas muy bien organizado. La crate metrics es la biblioteca principal, y metrics-exporter-prometheus se encarga de exponer las métricas en el formato que entiende Prometheus.
Cómo funciona
El flujo es simple:
- Declaras métricas (contadores, gauges, histograms) en tu código
- Las actualizas cuando ocurren eventos (peticiones, errores, duraciones)
- El exporter genera el texto en formato Prometheus
- Prometheus (o cualquier scraper) consulta el endpoint y almacena los datos
Instalar las crates
Añade esto a tu Cargo.toml:
[dependencies]
# ... las que ya tienes ...
metrics = "0.24"
metrics-exporter-prometheus = "0.16"
metrics proporciona las macros y tipos para declarar métricas. metrics-exporter-prometheus construye el endpoint HTTP con el formato de Prometheus.
Contadores, gauges, histograms
Tres tipos de métricas, cada uno para un propósito distinto.
Counter — cuenta eventos
Un contador solo aumenta. Nunca decrece. Sirve para contar:
- Peticiones totales recibidas
- Errores 4xx/5xx
- Tareas creadas, actualizadas, eliminadas
use metrics::counter;
// En un handler
counter!("http_requests_total", "method" => "GET", "path" => "/tasks").increment(1);
counter!("tasks_created_total").increment(1);
Los labels (method, path) permiten desglosar la métrica. En Prometheus puedes hacer consultas como sum(http_requests_total) para el total global, o http_requests_total{path="/tasks"} para las de una ruta concreta.
Gauge — valores que suben y bajan
Un gauge representa un valor que puede aumentar o disminuir. Sirve para:
- Número de tareas almacenadas
- Conexiones activas
- Uptime en segundos
- Memoria usada
use metrics::gauge;
// Al crear o eliminar tareas
gauge!("tasks_stored").set(db.len() as f64);
// Uptime tracker en un bucle separado
gauge!("uptime_seconds").set(elapsed.as_secs() as f64);
Histogram — distribuciones de tiempo
Un histogram registra observaciones y las agrupa en buckets predefinidos. Sirve para medir:
- Duración de las peticiones
- Tamaño de las respuestas
- Latencia a base de datos
use metrics::histogram;
// Mides el tiempo y registras la observación
let start = Instant::now();
let response = next.run(request).await;
let duration = start.elapsed();
histogram!("http_request_duration_seconds", "method" => method, "path" => path)
.record(duration.as_secs_f64());
Prometheus usa los histograms para calcular percentiles: p50, p95, p99. Te dicen que «el 95% de las peticiones tardan menos de X milisegundos».
Middleware de métricas para Axum
El sitio ideal para recolectar métricas es un middleware. Interceptas cada petición, registras los datos, y dejas que el handler haga su trabajo. Así no tienes que meter código de métricas en cada handler.
El middleware completo
use std::time::Instant;
use axum::{http::Request, middleware::Next, response::Response};
use metrics::{counter, histogram};
async fn metrics_middleware(
request: Request,
next: Next,
) -> Response {
let start = Instant::now();
let method = request.method().clone();
let path = request.uri().path().to_string();
// Contador de peticiones entrantes
counter!("http_requests_total", "method" => method.to_string(), "path" => path.clone())
.increment(1);
// Pasar la petición al handler
let response = next.run(request).await;
// Medir duración
let duration = start.elapsed();
histogram!("http_request_duration_seconds", "method" => method.to_string(), "path" => path.clone())
.record(duration.as_secs_f64());
// Contar errores por código de estado
let status = response.status().as_u16();
if status >= 400 && status < 500 {
counter!("http_errors_total", "code" => status.to_string(), "type" => "4xx")
.increment(1);
} else if status >= 500 {
counter!("http_errors_total", "code" => status.to_string(), "type" => "5xx")
.increment(1);
}
response
}
Registro y respuesta por ruta
El middleware registra la ruta real (el path de la petición). Esto es útil para ver qué endpoints se usan más. En Prometheus verás algo como:
http_requests_total{method="GET",path="/health"} 150
http_requests_total{method="GET",path="/tasks"} 42
http_requests_total{method="POST",path="/tasks"} 12
http_requests_total{method="DELETE",path="/tasks/{id}"} 3
La clave del middleware es que no interfiere con el handler. Solo observa y registra. Es un ejemplo clásico del patrón decorator: envuelves la función original y añades comportamiento alrededor.
Endpoint /metrics con Prometheus
metrics-exporter-prometheus construye automáticamente el endpoint prometheus. Lo configuras al arrancar la aplicación y lo montas como una ruta de Axum.
Builder de Prometheus
El exporter usa el patrón builder. Configuras un recorder que recoge todas las métricas, y luego construyes un handle HTTP que sirve el texto:
use metrics_exporter_prometheus::{ PrometheusBuilder, PrometheusHandle };
fn setup_metrics() -> PrometheusHandle {
PrometheusBuilder::new()
.listen_address("0.0.0.0:9000") // Puerto separado para métricas
.install()
.expect("Error al instalar el exporter de métricas")
}
O, si prefieres servirlo desde el mismo puerto que la API (recomendado para self-hosted):
use metrics_exporter_prometheus::{ PrometheusBuilder, PrometheusHandle };
fn setup_metrics() -> PrometheusHandle {
let recorder = PrometheusBuilder::new().build_recorder();
let handle = recorder.handle();
metrics::set_boxed_recorder(Box::new(recorder))
.expect("Error al instalar el recorder de métricas");
handle
}
Con el handle, creas un handler de Axum que devuelve el texto:
use axum::response::Text;
use metrics_exporter_prometheus::PrometheusHandle;
async fn metrics_handler(handle: axum::extract::State<PrometheusHandle>) -> Text<String> {
Text(handle.render())
}
El texto que genera handle.render() tiene este formato:
# HELP http_requests_total http_requests_total
# TYPE http_requests_total counter
http_requests_total{method="GET",path="/health"} 42
http_requests_total{method="GET",path="/tasks"} 17
# HELP http_request_duration_seconds http_request_duration_seconds
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",path="/health",le="0.005"} 38
http_request_duration_seconds_bucket{method="GET",path="/health",le="0.01"} 42
http_request_duration_seconds_bucket{method="GET",path="/health",le="+Inf"} 42
http_request_duration_seconds_sum{method="GET",path="/health"} 0.187
http_request_duration_seconds_count{method="GET",path="/health"} 42
Eso es exactamente lo que Prometheus espera. No necesitas formatear nada a mano. La crate lo hace por ti.
Métricas útiles para un self-hoster
No todas las métricas son útiles. Estas son las que realmente importan cuando gestionas tu propia infraestructura:
Requests totales
Cuántas peticiones ha recibido tu API desde que arrancó. Útil para ver tendencias: ¿está creciendo el uso? ¿hay picos inesperados?
counter!("http_requests_total", "method" => method, "path" => path).increment(1);
Requests por ruta te permite ver qué endpoints se usan más
Si /tasks recibe 1000 peticiones y /health recibe 10, sabes que tu API está siendo usada de verdad. Si /health recibe 10.000 peticiones y /tasks recibe 0, algo raro pasa (quizás un scraper mal configurado).
Duración media de las peticiones
Mides con un histograma porque la media sola no basta. Una media de 200ms puede ocultar que el 10% de las peticiones tardan 5 segundos. Con el histograma ves la distribución real.
histogram!("http_request_duration_seconds", "method" => method, "path" => path)
.record(duration.as_secs_f64());
Errores 4xx y 5xx
Los 4xx son errores del cliente (petición mal formada, auth incorrecta). Los 5xx son errores del servidor (bug, DB caída, timeout). Los segundos son los que realmente importan.
counter!("http_errors_total", "code" => status.to_string(), "type" => "5xx").increment(1);
Si ves que los 5xx crecen, algo se ha roto. Si ves que los 4xx crecen, quizás alguien está haciendo scraping sin la API key correcta.
Uptime
Desde cuándo está corriendo el servicio. Un gauge que actualizas cada pocos segundos en un hilo separado.
gauge!("uptime_seconds").set(elapsed.as_secs() as f64);
O, más simple, lo devuelves en el /health y dejas que Prometheus calcule el uptime con time() - process_start_time_seconds.
Proyecto: extender crustaceo-tasks con healthchecks y métricas
Vas a modificar crustaceo-tasks para añadir todo lo que has aprendido. No empiezas de cero; extiendes el proyecto del capítulo anterior.
Cargo.toml actualizado
Añade las nuevas dependencias. El Cargo.toml completo queda así:
[package]
name = "crustaceo-tasks"
version = "0.2.0"
edition = "2021"
description = "API REST de tareas con healthchecks y métricas Prometheus"
homepage = "https://atareao.es/tutorial/rust/"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower-http = { version = "0.6", features = ["cors"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
metrics = "0.24"
metrics-exporter-prometheus = "0.16"
main.rs completo con healthchecks y métricas
use std::sync::{Arc, Mutex, atomic::{AtomicBool, Ordering}};
use std::time::{Duration, Instant};
use axum::{
extract::{Path, State},
http::{Request, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Json, Response},
routing::{delete, get, post, put},
Router,
};
use chrono::{DateTime, Utc};
use metrics::{counter, gauge, histogram};
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
use serde::{Deserialize, Serialize};
use tower_http::cors::CorsLayer;
use tracing::info;
use uuid::Uuid;
// ─── Modelo de datos ──────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Task {
id: String,
title: String,
description: String,
completed: bool,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
struct CreateTaskRequest {
title: String,
description: Option<String>,
}
#[derive(Debug, Deserialize)]
struct UpdateTaskRequest {
title: Option<String>,
description: Option<String>,
completed: Option<bool>,
}
// ─── Estado compartido ──────────────────────────────────────────
type TaskDb = Arc<Mutex<Vec<Task>>>;
#[derive(Clone)]
struct AppState {
db: TaskDb,
start_time: Instant,
startup_completed: Arc<AtomicBool>,
metrics_handle: PrometheusHandle,
}
// ─── Errores tipados ────────────────────────────────────────────
#[derive(Debug)]
enum AppError {
NotFound(String),
BadRequest(String),
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
};
let body = Json(serde_json::json!({
"error": message,
"code": status.as_u16(),
}));
(status, body).into_response()
}
}
// ─── Healthchecks ────────────────────────────────────────────────
#[derive(Serialize)]
struct HealthResponse {
status: String,
service: String,
version: String,
liveness: bool,
readiness: bool,
startup: bool,
uptime_seconds: u64,
checks: HealthChecks,
}
#[derive(Serialize)]
struct HealthChecks {
database: String,
memory: String,
}
/// Liveness: responde siempre que el proceso esté vivo.
/// No comprueba dependencias. Si esto falla, el contenedor se reinicia.
async fn liveness() -> Json<serde_json::Value> {
Json(serde_json::json!({
"status": "ok",
"probe": "liveness",
}))
}
/// Readiness: comprueba que las dependencias están disponibles.
/// Si falla, el orquestador deja de enviar tráfico.
async fn readiness(state: State<AppState>) -> Result<Json<serde_json::Value>, StatusCode> {
// Comprobar que el mutex no está envenenado
match state.db.lock() {
Ok(_) => Ok(Json(serde_json::json!({
"status": "ok",
"probe": "readiness",
}))),
Err(_) => Err(StatusCode::SERVICE_UNAVAILABLE),
}
}
/// Health completo: combina las tres probes y devuelve métricas de estado.
async fn health(
state: State<AppState>,
) -> Json<HealthResponse> {
let db_ok = state.db.lock().is_ok();
let uptime = state.start_time.elapsed().as_secs();
let started = state.startup_completed.load(Ordering::Relaxed);
let status = if started && db_ok {
"ok"
} else if started && !db_ok {
"degraded"
} else {
"starting"
};
Json(HealthResponse {
status: status.into(),
service: "crustaceo-tasks".into(),
version: env!("CARGO_PKG_VERSION").into(),
liveness: true,
readiness: db_ok,
startup: started,
uptime_seconds: uptime,
checks: HealthChecks {
database: if db_ok { "ok".into() } else { "error".into() },
memory: "ok".into(),
},
})
}
/// Handler para /metrics: renderiza todas las métricas en formato Prometheus.
async fn metrics_handler(state: State<AppState>) -> String {
state.metrics_handle.render()
}
// ─── Handlers CRUD ──────────────────────────────────────────────
async fn list_tasks(state: State<AppState>) -> Result<Json<Vec<Task>>, AppError> {
let db = state.db.lock().map_err(|e| {
AppError::Internal(format!("Error de concurrencia: {}", e))
})?;
let tasks = db.clone();
gauge!("tasks_stored").set(tasks.len() as f64);
Ok(Json(tasks))
}
async fn create_task(
state: State<AppState>,
Json(payload): Json<CreateTaskRequest>,
) -> Result<(StatusCode, Json<Task>), AppError> {
let trimmed = payload.title.trim().to_string();
if trimmed.is_empty() {
return Err(AppError::BadRequest(
"El campo 'title' no puede estar vacío".into(),
));
}
let task = Task {
id: Uuid::new_v4().to_string(),
title: trimmed,
description: payload.description.unwrap_or_default(),
completed: false,
created_at: Utc::now(),
updated_at: Utc::now(),
};
let mut db = state.db.lock().map_err(|e| {
AppError::Internal(format!("Error de concurrencia: {}", e))
})?;
let task_clone = task.clone();
db.push(task);
counter!("tasks_created_total").increment(1);
gauge!("tasks_stored").set(db.len() as f64);
Ok((StatusCode::CREATED, Json(task_clone)))
}
async fn get_task(
state: State<AppState>,
Path(id): Path<String>,
) -> Result<Json<Task>, AppError> {
let db = state.db.lock().map_err(|e| {
AppError::Internal(format!("Error de concurrencia: {}", e))
})?;
db.iter()
.find(|t| t.id == id)
.cloned()
.ok_or_else(|| AppError::NotFound(format!("Tarea {} no encontrada", id)))
.map(Json)
}
async fn update_task(
state: State<AppState>,
Path(id): Path<String>,
Json(payload): Json<UpdateTaskRequest>,
) -> Result<Json<Task>, AppError> {
let mut db = state.db.lock().map_err(|e| {
AppError::Internal(format!("Error de concurrencia: {}", e))
})?;
let task = db
.iter_mut()
.find(|t| t.id == id)
.ok_or_else(|| AppError::NotFound(format!("Tarea {} no encontrada", id)))?;
if let Some(title) = payload.title {
let trimmed = title.trim().to_string();
if trimmed.is_empty() {
return Err(AppError::BadRequest(
"El campo 'title' no puede estar vacío".into(),
));
}
task.title = trimmed;
}
if let Some(description) = payload.description {
task.description = description;
}
if let Some(completed) = payload.completed {
task.completed = completed;
}
task.updated_at = Utc::now();
counter!("tasks_updated_total").increment(1);
Ok(Json(task.clone()))
}
async fn delete_task(
state: State<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, AppError> {
let mut db = state.db.lock().map_err(|e| {
AppError::Internal(format!("Error de concurrencia: {}", e))
})?;
let len_before = db.len();
db.retain(|t| t.id != id);
if db.len() == len_before {
return Err(AppError::NotFound(format!("Tarea {} no encontrada", id)));
}
counter!("tasks_deleted_total").increment(1);
gauge!("tasks_stored").set(db.len() as f64);
Ok(StatusCode::NO_CONTENT)
}
// ─── Middleware de métricas ──────────────────────────────────────
async fn metrics_middleware(
request: Request,
next: Next,
) -> Response {
let start = Instant::now();
let method = request.method().clone();
let path = request.uri().path().to_string();
// Contar petición entrante
counter!("http_requests_total", "method" => method.to_string(), "path" => path.clone())
.increment(1);
// Ejecutar handler y medir duración
let response = next.run(request).await;
let duration = start.elapsed();
histogram!("http_request_duration_seconds", "method" => method.to_string(), "path" => path.clone())
.record(duration.as_secs_f64());
// Clasificar error por código de estado
let status = response.status().as_u16();
if status >= 400 && status < 500 {
counter!("http_errors_total", "code" => status.to_string(), "type" => "4xx")
.increment(1);
} else if status >= 500 {
counter!("http_errors_total", "code" => status.to_string(), "type" => "5xx")
.increment(1);
}
response
}
// ─── Middleware de logging ───────────────────────────────────────
async fn logging_middleware(
request: Request,
next: Next,
) -> Response {
let start = Instant::now();
let method = request.method().clone();
let uri = request.uri().clone();
let response = next.run(request).await;
let status = response.status();
let duration = start.elapsed();
info!(
method = %method,
path = %uri,
status = %status.as_u16(),
duration_ms = duration.as_millis(),
"{} {} -> {} ({}ms)",
method, uri, status, duration.as_millis(),
);
response
}
// ─── Punto de entrada ────────────────────────────────────────────
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "crustaceo_tasks=info,tower_http=info".into()),
)
.init();
// Configurar métricas Prometheus
let recorder = PrometheusBuilder::new().build_recorder();
let handle = recorder.handle();
metrics::set_boxed_recorder(Box::new(recorder))
.expect("Error al instalar el recorder de métricas");
// Registrar métrica de uptime al arranque
let start_time = Instant::now();
let state = AppState {
db: Arc::new(Mutex::new(Vec::new())),
start_time,
startup_completed: Arc::new(AtomicBool::new(true)), // startup inmediato
metrics_handle: handle,
};
// Middleware de auth compartido
let sensitive_routes = axum::Router::new()
.route("/tasks", get(list_tasks).post(create_task))
.route("/tasks/{id}", get(get_task).put(update_task).delete(delete_task))
.route_layer(middleware::from_fn(auth_middleware));
let app = Router::new()
// Healthchecks (sin autenticación)
.route("/health/liveness", get(liveness))
.route("/health/readiness", get(readiness))
.route("/health", get(health))
// Métricas (sin autenticación)
.route("/metrics", get(metrics_handler))
// CRUD tasks (con autenticación)
.merge(sensitive_routes)
// Estado global
.with_state(state)
// Middlewares
.layer(middleware::from_fn(metrics_middleware))
.layer(middleware::from_fn(logging_middleware))
.layer(CorsLayer::permissive());
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.unwrap();
println!("🦀 crustaceo-tasks v{}", env!("CARGO_PKG_VERSION"));
println!("📡 Escuchando en http://0.0.0.0:3000");
println!("❤️ /health — Health check completo");
println!("📊 /metrics — Métricas Prometheus");
println!("📋 /tasks — CRUD de tareas (requiere X-API-Key)");
axum::serve(listener, app).await.unwrap();
}
// ─── Middleware de autenticación ────────────────────────────────
async fn auth_middleware(
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let api_key = std::env::var("API_KEY")
.unwrap_or_else(|_| "crustaceo-secreto-2026".to_string());
let provided = request
.headers()
.get("X-API-Key")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if provided == api_key {
Ok(next.run(request).await)
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
Desglose del código
El main ha cambiado respecto al capítulo anterior. Veamos las novedades:
- setup_metrics(): crea el recorder de Prometheus. El
PrometheusHandlese pasa al estado de Axum y lo usa el handler de/metrics. - AppState extendido: ahora tiene
start_time(Instant),startup_completed(AtomicBool) ymetrics_handle(PrometheusHandle). - /health unificado: responde con liveness (siempre true), readiness (comprueba el Mutex), startup (true después de init) y uptime en segundos.
- /health/liveness: endpoint separado para que Docker lo use como healthcheck.
- /health/readiness: endpoint separado para el orquestador.
- /metrics: llama a
handle.render()que devuelve el texto en formato Prometheus. - metrics_middleware: envuelve todas las rutas. Cuenta peticiones, mide duración y clasifica errores.
- Métricas de negocio:
tasks_created_total,tasks_updated_total,tasks_deleted_total,tasks_stored(gauge) en los handlers CRUD.
docker-compose.yml con Prometheus
Ya tienes la API con métricas. Ahora necesitas que Prometheus las recolecte. Crea un docker-compose.yml en la raíz de crustaceo-tasks:
services:
api:
build: .
container_name: crustaceo-tasks
ports:
- "3000:3000"
environment:
- API_KEY=crustaceo-secreto-2026
- RUST_LOG=crustaceo_tasks=info,tower_http=info
healthcheck:
test: curl -f http://localhost:3000/health || exit 1
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
labels:
- "app=crustaceo-tasks"
prometheus:
image: prom/prometheus:latest
container_name: prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --web.console.libraries=/etc/prometheus/console_libraries
- --web.console.templates=/etc/prometheus/consoles
- --web.enable-lifecycle
Y el archivo prometheus.yml en el mismo directorio:
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'crustaceo-tasks'
static_configs:
- targets: ['api:3000']
metrics_path: /metrics
relabel_configs:
- source_labels: [__address__]
target_label: instance
replacement: 'crustaceo-tasks'
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
Explicación del scrape config
La configuración de Prometheus es simple pero crucial:
- scrape_interval: 15s: cada 15 segundos Prometheus consulta los endpoints. Puedes subirlo a 5s si quieres más resolución o bajarlo a 30s si quieres menos carga.
- job_name: identifica el servicio. En dashboards y alertas usas este nombre para filtrar.
- targets:
['api:3000']. Fíjate que usa el nombre del servicio de docker-compose (api), nolocalhost. Docker Compose resuelve los nombres de servicio internamente. - metrics_path: /metrics: la ruta donde la API sirve las métricas.
- relabel_configs: asigna una etiqueta
instancepersonalizada. Por defecto seríaapi:3000, pero la cambias acrustaceo-taskspara identificarlo mejor.
Con esto, docker compose up levanta los dos servicios y Prometheus empieza a recolectar métricas inmediatamente.
Alertas básicas
Tener métricas está bien. Que te avisen cuando algo va mal está mejor. Prometheus tiene su propio sistema de alertas (Alertmanager), pero para empezar puedes configurar alertas simples en el archivo de reglas de Prometheus.
Crea alert-rules.yml:
groups:
- name: crustaceo-tasks
rules:
- alert: APIHealthCheckFailing
expr: |
count_over_time(
{job="crustaceo-tasks", __name__=~"http_errors_total", type="5xx"}
[5m]
) > 10
for: 1m
labels:
severity: critical
annotations:
summary: "La API crustaceo-tasks está fallando"
description: |
Se han detectado más de 10 errores 5xx en los últimos 5 minutos.
Revisa el estado en http://crustaceo-tasks:3000/health
- alert: APIHighLatency
expr: |
histogram_quantile(0.95,
sum(rate(http_request_duration_seconds_bucket[5m])) by (le)
) > 1.0
for: 2m
labels:
severity: warning
annotations:
summary: "Latencia alta en crustaceo-tasks"
description: |
El percentil 95 de latencia supera 1 segundo.
Valor actual: {{ $value }}s
- alert: APIDown
expr: |
absent(up{job="crustaceo-tasks"} == 1)
for: 1m
labels:
severity: critical
annotations:
summary: "crustaceo-tasks no responde"
description: |
Prometheus no puede recolectar métricas de crustaceo-tasks.
El servicio podría estar caído.
Las tres alertas cubren los escenarios más comunes:
- APIHealthCheckFailing: más de 10 errores 5xx en 5 minutos. Algo se ha roto.
- APIHighLatency: el percentil 95 de latencia supera 1 segundo. El servicio está degradado.
- APIDown: Prometheus no puede recolectar métricas. El servicio no responde.
Para que Prometheus cargue las reglas, añade esta línea al prometheus.yml:
rule_files:
- 'alert-rules.yml'
Y en el servicio de Prometheus del docker-compose, monta el archivo:
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./alert-rules.yml:/etc/prometheus/alert-rules.yml:ro
Notificaciones
Las alertas las evalúa Prometheus, pero necesita Alertmanager para notificarte (email, Telegram, Slack). Configurar Alertmanager se sale del alcance de este capítulo, pero el tutorial de Monitoreo en atareao.es cubre cómo integrarlo con Telegram.
De momento, las alertas quedan registradas en la interfaz de Prometheus (http://localhost:9090/alerts). Ya es un avance: puedes ver en qué estado están sin mirar logs.
Uptime tracker
El uptime se calcula con el Instant que guardas en AppState al arrancar. En el healthcheck devuelves los segundos transcurridos. En las métricas puedes exponerlo como gauge.
Si quieres actualizar el gauge periódicamente, puedes lanzar un hilo con tokio:
let uptime_state = state.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(5));
loop {
interval.tick().await;
let uptime = uptime_state.start_time.elapsed().as_secs_f64();
gauge!("uptime_seconds").set(uptime);
}
});
Este bucle se ejecuta en segundo plano y actualiza la métrica uptime_seconds cada 5 segundos. Así Prometheus siempre tiene el valor actual cuando scrapea.
Scrape config desde el host
Si prefieres que Prometheus no corra en Docker (o ya tienes una instancia de Prometheus en el host), configura el scrape apuntando a localhost:
scrape_configs:
- job_name: 'crustaceo-tasks'
static_configs:
- targets: ['localhost:3000']
metrics_path: /metrics
Esto asume que crustaceo-tasks está expuesto en el puerto 3000 del host. Si usas Docker con ports: 3000:3000, funciona igual.
Verificación
Todo este código no vale nada si no lo pruebas. Aquí tienes los pasos para verificar que todo funciona.
Compilar
cd ~/crustaceo-tasks
cargo build
La compilación debe completarse sin errores. Si tienes problemas con las versiones de las crates metrics o metrics-exporter-prometheus, ajusta las versiones en tu Cargo.toml a las últimas disponibles. Las que puse en el capítulo son las estables en el momento de escribir, pero el ecosistema Rust se mueve rápido.
Salida esperada (aproximada):
Compiling metrics v0.24.0
Compiling metrics-exporter-prometheus v0.16.0
Compiling crustaceo-tasks v0.2.0
Finished `dev` profile [unoptimized + debuginfo] target(s) in 12.34s
Ejecutar
cargo run
Verás el mensaje de bienvenida con las rutas disponibles:
🦀 crustaceo-tasks v0.2.0
📡 Escuchando en http://0.0.0.0:3000
❤️ /health — Health check completo
📊 /metrics — Métricas Prometheus
📋 /tasks — CRUD de tareas (requiere X-API-Key)
Deja el servidor corriendo y abre otra terminal para las pruebas.
Probar /health
curl -s http://localhost:3000/health | python3 -m json.tool
Respuesta esperada:
{
"checks": {
"database": "ok",
"memory": "ok"
},
"liveness": true,
"readiness": true,
"service": "crustaceo-tasks",
"startup": true,
"status": "ok",
"uptime_seconds": 5,
"version": "0.2.0"
}
Prueba también los endpoints separados:
curl -s http://localhost:3000/health/liveness
# {"probe":"liveness","status":"ok"}
curl -s http://localhost:3000/health/readiness
# {"probe":"readiness","status":"ok"}
Probar /metrics
curl -s http://localhost:3000/metrics
Respuesta esperada (truncada):
# HELP http_requests_total http_requests_total
# TYPE http_requests_total counter
http_requests_total{method="GET",path="/health"} 3
http_requests_total{method="GET",path="/metrics"} 1
# HELP http_request_duration_seconds http_request_duration_seconds
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",path="/health",le="0.005"} 2
http_request_duration_seconds_bucket{method="GET",path="/health",le="0.01"} 3
http_request_duration_seconds_bucket{method="GET",path="/health",le="0.025"} 3
http_request_duration_seconds_bucket{method="GET",path="/health",le="0.05"} 3
http_request_duration_seconds_bucket{method="GET",path="/health",le="0.1"} 3
http_request_duration_seconds_bucket{method="GET",path="/health",le="0.25"} 3
http_request_duration_seconds_bucket{method="GET",path="/health",le="0.5"} 3
http_request_duration_seconds_bucket{method="GET",path="/health",le="1.0"} 3
http_request_duration_seconds_bucket{method="GET",path="/health",le="2.5"} 3
http_request_duration_seconds_bucket{method="GET",path="/health",le="5.0"} 3
http_request_duration_seconds_bucket{method="GET",path="/health",le="10.0"} 3
http_request_duration_seconds_bucket{method="GET",path="/health",le="+Inf"} 3
http_request_duration_seconds_sum{method="GET",path="/health"} 0.002345
http_request_duration_seconds_count{method="GET",path="/health"} 3
# HELP uptime_seconds uptime_seconds
# TYPE uptime_seconds gauge
uptime_seconds 27
Si ves esto, enhorabuena: tu API ya habla Prometheus con fluidez.
Probar el CRUD con métricas
Haz algunas peticiones al CRUD y vuelve a consultar /metrics:
curl -s -X POST http://localhost:3000/tasks \
-H "X-API-Key: crustaceo-secreto-2026" \
-H "Content-Type: application/json" \
-d '{"title": "Probar métricas"}' > /dev/null
curl -s http://localhost:3000/metrics | grep "tasks"
Verás que tasks_created_total ha aumentado y tasks_stored (gauge) muestra 1.
Probar con docker-compose
Si quieres ver Prometheus en acción:
# Crea los archivos necesarios
cd ~/crustaceo-tasks
cat > prometheus.yml << 'EOF'
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'crustaceo-tasks'
static_configs:
- targets: ['api:3000']
metrics_path: /metrics
EOF
cat > docker-compose.yml << 'EOF'
services:
api:
build: .
container_name: crustaceo-tasks
ports:
- "3000:3000"
environment:
- API_KEY=crustaceo-secreto-2026
- RUST_LOG=crustaceo_tasks=info
prometheus:
image: prom/prometheus:latest
container_name: prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
EOF
# Construir y arrancar
docker compose up -d --build
Dale unos segundos a que arranquen los servicios, luego abre:
- API: http://localhost:3000/health
- Métricas: http://localhost:3000/metrics
- Prometheus: http://localhost:9090/targets — aquí ves si el scrape está funcionando
- Consulta en Prometheus: http://localhost:9090/graph — prueba
http_requests_totalen la pestaña Graph
Para parar todo:
docker compose down
Conclusión
Este capítulo cerramos el círculo,
- Healthchecks en tres niveles: liveness (¿vive?), readiness (¿responde?), startup (¿inicializado?). Cada uno con un propósito distinto para el orquestador.
- Métricas con la crate
metrics: contadores para eventos que solo aumentan, gauges para valores que fluctúan, histograms para distribuciones de tiempo. metrics-exporter-prometheus: convierte las métricas internas al formato de texto que Prometheus entiende, sin tener que formatear nada a mano.- Middleware de métricas para Axum: una capa que intercepta cada petición, registra método, ruta, duración y código de estado, y actualiza las métricas correspondientes.
- Prometheus scrape config: cómo configurar Prometheus para que recolecte las métricas de tu API cada N segundos.
- Alertas básicas: reglas que disparan notificaciones cuando el healthcheck falla, la latencia sube o el servicio deja de responder.
- Integración con Docker Compose: un solo comando levanta la API y Prometheus juntos, con las métricas fluyendo automáticamente.
Tu API ya no es una caja negra. Sabes cuántas peticiones recibe, cuánto tardan, cuántas fallan, y desde cuándo está funcionando. Y si algo va mal, te enteras antes de que te lo diga un usuario.
Más información,
- Crate metrics — documentación oficial de la crate de métricas para Rust
- Crate metrics-exporter-prometheus — exporter que convierte métricas al formato Prometheus
- Prometheus documentation — scrape config — referencia de la configuración de scrape
- Prometheus — alerting rules — cómo definir reglas de alerta
- Kubernetes — Liveness, Readiness and Startup Probes — la especificación oficial de los tres tipos de probe