Skip to content

Arquitectura — MiniObserv


1. Visión general

MiniObserv es una plataforma de observabilidad de infraestructura minimalista y autoalojada. Su objetivo es responder a una pregunta concreta: ¿qué está haciendo este servidor ahora y en los últimos N minutos?

Lo que es:

  • Un sistema de recolección y almacenamiento de métricas de sistema (CPU, memoria, disco, red).
  • Una API de consulta con agregaciones temporales basada en TimescaleDB.
  • Una solución operativa lista para ejecutarse con docker compose up.

Lo que no es:

  • No es una plataforma de trazabilidad distribuida ni de logs estructurados.
  • No está diseñado para escalado horizontal multi-instancia de servidor en esta versión.

2. Diagrama de componentes

┌────────────────────────────────────────────────────────────────┐
│  Host monitorizado                                             │
│                                                                │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │  Agente (cmd/agent)                                      │  │
│  │                                                          │  │
│  │  ┌──────────────┐    ┌────────────────────────────────┐  │  │
│  │  │  Collectors  │───▶│  Agent (goroutines)            │  │  │
│  │  │  cpu         │    │  collectLoop → batches chan    │  │  │
│  │  │  memory      │    │  senderLoop  ← batches chan    │  │  │
│  │  │  disk        │    └──────────────┬─────────────────┘  │  │
│  │  │  network     │                   │                     │  │
│  │  └──────────────┘                   │ HTTP POST           │  │
│  └──────────────────────────────────── │ ────────────────────┘  │
│                                        │ JWT Bearer             │
└───────────────────────────────────────────────────────────────-┘


┌────────────────────────────────────────────────────────────────┐
│  Servidor (cmd/server)                                         │
│                                                                │
│  ┌──────────────┐   ┌────────────────┐   ┌────────────────┐   │
│  │  JWTMiddle-  │──▶│  api.Handle-   │──▶│  storage.      │   │
│  │  ware        │   │  Ingest /      │   │  pgxMetric-    │   │
│  │              │   │  HandleQuery   │   │  Repository    │   │
│  └──────────────┘   └───────┬────────┘   └───────┬────────┘   │
│                             │                     │            │
│                             │ Heartbeat           │ pgx.Batch  │
│                             ▼                     │ pgxpool    │
│                    ┌────────────────┐             │            │
│                    │  HostTracker   │──────────── │ ──────┐    │
│                    │  (en memoria)  │             │       │    │
│                    └───────┬────────┘             │       │    │
│                            │ host.down            │       │    │
│                            ▼                      │       │    │
│                    ┌────────────────┐             │       │    │
│                    │  Webhook-      │             │       │    │
│                    │  Notifier      │             │       │    │
│                    │  (goroutine)   │             │       │    │
│                    └────────────────┘             │       │    │
│                                                   │       │    │
│  GET /healthz  ──▶  HandleHealthz (sin auth)      │       │    │
│  GET /readyz   ──▶  HandleReadyz  (sin auth)      │       │    │
│  GET /api/v1/hosts ──▶ HandleHosts (sin auth) ◀───────────┘    │
└───────────────────────────────────────────────────────────────┘


                              ┌─────────────────────────────────┐
                              │  TimescaleDB (PostgreSQL 16)     │
                              │                                  │
                              │  tabla: metrics                  │
                              │  hypertable por time             │
                              │  chunk: 1 día                    │
                              │  índice: (host, name, time DESC) │
                              └─────────────────────────────────┘

3. Flujo de datos

1. Ticker (COLLECT_INTERVAL)


2. collector.Registry.CollectAll()
   ├── CPUCollector     → cpu.usage_pct (por núcleo y total)
   ├── MemoryCollector  → mem.used_pct, mem.used_bytes, mem.total_bytes
   ├── DiskCollector    → disk.used_pct, disk.used_bytes, disk.total_bytes
   └── NetworkCollector → net.bytes_in, net.bytes_out (delta desde tick anterior)


3. []model.Metric → model.MetricBatch{Host, Metrics}


4. batches chan (buffer 10) — desacopla recolección de envío


5. HTTPSender.Send() — POST /api/v1/metrics con Bearer JWT
   └── backoff exponencial en errores transitorios (1 s → 60 s, ±25 % jitter)


6. JWTMiddleware valida el token HS256


7. api.HandleIngest() — decodifica JSON, valida, límite 1000 métricas


8. storage.Insert() — pgx.Batch, un round-trip por batch


9. TimescaleDB — INSERT INTO metrics (time, host, name, value, labels)


10. GET /api/v1/metrics/query → time_bucket() + avg/max/min → []QueryPoint

4. Estructura de paquetes

