Runbook de Respuesta a Incidentes¶
El procedimiento único y autoritativo para gestionar incidentes en producción en tlsstress.art — desde el primer page hasta el postmortem. Acompaña al manual de operación de observabilidad (
docs/observability/RUNBOOK.md) y a las reglas de alerta enobservability/cloud/prometheus/alerts.yml,observability/cloud/alertmanager/alertmanager.yml, yk8s/74-token-ledger-prometheus-rules.yaml.
Alcance. Este runbook cubre la ruta de dinero / aprovisionamiento / datos:
registro del cliente → pago Stripe → aprovisionamiento del tenant → ledger de la
token-economy → dashboard del operador on-prem, además de la plataforma que los
observa. No reemplaza los chaos drills por componente (chaos-*.md) ni el DR
drill (dr-drill.md); te dice cómo clasificar, triar, mitigar, comunicar y
aprender de un incidente en todos ellos.
Regla de oro. Mitiga primero el impacto al cliente, encuentra la causa raíz después. Un cliente que pagó sin tenant, un ledger corrupto o una base de datos que la app no alcanza son todos SEV-1 hasta que se demuestre lo contrario.
1. Clasificación de severidad¶
La severidad se fija por el impacto al cliente/financiero, no por qué componente se rompió. Fíjala en los primeros 5 minutos; puede subir o bajar conforme descubres más.
| Severidad | Definición | Ejemplos | ¿Page? | Cadencia de comunicación |
|---|---|---|---|---|
| SEV-1 | Dinero perdido/en riesgo, corrupción de datos, o un cliente pagado roto de forma material | ProvisioningJobsStuck (pagado, sin tenant), TokenLedgerOverspentUtxo (corrupción de ledger), AppDBDown (DB inalcanzable), RDSStorageCritical, R2BucketPublic |
Sí (pager critical) | Cada 30 min |
| SEV-2 | Degradado pero sin dinero perdido aún; indicador anticipado de un SEV-1 | RDSConnectionsHigh, RDSStorageLow, TokenLedgerStaleBillingWebhooks, StripeFailedPaymentsSpike, DashboardDBDown |
Sí si es de madrugada | Cada 2 h |
| SEV-3 | Localizado / cosmético / vantage única; sin impacto al cliente | JourneyVantageChallenged, CloudflareThreatSpike (info), RUMPoorLCP, una vantage sintética inestable |
No | En la resolución |
La severidad también es una etiqueta en la alerta. severity="critical" en
Prometheus enruta al receiver pager (re-page cada hora + Slack @channel);
warning/info caen en el firehose #tlsstress-observability. Ver
alertmanager.yml.
2. El flujo de guardia (triaje → mitigación → resolución → postmortem)¶
flowchart TD
A([Alerta dispara / reporte recibido]) --> B{Reconocer<br/>en 5 min}
B -->|pager critical| C[Abrir el incidente:<br/>fijar SEV, iniciar hilo]
B -->|firehose Slack| C
C --> D{Clasificar severidad<br/>por impacto dinero/cliente}
D -->|SEV-1| E[Declarar incidente:<br/>IC + escriba + comunicación]
D -->|SEV-2/3| F[Respondedor único]
E --> G[TRIAJE: leer el runbook_url<br/>de la alerta + dashboards golden]
F --> G
G --> H{¿Causa raíz conocida<br/>o mitigación obvia?}
H -->|No| I[Escalar al dueño del dominio<br/>ver §6 escalamiento]
I --> G
H -->|Sí| J[MITIGAR: detener el sangrado<br/>self-heal / rollback / escalar]
J --> K{¿Impacto al cliente<br/>detenido?}
K -->|No| L[Subir la severidad,<br/>ampliar el escalamiento]
L --> J
K -->|Sí| M[RESOLVER: verificar alerta limpia,<br/>datos reconciliados, sin backlog]
M --> N{¿SEV-1 o SEV-2?}
N -->|Sí| O[POSTMORTEM<br/>en 5 días hábiles]
N -->|No| P([Cerrar incidente])
O --> Q[Ítems de acción seguidos<br/>hasta su conclusión]
Q --> P
2.1 Triaje (primeros 10 minutos)¶
- Reconoce el page para que el equipo sepa que está asumido (silencia el re-page del pager).
- Lee la alerta. Toda alerta crítica de dinero/DB lleva un
runbook_url— síguelo. Las anotacionessummaryydescriptionestán escritas para decirte qué se rompió y la primera acción. - Abre los dashboards golden (
…/grafana):cloud-overview,business-revenue,aws-infra, y el cockpit del operador para incidentes DUT-side. Confirma que la alerta es real (no una sola vantage inestable) y halla el radio de impacto. - Busca una señal más fuerte. Un
criticala menudo viene conwarnings correlacionados (p. ej.RDSConnectionsHigh→AppDBDown). Lasinhibit_rulesdel Alertmanager suprimen los warnings derivados, así que confía en el critical.
2.2 Mitigar¶
Detén el impacto al cliente con la palanca segura más rápida, aunque sea
temporal: self-heal (dejar que obs-db-selfheal redespliegue), rollback del último
deploy, escalar el recurso, o poner feature-flag en la ruta rota. Documenta cada
acción en el hilo — el postmortem necesita la línea de tiempo.
2.3 Resolver¶
Un incidente solo está resuelto cuando todas estas condiciones se cumplen:
- La alerta que disparó se limpió (y se mantiene limpia durante un repeat_interval).
- El impacto al cliente desapareció (re-ejecuta el sintético / journey relevante).
- Los datos están reconciliados — ningún job de aprovisionamiento atascado,
ninguna UTXO over-spent, ningún backlog de webhook de billing sin procesar.
- La mitigación es duradera, o hay un follow-up registrado para hacerla duradera.
2.4 Postmortem¶
Todo SEV-1 y SEV-2 recibe un postmortem sin culpables en 5 días hábiles (plantilla en §8). Los SEV-3 reciben una línea en el hilo del incidente.
3. Alertas clave de dinero / aprovisionamiento / datos¶
Son las alertas que, al dispararse, significan que un cliente está o puede estar
pagando por nada — o que el ledger financiero está mal. Trátalas como SEV-1 a menos
que el triaje pruebe lo contrario. Fuente de verdad de las alertas de ledger:
k8s/74-token-ledger-prometheus-rules.yaml.
3.1 ProvisioningJobsStuck — cliente pagado, sin tenant (SEV-1)¶
- Expr:
tlsstress_provisioning_stuck > 0por 30m · severity: critical. - Significa: uno o más jobs de aprovisionamiento están atascados (>15 min sin progreso, o ≥8 intentos). Un cliente que pagó no recibió su tenant. Es la peor clase de incidente: pérdida silenciosa de dinero con un cliente insatisfecho.
- Primeras acciones:
- Lee el
runbook_urlde la alerta (RUNBOOK#provisioning-stuck). - Revisa el orchestrator Temporal para el/los workflow(s) que fallaron y el
cron
reconcile-provisioning— ¿está corriendo y progresando? - Identifica al/los cliente(s) afectado(s) y el paso de la saga que falló. No
re-drive a ciegas: un re-drive ingenuo puede acuñar un segundo deployment
dpl_o poner en cero una quota pagada. Re-drive solo el paso que falló. - Si no puedes aprovisionar dentro del SLA, comunícate con el cliente (§7) y prepara una ruta de reembolso/crédito con finanzas.
3.2 AppDBDown — la app no alcanza su base de datos (SEV-1)¶
- Expr:
probe_success{job="blackbox-ready"} == 0por 2m · severity: critical. - Significa: la sonda profunda
/api/ready(que ejecutaSELECT 1en cada pool) está fallando → la app está arriba pero su DB es inalcanzable, o la app está caída. Es la alerta que la caída del admin del 2026-06-16 no tenía: la app servíaHTTP 200amable desde el/api/healthsuperficial mientras su DB estaba inalcanzable, así que nada disparó por ~33 horas. - Primeras acciones (RCA completo en RUNBOOK
#61-db-unreachable): curl -s https://<app>.tlsstress.art/api/ready | jq— ve qué check de DB estáok:false.- El
obs-db-selfhealya redespliega el servicio App Runner en un 503 (re-obtiene el secret + reconstruye el pool). Observa el deploy; si recupera, terminaste. - ¿Persiste? Verifica que el RDS esté
available(aws rds describe-db-instances) y que elDATABASE_URLde la app autentique como su rol no-maestro dedicado (octopus_admin_app/tlsstress_app) — nunca el maestro rotadotlsstress_admin. RDS rota el secret-maestro gestionado cada ~7 días; una app apuntada al maestro se rompe en cada rotación.
La lección del 2026-06-16, codificada. Las apps ahora conectan como roles no-maestros dedicados, el
/api/readyes una sonda profunda, elAppDBDowndispara en ~2 min, y elobs-db-selfhealredespliega automáticamente. Si ves "DB indisponible" en una app pero el blackbox está verde, estás viendo un health check superficial — sondea/api/ready, no/api/health.
3.3 Integridad del ledger — las alertas de la token-economy (SEV mixta)¶
El ledger de la token-economy es la fuente de verdad financiera. Sus alertas viven
en k8s/74-token-ledger-prometheus-rules.yaml y hacen scrape de
app.tlsstress.art/api/metrics (bearer del AWS Secrets Manager
tlsstress-phase0/metrics-token).
| Alerta | Expr | Sev | Significa + primera acción |
|---|---|---|---|
TokenLedgerOverspentUtxo |
tlsstress_utxo_overspent > 0 (1m) |
critical | Corrupción de ledger — una nota con spent_amount > amount. El CHECK de la base lo hace imposible, así que un valor no-cero es un bug profundo. Congela los rescates, investiga de inmediato, no dejes que se propague. |
ProvisioningJobsStuck |
tlsstress_provisioning_stuck > 0 (30m) |
critical | Ver §3.1 — cliente pagado sin tenant. |
TokenLedgerScrapeDown |
absent(tlsstress_tsu_circulating) (10m) |
warning | Sin métricas del ledger por 10m — METRICS_TOKEN rotado sin actualizar el secret montado, o /api/metrics caído. Quedas ciego a todas las alertas de ledger hasta arreglarlo. |
TokenLedgerStaleBillingWebhooks |
tlsstress_webhooks_stale_billing > 0 (30m) |
warning | Webhooks Stripe de billing atascados no-terminales >1h — un crédito puede no haberse aplicado (cliente pagó, saldo no recargado). Revisa el procesador de webhooks + el dashboard Stripe. |
TokenLedgerStaleActiveTickets |
tlsstress_tickets_stale_active > 0 (30m) |
warning | Tickets ACTIVE más allá de expiry+24h — el sweep tlsstress-expire-tickets-hourly está enfermo. |
TokenLedgerOutboxBacklog |
tlsstress_outbox_pending > 100 (30m) |
warning | Outbox de webhooks acumulándose (>100 no entregados) — revisa deliver-webhooks + salud de los receptores. |
TokenLedgerUsageWithoutLiveness |
tlsstress_usage_without_liveness > 0 (1h) |
warning | Una licencia reportó uso en 24h sin heartbeat — posible tamper de token / clock-skew / replay de token copiado. El cobro lo cubre L2; esta es una señal de integridad. |
3.4 Saturación de RDS — page antes del 503 (SEV-2 → SEV-1)¶
El AppDBDown solo dispara cuando el DB ya está totalmente inalcanzable. El
exporter aws-rds-metrics page en la saturación primero (umbrales ajustados
para db.t4g.micro, ~112 conexiones máx, 1 GB RAM — re-ajusta al redimensionar):
RDSConnectionsHigh(> 90, warning) — agotamiento de pool acercándose; nuevas conexiones rechazan antes de que/api/readycambie. Revisa PgBouncer / fuga de conexión.RDSStorageLow(< 2 GB, warning) /RDSStorageCritical(< 1 GB, critical) — RDS detiene las escrituras cuando el storage se agota. Aumenta el storage asignado ahora:aws rds modify-db-instance --allocated-storage.RDSCPUHigh(> 85%, warning) /RDSMemoryLow(< 100 MB, warning) — presión de carga / riesgo de OOM.RDSMetricsBlind(rds_metrics_scrape_ok == 0, warning) — el exporter no puede leer CloudWatch (key RO expirada / IAM / throttling). Todas las alertas RDS de arriba quedan silenciosas hasta que esto recupere.
3.5 Cockpit del operador DUT-side — DashboardDown / DashboardDBDown (SEV-2)¶
Estos disparan en el Prometheus on-prem, no en la caja cloud
(observability/prometheus/alerts/web-agent-alerts.yml):
DashboardDown(up{job="dashboard"} == 0, critical) — el proceso/scrape del cockpit del operador es inalcanzable. Según CLAUDE.md, el dashboard es la única interfaz del operador, así que esto ciega al operador.DashboardDBDown(dashboard_db_up == 0, critical) — el cockpit está arriba pero su Postgres es inalcanzable (la clase de fallo de DB silencioso del 2026-06-16, reflejada DUT-side). El cockpit muestra datos congelados y un badge rojo en vivo "sem dados (DB)". Revisa el PgBouncer / Postgres on-prem; el/api/readydel dashboard es la sonda profunda.
3.6 Señales de negocio / pago (SEV-2/3)¶
Del grupo business de la cloud (poller RO de Stripe):
- StripeFailedPaymentsSpike (> 5 hoy, warning) — indicador anticipado de un
problema de tarjeta/procesador o fraude.
- StripeOpenDisputes (> 0, warning) — los chargebacks necesitan respuesta humana
dentro de la ventana de Stripe.
4. El dead-man's-switch (quién vigila al vigía)¶
Dos meta-alertas mantienen honesta a la plataforma:
Watchdog(vector(1), siempre disparando) enruta al receiverdeadman→ un heartbeat externo en healthchecks.io. Cuando el heartbeat deja de llegar, el servicio externo te page — es decir, cuando todo el stack de obs está caído y ya no puede alertar. Es la única alerta que quieres mantener disparando.ObsComponentDown(up{job=~"grafana|loki|tempo|vector|prometheus|…"} == 0, critical) — un componente de obs cayó; la plataforma puede estar parcialmente ciega.
Si dejas de recibir cualquier alerta y la caja se ve bien, sospecha de la ruta de alerta (webhook Slack rotado, Alertmanager atascado) antes de asumir que todo está bien.
5. Chuleta de primera respuesta¶
# Caja de obs cloud (Hetzner)
ssh -i ~/.ssh/tlsstress_f2_hetzner root@89.167.3.1
cd /opt/obs && docker compose ps # ¿el stack está sano?
docker compose logs -f alertmanager # ¿la alerta fluye?
# ¿El DB de una app es realmente alcanzable? (sonda profunda — lección del 2026-06-16)
curl -s https://app.tlsstress.art/api/ready | jq
curl -s https://admin.tlsstress.art/api/ready | jq
# ¿Métricas del ledger vivas? (bearer en AWS SM tlsstress-phase0/metrics-token)
# tlsstress_tsu_circulating ausente → TokenLedgerScrapeDown
# tlsstress_provisioning_stuck > 0 → ProvisioningJobsStuck
# tlsstress_utxo_overspent > 0 → TokenLedgerOverspentUtxo (corrupción)
# Estado del RDS cuando AppDBDown / RDS* dispara
aws rds describe-db-instances --query 'DBInstances[].DBInstanceStatus'
6. Escalamiento¶
Escala cuando: no puedas mitigar en los primeros 30 minutos de un SEV-1, el radio de impacto esté creciendo, el fix necesite una acción privilegiada que no posees, o la integridad de dinero/datos esté en duda.
| Dominio | Señal | Dueño / ruta |
|---|---|---|
| Aprovisionamiento / saga | ProvisioningJobsStuck |
Dueño del orchestrator Temporal; cron reconcile-provisioning; no re-drive a ciegas |
| Base de datos / RDS | AppDBDown, RDS*, DashboardDBDown |
Guardia de DB; DDL in-VPC vía instancia NAT (SSM) — abre el ingress temp de SG hacia sg-02bf33572b96f2855 + lectura temp de secret, quita ambos después |
| Dinero / ledger | TokenLedger*, Stripe* |
Finanzas + dueño del ledger; congela rescates en caso de corrupción |
| Edge / Cloudflare | EdgeVantageDown, CloudflareThreatSpike |
Dueño del edge; WAF / DNS |
| Backups / DR | R2Backup*, R2BucketPublic, R2BucketLockDisabled |
Dueño de DR; R2BucketPublic es un SEV-1 de seguridad — cierra el acceso público ahora |
El pager. Las alertas
criticalre-pagean cada hora y hacen@channelen Slack. Para paging garantizado de teléfono de madrugada, cablea el slot pre-construido enalertmanager.yml(ntfy.sh$0, PagerDuty free tier, o SMTP) — ver RUNBOOK §6.3. Hasta entonces, un critical a las 3 a.m. depende de que alguien esté mirando Slack.
7. Comunicación¶
| Audiencia | Cuándo | Canal | Contenido |
|---|---|---|---|
| Interno (hilo del incidente) | En la declaración, luego en cada cadencia (§1) | #tlsstress-observability |
SEV, impacto, hipótesis actual, hora del próximo update |
| Cliente(s) afectado(s) | SEV-1 con impacto al cliente (p. ej. aprovisionamiento fallido), en cuanto esté acotado | E-mail de @tlsstress.art |
Qué está afectado, que estás en ello, ETA o próximo update — sin jerga interna, sin culpa |
| Status page | Outage visible al cliente | https://status.tlsstress.art |
Estado de componente en lenguaje simple; actualiza al mitigar + resolver |
Reglas prácticas: un canal de incidente (sin hilos paralelos); el Incident Commander es dueño de la cadencia de comunicación; nunca prometas una causa raíz antes de tenerla; subestima los ETAs. Para casos de aprovisionamiento/reembolso, involucra a finanzas temprano.
8. Plantilla de postmortem¶
Copia esto en docs/postmortems/YYYY-MM-DD-<slug>.md. Sin culpables — foco en
sistemas y señales, nunca en individuos.
# Postmortem — <título corto> (<YYYY-MM-DD>)
- **Severidad:** SEV-_ · **Duración:** <detección→resolución> · **Impacto al cliente:** <quién/qué/$>
- **Detectado por:** <nombre de la alerta | reporte del cliente | manual> · **Retraso de detección:** <inicio-del-impacto → primera alerta>
- **Incident Commander:** <nombre> · **Escriba:** <nombre>
## Resumen
<2–3 frases: qué se rompió, a quién afectó, cómo se resolvió.>
## Línea de tiempo (UTC)
| Hora | Evento |
|---|---|
| 03:28 | <p. ej. secret-maestro del RDS rotado> |
| ... | <primera alerta / page> |
| ... | <mitigación aplicada> |
| ... | <resuelto> |
## Causa raíz
<La cadena real de causalidad. Usa "5 porqués". Distingue el disparador de la
condición subyacente que lo hizo posible.>
## Detección
<¿Disparó la alerta correcta? ¿Qué tan rápido? Si la detección se retrasó (el caso
~33h-ciego del 2026-06-16: /api/health superficial, 200 amable, sin alerta de DB),
ese gap es en sí mismo un ítem de acción.>
## Resolución & recuperación
<Qué detuvo el sangrado. ¿Se reconciliaron los datos (jobs atascados limpiados,
ledger consistente, backlog de webhook drenado)?>
## Qué salió bien / qué salió mal
- Salió bien: <p. ej. self-heal redesplegó automáticamente>
- Salió mal: <p. ej. sin pager de teléfono → 4h para reconocer>
## Ítems de acción (dueño · fecha · seguimiento)
| Acción | Dueño | Fecha | Link |
|---|---|---|---|
| <p. ej. agregar alerta de /api/ready profundo> | | | |
## Lecciones / prevenciones
<Qué cambio sistémico previene esta *clase* de incidente — no solo esta instancia.>
9. Runbooks & referencias relacionados¶
- Operación de observabilidad:
docs/observability/RUNBOOK.md(§6 incidentes, §6.1 DB-inalcanzable, §6.2 RDS, §6.3 pager) - Alertas de ledger:
k8s/74-token-ledger-prometheus-rules.yaml - Reglas de alerta cloud + enrutamiento:
observability/cloud/prometheus/alerts.yml,observability/cloud/alertmanager/alertmanager.yml - Alertas del dashboard DUT-side:
observability/prometheus/alerts/web-agent-alerts.yml - DR drill:
docs/runbooks/dr-drill.md· Restauración:docs/runbooks/restore-from-backup.md - Arquitectura:
docs/ARCHITECTURE.md