Back to sh0
sh0

Monitoreo y alertas: Email, Slack, Discord, Telegram, Webhooks

Construyendo un sistema de monitoreo con recolección periódica de estadísticas Docker, evaluación de alertas basada en umbrales y despacho multi-canal a Email, Slack, Discord, Telegram y webhooks personalizados.

Thales & Claude | March 30, 2026 7 min sh0
EN/ FR/ ES
monitoringalertsmetricsslackdiscordtelegramdevopsrust

Una plataforma de despliegue sin monitoreo es una plataforma de despliegue donde los problemas los descubren los usuarios. Tus clientes notan la caída antes que tú. Tu canal de Slack se llena de quejas mientras tu panel muestra todo verde. Accedes por SSH, verificas las estadísticas de Docker y descubres que un contenedor ha sido matado por OOM durante las últimas tres horas.

Construimos el monitoreo y las alertas a lo largo de tres fases del desarrollo de sh0. La Fase 10 estableció el pipeline de recolección de métricas y el motor de evaluación de alertas. La Fase 17 añadió el despacho de alertas multi-canal -- email, Slack, Discord, Telegram y webhooks personalizados. Una sesión de refactorización posterior reconstruyó el panel de monitoreo con gráficos sparkline en tiempo real y desgloses de recursos por aplicación.

Esta es la historia completa: desde las estadísticas Docker hasta un mensaje de Telegram que te dice que tu aplicación está caída.

Fase 10: El pipeline de recolección de métricas

El sistema de monitoreo tenía tres tareas en segundo plano, cada una ejecutándose en su propio intervalo:

  • MetricCollector -- cada 10 segundos, recopila estadísticas de CPU, memoria y red de todos los contenedores gestionados
  • AlertEvaluator -- cada 30 segundos, compara las últimas métricas contra umbrales configurados
  • MetricPruner -- cada hora, elimina métricas más antiguas que la ventana de retención

Recolectando estadísticas de contenedor

El recolector listaba todos los contenedores Docker con la etiqueta sh0.managed=true, luego llamaba a la API de estadísticas de Docker para cada uno:

rustpub struct MetricCollector {
    db: Arc<DbPool>,
    docker: Arc<DockerClient>,
}

impl MetricCollector {
    pub async fn collect(&self) -> Result<()> {
        let containers = self.docker
            .list_containers_with_label("sh0.managed=true")
            .await?;

        let mut metrics = Vec::new();

        for container in &containers {
            let stats = self.docker.container_stats(&container.id).await?;

            metrics.push(Metric::new(&container.app_id, "cpu", stats.cpu_percent));
            metrics.push(Metric::new(&container.app_id, "memory", stats.memory_bytes as f64));
            metrics.push(Metric::new(&container.app_id, "network_rx", stats.network_rx as f64));
            metrics.push(Metric::new(&container.app_id, "network_tx", stats.network_tx as f64));
        }

        // Agregados a nivel de servidor
        let total_cpu: f64 = metrics.iter()
            .filter(|m| m.metric_type == "cpu")
            .map(|m| m.value)
            .sum();

        metrics.push(Metric::new("server", "server_cpu", total_cpu));
        metrics.push(Metric::new("server", "server_memory_percent", /* calculado */));
        metrics.push(Metric::new("server", "server_memory_limit", total_memory as f64));

        // Inserción por lotes en una sola transacción
        Metric::insert_batch(&self.db, &metrics).await?;

        Ok(())
    }
}

La inserción por lotes fue crítica para el rendimiento. Con 20 contenedores en ejecución, cada ciclo de recolección producía más de 80 métricas. Inserciones individuales crearían 80 transacciones de base de datos. Una inserción por lotes creaba una transacción, reduciendo la E/S de disco en dos órdenes de magnitud.

Evaluación de alertas

El AlertEvaluator cargaba todas las reglas de alerta habilitadas y las comparaba contra las últimas métricas:

rustpub struct AlertEvaluator {
    db: Arc<DbPool>,
    dispatcher: Option<Arc<AlertDispatcher>>,
}

impl AlertEvaluator {
    pub async fn evaluate(&self) -> Result<()> {
        let alerts = Alert::list_enabled(&self.db).await?;

        for alert in &alerts {
            let should_fire = match alert.alert_type.as_str() {
                "high_cpu" => {
                    let metric = Metric::latest(&self.db, &alert.app_id, "cpu").await?;
                    metric.map(|m| m.value > alert.threshold).unwrap_or(false)
                }
                "high_memory" => {
                    let metric = Metric::latest(&self.db, &alert.app_id, "memory").await?;
                    metric.map(|m| m.value > alert.threshold).unwrap_or(false)
                }
                "app_down" => {
                    let containers = self.docker
                        .list_containers_for_app(&alert.app_id).await?;
                    containers.is_empty() || containers.iter().all(|c| c.state != "running")
                }
                _ => false,
            };

            if should_fire && !self.in_cooldown(alert) {
                self.fire_alert(alert).await?;
            }
        }

        Ok(())
    }
}

Se soportaban tres tipos de alerta:

  • high_cpu -- se dispara cuando el uso de CPU de la aplicación excede el umbral configurado (ej. 80%)
  • high_memory -- se dispara cuando el uso de memoria excede el umbral
  • app_down -- se dispara cuando no existen contenedores en ejecución para la aplicación

El enfriamiento de 5 minutos prevenía tormentas de alertas. Sin él, un pico sostenido de CPU generaría una alerta cada 30 segundos -- inundando la bandeja de entrada y el canal de Slack del usuario y entrenándolos a ignorar alertas.