github.com/kamerrezz/theminidog/
├── cmd/
│   ├── agent/          — punto de entrada del agente: carga config, construye
│   │                     el grafo de dependencias, arranca Agent.Run()
│   └── server/         — punto de entrada del servidor: carga config, aplica
│                         migraciones, arranca el servidor HTTP
├── internal/
│   ├── agent/
│   │   ├── agent.go    — coordinación: collectLoop + senderLoop con goroutines
│   │   ├── collector/
│   │   │   ├── collector.go  — interfaz Collector y Registry
│   │   │   ├── cpu.go        — uso de CPU por núcleo (gopsutil)
│   │   │   ├── memory.go     — uso de RAM
│   │   │   ├── disk.go       — uso de disco por punto de montaje
│   │   │   └── network.go    — bytes in/out por interfaz (delta semántico)
│   │   └── sender/
│   │       └── sender.go     — HTTPSender con backoff exponencial y JWT
│   ├── config/
│   │   ├── agent.go    — LoadAgent(): variables de entorno del agente
│   │   └── server.go   — LoadServerConfig(): variables de entorno del servidor
│   ├── model/
│   │   └── metric.go   — Metric, MetricBatch, validación y lista blanca de nombres
│   └── server/
│       ├── server.go   — ciclo de vida HTTP: arranque, graceful shutdown
│       ├── api/
│       │   ├── router.go     — registro de rutas
│       │   ├── metrics.go    — HandleIngest, HandleQuery
│       │   ├── hosts.go      — HandleHosts: estado de salud de hosts (GET /api/v1/hosts)
│       │   ├── health.go     — HandleHealthz, HandleReadyz
│       │   ├── middleware.go — JWTMiddleware HS256
│       │   └── errors.go     — writeError: formato JSON estándar
│       ├── alerting/
│       │   └── notifications.go — WebhookNotifier: entrega fire-and-forget con timeout 5 s
│       └── storage/
│           ├── metrics.go    — pgxMetricRepository: Insert (batch) y Query
│           └── hosts.go      — HostTracker en memoria: Heartbeat, HostStatuses, detección de down
├── migrations/
│   ├── 001_create_metrics.up.sql   — extensión TimescaleDB, tabla metrics, hypertable, índice
│   ├── 002_create_alerts.up.sql    — tabla de reglas de alerta y alertas activas
│   └── 003_create_logs.up.sql      — tabla logs, PK compuesta (id, time), hypertable
└── deployments/
    └── docker-compose.yml          — stack completo: TimescaleDB + servidor + agente

5. Diseño del almacenamiento

Por qué TimescaleDB

TimescaleDB extiende PostgreSQL con hypertables: tablas particionadas automáticamente por tiempo. Esto permite:

  • Consultas eficientes por rango temporal sin necesidad de particionamiento manual.
  • time_bucket(): agrupación temporal nativa con granularidades arbitrarias.
  • Retención de datos configurable mediante políticas (no implementada en esta versión, pero disponible).
  • Compatibilidad total con el ecosistema PostgreSQL (pgx, migraciones, JSON, índices).

Modelo estrecho

La tabla tiene exactamente cinco columnas:

sql
CREATE TABLE metrics (
    time   TIMESTAMPTZ      NOT NULL,
    host   TEXT             NOT NULL,
    name   TEXT             NOT NULL,
    value  DOUBLE PRECISION NOT NULL,
    labels JSONB
);

Este diseño "estrecho" tiene ventajas deliberadas:

  • Esquema fijo: no se requieren migraciones al añadir nuevas métricas; solo se cambia el código del agente.
  • Labels JSONB: permite metadatos variables por tipo de métrica (core=0, mount=/, iface=eth0) sin columnas adicionales.
  • Una fila por medición: facilita el razonamiento sobre los datos y simplifica las consultas.

Hypertable

sql
SELECT create_hypertable('metrics', 'time',
    chunk_time_interval => INTERVAL '1 day',
    if_not_exists => TRUE
);

Cada día de datos se almacena en un chunk independiente. Las consultas por rango temporal solo tocan los chunks relevantes, lo que reduce drásticamente el I/O.

Índice compuesto

sql
CREATE INDEX idx_metrics_host_name_time ON metrics (host, name, time DESC);

El patrón de consulta habitual es WHERE host = $1 AND name = $2 AND time BETWEEN $3 AND $4. El índice cubre este acceso en orden descendente, ideal para consultas de "los últimos N minutos".

Inserción en batch

El repositorio usa pgx.Batch para enviar todas las métricas de un tick en un único round-trip de red. Es obligatorio llamar a defer br.Close() para liberar la conexión al pool.


6. Seguimiento de hosts y notificaciones (Semana 5)

HostTracker

storage.HostTracker mantiene en memoria el timestamp de la última actividad de cada host. El handler HandleIngest llama a Heartbeat(host) en cada ingesta exitosa.

Un goroutine de vigilancia (watchLoop) evalúa periódicamente el estado de cada host según dos umbrales:

