# 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) |
