Observabilidad: métricas, logs y salud del relay

Por: Artiko
buzznostrobservabilidadprometheusmetricaslogsopentelemetrysre

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.

RutaPuertoQué devuelve
/_liveness8080 y 3000200 OK con el texto ok. No consulta nada
/_readiness8080 y 3000JSON con el estado de las dependencias
/_status8080{"service":"buzz-relay","version":"…","uptime_seconds":N}
/_mesh8080Estado del mesh; con mesh apagado, {"enabled": false}
/health3000200 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:

  1. 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.
  2. 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.
  3. 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 con 503 relay restarting mientras 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étricaTipoEtiquetasSignificado
buzz_ws_connections_activegaugeConexiones WebSocket vivas en este proceso
buzz_subscriptions_activegaugeSuscripciones REQ registradas
buzz_ws_auth_timeouts_totalcounterCierres por no completar NIP-42 a tiempo
buzz_ws_backpressure_disconnects_totalcounterDesconexiones por cliente lento con buffer lleno
buzz_ws_send_batch_sizehistogramTamaño de los lotes de envío
buzz_admission_rejections_totalcountertransport = websocket | http, reason = quota | unavailableRechazos del rate limiter compartido
buzz_auth_attempts_totalcounterIntentos de autenticación
buzz_auth_failures_totalcounterreason = nip42_invalid, allowlist_denied, not_relay_member, …Fallos por motivo
buzz_events_received_totalcounterkindIngesta de flota, sin etiqueta de comunidad
buzz_community_events_received_totalcountercommunityIngesta por comunidad, sin etiqueta de kind
buzz_event_processing_secondshistogramPipeline completo, no solo el insert
buzz_fanout_recipientshistogramDestinatarios 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

VariableDefaultEfecto
BUZZ_USAGE_METRICS_INTERVAL_SECS300, piso 5Cada cuánto corre el poller de uso
BUZZ_USAGE_METRICS_IDLE_TIMEOUT_SECS900, elevado a al menos 3× el intervaloVida de un gauge sin refresco
BUZZ_USAGE_METRICS_PER_COMMUNITYallall emite series por comunidad; off deja solo totales de flota
BUZZ_POOL_METRICS_INTERVAL_SECS10, piso 1Cadencia 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.

VariableDefaultPara qué
OTEL_EXPORTER_OTLP_ENDPOINTsin definirEndpoint del colector; su ausencia desactiva las trazas
OTEL_SERVICE_NAMEbuzz-relayNombre del servicio en el recurso de traza
OTEL_RESOURCE_ATTRIBUTESAtributos extra; se superponen al final, así que un service.name aquí gana
OTEL_TRACES_SAMPLERparentbased_always_onMuestreo
OTEL_TRACES_SAMPLER_ARGArgumento del muestreador
BUZZ_OTEL_FILTERsin definirFiltro 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-acpbuzz-agentbuzz-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étricaTipoEtiquetas
push_gateway_apns_deliveries_totalcounteroutcome = accepted, invalid_endpoint, retry, refresh_credential, configuration_fault, permanent_request_fault
push_gateway_apns_delivery_secondshistogram
push_gateway_apns_credential_refreshes_totalcounter
push_gateway_admissions_totalcounterresult = admitted, rejected, unavailable
push_gateway_delivery_errors_totalcounterclass estática
push_gateway_reaper_failures_totalcounter
push_gateway_readiness_failures_totalcountercause = 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.

AlertaDispara cuandoSeveridadAcción
PushGatewayConfigurationFaultcualquier configuration_fault durante 10 mcriticalEl 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
PushGatewayAdmissionUnavailablecualquier admisión unavailable durante 5 mcriticalPostgreSQL inalcanzable. Revisa conectividad y la NetworkPolicy postgresEgressCidrs
PushGatewayReadinessAuthorityFailingfallos de readiness por authority durante 5 mwarningLas réplicas salen del Service. Arregla la salud de la base antes de caer bajo el PodDisruptionBudget
PushGatewayReaperFailingel reaper falló ≥2 veces en 30 m (corre cada 5 m)warningNo se barren reservas expiradas. Revisa la disponibilidad de escritura de la base
PushGatewayHighApnsRetryRatefracción reintentable > prometheusRule.apnsRetryRatioThreshold (default 0.25) en ventana de 10 m, sobre apnsRetryMinSamples (default 20) intentos, sostenido 15 mwarningAPNs 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ún PrometheusRule. Trae un ServiceMonitor opt-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ñalBaseUmbral sugeridoPor qué
Relay no listo/_readiness o up del jobTodas las réplicas no listas > 2 minCorte total. Distingue shutting_down de not_ready mirando el cuerpo
Admisión no disponiblerate(buzz_admission_rejections_total{reason="unavailable"}[5m])> 0 sostenido 5 minEl limitador falla cerrado: rechaza tráfico legítimo
Pool de base agotadobuzz_db_pool_active / buzz_db_pool_max> 0.9 durante 10 minCon BUZZ_DB_POOL_SIZE default 50, precede al colapso de latencia
Redis con esperabuzz_redis_pool_waiting> 0 sostenido 5 minFan-out y rate limits dependen de este pool

Advertencias (ticket, no guardia)

SeñalBaseUmbral sugeridoPor qué
Latencia de ingestap99 de buzz_event_processing_seconds> 1 s durante 15 minEl bucket máximo de la escala es 5 s: por encima pierdes resolución
Backpressurerate(buzz_ws_backpressure_disconnects_total[10m])Cualquier valor no trivialClientes lentos o BUZZ_SEND_BUFFER corto
Fallos de authrate(buzz_auth_failures_total{reason="nip42_invalid"}[15m])Salto sobre la línea baseRelojes desincronizados contra la ventana de ±60 s
Errores de auditoríarate(buzz_audit_log_errors_total[15m])> 0 durante 15 minLa auditoría es fire-and-forget: nadie más te avisa
Barrido de almacenamientobuzz_storage_sweep_ok== 0 durante 2 hCasi siempre es s3:ListBucket faltante
Timeouts de gitrate(buzz_git_upload_pack_timeouts_total[30m])> 0 sostenidoRepos grandes contra los límites de pack
Réplica y fan-outbuzz_db_replica_fence_lag_seconds, buzz_multinode_fanout_lag_totalSobre tu SLO de frescura / crecimiento sostenidoLecturas viejas o divergencia entre réplicas
Sin líder de usosum(buzz_usage_poller_is_leader)== 0 durante 15 minNadie 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 en 8080 (BUZZ_HEALTH_PORT) y métricas Prometheus en 9102 (BUZZ_METRICS_PORT). En producción solo se publica el primero.
  • /_readiness chequea Postgres y Redis en paralelo con timeout de 2 s y devuelve 503 con el detalle por dependencia; durante el drenaje responde shutting_down antes de tocar la base.
  • El prometheus.yml del repositorio es solo de desarrollo: scrape_interval: 5s contra host.docker.internal:9102, porque el relay corre en el host y Prometheus en Docker. En Kubernetes el chart trae un ServiceMonitor opt-in con interval: 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 cruce kind × community está evitado a propósito y la etiqueta community es el host resuelto, no el UUID.
  • Los logs son JSON a stdout siempre, filtrados por RUST_LOG (default buzz_relay=info). Las trazas OTLP son opcionales y no se conectan sin OTEL_EXPORTER_OTLP_ENDPOINT; BUZZ_OTEL_FILTER separa la verbosidad de trazas de la de logs y el formateador inyecta trace_id y span_id.
  • docker-compose.harness.yml es un stack aislado de pruebas bajo el proyecto buzz-harness, con puertos alternos y sin container_name fijos; su relay corre aparte en 3030/8088/9202. perf/relay_bus_scaling.py mide y asegura la reducción del fan-out con scope por comunidad, y benchmarks/harbor-buzz-orchestra mide 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, expone 8080 público y 8081 privado con /metrics, exige una base PostgreSQL dedicada, y su chart trae PodMonitor y PrometheusRule opt-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