Fase 17: Despacho de alertas multi-canal

El evaluador de alertas podía detectar problemas. No podía decírselo a nadie. La Fase 17 añadió el AlertDispatcher -- una capa de enrutamiento que entregaba mensajes de alerta a cinco canales.

La arquitectura del dispatcher

rustpub struct AlertDispatcher {
    http_client: reqwest::Client,
    smtp_config: Option<SmtpConfig>,
}

impl AlertDispatcher {
    pub async fn dispatch(
        &self,
        alert: &Alert,
        message: &AlertMessage,
    ) -> Result<()> {
        match alert.channel.as_str() {
            "email"    => self.send_email(alert, message).await,
            "slack"    => self.send_slack(alert, message).await,
            "discord"  => self.send_discord(alert, message).await,
            "telegram" => self.send_telegram(alert, message).await,
            "webhook"  => self.send_webhook(alert, message).await,
            _ => Err(anyhow!("Unknown channel: {}", alert.channel)),
        }
    }
}

Cada canal tenía su propio módulo de entrega, adaptado a la API y requisitos de formato de la plataforma.

Email: SMTP con HTML

Las alertas por email usaban lettre con STARTTLS, enviando mensajes multipart tanto en HTML como texto plano. Se soportaban múltiples destinatarios para notificaciones de equipo.

Slack: Block Kit

Las notificaciones de Slack usaban webhooks entrantes con formato Block Kit para alertas ricas y estructuradas.

Discord: Embeds

Los webhooks de Discord usaban objetos embed con severidad codificada por color, diseños basados en campos y marcas de tiempo ISO 8601.

Telegram: MarkdownV2

La API Bot de Telegram requería formato MarkdownV2 con escape agresivo de caracteres. Los caracteres especiales (_, *, [, ], (, ), ~, ` `, >, #, +, -, =, |, {, }, ., !`) todos necesitaban escape con barra invertida.

Webhooks personalizados: HMAC-SHA256

Para usuarios que querían integrar con sus propios sistemas, el canal de webhook genérico enviaba una solicitud POST con un payload JSON y verificación de firma HMAC-SHA256 opcional. La cabecera X-sh0-Signature permitía a los receptores de webhook verificar que la solicitud realmente venía de sh0 y no fue falsificada.

Alertas de fallo de despliegue

El despacho de alertas no se limitaba a umbrales de métricas. El pipeline de despliegue estaba conectado para despachar alertas cuando un despliegue fallaba. Esto significaba que un despliegue fallido podía simultáneamente disparar un mensaje de Slack al canal del equipo, un mensaje de Telegram al desarrollador de guardia y un webhook al sistema de gestión de incidentes.

Alertas de prueba

El panel añadió un botón "Probar" en cada tarjeta de alerta. Al hacer clic enviaba un AlertMessage de muestra a través del canal configurado sin esperar una violación real de umbral. Esto permitía a los usuarios verificar sus URLs de webhook, configuraciones de canal de Slack y tokens de bot de Telegram antes de un incidente real.

El panel de monitoreo

La página de monitoreo fue reescrita de una simple visualización de métricas a un panel de cuatro pestañas:

Resumen -- tres tarjetas de métricas grandes para CPU, Memoria y Disco, cada una con una visualización de porcentaje, una barra de progreso codificada por color (verde debajo de 60%, amarillo entre 60-80%, rojo encima de 80%) y un gráfico sparkline de 60 puntos con auto-actualización de 5 segundos. La memoria mostraba un subtítulo "usado / límite" en bytes legibles.

Aplicaciones -- desglose de recursos por aplicación con diseño basado en tarjetas mostrando % CPU, % Memoria y bytes, Red RX/TX. Ordenable por CPU o Memoria. Auto-actualización de 10 segundos. Esta era la vista que respondía "¿qué aplicación está consumiendo todos los recursos?"

Uptime -- lista global de verificación de disponibilidad con indicadores de estado (activo/caído/desconocido), URL, insignia de método HTTP, intervalo de verificación y porcentaje de uptime. Historial de incidentes expandible en línea. Modales de crear/eliminar para gestionar verificaciones.

Alertas -- la interfaz CRUD de alertas existente con el botón de prueba, desplegable de canal (ahora incluyendo Discord y Telegram) y configuración de umbrales.

Los números

El sistema de monitoreo abarcó tres crates Rust (sh0-monitor, sh0-api, sh0-db), cinco módulos de despacho, dos modelos de base de datos (Metric, Alert) y una página completa de panel con cuatro pestañas. La recolección de métricas se ejecutaba cada 10 segundos, la evaluación de alertas cada 30 segundos, la poda de métricas cada hora. Cinco canales de notificación cubrían las plataformas donde los desarrolladores realmente reciben alertas.

Para un PaaS, esta era la compensación correcta. Los usuarios que necesitaban observabilidad a nivel de Grafana podían desplegar Grafana como plantilla de un clic (Artículo 19). Para todos los demás, el monitoreo integrado respondía las dos preguntas que más importan: "¿Mi aplicación está funcionando?" y "¿Se está quedando sin recursos?"


Esto concluye la inmersión profunda en monitoreo y alertas de la serie "Cómo construimos sh0.dev". La serie completa cubre el viaje de 14 días desde un workspace Cargo vacío hasta un PaaS de producción -- construido por un CEO en Abiyán y un CTO de IA, con cero ingenieros humanos.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles