Observabilidad: métricas, logs y salud del relay
Observabilidad: métricas, logs y salud del relay
Montaste el relay (capítulo 3), lo configuraste (capítulo 4), lo desplegaste (capítulos 5 y 6) y lo dividiste en comunidades (capítulo 11). Este capítulo responde la pregunta que aparece a la semana de estar en producción: cómo se ve el relay por dentro cuando algo va mal.
Buzz expone tres superficies de observabilidad, en tres puertos distintos. La regla operativa es que solo una de las tres es pública.
flowchart LR
cli["Clientes Nostr<br/>WS y REST"] --> app["Puerto 3000<br/>BUZZ_BIND_ADDR<br/>trafico de aplicacion"]
k8s["Kubelet o healthcheck<br/>de Docker"] --> health["Puerto 8080<br/>BUZZ_HEALTH_PORT<br/>_liveness _readiness _status _mesh"]
prom["Prometheus"] --> metrics["Puerto 9102<br/>BUZZ_METRICS_PORT<br/>GET /metrics"]
app --> logs["stdout en JSON<br/>siempre activo"]
health --> logs
metrics --> logs
logs --> collector["Agregador de logs<br/>o colector OTLP opcional"]
El Dockerfile lo deja escrito en su EXPOSE 3000 8080 9102, con el comentario
3000: app (WS + REST) · 8080: /_liveness, /_readiness · 9102: /metrics. En el bundle de Compose de
producción (deploy/compose/compose.yml) el relay fija BUZZ_HEALTH_PORT=8080 y
BUZZ_METRICS_PORT=9102 por variable de entorno pero no publica esos puertos al host: solo sale
${BUZZ_HTTP_PORT:-3000}. Métricas y salud son superficie interna.
1. Health checks
El relay construye dos routers. build_health_router corre en BUZZ_HEALTH_PORT con cuatro rutas;
el router de aplicación en el puerto 3000 repite /_liveness y /_readiness y agrega /health.
| Ruta | Puerto | Qué devuelve |
|---|---|---|
/_liveness | 8080 y 3000 | 200 OK con el texto ok. No consulta nada |
/_readiness | 8080 y 3000 | JSON con el estado de las dependencias |
/_status | 8080 | {"service":"buzz-relay","version":"…","uptime_seconds":N} |
/_mesh | 8080 | Estado del mesh; con mesh apagado, {"enabled": false} |
/health | 3000 | 200 OK con el texto ok, equivalente a liveness |
1.1 La lógica exacta de /_readiness
Es el endpoint que decide si el pod recibe tráfico. Su comportamiento, tal como está en
crates/buzz-relay/src/router.rs:
flowchart TD
start["GET /_readiness"] --> drain{"flag shutting_down activo?"}
drain -- si --> s503a["503 con status shutting_down"]
drain -- no --> checks["ping a Postgres y get del pool de Redis<br/>en paralelo, con timeout de 2 s"]
checks --> ok{"pg_ok y redis_ok?<br/>el timeout degrada ambos a false"}
ok -- si --> s200["200 con status ready"]
ok -- no --> s503c["503 con status not_ready<br/>y el detalle por dependencia"]
Tres cosas que conviene interiorizar:
- Readiness es un chequeo de dependencias, no de proceso. Si Postgres o Redis caen, todos los pods salen del balanceador aunque el proceso esté vivo. Alertar sobre readiness sin alertar sobre Postgres y Redis produce paginadas ambiguas.
- El timeout es de 2 segundos y es duro. Si las dos comprobaciones no terminan a tiempo, el
resultado se degrada a
(false, false): un Postgres lento —no caído— también saca al pod. - El flag de apagado se chequea primero. Durante el drenaje por
SIGTERM, readiness devuelve 503 con{"status":"shutting_down"}antes de tocar la base. Además, el handler del upgrade WebSocket rechaza sockets nuevos con503 relay restartingmientras ese flag está activo, porque readiness solo detiene el enrutamiento de Kubernetes: los upgrades directos y en vuelo siguen llegando durante la ventana de gracia.
El healthcheck del Compose de producción usa esta ruta: abre /dev/tcp/127.0.0.1/8080, hace
GET /_readiness y busca 200 OK, con interval: 10s, timeout: 3s, retries: 12 y
start_period: 30s; Caddy depende de relay: service_healthy. /_status es la forma barata de
saber qué versión corre realmente y hace cuánto —primera consulta de cualquier runbook—, y /_mesh
distingue explícitamente “mesh apagado” ({"enabled": false}) de “mesh encendido con cero peers”,
que devuelve tabla de peers, estado de conexión y phi, contadores por peer y total de rechazos de
fence. El mesh es opt-in (BUZZ_MESH, default false).
2. El endpoint de métricas
2.1 Cómo se instala el exporter
crates/buzz-relay/src/metrics.rs monta un PrometheusBuilder con un listener HTTP propio en
0.0.0.0:<BUZZ_METRICS_PORT>. Sin autenticación, sin CORS, sin middleware: un puerto crudo que
sirve texto Prometheus en GET /metrics. Por eso no se publica al host.
Dos decisiones del builder que se notan en operación. La primera es el idle_timeout sobre
MetricKindMask::GAUGE: los gauges que el relay deja de emitir se eliminan de la exposición tras
un plazo que sale de BUZZ_USAGE_METRICS_IDLE_TIMEOUT_SECS (default 900 s, con piso automático de
tres veces el intervalo del poller de uso); sin esto, una comunidad borrada dejaría su serie
congelada en el último valor para siempre. La segunda son los buckets por métrica:
http_request_latency_ms usa milisegundos (5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000),
cualquier métrica cuyo nombre termine en _seconds usa la escala de segundos (0.001 a 5.0), las
familias de git tienen buckets propios de duración, bytes y número de packs, y
buzz_fanout_recipients usa buckets de conteo (0, 1, 5, 10, 25, 50, 100, 500, 1000).
2.2 Catálogo de métricas
El middleware track_metrics emite dos series de framework: http_requests_total (counter) y
http_request_latency_ms (histogram), ambas con etiquetas code, caller y action. action es
el patrón de ruta (/api/channels/{channel_id}), nunca la URI cruda: usar la URI en los 404
crearía cardinalidad ilimitada desde los escáneres. Por lo mismo, el middleware omite /_*,
/health, /metrics y cualquier petición sin ruta emparejada — si buscas los 404 de tu relay en
Prometheus y no aparecen, están deliberadamente fuera. caller sale de la cabecera
x-envoy-downstream-service-cluster (mallas tipo Istio) validada a máximo 64 caracteres
alfanuméricos, guion y guion bajo, con fallback a unknown.
Las métricas propias del relay llevan el prefijo buzz_.
| Métrica | Tipo | Etiquetas | Significado |
|---|---|---|---|
buzz_ws_connections_active | gauge | — | Conexiones WebSocket vivas en este proceso |
buzz_subscriptions_active | gauge | — | Suscripciones REQ registradas |
buzz_ws_auth_timeouts_total | counter | — | Cierres por no completar NIP-42 a tiempo |
buzz_ws_backpressure_disconnects_total | counter | — | Desconexiones por cliente lento con buffer lleno |
buzz_ws_send_batch_size | histogram | — | Tamaño de los lotes de envío |
buzz_admission_rejections_total | counter | transport = websocket | http, reason = quota | unavailable | Rechazos del rate limiter compartido |
buzz_auth_attempts_total | counter | — | Intentos de autenticación |
buzz_auth_failures_total | counter | reason = nip42_invalid, allowlist_denied, not_relay_member, … | Fallos por motivo |
buzz_events_received_total | counter | kind | Ingesta de flota, sin etiqueta de comunidad |
buzz_community_events_received_total | counter | community | Ingesta por comunidad, sin etiqueta de kind |
buzz_event_processing_seconds | histogram | — | Pipeline completo, no solo el insert |
buzz_fanout_recipients | histogram | — | Destinatarios por evento en el fan-out |
reason="unavailable" es la señal crítica: el store de admisión en Redis no respondió y el relay
falló cerrado, rechazando tráfico legítimo. No es lo mismo que quota, que es el sistema
funcionando como se diseñó. En autenticación, not_relay_member subiendo de golpe suele significar
que alguien tocó el roster (capítulo 8),
y nip42_invalid suele ser un cliente con el reloj fuera de la ventana de ±60 s.
El cruce kind × community está evitado a propósito: el rango efímero 20000–29999 lo controla el
cliente, así que multiplicarlo por comunidades produciría millones de series. La regla escrita en el
código es usar el contador por comunidad para throughput y el de flota para desgloses por kind.
Pools y réplica. buzz_db_pool_size, _idle, _active, _max; los mismos cuatro con prefijo
buzz_db_read_pool_; buzz_db_replica_fence_open, buzz_db_replica_fence_lag_seconds,
buzz_db_replica_heartbeat_age_seconds, buzz_db_read_session_degraded; y buzz_redis_pool_size,
_max, _available, _waiting. Las publica una tarea de fondo cuyo periodo sale de
BUZZ_POOL_METRICS_INTERVAL_SECS (default 10 s, piso 1). buzz_redis_pool_waiting por encima
de cero de forma sostenida es la señal temprana de que BUZZ_REDIS_POOL_SIZE se quedó corto.
Fan-out multinodo, cachés, auditoría y NIP-43. buzz_multinode_fanout_total y _lag_total,
buzz_cache_invalidation_lag_total, buzz_conn_control_lag_total, los pares
_hits_total/_misses_total de buzz_membership_cache y buzz_accessible_channels_cache,
buzz_audit_enabled, buzz_audit_log_seconds, buzz_audit_log_errors_total,
buzz_audit_send_errors_total y la familia buzz_nip43_membership_* (publicaciones, duración,
reconciliaciones y sus fallos). Recuerda del
capítulo 10 que la auditoría es fire-and-forget:
buzz_audit_log_errors_total subiendo no hace fallar la ingesta, y por eso hay que vigilarla en
vez de esperar que un usuario se queje.
Git y medios. La familia buzz_git_* cubre hidrataciones (buzz_git_hydrations_total,
buzz_git_hydrate_seconds/_bytes/_packs), streaming de packs
(buzz_git_upload_pack_stream_seconds/_bytes, buzz_git_upload_pack_timeouts_total), la caché de
packs (buzz_git_pack_cache_lookups_total, _entries, _bytes, _evictions_total,
_bypasses_total, _copy_fallbacks_total, _populations_active, _population_wait_seconds) y la
compactación (buzz_git_pack_compactions_total, buzz_git_pack_compaction_seconds/_bytes,
_packs_before, _packs_after, _required_failures_total); detalle en el
capítulo 9. En medios,
buzz_media_upload_rejections_total y buzz_media_legacy_upload_route_total; la segunda es de
migración: si no está en cero, tienes clientes viejos en una ruta de subida heredada.
Uso, almacenamiento y liderazgo. Aquí vive la mayor parte de la cardinalidad. Los totales de
flota buzz_communities_total y buzz_total_* (usuarios y canales con etiqueta type, mensajes,
usuarios y canales activos, miembros del relay, suscripciones, conexiones WS, usuarios en línea por
pod, workflows, repos git, bytes y objetos de almacenamiento) tienen su espejo por comunidad en
buzz_community_* con la etiqueta community. El barrido S3 añade buzz_storage_sweep_ok,
_failures, _age_seconds, _duration_seconds, buzz_storage_orphan_blobs/_blob_bytes/
_sidecars, buzz_storage_unknown_key_objects/_bytes, buzz_storage_multi_variant_shas/_bytes
y buzz_storage_unmapped_community_bytes. Y buzz_usage_poller_is_leader vale 1.0 en el líder.
Tres precisiones que evitan dashboards mentirosos: la etiqueta community es el host resuelto, no
el UUID —para que una comunidad renombrada o eliminada reciba su cero final con la misma etiqueta—;
los gauges *_pod son locales al proceso, así que sumarlos entre réplicas es válido pero tratar
el de un pod como total global no; y el liderazgo del poller explica por qué las series de uso
“saltan” entre pods tras un failover.
2.3 Control de cardinalidad y cadencia
| Variable | Default | Efecto |
|---|---|---|
BUZZ_USAGE_METRICS_INTERVAL_SECS | 300, piso 5 | Cada cuánto corre el poller de uso |
BUZZ_USAGE_METRICS_IDLE_TIMEOUT_SECS | 900, elevado a al menos 3× el intervalo | Vida de un gauge sin refresco |
BUZZ_USAGE_METRICS_PER_COMMUNITY | all | all emite series por comunidad; off deja solo totales de flota |
BUZZ_POOL_METRICS_INTERVAL_SECS | 10, piso 1 | Cadencia de los gauges de pools |
El comentario del código es explícito: con unas 25 combinaciones de etiquetas de gauge por comunidad,
un relay que aloje miles de comunidades genera un coste de cinco cifras mensuales en un backend
facturado por series únicas. BUZZ_USAGE_METRICS_PER_COMMUNITY=off es la palanca de coste, y los
totales buzz_total_* se emiten siempre. Un valor desconocido produce un warning y cae a all; hay
un modo top:<k> planificado pero no implementado, así que no lo configures.
El barrido de almacenamiento tiene variables propias: BUZZ_STORAGE_SWEEP_INTERVAL_SECS (default
3600, piso 60), BUZZ_STORAGE_SWEEP_TIMEOUT_SECS (120), BUZZ_STORAGE_SWEEP_MAX_OBJECTS
(1000000) y el kill switch BUZZ_STORAGE_METRICS=off. El chart documenta el modo de fallo más
común: las credenciales S3 deben otorgar además s3:ListBucket sobre el ARN del bucket, o el primer
barrido falla con AccessDenied y buzz_storage_sweep_ok se queda en 0 para siempre, sin afectar a
ninguna otra funcionalidad de medios. Ojo con el reintento: tras un fallo, el siguiente intento sale
en el siguiente tick del poller de uso (300 s por defecto), no a cadencia de barrido.
3. prometheus.yml: el scrape que trae el repo
El archivo del repositorio es corto y solo cubre desarrollo local:
# Local dev Prometheus config — scrapes buzz-relay metrics.
# The relay runs on the host (not in Docker), so we use host.docker.internal.
# Default metrics port is 9102; override with BUZZ_METRICS_PORT.
global:
scrape_interval: 5s
scrape_configs:
- job_name: buzz-relay
static_configs:
- targets: ["host.docker.internal:9102"]
Ese host.docker.internal explica el modelo de desarrollo de Buzz: el relay no está en
docker-compose.yml. Corre en el host con just relay o just dev y solo la infraestructura vive
en contenedores, así que Prometheus —que sí es un contenedor— tiene que salir al host. El archivo se
monta en dos sitios: el servicio prometheus de docker-compose.yml (imagen prom/prometheus:latest
publicada en 9090:9090, volumen buzz-prometheus-data, montando ./prometheus.yml) y el override
deploy/compose/compose.dev.yml, que publica un prometheus en ${PROMETHEUS_PORT:-9090} montando
../../prometheus.yml y se activa con BUZZ_COMPOSE_DEV=true ./run.sh start.
Es un override de administración local, no un stack de monitoreo de producción: un
scrape_interval de 5 s sobre un target estático sirve para depurar en tu máquina, no para un fleet.
En producción apunta tu Prometheus real al puerto de métricas con la retención de tu organización.
3.1 Scrape en Kubernetes
El chart de Helm trae un ServiceMonitor de prometheus-operator, opt-in y apagado por defecto:
serviceMonitor.enabled: false, con interval: 30s, scrapeTimeout: 10s y namespace/labels
vacíos. Al activarlo, el chart renderiza un ServiceMonitor que selecciona el Service del relay por
las etiquetas de selector del chart, raspa el puerto nombrado metrics (el 9102 de
service.metricsPort) y limita namespaceSelector al namespace del release.
Hay un segundo consumidor: el autoescalado. autoscaling.websocketMetricEnabled está en true por
defecto y usa autoscaling.websocketMetricName: buzz_ws_connections_active con
autoscaling.targetWebsocketConnections: 5000, lo que requiere un adaptador de métricas
personalizado que exponga ese gauge a nivel de pod; sin él, esa mitad del HPA no hace nada. La otra
mitad, targetCPUUtilizationPercentage: 65, solo necesita Metrics Server.
4. Logs estructurados
El relay instala un subscriber de tracing con fmt::layer().json() siempre activo. No hay modo
texto plano: la salida a stdout es JSON, una línea por evento.
El filtro sale de RUST_LOG. Si la variable no está definida, el default del binario es
buzz_relay=info y nada más. Los ejemplos del repo:
RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_http=debug # .env.example
RUST_LOG=buzz_relay=info,buzz_db=info,buzz_auth=info,buzz_pubsub=info,tower_http=info # deploy/compose/.env.example
La sintaxis es la de EnvFilter: pares target=nivel separados por comas, con el nombre del crate
en guiones bajos. Subir un solo subsistema es barato y quirúrgico —
RUST_LOG=buzz_relay=info,buzz_auth=debug da el detalle del camino de autenticación sin inundar el
resto.
4.1 Trazas OpenTelemetry
El subscriber tiene dos capas independientes sobre el mismo flujo de spans y eventos: la capa fmt
en JSON hacia stdout, filtrada por RUST_LOG, y la capa OpenTelemetry, filtrada por
BUZZ_OTEL_FILTER. La segunda es opcional y no se conecta a nada si
OTEL_EXPORTER_OTLP_ENDPOINT no está definida: con la variable ausente el módulo de telemetría es
un no-op y los logs JSON siguen igual. Con ella definida, construye un SdkTracerProvider con
exportador OTLP por gRPC hacia tu colector.
| Variable | Default | Para qué |
|---|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | sin definir | Endpoint del colector; su ausencia desactiva las trazas |
OTEL_SERVICE_NAME | buzz-relay | Nombre del servicio en el recurso de traza |
OTEL_RESOURCE_ATTRIBUTES | — | Atributos extra; se superponen al final, así que un service.name aquí gana |
OTEL_TRACES_SAMPLER | parentbased_always_on | Muestreo |
OTEL_TRACES_SAMPLER_ARG | — | Argumento del muestreador |
BUZZ_OTEL_FILTER | sin definir | Filtro de targets solo para la capa OTEL |
BUZZ_OTEL_FILTER existe por una razón concreta: separa la verbosidad de logs de la de trazas, para
que subir RUST_LOG a debug durante una depuración no rompa la parentela de spans exportados. El
formateador JSON inyecta trace_id y span_id como cadenas hexadecimales en minúsculas al principio
del objeto cuando el evento ocurre dentro de un span OTEL válido — ese es el pegamento que permite
saltar de una línea de log a su traza. Los eventos fuera de un span válido conservan el formato JSON
estándar de tracing-subscriber.
5. docker-compose.harness.yml: el stack aislado de pruebas
Este archivo no tiene nada que ver con producción. Es un stack de respaldo aislado para correr un
relay de pruebas sin tocar el relay compartido de :3000 ni el stack de desarrollo por defecto.
Todo su diseño gira alrededor de no colisionar: proyecto Compose propio buzz-harness, sin
container_name fijos —esos nombres colisionan entre proyectos— y puertos alternos: Postgres
5471, Redis 6471, MinIO 9471 con consola 9472. Los servicios llevan healthchecks propios
(pg_isready -U buzz, redis-cli ping, /minio/health/live) y un minio-init one-shot que crea el
bucket buzz-media y le aplica mc anonymous set none.
docker compose -p buzz-harness -f docker-compose.harness.yml up -d # levantar
docker compose -p buzz-harness -f docker-compose.harness.yml down -v # bajar y borrar volumenes
El relay no está en este Compose: lo arranca scripts/start-isolated-test-relay.sh, que compila
desde el código de la rama actual y lo corre en primer plano con su propia terna de puertos: main
3030, health 8088 y metrics 9202. Ese último dato es el que conecta el harness con este
capítulo: para raspar el relay de pruebas el target no es 9102 sino 9202.
6. Benchmarks: qué mide cada carpeta
El repositorio trae dos artefactos de medición que responden preguntas distintas. Ninguno es un monitor de producción.
6.1 perf/ — escalado del bus de fan-out
perf/relay_bus_scaling.py aísla una sola frontera: el bus de Redis. No incluye ingesta en base
de datos, framing de WebSocket, renderizado de cliente ni lógica de negocio del relay. Verifica la
hipótesis del bus con scope por comunidad: en el modelo viejo cada pod recibía todos los eventos de
todas las comunidades; en el nuevo, cada pod retiene solo los topics de las comunidades resueltas por
el servidor para las que tiene suscriptores locales (buzz:{community_id}:global o
buzz:{community_id}:channel:{channel_id}).
# Modo medido (default): habla RESP directo contra Redis, solo stdlib de Python
REDIS_URL=redis://127.0.0.1:6379/0 ./perf/relay_bus_scaling.py --mode redis
./perf/relay_bus_scaling.py --mode model # aritmetica determinista, sin servicios
python3 -m unittest discover -s perf -p 'test_*.py' # tests que fijan el contrato
El escenario base es 64 comunidades × 100 eventos/s, una comunidad suscrita, pods = 1, 2, 4, con
una reducción documentada de 64× y 98,44 % de entrega irrelevante por pod en modo global contra
0,00 % en modo scoped. Lo relevante para un operador: el harness falla con código distinto de
cero salvo que la reducción observada sea al menos el 95 % del ideal
comunidades / comunidades_suscritas y que la entrega irrelevante en modo scoped no supere
--max-scoped-irrelevant-pct (default 0.0). Es una aserción, no un informe. El README advierte
además que la latencia del relay vivo, la capacidad de la base y el renderizado del cliente deben
medirse por separado con un stack completo.
6.2 benchmarks/ — orquesta de agentes sobre el stack real
benchmarks/harbor-buzz-orchestra es un agente personalizado de Harbor que corre un equipo definido
por manifiesto a través del stack real de Buzz. Mide capacidad de agentes, no rendimiento del
relay: un orquestador y N workers coordinan sobre relay y Postgres, y cada agente corre en el
contenedor de tarea como el mismo árbol buzz-acp → buzz-agent → buzz-dev-mcp del escritorio.
just benchmark # Terminal-Bench 2.1 completo, k=5
just benchmark --path <TASK_DIR> -k 1 # una tarea local, un intento
just benchmark -i "cobol*" --attempts 3 # subconjunto del dataset
just benchmark --gui # observar la corrida en vivo
just benchmark-down # parar el stack
just benchmark ejecuta benchmarks/harbor-buzz-orchestra/scripts/benchmark.py vía uv run y
levanta su propio stack Docker dedicado: proyecto Compose buzz-benchmark, relay en :3600,
Postgres en :5633 y secretos generados una vez en .benchmark/. No comparte nada con tu relay de
producción. Dos advertencias del README: con --gui, el usuario observador se agrega a la membresía
y se abre la app de escritorio —mirar, no escribir, porque un mensaje humano a mitad de una
prueba contamina la corrida—; y los binarios de agente subidos a los contenedores deben ser builds de
Linux acordes a la arquitectura de la imagen de tarea.
7. Push gateway: qué problema resuelve
buzz-push-gateway es el último salto público hacia APNs, pensado para push.buzz.xyz. La frase que
lo justifica está en la primera línea de docs/push-gateway-deployment.md: constrúyelo con
Dockerfile.push-gateway, no lo corras dentro de la imagen del relay y no le des credenciales de
APNs a los relays. Las notificaciones push a iOS requieren una clave proveedor .p8 de Apple y los
tokens de dispositivo de los usuarios; si cada relay auto-hospedado las tuviera, cualquier operador
podría enviar push arbitrario a cualquier dispositivo y cada relay comprometido filtraría tokens. El
gateway invierte la relación: los relays reciben capacidades opacas y nunca ven tokens APNs ni
credenciales del proveedor.
flowchart LR
app["Cliente iOS"] -->|"App Attest, enrolamiento y delegacion"| gw["buzz-push-gateway<br/>puerto publico 8080"]
gw --> apns["APNs de Apple"]
gw --> pg["PostgreSQL dedicado<br/>seis tablas push_gateway"]
relay["Relay Buzz"] -->|"capacidad opaca, NIP-98"| gw
lease["Lease NIP-PL cifrado"] --> relay
scrape["Prometheus"] --> gwh["Listener privado 8081<br/>_liveness _readiness /metrics"]
gwh -.-> gw
7.1 La imagen y el despliegue
Dockerfile.push-gateway es un build multi-stage con cargo-chef fijado en 0.1.71 sobre
rust:1.95-bookworm, que compila
cargo build --release --locked -p buzz-push-gateway --bin buzz-push-gateway, hace strip del
binario y lo copia a un runtime debian:bookworm-slim con solo ca-certificates. Corre como
buzz:buzz con uid/gid 1000, WORKDIR /var/lib/buzz, EXPOSE 8080 8081 y entrypoint
/usr/local/bin/buzz-push-gateway. La imagen publicada es ghcr.io/block/buzz-push-gateway.
El listener público es BUZZ_PUSH_BIND_ADDR (default 0.0.0.0:8080), donde se enruta
https://push.buzz.xyz; el privado de salud es BUZZ_PUSH_HEALTH_ADDR (default 0.0.0.0:8081) y
sirve /_liveness, /_readiness y /metrics. Readiness falla cuando la autoridad PostgreSQL no
está disponible, y el apagado deja de aceptar peticiones nuevas antes de drenar las llamadas APNs en
vuelo. El puerto 8081 nunca se expone públicamente: el chart no tiene ninguna concesión de
ingress de pod para él, y el tráfico de sondas de origen kubelet está exento de NetworkPolicy.
La configuración obligatoria incluye DATABASE_URL, BUZZ_PUSH_PUBLIC_DELIVERY_URL,
BUZZ_PUSH_MAX_GRANT_LIFETIME_SECONDS, BUZZ_PUSH_MAX_INSTALLATION_LIFETIME_SECONDS (default 90
días, máximo un año), BUZZ_PUSH_ENABLED_PROFILES, el App ID y el certificado raíz de App Attest,
las credenciales APNs (BUZZ_PUSH_APNS_KEY_PATH, _KEY_ID, _TEAM_ID, _TOPIC) y dos llaveros
AEAD independientes: BUZZ_PUSH_GRANT_KEYS y BUZZ_PUSH_TOKEN_KEYS, cuyos ids y bytes deben ser
distintos y nunca reutilizarse entre sí. Tres puntos operativos que suelen morder: todas las
réplicas comparten una sola base PostgreSQL, y debe ser dedicada al gateway, no la del relay,
porque SQLx guarda su historial _sqlx_migrations en public; el servicio siega desafíos
expirados, filas de replay, filas de cuota ociosas, delegaciones revocadas o expiradas e
instalaciones elegibles para retención al arrancar y cada cinco minutos; y Kubernetes no
reinicia pods cuando cambian los bytes de un Secret, así que rotar claves AEAD o APNs exige un
kubectl rollout restart explícito y verificar readiness antes de quitar las predecesoras.
Del lado del relay, BUZZ_PUSH_GATEWAY_DELIVERY_URL apunta por defecto a la URL pública exacta
https://push.buzz.xyz/v1/deliveries/apns. Puedes apuntarla a otra URL HTTPS exacta con la misma
ruta, o desactivar el push NIP-PL poniéndola en cadena vacía. Con el push activo, el relay
anuncia su descriptor NIP-PL en NIP-11 y arranca el matcher y el worker de entrega. Estado real: la
integración operativa del relay está completa, pero el uso extremo a extremo todavía requiere el
flujo de enrolamiento y delegación App Attest del cliente, que es lo que coloca una capacidad
opaca emitida por el gateway —y no un token APNs crudo— dentro del lease cifrado.
7.2 Métricas del gateway
Se sirven en GET /metrics del listener privado 8081, nunca del 8080 público. Las series
están saneadas y con cardinalidad acotada: los valores de etiqueta salen únicamente de conjuntos
cerrados. Ningún endpoint, token de dispositivo, pubkey de relay o id de petición se usa como
etiqueta.
| Métrica | Tipo | Etiquetas |
|---|---|---|
push_gateway_apns_deliveries_total | counter | outcome = accepted, invalid_endpoint, retry, refresh_credential, configuration_fault, permanent_request_fault |
push_gateway_apns_delivery_seconds | histogram | — |
push_gateway_apns_credential_refreshes_total | counter | — |
push_gateway_admissions_total | counter | result = admitted, rejected, unavailable |
push_gateway_delivery_errors_total | counter | class estática |
push_gateway_reaper_failures_total | counter | — |
push_gateway_readiness_failures_total | counter | cause = not_accepting, authority |
push_gateway_delivery_errors_total es deliberadamente estrecha: cuenta solo clases de salida
seleccionadas del handler /v1/deliveries/apns (invalid_grant, temporarily_unavailable,
profile_mismatch, token_custody, finish_failed). La validación en los handlers de enrolamiento,
delegación, rotación y revocación no entra aquí. Es una señal del camino caliente de entrega, no
una tasa total de errores de la API.
El scrape es opt-in y está apagado por defecto. Para activarlo: podMonitor.enabled=true
renderiza un PodMonitor sobre el puerto health, y networkPolicy.monitoring.enabled=true con
networkPolicy.monitoring.namespaceSelector / podSelector nombrando a tu scraper añade una sola
regla de ingress a 8081 acotada a esa fuente, nunca una concesión general.
7.3 Alertas que sí trae el proyecto
Esta es la única familia de alertas provista por el repositorio: un PrometheusRule opt-in del
chart del push gateway, activado con prometheusRule.enabled=true.
| Alerta | Dispara cuando | Severidad | Acción |
|---|---|---|---|
PushGatewayConfigurationFault | cualquier configuration_fault durante 10 m | critical | El token o topic del proveedor APNs está mal. Revisa la .p8, BUZZ_PUSH_APNS_KEY_ID, _TEAM_ID y _TOPIC. No se invalidan endpoints, pero no se entrega nada |
PushGatewayAdmissionUnavailable | cualquier admisión unavailable durante 5 m | critical | PostgreSQL inalcanzable. Revisa conectividad y la NetworkPolicy postgresEgressCidrs |
PushGatewayReadinessAuthorityFailing | fallos de readiness por authority durante 5 m | warning | Las réplicas salen del Service. Arregla la salud de la base antes de caer bajo el PodDisruptionBudget |
PushGatewayReaperFailing | el reaper falló ≥2 veces en 30 m (corre cada 5 m) | warning | No se barren reservas expiradas. Revisa la disponibilidad de escritura de la base |
PushGatewayHighApnsRetryRate | fracción reintentable > prometheusRule.apnsRetryRatioThreshold (default 0.25) en ventana de 10 m, sobre apnsRetryMinSamples (default 20) intentos, sostenido 15 m | warning | APNs degradado o limitando. Las entregas se retrasan, no se pierden |
8. Alertas para el relay: recomendación del operador
Hay que ser explícito, porque es la diferencia entre “lo dice el proyecto” y “lo dice este tutorial”:
El chart principal
deploy/charts/buzz/NO incluye ningúnPrometheusRule. Trae unServiceMonitoropt-in para que Prometheus raspe las métricas, y nada más. Toda la definición de alertas del relay es responsabilidad del operador. Las reglas y umbrales de esta sección son una recomendación de este capítulo, no configuración provista por el proyecto: valídalas contra tu propio tráfico antes de conectarlas a una guardia. Los únicos umbrales del proyecto son los del push gateway de la sección 7.3 y los defaults citados junto a su variable de entorno.
Críticas (paginan)
| Señal | Base | Umbral sugerido | Por qué |
|---|---|---|---|
| Relay no listo | /_readiness o up del job | Todas las réplicas no listas > 2 min | Corte total. Distingue shutting_down de not_ready mirando el cuerpo |
| Admisión no disponible | rate(buzz_admission_rejections_total{reason="unavailable"}[5m]) | > 0 sostenido 5 min | El limitador falla cerrado: rechaza tráfico legítimo |
| Pool de base agotado | buzz_db_pool_active / buzz_db_pool_max | > 0.9 durante 10 min | Con BUZZ_DB_POOL_SIZE default 50, precede al colapso de latencia |
| Redis con espera | buzz_redis_pool_waiting | > 0 sostenido 5 min | Fan-out y rate limits dependen de este pool |
Advertencias (ticket, no guardia)
| Señal | Base | Umbral sugerido | Por qué |
|---|---|---|---|
| Latencia de ingesta | p99 de buzz_event_processing_seconds | > 1 s durante 15 min | El bucket máximo de la escala es 5 s: por encima pierdes resolución |
| Backpressure | rate(buzz_ws_backpressure_disconnects_total[10m]) | Cualquier valor no trivial | Clientes lentos o BUZZ_SEND_BUFFER corto |
| Fallos de auth | rate(buzz_auth_failures_total{reason="nip42_invalid"}[15m]) | Salto sobre la línea base | Relojes desincronizados contra la ventana de ±60 s |
| Errores de auditoría | rate(buzz_audit_log_errors_total[15m]) | > 0 durante 15 min | La auditoría es fire-and-forget: nadie más te avisa |
| Barrido de almacenamiento | buzz_storage_sweep_ok | == 0 durante 2 h | Casi siempre es s3:ListBucket faltante |
| Timeouts de git | rate(buzz_git_upload_pack_timeouts_total[30m]) | > 0 sostenido | Repos grandes contra los límites de pack |
| Réplica y fan-out | buzz_db_replica_fence_lag_seconds, buzz_multinode_fanout_lag_total | Sobre tu SLO de frescura / crecimiento sostenido | Lecturas viejas o divergencia entre réplicas |
| Sin líder de uso | sum(buzz_usage_poller_is_leader) | == 0 durante 15 min | Nadie emite las métricas de uso |
Qué NO conviene alertar: buzz_admission_rejections_total{reason="quota"} (es el rate limiter
funcionando: grafícalo, no lo pagines); buzz_storage_sweep_failures como total acumulado entre pods
(es un gauge local al proceso que se reinicia en cada failover); los gauges *_pod tratados como
totales globales; y un /_mesh con {"enabled": false}, que es el estado normal sin BUZZ_MESH.
8.1 Verificación manual rápida
curl -fsS http://127.0.0.1:8080/_status # version y uptime: un uptime bajo delata reinicios
curl -sS -w '\n%{http_code}\n' http://127.0.0.1:8080/_readiness # el cuerpo dice cual dependencia fallo
curl -fsS http://127.0.0.1:9102/metrics \
| grep -E '^buzz_(ws_connections_active|subscriptions_active|admission_rejections_total|db_pool|redis_pool)'
En el bundle de Compose los puertos 8080 y 9102 no están publicados: entra al contenedor con
docker compose exec relay … o levanta el override BUZZ_COMPOSE_DEV=true para exponerlos
localmente. Nunca los abras a internet.
Resumen
- Tres puertos: aplicación en
3000(BUZZ_BIND_ADDR), salud en8080(BUZZ_HEALTH_PORT) y métricas Prometheus en9102(BUZZ_METRICS_PORT). En producción solo se publica el primero. /_readinesschequea Postgres y Redis en paralelo con timeout de 2 s y devuelve 503 con el detalle por dependencia; durante el drenaje respondeshutting_downantes de tocar la base.- El
prometheus.ymldel repositorio es solo de desarrollo:scrape_interval: 5scontrahost.docker.internal:9102, porque el relay corre en el host y Prometheus en Docker. En Kubernetes el chart trae unServiceMonitoropt-in coninterval: 30s. - El catálogo cubre conexiones, admisión, autenticación, pipeline de eventos, pools, fan-out, cachés,
auditoría, NIP-43, git, medios, uso por comunidad y barrido de almacenamiento. La cardinalidad se
controla con
BUZZ_USAGE_METRICS_PER_COMMUNITY, el crucekind × communityestá evitado a propósito y la etiquetacommunityes el host resuelto, no el UUID. - Los logs son JSON a stdout siempre, filtrados por
RUST_LOG(defaultbuzz_relay=info). Las trazas OTLP son opcionales y no se conectan sinOTEL_EXPORTER_OTLP_ENDPOINT;BUZZ_OTEL_FILTERsepara la verbosidad de trazas de la de logs y el formateador inyectatrace_idyspan_id. docker-compose.harness.ymles un stack aislado de pruebas bajo el proyectobuzz-harness, con puertos alternos y sincontainer_namefijos; su relay corre aparte en3030/8088/9202.perf/relay_bus_scaling.pymide y asegura la reducción del fan-out con scope por comunidad, ybenchmarks/harbor-buzz-orchestramide equipos de agentes sobre un stack Docker dedicado.- El push gateway existe para que los relays nunca vean credenciales APNs ni tokens de dispositivo.
Se construye con
Dockerfile.push-gateway, expone8080público y8081privado con/metrics, exige una base PostgreSQL dedicada, y su chart traePodMonitoryPrometheusRuleopt-in — las únicas alertas provistas por el proyecto. - El chart principal del relay no trae reglas de alerta: todo el alerting del relay es recomendación del operador, y la sección 8 es un punto de partida a validar contra tu tráfico.
Siguiente: Backups, migraciones y actualizaciones