Alerthub es un servicio escrito en Go que recibe los webhooks de alerta generados por Grafana Alerting, los normaliza y los almacena en una base de datos PostgreSQL.
Su objetivo es convertirse en el hub central de alertas para tu equipo, permitiéndoles consultar, analizar y reportar el estado de las alertas desde:
- un archivo Excel para reportería,
- o dashboards interactivos en Grafana.
Cada alerta se procesa bajo un modelo unificado, se le asigna un fingerprint consistente, y se registra junto con su estado (abierta o cerrada), permitiendo trazabilidad completa del ciclo de vida de la alerta.
Alerthub se ubica entre Grafana Alerting y PostgreSQL para convertir eventos de alerta en un historial consultable. Grafana envía eventos (firing / resolved) mediante un contact point (tipo Alertmanager) hacia Alerthub; el servicio recibe el payload, lo normaliza, genera un fingerprint y persiste el ciclo de vida en PostgreSQL. Luego esa información se consume desde dashboards de Grafana o exportaciones para reportería.
Una pequeña interfaz para visualizar las alertas recibidas directamente desde el navegador.
Exportá los datos desde la base de datos para generar reportería o consolidar métricas en Excel.
Un dashboard en Grafana que muestra todo el historial de alertas almacenado en la base de datos de Alerthub, sin aplicar ningún tipo de filtro.
Ideal para:
- Revisar el estado completo de todas las alertas históricas.
- Auditar eventos sin importar el namespace, etiquetas o fuente.
- Construir una vista centralizada de incidentes.
- Características principales
- Estructura del proyecto
- Requisitos
- Ejecución local
- Pruebas
- Despliegue con Helm
- Ejemplo de funcionamiento
- Referencia de labels y filtros
- Ejemplos de consultas SQL
- Panel de administración
- Ingesta compatible con Alertmanager: expone
/api/v1/alertsy/api/v2/alerts, aceptando tanto arreglos planos como la envoltura estándar (externalURL,alerts). - Autenticación opcional: admite Basic Auth y tokens Bearer configurables por variables de entorno.
- Normalización y enriquecimiento: genera un fingerprint determinístico, completa etiquetas faltantes (por ejemplo
clusterodeployment) y clasifica alertas de "no data" para facilitar los filtros. - Persistencia en PostgreSQL: almacena cada evento en la tabla
alert_eventsy mantiene el estado actual y los episodios enalert_stateyalert_episodes. - Healthchecks simples: incluye
/con un estado HTML básico y/healthzpara comprobaciones automatizadas.
cmd/alerthub: punto de entrada del binario, configura el servidor HTTP.internal/config: carga de variables de entorno.internal/httpapi: manejo de rutas y procesamiento del payload de alertas.internal/ingest: lógica de normalización y generación de fingerprints.internal/store: acceso a la base de datos y operaciones de estado.pkg/models: estructuras compartidas entre los paquetes.migrations/: scripts SQL para crear las tablas necesarias.helm/chart-alerthub/: chart de Helm oficial que empaqueta la aplicación, la base de datos Postgres y los recursos auxiliares.
- Go 1.18 o superior.
- PostgreSQL 13+ con la extensión
pgcryptohabilitada (usada paragen_random_uuid()). - Variables de entorno definidas (ver sección siguiente).
- Exporta las variables de entorno necesarias, en particular
DATABASE_URLapuntando a tu instancia de PostgreSQL. | Variable | Descripción | Valor por defecto | | --- | --- | --- | |PORT| Puerto HTTP expuesto por el servicio. |8080| |DATABASE_URL| Cadena de conexión a PostgreSQL (obligatoria). | sin valor | |SOURCE| Nombre de la fuente usado para etiquetar los eventos. |alertmanager| |AUTH_BEARER| Token Bearer aceptado para ingesta (opcional). |""| |AUTH_BASIC_USER| Usuario para autenticación Basic (opcional). |""| |AUTH_BASIC_PASS| Contraseña asociada al usuario Basic. |""| |CLUSTER_NAME| Valor por defecto para la etiquetaclustercuando no llega en el payload. |""|
Si se definen simultáneamente autenticación Basic y Bearer, la solicitud se acepta cuando cumple con cualquiera de los dos mecanism
- Aplica las migraciones descritas arriba o reutiliza los scripts de
deploy/local/db-initsi deseas recrear el esquema completo. Los scripts demigrations/crean las tablasalert_events,alert_stateyalert_episodesjunto con los índices necesarios. Para una instalación local rápida puedes ejecutar:
createdb alerthub
psql "$DATABASE_URL" -f migrations/001_init.sql
psql "$DATABASE_URL" -f migrations/020_state.sql
psql "$DATABASE_URL" -f migrations/030_nodepool.sql-
Ejecuta el servidor:
go run ./cmd/alerthub
El servicio quedará escuchando en http://localhost:8080 (o el puerto configurado) y aceptará peticiones en los endpoints /api/v1/alerts y /api/v2/alerts. No es necesario agregar esos endopint solo la URL base ya que Grafana agrega esos path automaticamente.
Los tests unitarios cubren la normalización de alertas y la persistencia de categorías. Para ejecutarlos:
go test ./...En raras ocasiones Grafana no logra notificar el evento Resolved lo que genera un inconveniente en Alerthub ya que seguirá mostrando la alerta como "firing" aunque en realidad ya haya finalizado. Para estos casos existe el endpoint /admin, que muestra todas las alertas activas y permite marcarlas como resueltas manualmente. Ademas al marcarla como resuelta se agrega a la alerta en el annotation "manual_resolution": "eliminacion manual" para poder tener un seguimiento de cuales alertas fueron resueltas manualmente
Para cerrar una alerta :
- Abrí
http://<host>:<port>/adminy localizá el fingerprint de la alerta que quedó abierta. - Presioná Resolve para que Alerthub genere el evento de cierre,agregue el annotation y actualice el estado en la base.
Este flujo desbloquea los tableros cuando el hook de Resolved no llega (un comportamiento común en algunos canales de Grafana) y evita que las métricas sigan reportando un incidente activo.
El método soportado para orquestar Alerthub en Kubernetes es el chart helm/chart-alerthub/. Necesitas definir, al menos:
global.image.repositoryyglobal.image.tagcon la imagen de contenedor que quieres desplegar.- Un secreto referenciado en
deployment.envFromque expongaDATABASE_URLy las credenciales necesarias. Alternativamente, activacredentials.autoGenerate.enabledpara que el chart cree y propague automáticamente las credenciales de PostgreSQL y el servicio HTTP básico. - (Opcional)
postgres.secretNamecuando utilices la base de datos embebida gestionada por el chart.
Puedes tomar helm/example/values.yaml como base y adaptarlo a tu entorno. Una vez que tengas los valores listos, instala el chart con:
helm install alerthub ./helm/chart-alerthub \
--namespace alerthub-demo \
--create-namespace \
-f my-values.yamlSustituye my-values.yaml por tu archivo de configuración (puedes comenzar copiando helm/example/values.yaml) y ajusta el namespace según corresponda. Si necesitas ejecutar migraciones durante el despliegue, puedes apoyarte en initSql (habilitado por defecto) o aplicar manualmente los scripts de migrations/ antes de iniciar el servicio.
El ConfigMap pg-init-sql incluido en el chart monta los archivos 000_extensions.sql, 010_events.sql y 020_events.sql en /docker-entrypoint-initdb.d, y un Job auxiliar los ejecuta automáticamente contra la base declarada (ya sea la desplegada por el propio chart o una instancia externa como RDS). Esta lógica está activa por defecto (initSql.enabled: true), por lo que basta con desplegar el chart para que la base quede inicializada.
Si en tu organización gestionas las migraciones por fuera del chart (por ejemplo con una herramienta dedicada), puedes desactivar el ConfigMap definiendo initSql.enabled: false. Cuando necesites añadir scripts adicionales, sobreescribe el mapa initSql.files en tus valores, asegurándote de listar las claves correspondientes en initSql.items cuando agregues nuevos archivos. Si ya cuentas con un Secret que expone DATABASE_URL, referencia su nombre mediante initSql.databaseUrlSecret para que el Job reutilice esas credenciales al momento de aplicar los scripts.
Para integrar Grafana con Alerthub necesitas preparar tanto el canal de salida de alertas como la fuente de datos para los dashboards:
-
Punto de contacto (Contact point) de tipo Alertmanager.
-
Crea un contact point de tipo Alertmanager y apunta la URL al
Ingressque expone Alerthub. El README menciona los endpoints/api/v1/alertsy/api/v2/alertsporque son los paths que el servicio realmente expone; al configurar el contact point basta con indicar la URL base (por ejemplohttps://alerthub.mi-dominio.com) ya que Grafana agrega automáticamente la ruta de ingesta (/api/v1/alertspor defecto) al enviar el webhook. -
Si utilizas las credenciales generadas por el chart de Helm, recupéralas desde el secreto
<release>-generated-credentials(valor por defecto) con:kubectl get secret <nombre-del-secret> -n <namespace> \ -o jsonpath='{.data.AUTH_BASIC_USER}' | base64 -d kubectl get secret <nombre-del-secret> -n <namespace> \ -o jsonpath='{.data.AUTH_BASIC_PASS}' | base64 -d
-
Configura el contact point en Grafana usando esos valores como usuario/contraseña de la autenticación Basic.
-
-
Datasource PostgreSQL para consultas y dashboards.
- Añade una nueva fuente de datos de tipo PostgreSQL apuntando al
Serviceque expone la base desplegada por el chart (ClusterIPoLoadBalancer). - Las credenciales y la base por defecto también se encuentran en el mismo secreto (
POSTGRES_USER,POSTGRES_PASSWORD,POSTGRES_DB). - Establece la opción de SSL según la configuración de tu clúster (el despliegue de ejemplo deshabilita TLS porque la comunicación ocurre dentro del clúster).
- Añade una nueva fuente de datos de tipo PostgreSQL apuntando al
Con estos pasos Grafana podrá enviar alertas a Alerthub y consultar el histórico almacenado para alimentar los tableros incluidos en el repositorio.
-
Payload recibido en
/api/v1/alerts[ { "status": "", "labels": { "alertname": "HighErrorRate", "namespace": "payments", "pod": "payments-api-7c9d7f4c8-abc12", "severity": "critical", "__alert_rule_uid__": "grafana-rule-42" }, "annotations": { "summary": "Error rate above threshold", "description": "5xx ratio exceeded" }, "startsAt": "2024-04-20T12:34:56Z", "endsAt": "0001-01-01T00:00:00Z", "generatorURL": "https://grafana.example.com/alerting/grafana/grafana-rule-42/view" } ] -
Normalización en
internal/httpapi/routes.go: se calcula unstatusa partir destartsAt/endsAt, se robustecenrule_uid(leyendo__alert_rule_uid__,uido la URL del panel) yrule_name, y se completa la etiquetaclustercuando llega vacía usando la cabeceraCLUSTER_NAMEo el labelnode. 【F:internal/httpapi/routes.go†L85-L147】 -
Enriquecimiento en
internal/ingest/normalizer.go: el normalizador aplica reglas adicionales, como derivardeploymentdesde el nombre del pod (payments-api-7c9d7f4c8-abc12→payments-api), calcular el fingerprint y decidir lacategory. 【F:internal/ingest/normalizer.go†L21-L53】 -
Persistencia con
internal/store/events.go: la estructura normalizada se almacena enalert_events, guardando todo el mapa de labels y anotaciones como JSONB. 【F:internal/store/events.go†L11-L35】 -
Consultas posteriores: las columnas generadas definidas en
deploy/local/db-init/010_events.sqlexponencluster,namespace,pod,nodepool,deployment,queue,vhostyseveritycomo campos directos para filtros frecuentes. El resto de etiquetas siguen disponibles enalert_events.labels, consultables conlabels->>'clave'o filtros GIN (labels @> '{"mi_label":"valor"}'). 【F:deploy/local/db-init/010_events.sql†L1-L58】
Esta guía resume las columnas disponibles en alert_events tras el proceso de normalización de Alerthub.
| Campo / filtro | Descripción | Fuente |
|---|---|---|
cluster |
Columna generada que proyecta labels->>'cluster'; se rellena automáticamente con valores de cabeceras HTTP si la alerta no lo envía. Permite segmentar eventos por clúster. |
JSON de labels + normalizador |
namespace |
Exposición directa de labels->>'namespace' para filtrar alertas por espacio de nombres. |
JSON de labels |
pod |
Copia directa del labels->>'pod', útil para diagnósticos puntuales. |
JSON de labels |
nodepool |
Columna derivada de labels->>'nodepool' con índice dedicado para dashboards de infraestructura. |
JSON de labels |
deployment |
Proyecta labels->>'deployment'; cuando falta, se infiere a partir del nombre del pod (Deployment/StatefulSet). |
JSON de labels + normalizador |
queue |
Expone labels->>'queue' para agrupar incidentes en colas (RabbitMQ, Sidekiq, etc.). |
JSON de labels |
vhost |
Copia labels->>'vhost', complementando las consultas sobre colas. |
JSON de labels |
severity |
Mapea labels->>'severity' para clasificar alertas por severidad declarada. |
JSON de labels |
status |
Estado normalizado del evento (firing o resolved) controlado por un CHECK. |
Columna nativa |
category |
Clasificación automática (queue, nodepool, pod, other) o nodata si se detecta un patrón de “sin datos”. |
Normalizador / generador de fingerprint |
labels (JSONB) |
Contiene el resto de pares clave/valor. Se accede mediante operadores JSON (labels->>'mi_label', labels @> ...). |
Payload crudo de Alertmanager |
⚠️ Advertencia sobre etiquetas opcionalesAlerthub solamente persiste las etiquetas (
labels) exactamente como llegan en el webhook de Grafana o Alertmanager. No genera automáticamente campos comopod,namespace,clusteru otros si la alerta no los envía, con la única excepción decluster, que puede rellenarse usando la variable de entornoCLUSTER_NAMEo reutilizando el valor denodesi está presente en el payload. Asimismo, durante la normalización solo se derivadeploymenta partir delpod; el resto de etiquetas se respetan tal cual fueron recibidas. Si una alerta llega sin ciertos labels, Alerthub no podrá exponerlos en sus consultas porque nunca fueron provistos por el origen.
Alerthub solo puede indexar y exponer en dashboards los labels que existen en la alerta de Grafana (con la única excepción de cluster, que puede completarse vía CLUSTER_NAME si no llega). Por eso, al definir una alerta, se debe incluir explícitamente en labels los campos que después querés usar para filtrar, agrupar y auditar.
Este ejemplo incluye los labels típicos de operación en Kubernetes para que luego puedas filtrar por cluster, namespace, pod y severity desde Grafana/SQL:
apiVersion: 1
groups:
- name: k8s-workloads
rules:
- uid: cpu_pod_prod
title: "CPU alta en pod"
condition: A
labels:
cluster: prod
namespace: billing
pod: billing-api-7f6d9c8bf7-xmzqh
severity: critical
annotations:
summary: "Uso de CPU sobre el umbral"Resultado: el evento queda almacenado con esas claves y Alerthub puede proyectarlas como columnas y variables de dashboard.
Si omitís labels importantes, Grafana no los enviará en el webhook y Alerthub no podrá materializarlos ni usarlos para filtros posteriores:
apiVersion: 1
groups:
- name: k8s-workloads
rules:
- uid: cpu_pod_prod
title: "CPU alta en pod"
condition: A
labels:
severity: critical
annotations:
summary: "Uso de CPU sobre el umbral"El segundo ejemplo seguirá ingresando a Alerthub, pero al faltar cluster, namespace o pod no será posible filtrarlo por esos campos en los dashboard. Verifica tus reglas de Grafana para garantizar que cada alerta envíe las etiquetas necesarias.
Alerthub almacena todos los labels en alert_events.labels (JSONB). Por defecto, la recomendación es consultar el JSONB directamente y evitar crear nuevas columnas, ya que agregar columnas implica cambios de esquema/migraciones y mayor costo operativo (especialmente en entornos productivos o bases gestionadas como RDS).
Cuando aparece un label nuevo que querés usar en dashboards o consultas, tenés dos opciones:
Usá expresiones como:
labels->>'mi_label'labels ? 'mi_label'(verificar existencia)labels @> '{"mi_label":"valor"}'::jsonb(match exacto)
Ventajas:
- No requiere modificar la base (sin migraciones).
- Es ideal para labels experimentales, poco frecuentes o específicos de algunos equipos/servicios.
- Permite iterar rápido sobre dashboards sin tocar el schema.
Consideraciones:
- Puede ser menos eficiente para filtros muy usados, porque la consulta opera sobre JSONB.
- Podés mitigar esto con un índice GIN sobre
labels(si aplica a tu caso) para acelerar búsquedas@>.
-
Segmentar por etiquetas no materializadas y exigir su presencia
SELECT rule_name, labels->>'environment' AS environment, labels->>'team' AS owning_team, count(*) AS total_eventos FROM alert_events WHERE status = 'firing' AND labels->>'environment' IN ('prod', 'staging') AND labels ? 'team' GROUP BY rule_name, environment, owning_team ORDER BY total_eventos DESC;
Devuelve un ranking de reglas activas, mostrando el entorno (
environment) y el equipo (team) obtenidos directamente del JSON. La cláusulalabels ? 'team'garantiza que solo se incluyan filas con esa etiqueta. -
Buscar coincidencias exactas dentro del JSON
SELECT event_id, rule_name, starts_at, labels->>'service' AS service, labels FROM alert_events WHERE labels @> '{"service":"payments","tier":"critical"}'::jsonb ORDER BY starts_at DESC LIMIT 20;
Obtiene los 20 eventos más recientes cuya carga de etiquetas contiene exactamente
service=paymentsytier=critical, devolviendo tanto columnas derivadas como el JSON completo para análisis rápido.
-
Estado actual por categoría
nodepoolSELECT nodepool, count(*) AS firing_events, min(starts_at) AS oldest_open_event FROM alert_events WHERE status = 'firing' AND category = 'nodepool' GROUP BY nodepool ORDER BY firing_events DESC;
Resume cuántas alertas de categoría
nodepoolestán abiertas por pool y desde cuándo existe la más antigua. -
Detectar episodios marcados como
nodataSELECT event_id, rule_name, starts_at, ends_at, labels->>'datasource' AS datasource_hint FROM alert_events WHERE category = 'nodata' ORDER BY starts_at DESC;
Lista eventos reclasificados como
nodata, junto con una pista deldatasourceextraída del JSON de labels.
Materializar un label como columna (por ejemplo team, owner, service) puede ser útil, pero se recomienda hacerlo solo cuando el beneficio operativo o de performance lo justifique, ya que implica cambios de esquema y mantenimiento adicional.
- El label se usa en dashboards críticos y con alto tráfico (muchas consultas / refresh frecuente).
- Necesitás índices B-Tree o índices compuestos (por ejemplo
cluster + namespace + owner) para acelerar filtros comunes. - Querés simplificar queries repetitivas y evitar depender de expresiones JSONB en cada panel.
- Requiere modificar el esquema (migración).
- Debe mantenerse alineado entre entornos (local / Helm / producción) para evitar drift.
- Aumenta la superficie de mantenimiento (índices, compatibilidad, upgrades, troubleshooting).
Recomendación: antes de crear columnas, evaluar si un índice por expresión sobre
labels->>'mi_label'resuelve el problema sin tocar el modelo.
-
Edita
deploy/local/db-init/010_events.sql:- Dentro del bloque
CREATE TABLE ... alert_events, agrega la columna siguiendo este patrón:nombre_label text GENERATED ALWAYS AS ((labels->>'nombre_label')) STORED,
- En el bloque
-- Asegurar columnas GENERADAS..., agrega también:ALTER TABLE alert_events ADD COLUMN IF NOT EXISTS nombre_label text GENERATED ALWAYS AS ((labels->>'nombre_label')) STORED;Esto garantiza compatibilidad cuando la tabla ya existe.
- Dentro del bloque
-
Replica exactamente las mismas líneas en:
helm/chart-alerthub/templates/init-sql-configmap.yamlDentro de la sección010_events.sql, respetando la indentación YAML.
-
Si el nuevo label será filtrado con frecuencia, agrega un índice dedicado en ambos scripts:
deploy/local/db-init/010_events.sqlhelm/chart-alerthub/templates/init-sql-configmap.yaml
Nota sobre índices dedicados: cuando se espera filtrar habitualmente por el nuevo label, agrega:
CREATE INDEX IF NOT EXISTS ix_events_<label> ON alert_events (<label>);Reemplaza
<label>por el nombre real de la columna. Esto evita escaneos completos al construir dashboards.
Sustituye owner y 'plataforma' por el nombre del label y el valor real que quieras documentar.
-- deploy/local/db-init/010_events.sql (bloque CREATE TABLE)
owner text GENERATED ALWAYS AS ((labels->>'owner')) STORED,
-- deploy/local/db-init/010_events.sql (bloque ALTER TABLE)
ALTER TABLE alert_events
ADD COLUMN IF NOT EXISTS owner text GENERATED ALWAYS AS ((labels->>'owner')) STORED;
-- Índice opcional si el filtro es frecuente (decláralo también en helm/chart-alerthub/templates/init-sql-configmap.yaml)
CREATE INDEX IF NOT EXISTS ix_events_owner ON alert_events (owner);
-- Consulta ejemplo
SELECT
received_at,
rule_name,
severity
FROM alert_events
WHERE owner = 'plataforma';Estos fragmentos ilustran cómo combinar los campos derivados y el JSON original de etiquetas. Cada bloque mantiene el formato SQL listo para copiar y ejecutar.
-
Alertas abiertas en las últimas 24 horas por namespace y severidad
SELECT namespace, severity, count(*) AS open_alerts FROM alert_events WHERE status = 'firing' AND cluster = 'prod' AND starts_at >= now() - interval '24 hours' GROUP BY namespace, severity ORDER BY open_alerts DESC;
Devuelve un ranking de alertas activas agrupadas por
namespaceyseveritypara el clústerproddurante la última ventana de 24 horas. -
Incidencias de colas
paymentsen producciónSELECT fingerprint, rule_name, category, queue, vhost, labels->>'environment' AS environment FROM alert_events WHERE status = 'firing' AND category = 'queue' AND queue = 'payments' AND labels->>'environment' = 'prod';
Lista cada evento activo relacionado con la cola
payments, mostrando la regla que disparó, su categoría y el entorno almacenado en el JSON de labels.