UmbralVariablePredeterminadoEstado resultante
Tiempo sin reporte < HOST_STALE_AFTERHOST_STALE_AFTER20sok
Tiempo sin reporte entre HOST_STALE_AFTER y HOST_DOWN_AFTERstale
Tiempo sin reporte > HOST_DOWN_AFTERHOST_DOWN_AFTER50sdown

Cuando un host pasa a down, HostTracker llama directamente a los notificadores registrados con un evento host.down. Esto es independiente del sistema de alertas por umbral de métricas.

WebhookNotifier

alerting.WebhookNotifier implementa la interfaz Notifier. Recibe un evento, serializa el payload JSON y lo envía mediante POST HTTP con un timeout de 5 segundos. La entrega es "fire-and-forget": no hay reintentos en v1.

Cada notificación se ejecuta en una goroutine separada con context.WithoutCancel para desacoplar su ciclo de vida de la petición HTTP que la originó.

Endpoint GET /api/v1/hosts

api.HandleHosts consulta HostTracker.HostStatuses() y serializa el resultado. No requiere autenticación y es adecuado para sondas externas de monitoreo del estado de la flota.


8. Flujo de autenticación

AGENT_TOKEN (secreto compartido, ≥16 chars)

        ├── Agente: genera JWT HS256 con exp=now+24h
        │         Header: {"alg":"HS256","typ":"JWT"}
        │         Payload: {"sub":"agent","exp":<unix>}
        │         Firma: HMAC-SHA256(header.payload, AGENT_TOKEN)

        └── Servidor: JWTMiddleware valida en cada petición autenticada
                      ├── Verifica firma con AGENT_TOKEN
                      ├── Verifica algoritmo == HS256 (bloquea alg=none y RS256)
                      └── Verifica expiración automáticamente

Las rutas /healthz y /readyz no requieren autenticación (sondas de infraestructura).


9. Convención de nombres de métricas

MiniObserv usa un conjunto cerrado de nueve nombres canónicos. El servidor rechaza cualquier nombre fuera de esta lista con HTTP 400.

NombreTipoLabelsDescripción
cpu.usage_pctporcentaje (0–100)core=total|0|1|…Uso de CPU por núcleo y total
mem.used_pctporcentaje (0–100)Porcentaje de RAM usada
mem.used_bytesbytesBytes de RAM en uso
mem.total_bytesbytesBytes de RAM total
disk.used_pctporcentaje (0–100)mount=/Uso de disco por punto de montaje
disk.used_bytesbytesmount=/Bytes de disco usados
disk.total_bytesbytesmount=/Bytes de disco total
net.bytes_inbytes (delta)iface=eth0Bytes recibidos desde el tick anterior
net.bytes_outbytes (delta)iface=eth0Bytes enviados desde el tick anterior

10. Semántica de deltas en red

Los contadores del sistema operativo para net.bytes_in y net.bytes_out son acumulativos: siempre crecen desde el arranque. MiniObserv convierte estos contadores en deltas por intervalo:

tick N:   lee BytesRecv=1000 → guarda como prev; devuelve nil
tick N+1: lee BytesRecv=1150 → delta = 1150 - 1000 = 150 bytes → emite net.bytes_in{iface=eth0}=150

Consecuencias:

  • El primer tick del agente devuelve cero métricas de red. Esto es correcto.
  • Si una interfaz aparece por primera vez en el tick N+1 (no estaba en tick N), ese ciclo también se omite.
  • Si el contador del sistema operativo retrocede (reinicio, overflow de 32 bits), el delta se clampea a cero.
  • El loopback (lo) siempre se excluye.

11. Referencia de configuración

Agente

VariableObligatoriaPredeterminadoValidación
SERVER_URLURL válida http:// o https://
AGENT_TOKENnovacíoSin validación; vacío = sin autenticación JWT
AGENT_HOSTnoos.Hostname()Cualquier string no vacío
COLLECT_INTERVALno10sDuración Go válida entre 1 s y 300 s
LOG_LEVELnoinfodebug, info, warn, error
LOG_PATHSnovacíoRutas separadas por coma

Servidor

VariableObligatoriaPredeterminadoValidación
DATABASE_URLDSN postgres:// o postgresql:// válido
AGENT_TOKENMínimo 16 caracteres
LISTEN_ADDRno:8080Dirección de escucha válida
MIGRATIONS_PATHno./migrationsRuta al directorio de migraciones
LOG_LEVELnoinfodebug, info, warn, error
REQUEST_TIMEOUTno10sEntre 1 s y 120 s
SHUTDOWN_TIMEOUTno5sEntre 1 s y 30 s
ALERT_NOTIFICATIONSnoArray JSON de objetos webhook para alertas y eventos host.down
HOST_STALE_AFTERno20sTiempo tras el cual un host silencioso pasa a estado stale
HOST_DOWN_AFTERno50sTiempo tras el cual un host silencioso pasa a estado down y se dispara el notificador

Released under the MIT License.