# Report 0016 — unibus: métricas profundas de NATS/JetStream (limpio, sin debug-log) - **Fecha:** 07/06/2026 - **Autor:** Claude (agente NATS metrics) - **Ámbito:** `projects/message_bus/apps/unibus` (flag), `functions/infra` (parser del registry), `projects/fleet_monitoring/apps/unibus_exporter` (scraper), `projects/fleet_monitoring/hub` (dashboard + deploy) - **Estado:** done (código + dashboard listos y probados; rollout al cluster pendiente, por diseño) ## Resumen Se añadieron las métricas server-level de NATS/JetStream (msgs/s, conexiones, slow consumers, memoria, KV bucket msgs, RAFT leader por stream, reinicios) a la monitorización del cluster `unibus`, **de forma limpia**: desacoplando el endpoint de monitoring del nats-server (puerto 8222) del debug-log verboso al que estaba acoplado. El problema original: el monitoring 8222 solo se abría con `UNIBUS_NATS_DEBUG=1`, que enciende el log verboso del nats-server (rutas/RAFT/subjects de rooms a journald en claro), incompatible con el endurecimiento del issue 0007. La causa era únicamente cómo estaba escrito el toggle: monitoring y debug-log estaban acoplados sin necesidad. La solución es un flag dedicado `UNIBUS_NATS_MONITOR=1` que abre el monitoring loopback **sin tocar el nivel de log**, un scraper local por nodo (modo nuevo del `unibus_exporter`) que lee `/varz`, `/connz`, `/jsz` por loopback y empuja a VictoriaMetrics reusando las funciones del grupo `fleet-metrics`, y un dashboard Grafana `unibus-nats`. **No se reinició el cluster** (eso es el rollout consolidado del binario 0.11.0). ## Cambios ### Parte 1 — flag desacoplado en unibus (rama `quick/nats-monitor-flag`, pusheada) Repo: `dataforge/unibus`. Commit `1c93251`. | Archivo | Cambio | |---|---| | `pkg/embeddednats/embeddednats.go` | Función pura nueva `natsLogOpts(debugEnv, monitorEnv) (noLog, debug, trace, monitor)`; `StartServer` la usa. El monitoring se abre con `monitor` (no con `debug`). | | `pkg/embeddednats/monitor_test.go` | Tests del desacoplamiento (tabla pura) + server real con `MONITOR=1` (8222 loopback) + server sin flag (cerrado). | | `deploy/cluster/membershipd-cluster.service.d/nats-monitor.conf` | Drop-in systemd aditivo (`Environment=UNIBUS_NATS_MONITOR=1`). | | `deploy/cluster/README.md` | Sección "NATS server metrics" con runbook de activación rolling + gate R3. | | `app.md` | Bump 0.10.0 → 0.11.0 + growth log. | Diff esencial (lógica): ```go // natsLogOpts: pura, testeable. MONITOR=1 abre el endpoint dejando el log en silencio. func natsLogOpts(debugEnv, monitorEnv string) (noLog, debug, trace, monitor bool) { debug = debugEnv == "1" || debugEnv == "2" trace = debugEnv == "2" monitor = monitorEnv == "1" || debug // debug sigue implicando monitor (compat) noLog = !debug return noLog, debug, trace, monitor } // ... opts.NoLog, opts.Debug, opts.Trace = noLog, debugNATS, traceNATS if monitorNATS { opts.HTTPHost = "127.0.0.1" // loopback hardcoded — el monitoring NUNCA es público opts.HTTPPort = 8222 } ``` El bind `127.0.0.1` se mantiene hardcoded: el endpoint no tiene auth y nunca debe ser alcanzable por red. El `|| debugNATS` preserva el comportamiento viejo (DEBUG sigue abriendo el monitoring). ### Función nueva del registry — `parse_nats_monitor_go_infra` Creada vía `fn-constructor` en el repo padre (`functions/infra/`). Análoga a `parse_unibus_health`, mismo paquete `infra`, reusa el tipo `PromSample`. ``` func ParseNatsMonitor(node string, varz, connz, jsz []byte) ([]PromSample, error) ``` Convierte `/varz` + `/connz` + `/jsz?streams=1` en `[]PromSample` con labels `node`/`instance`. Robusta: `/varz` es el core (error si no parsea); `/connz` y `/jsz` best-effort. Series: ver tabla del dashboard abajo. Tag de grupo `fleet-metrics` (+ `nats`, `jetstream`, `monitoring`). ### Parte 2a — modo NATS-local en `unibus_exporter` (rama `quick/nats-deep-metrics`, commit local) Repo: `dataforge/unibus_exporter` (sub-repo **sin remote aún**). Commit `dcdae92`. Segundo modo de scrape, componible con el modo healthz existente: | Archivo | Cambio | |---|---| | `config.go` | Bloque `nats_monitor {enabled, node, base_url}` + overrides `UNIBUS_NODE`/`UNIBUS_NATS_URL`; validación relajada (healthz, NATS o ambos). | | `main.go` | `scrapeNATS()` lee loopback 8222 (`/varz`,`/connz`,`/jsz?streams=1`), llama `ParseNatsMonitor`, reusa `FormatPromExposition` + `PushPromRemote`. `getJSON` genérico. | | `nats_mode_test.go` | `scrapeNATS` contra fixtures reales + varz caído + validación de config. | | `testdata/nats_{varz,connz,jsz}.json` | Fixtures reales capturados de un nats-server 2.11.15. | | `systemd/unibus-exporter-nats.service` | Unit del modo NATS-local (por nodo). | | `unibus.nats.example.json` | Config de ejemplo nats-only. | | `app.md` | Bump 0.1.0 → 0.2.0 + tabla de métricas NATS + growth log. | El modo healthz no se tocó. Como el monitoring de NATS es loopback-only y sin auth, este modo corre **en cada nodo** (un exporter local por nodo), distinto del exporter healthz central de magnus. ### Parte 2b — dashboard + deploy (rama `quick/nats-deep-metrics`, pusheada) Repo: `dataforge/fleet_monitoring`. Commit `dfd55dc`. | Archivo | Cambio | |---|---| | `hub/dashboards/unibus-nats.json` | Dashboard `unibus-nats` (uid), datasource `victoriametrics`, 12 paneles. Lo recoge el provider `fleet` existente (escanea el path de dashboards). | | `hub/deploy_unibus_nats_exporter.sh` | Deploy del exporter en modo NATS-local por nodo. magnus → VM local sin auth; homer/datardos → ingesta pública con basic auth (`pass fleet/ingest-pass`, nunca en argv). Instala `unibus-exporter-nats.service` y sondea 8222 para avisar si el monitoring no está abierto. NO reinicia membershipd. | ## Verificación (evidencia ejecutable) ### P1 — flag (unibus, `CGO_ENABLED=0`) ``` $ go build ./... → BUILD_EXIT=0 $ go test ./pkg/embeddednats/ -run 'TestNatsLogOpts|TestMonitor' -v --- PASS: TestNatsLogOptsDecoupled (7 subcases: default off, monitor-only ⇒ log quiet, debug⇒monitor, trace⇒debug+monitor, both, garbage ignorado ×2) --- PASS: TestMonitorEndpointLoopback (server real MONITOR=1: /varz 200 en 127.0.0.1:8222) --- PASS: TestMonitorDisabledByDefault (sin flag: MonitorAddr nil, endpoint cerrado) $ go test ./pkg/embeddednats/ → ok 5.953s (suite completa, cluster_test incluido) ``` ### Función del registry (`CGO_ENABLED=1 -tags fts5`) ``` $ go test ./functions/infra/ -run 'ParseNatsMonitor|Battery' → ok 0.004s Golden verifica: nats_msgs_in_total=17, nats_msgs_out_total=17, nats_mem_bytes=18288640, nats_jetstream_streams=3, kv_bucket_msgs{UNIBUS_users|rooms|members}=2, nats_jetstream_raft_leader{KV_UNIBUS_users}=1. $ ./fn index → Indexed 1451 functions (sin error en la función) $ ./fn show parse_nats_monitor_go_infra → indexada OK ``` ### P2a — exporter (`CGO_ENABLED=0`) ``` $ go build ./... → BUILD_OK $ go vet ./... → VET_OK $ go test ./... → ok 0.005s TestScrapeNATSFromFixtures, TestScrapeNATSVarzDown, TestConfigNatsModeValidation ``` ### E2E real — scraper contra un nats-server embebido vivo Se levantó un nats-server embebido **con el binario 0.11.0 real** (`UNIBUS_NATS_MONITOR=1`, 8222 abierto, 3 KV buckets creados), se corrió el exporter en modo NATS-local apuntando a `127.0.0.1:8222` y empujando a un VictoriaMetrics simulado (receptor HTTP local). El exporter empujó **29 series** reales, recibidas y verificadas: ``` unibus_exporter starting: nodes=0 nats="http://127.0.0.1:8222 (node=probe)" ... pushed 29 samples (nodes=0 nats=true) # series recibidas en el VM simulado (extracto): nats_msgs_in_total{instance="probe",node="probe"} 17 nats_msgs_out_total{instance="probe",node="probe"} 17 nats_connections{instance="probe",node="probe"} 1 nats_mem_bytes{instance="probe",node="probe"} 1.855488e+07 nats_slow_consumers{...} 0 kv_bucket_msgs{bucket="UNIBUS_users",...} 2 kv_bucket_msgs{bucket="UNIBUS_rooms",...} 2 kv_bucket_msgs{bucket="UNIBUS_members",...} 2 nats_jetstream_raft_leader{stream="KV_UNIBUS_users",...} 1 nats_jetstream_streams{...} 3 nats_server_start_seconds{...} 1.78085962e+09 nats_up{...} 1 nats_scrape_error{...} 0 ``` Esto valida toda la cadena: nats real → scrape loopback 8222 → `ParseNatsMonitor` → formato Prometheus → push a VictoriaMetrics. El harness y los temporales se limpiaron tras la prueba. ### Dashboard ``` $ python3 -c "json.load(...)" → OK uid=unibus-nats title='unibus — NATS server' panels=12 ``` Paneles: NATS up · conexiones · msgs/s in · slow consumers · JetStream msgs · reinicios (1h); msgs/s por nodo (in/out) · conexiones por nodo · KV bucket msgs por bucket · memoria por nodo; tabla "Leader RAFT por stream" · tabla "JetStream por nodo". ## Runbook de activación (consolidada, HUMANO/orquestador — tras el rollout 0.11.0) El monitoring requiere reiniciar `membershipd` (reinicia el miembro RAFT del nodo). Se hace dentro del rollout consolidado del binario 0.11.0, **nunca dos nodos abajo a la vez**. 1. **Dependencia previa (registry):** commitear la función nueva al registry y push directo a master (es aditivo, exento de TBD): `functions/infra/parse_nats_monitor.{go,md,_test.go}` + `functions/infra/testdata/nats_*.json`. Sin esto el exporter no compila en otro entorno. 2. **Rollout del binario 0.11.0** a los 3 nodos en orden **magnus → homer → datardos**, instalando en cada uno el drop-in `nats-monitor.conf` + `daemon-reload` + `restart membershipd-cluster`. **Entre cada nodo**, esperar reconvergencia R3 (`followers_current=2/2` en los 6 KV buckets) y `healthz` verde en el nodo reiniciado antes de pasar al siguiente (comandos en `deploy/cluster/README.md` § "NATS server metrics"). 3. **Desplegar el scraper por nodo** (una vez que su 8222 está abierto): `./hub/deploy_unibus_nats_exporter.sh magnus om` (y `homer homer`, `datardos dd`). 4. **Provisionar el dashboard:** copiar `hub/dashboards/unibus-nats.json` a `/var/lib/grafana/dashboards` en magnus (mismo mecanismo que `unibus-cluster.json`; el provider `fleet` lo recoge). 5. **Verificar:** series `nats_*`/`kv_bucket_msgs` en VictoriaMetrics y el dashboard `unibus-nats` en Grafana. ## Gaps / pendientes - **Función del registry sin commitear (dependencia de integración):** `parse_nats_monitor` está creada, indexada y testeada en el working tree del padre `fn_registry`, pero NO se commiteó (aislamiento: el prompt prohíbe tocar el padre; el orquestador la integra). Es el paso 1 del runbook. El exporter la importa, así que debe ir al registry antes de integrar el exporter. - **`unibus_exporter` sin remote Gitea:** el sub-repo aún no está sincronizado a `dataforge/`. El commit `dcdae92` quedó **local** en la rama `quick/nats-deep-metrics`; el push se hará vía `/full-git-push` (que crea el repo) o configurando el remote. Las otras dos ramas (`quick/nats-monitor-flag` en unibus, `quick/nats-deep-metrics` en fleet) sí están pusheadas. - **Validación contra el cluster vivo no realizada (por diseño):** el scraper se validó e2e contra un nats-server embebido local con el binario 0.11.0 real, no contra magnus/homer/datardos. Activar `UNIBUS_NATS_MONITOR` en datardos habría exigido reiniciar un nodo RAFT de producción con otros agentes trabajando contra el cluster vivo — riesgo no justificado. Queda para el rollout. - **`NRestarts` por proceso/stream no lo expone NATS 8222:** se emite `nats_server_start_seconds` como proxy (un cambio de su valor = el nats-server reinició; el panel "Reinicios (1h)" usa `changes()`). El contador de reinicios del proceso systemd (`NRestarts`) vendría de otra fuente. - **Nit de linter (cosmético):** `parse_nats_monitor.go` usa `HasPrefix`+`TrimPrefix` donde el linter sugiere `CutPrefix`. No afecta corrección; opcional de pulir. - **Cluster NO reiniciado:** magnus/homer/datardos intactos (8222 cerrado, healthz OK). Correcto según el encargo. ## Ramas | Repo | Rama | Estado | |---|---|---| | `dataforge/unibus` | `quick/nats-monitor-flag` | commit `1c93251`, **pusheada** (NO merge) | | `dataforge/fleet_monitoring` | `quick/nats-deep-metrics` | commit `dfd55dc`, **pusheada** (NO merge) | | `dataforge/unibus_exporter` | `quick/nats-deep-metrics` | commit `dcdae92`, **local** (sub-repo sin remote) | | `fn_registry` (padre) | working tree | función `parse_nats_monitor` sin commitear (paso 1 del runbook) |