Observabilidad — Guía técnica
Zentto utiliza Grafana Loki como plataforma centralizada de observabilidad: recolecta logs de todas las aplicaciones (API, workers, containers) y los pone a disposición en dashboards Grafana. El stack es ligero (~1.2 GB RAM) y está optimizado para correr en el mismo servidor que el ERP sin competir por recursos.
Arquitectura general
Componentes y puertos
| Servicio | Imagen | Puerto (host) | Puerto (interno) | URL pública / Uso | Memoria |
|---|---|---|---|---|---|
| Loki | grafana/loki:3.1.1 | 3101 (Docker gw) | 3100 | API para ingest + queries (via Promtail / apps) | 768 MB |
| Promtail | grafana/promtail:3.1.1 | 9080 | 9080 | Service discovery Docker, recolecta logs | 192 MB |
| Grafana | grafana/grafana:11.2.0 | 3303 | 3000 | https://logs.zentto.net (UI pública) | 256 MB |
Total: ~1.2 GB RAM. Sin restricción de memoria sería ~2-3 GB. Cada contenedor tiene mem_limit para coexistir en el servidor con el ERP.
Flujo de logs
Vía Promtail (stdout/stderr — automático)
- Cualquier aplicación en Docker escribe a stdout (console.log() en Node.js).
- Docker captura el log en su daemon JSON driver.
- Promtail (con acceso a /var/run/docker.sock) descubre containers automáticamente y tailing los logs.
- Promtail añade labels (container name, stream, compose project) y envía vía HTTP batch push a Loki.
- Loki almacena en filesystem con retención 7 días.
- Grafana queryea Loki vía LogQL para visualizar.
Ventaja: Sin cambios en el código de la app. Solo ejecuta console.log() como siempre; Promtail se encarga de recolectar.
Vía @zentto/obs SDK (push directo a Loki)
- Aplicación importa @zentto/obs y configura OBS_TRANSPORT=loki.
- SDK recibe llamadas como obs.log(), obs.error(), obs.audit(), obs.perf(), obs.event().
- SDK acumula eventos en lotes y hace push HTTP directo a Loki en http://172.18.0.1:3101/loki/api/v1/push.
- Los eventos llegan estructurados como JSON (no solo texto): se pueden queryear by campo.
Ventaja: Estructuración. Un evento obs.event('invoice.created', {...}) llega a Loki con campos indexados (no blob de texto).
Trampa crítica: LOKI_URL
El SDK debe usar LOKI_URL=http://172.18.0.1:3101, no http://127.0.0.1:3101 ni http://loki:3100.
Por qué: Loki corre en la red zentto-logs; la API en zentto-net. El gateway Docker 172.18.0.1 es el único punto de enrutamiento que funciona desde dentro de un contenedor hacia un puerto expuesto en el host. Si usas localhost, resuelve dentro del contenedor de la API (no a Loki). Si usas el hostname loki, no resuelve porque no están en la misma red.
Si LOKI_URL es incorrecto, el SDK no crashea: fallback a console.log silenciosamente. Los dashboards quedarán vacíos sin error alguno.
SDK de observabilidad @zentto/obs v2
El SDK unificado de observabilidad exporta las funciones obs.* y el middleware httpMiddleware(obs). Está en npm privado @zentto/obs.
Configuración requerida
# En .env de la API:
OBS_TRANSPORT=loki # transporte: loki (default) o stdout
LOKI_URL=http://172.18.0.1:3101 # OBLIGATORIO si OBS_TRANSPORT=loki
OBS_BATCH_SIZE=100 # tamaño de lotes antes de push
OBS_BATCH_INTERVAL_MS=5000 # intervalo máximo entre pushes
OBS_TIMEOUT_MS=3000 # timeout de request HTTP a Loki obs.log(level, message, context)
import { obs } from '@zentto/obs';
obs.log('info', 'Factura creada', {
facturaId: 123,
companyId: 1,
total: 1500.00
});
obs.log('warn', 'Stock bajo', {
articuloId: 456,
stock: 2
}); obs.error(error, context)
try {
await someOperation();
} catch (err) {
obs.error(err, {
module: 'ventas',
operation: 'createInvoice',
userId: req.user.sub
});
throw err;
} obs.audit(action, context)
obs.audit('UPDATE_PRICE', {
userId: req.user.sub, // string (sub del JWT)
userName: req.user.userName,
companyId: req.user.companyId,
module: 'inventario',
entity: 'Articulo',
entityId: articuloId,
before: { precio: 10.00 },
after: { precio: 15.00 },
ip: req.ip,
}); obs.perf(operation, durationMs, context)
const start = Date.now();
const result = await callSp('usp_doc_Factura_List', params);
obs.perf('sp.usp_doc_Factura_List', Date.now() - start, {
companyId,
rowCount: result.recordset.length,
}); obs.event(eventName, context)
obs.event('invoice.created', {
companyId,
invoiceId,
total: 1500.00,
currency: 'USD'
});
obs.event('lead.won', {
companyId,
leadId,
value: 25000,
pipeline: 'CORP'
}); Middleware automático (HTTP observability)
El middleware en middleware/observability.ts usa httpMiddleware(obs) de @zentto/obs y cubre automáticamente todos los endpoints HTTP sin intervención manual:
- Cada HTTP request: log estructurado con método, ruta, status, duración, userId, companyId, IP.
- Requests lentos (mayor a 1s): etiqueta especial perf=true.
- Errores 5xx: log de error con stack trace.
- POSTs exitosos en rutas conocidas: evento de negocio (ej. invoice.created en POST /v1/facturas).
Eventos de negocio detectados automáticamente
| Ruta (POST) | Evento generado |
|---|---|
/v1/auth/login | user.login |
/v1/facturas | invoice.created |
/v1/compras | purchase.created |
/v1/clientes | customer.created |
/v1/pos | pos.sale |
/v1/crm/leads | crm.lead.created |
Nota sobre userId: El middleware extrae el userId del campo sub del JWT (no de userId). Se almacena como string en Loki para búsquedas exactas.
Loki — Almacenamiento y retención
| Parámetro | Valor | Notas |
|---|---|---|
| Storage | filesystem | Sin object store externo (S3, etc.). Chunks en /loki/chunks. |
| Retención | 7 días (168 horas) | Compactor automático borra chunks después de 7 días. |
| Ingestion rate limit | 16 MB/s | Por tenant. Si se excede, rechaza samples. |
| Max streams per user | 10,000 | Evita cardinalidad explosiva por labels únicos. |
| Schema | v13 TSDB | Indices period: 24 horas. |
Promtail — Recolección de Docker
Promtail se conecta a Docker vía socket (/var/run/docker.sock) y descubre containers automáticamente.
Labels automáticos que añade
container: nombre del container (ej. zentto-api, zentto-notify).stream: stdout o stderr.project: nombre del proyecto Docker Compose (ej. zentto-observability), si existe.
En LogQL, filtrar por labels
# Todos los logs de zentto-api
{container="zentto-api"}
# Logs de API que son errores (nivel ERROR o error en el texto)
{container="zentto-api"} | regexp "ERROR|error"
# Logs de stderr (líneas de error)
{container="zentto-api", stream="stderr"} Grafana — Dashboards y queries
URL pública: https://logs.zentto.net
Grafana se conecta a Loki como datasource. Los dashboards usan LogQL (lenguaje de query de Loki, similar a PromQL).
Ejemplos de queries LogQL
Conteo de logs por ruta en las últimas 24 horas
sum by (path) (
count_over_time({container="zentto-api"} | json [24h])
) Endpoints más lentos (trampa documentada)
# INCORRECTO — devuelve UNA sola serie con path vacío
topk(10, avg by (path) (
avg_over_time({container="zentto-api"} | json dur=`durationMs` | unwrap dur [1h])
))
# CORRECTO — salen los 10 endpoints reales con su latencia
topk(10, avg by (path) (
avg_over_time({container="zentto-api"} | json | unwrap durationMs [1h])
)) dur=`durationMs` antes del
unwrap parece un renombrado y no lo es: en LogQL eso es un filtro de label
(compara el label dur con la cadena durationMs). El resultado no da
error — devuelve una única serie con el label path vacío, así que el panel sale
en blanco sin ninguna pista de por qué.
Solución: | unwrap durationMs directo, que conserva los labels que
extrajo el | json. Es lo que tuvo el panel "Endpoints más lentos" del
ERP vacío desde el primer día.
Eventos de negocio en el último día
sum by (companyId) (
count_over_time({container="zentto-api"} | json | eventName="invoice.created" [24h])
) Configuración del stack Loki
El stack se define en zentto-infra/observability/loki/ con tres archivos de configuración:
- docker-compose.yml: servicios (loki, promtail, grafana) con networks y volumes.
- loki-config.yml: config de Loki (storage, retención, schema).
- promtail-config.yml: config de Promtail (descubrimiento Docker, pipeline).
Variables de la API (.env)
OBS_TRANSPORT=loki # "loki" o "stdout" (dev)
LOKI_URL=http://172.18.0.1:3101 # OBLIGATORIO si OBS_TRANSPORT=loki
OBS_BATCH_SIZE=100 # Eventos antes de flush
OBS_BATCH_INTERVAL_MS=5000 # Intervalo máximo sin activity Si OBS_TRANSPORT no está definido o es "stdout", el SDK hace fallback a console.log. Esto permite desarrollo local sin Loki.
Operación y troubleshooting
Verificar que el stack está UP
# En el servidor, en el directorio del stack:
docker compose -f observability/loki/docker-compose.yml ps
# Debe mostrar:
# NAME STATUS
# zentto-loki Up (healthy)
# zentto-promtail Up
# zentto-grafana Up Verificar que Loki recibe logs
# Acceder a Grafana: https://logs.zentto.net
# → Connections → Loki (ya configured)
# → Logs
# → Seleccionar labels {container="zentto-api"}
# → Ver últimos logs en tiempo real Problemas comunes
| Síntoma | Causa probable | Solución |
|---|---|---|
| No hay logs en Grafana | LOKI_URL es incorrecto o Loki no arrancó | Verificar LOKI_URL=http://172.18.0.1:3101. Revisar docker compose logs zentto-loki. |
| Promtail no recolecta logs de un container nuevo | Promtail está caché del socket, o container no está en red zentto-logs | Reiniciar Promtail: docker compose restart zentto-promtail. Verificar que el container está en la red. |
| LogQL query retorna vacío o tarda mucho | Labels con cardinalidad alta (ej. paths únicos, uuids) | Acotar rango de tiempo. Usar regex en lugar de labels si es posible. Evitar labels con alta varianza. |
| Query "Endpoints más lentos" retorna vacío | Sintaxis incorrecta de unwrap (trampa documentada arriba) | Usar | unwrap durationMs directo, sin un dur=`durationMs` delante. Ver ejemplos de LogQL arriba. |
| Loki consume mucha memoria (out-of-memory) | Resultados de queries muy grandes en cache | Loki tiene mem_limit: 768m. Si falla, revisar queries Grafana (acotar rango de tiempo). |
Ver logs de los servicios
# Logs de Loki
docker compose -f observability/loki/docker-compose.yml logs -f zentto-loki
# Logs de Promtail
docker compose -f observability/loki/docker-compose.yml logs -f zentto-promtail
# Logs de Grafana
docker compose -f observability/loki/docker-compose.yml logs -f zentto-grafana Checklist para nuevos módulos
- Middleware HTTP: No requiere acción. El middleware en middleware/observability.ts cubre automáticamente todas las rutas.
- Eventos de negocio: Si el módulo genera eventos POST, añadir a eventMap en middleware/observability.ts para que se registren automáticamente.
- Auditoría: Para operaciones sensibles (cambio de precios, permisos), usar obs.audit().
- Performance: Para operaciones potencialmente lentas (reportes, queries complejas), usar obs.perf().
- Errores críticos: Para errores que requieran visibilidad inmediata, usar obs.error().
- Verificación: Hacer un POST en el nuevo endpoint; revisar en Grafana que los logs llegan con los labels correctos.
Migración desde ELK (histórico)
El stack Elasticsearch + Logstash + Kibana + Kafka se retiró el 22 de julio de 2026. Consumía ~13 GB de RAM en el servidor; la migración a Loki liberó 44 GB de disco y redujo la memoria a ~1.2 GB. Los vhosts kibana.zentto.net y kafka.zentto.net ya no existen.
Esta documentación asume que el stack Loki+Promtail+Grafana es el vigente y único. No hay transición gradual ni coexistencia.