# 09 · Observabilidad

El catálogo completo de incidencias, **cuáles están vivas hoy** y cuáles despiertan al activar
el canal inverso.

---

## 9.1 El principio

Con crontab y colas en vez de un operador mirando la pantalla, **el correo no es un extra: es
EL mecanismo de detección.** Una reservación que no se replica es invisible hasta que el socio
reclama — y enterarse por el socio es el peor desenlace posible.

Pero un canal de alertas que grita se apaga solo. Tres reglas resuelven esa tensión:

1. **Un solo correo por ejecución**, agregando todas las incidencias del ciclo.
2. **Throttle por firma**, con ventana **por código**.
3. **Bitácora siempre**, esté el correo throttleado o el monitoreo apagado.

La tercera es la que hace que "no silencioso" sea cierto. `MonitoreoReplicacion` —la clase
anterior— throttlea pero **no persiste**: una incidencia silenciada desaparecía, y esa fila es
justo la que hace falta después de un reclamo.

---

## 9.2 Catálogo `R1…R18`

| Código | Título | Estado hoy | Dónde |
|---|---|---|---|
| **`R1`** | **Venta esperando veredicto** | 🟢 **Activa** | Portal |
| `R2` | Veredicto no entregado | 🟢 Activa | Reservaciones V2 |
| `R3` | Fallo del canal de escritura | 🟢 Activa | Ambos |
| `R4` | Membresía no localizada | ⚪ Inerte* | Ambos |
| `R5` | Membresía inactiva | 🟢 Activa | Reservaciones V2 |
| `R6` | Rezago del outbox | 🟢 Activa | Ambos |
| `R7` | Réplica descartada (DLQ) | 🟢 Activa | Reservaciones V2 |
| `R8` | Cortacircuitos abierto | ⚪ Inerte* | Reservaciones V2 |
| `R9` | Catálogo desconocido | ⚪ **Inerte** | Portal |
| **`R10`** | **Snapshot ignorado por maestría privada** | 🟢 **Activa** | Reservaciones V2 |
| `R11` | Divergencias del reconciliador | ⚪ Inerte* | Reservaciones V2 |
| `R12` | Adopción por respaldo | ⚪ **Inerte** | Portal |
| `R13` | Descuadre de importes | ⚪ **Inerte** | Portal |
| `R14` | Canal de avisos al socio degradado | 🟢 Activa | Portal |
| `R15` | Leases vencidos | 🟢 Activa | Ambos |
| `R16` | *(reservado: descuadre al confirmar)* | — | Reservaciones V2 |
| `R17` | Solicitud no replicada todavía en el portal | 🟢 Activa | Portal |
| `R18` | **Snapshot de catálogo rechazado** | ⚪ **Inerte** | Portal |

\* Inertes en el **portal** porque su canal está apagado; en Reservaciones V2 dependen de que su
consumidor esté encendido.

**Las tres que despiertan al activar el canal inverso: `R9`, `R12` y `R13`.**

---

## 9.3 Las dos que más importan

### `R1` — venta esperando veredicto

**La alerta más importante del sistema.** El socio ya pagó y su reservación no existe en
ninguna parte como venta firme.

Hasta la Fase −1 esto solo escribía un `Log::warning` en `reservaciones:alertar-confirmando`,
así que el único camino real de detección era **la llamada del socio** — exactamente lo que el
docblock de ese comando decía que existía para evitar.

Su ventana de throttle es **10 minutos, no 60**: cuando hay dinero cobrado, esperar una hora
para el segundo aviso es inaceptable.

### `R10` — el detector de que el corte falló

**En régimen normal no debe sonar jamás.** Si suena una sola vez, algo está intentando pisar al
maestro y hay que mirarlo el mismo día.

Va **sin throttle** por eso mismo. Silenciarlo sería silenciar justo la señal que justifica las
cuatro capas de corte.

---

## 9.4 Throttle por código

```php
'throttle_minutos' => [
    'defecto' => 60,
    'R1'      => 10,   // dinero cobrado: una hora es inaceptable
    'R7'      => 0,    // dispara en la TRANSICION, evento unico por marca
    'R10'     => 0,    // nunca se silencia
],
```

**`0` = sin throttle, y es seguro SOLO donde se usa.** `R7` dispara en la *transición* a
`Descartado` —un evento único por marca, no un estado— y `R10` no debe ocurrir nunca. Ponerle
`0` a `R6` sería un error: ése sí se repite cada ciclo, y con un cron de un minuto serían 1,440
correos diarios del mismo caso.

### La trampa de `R7`

Se alerta en la **transición** a `Descartado`, **jamás** mientras siga ahí. Lo que ya está
descartado se reporta como **conteo** en el resumen del ciclo.

---

## 9.5 La firma: por qué diez reservaciones son un aviso

```php
$ancla = $datos['idMembresia'] ?? $datos['membresiaNo'] ?? $datos['idReservacion'] ?? 'global';
return $tipo . '|' . $ancla;
```

**Anclar a la membresía** es lo que hace que diez reservaciones del mismo socio con el mismo
problema sean un aviso y no diez. Un padrón desincronizado no son diez incidencias: es **una
con diez víctimas**.

> **Historia real, y la razón de que esto esté escrito aquí:** la primera versión de `R13`
> anclaba a `idReservacion` y comparaba la cabecera contra un detalle vacío. Como Reservaciones V2 filtra
> las habitaciones canceladas del snapshot, **las 57 reservaciones canceladas del portal
> disparaban una alerta cada una: 48 correos de un solo backfill.** El throttle funcionaba; la
> incidencia no debía existir. Se corrigió en dos frentes — no alertar cuando no hay detalle
> que cuadrar, y anclar la firma a la membresía.

---

## 9.6 `limpiarAviso()` — el patrón intermitente

```php
MonitorReservaciones::limpiarAviso('reconciliar-cupo|R6|holds');
```

Se llama cuando la incidencia **se resuelve**. Sin esto, un problema que aparece, se arregla y
reaparece dentro de la ventana pasaría mudo — el patrón intermitente, el más caro de
diagnosticar y el que más conviene ver repetirse.

Lo usan `reservaciones:reconciliar-cupo` y `reservaciones:alertar-confirmando` cuando su ciclo
sale limpio.

---

## 9.7 La bitácora

`rsv_bitacorareplicacion` guarda **todo**, incluso lo silenciado.

```sql
-- Incidencias por tipo, últimas 24 h
SELECT bitacoraReplicacionTipo, COUNT(*) n,
       SUM(bitacoraReplicacionAvisada) avisadas
  FROM rsv_bitacorareplicacion
 WHERE bitacoraReplicacionFecha >= NOW() - INTERVAL 1 DAY
 GROUP BY 1 ORDER BY n DESC;

-- Todo lo de una reservación
SELECT bitacoraReplicacionFecha, bitacoraReplicacionTipo,
       bitacoraReplicacionMensaje, bitacoraReplicacionAvisada
  FROM rsv_bitacorareplicacion
 WHERE idReservacion = ? ORDER BY idBitacoraReplicacion DESC;

-- Lo que se SILENCIO (la pregunta despues de un reclamo)
SELECT * FROM rsv_bitacorareplicacion
 WHERE bitacoraReplicacionAvisada = 0
   AND bitacoraReplicacionFecha >= NOW() - INTERVAL 7 DAY;
```

**Retención:** `REPLICACION_BITACORA_DIAS` (180 por defecto). La purga va dentro de
`reservaciones:reconciliar-cupo`, que ya corre cada 15 min — **un cron más es un cron más que
puede morir en silencio**.

---

## 9.8 Comandos programados

| Comando | Cron | Emite |
|---|---|---|
| `reservaciones:alertar-confirmando` | cada minuto | `R1` |
| `reservaciones:reconciliar-cupo` | cada 15 min | `R6`, `R15` + purga de bitácora |
| `reservaciones:expirar-temporales` | cada minuto | — (libera cupo) |
| `replicacion:revertir-leases` | cada minuto | — (leases del outbox) |

> ⚠️ **Todos requieren `schedule:work` vivo** (o un cron llamando a `schedule:run` cada
> minuto). Sin ese proceso no corre ninguno — ni las alertas. `reservaciones:reconciliar-cupo`
> vigila justamente eso con `revisarBarrido()`, que sigue usando `MonitoreoReplicacion` a
> propósito: mide si el scheduler está vivo, no el canal de réplica.

---

## 9.9 El correo tiene que servir para actuar

Contexto mínimo por incidencia:

```
Reservación (portal) · Reservación (privado) · Folio · Membresía No. · Socio ·
Estado · Maestro · Fecha de llegada · Importe · Versión de réplica ·
Intentos · Último error · Motivo devuelto · Endpoint · Excepción
```

**Un correo que solo diga "falló la réplica" no pasa la revisión.** El pie dice cuántas se
omitieron por throttle y **dónde consultarlas**.

> Pon `APP_NAME` en algo identificable. Con el valor por defecto los correos dicen
> "Servidor: Laravel (local)" y no se distinguen de los de otros sistemas en el mismo buzón.

---

**Anterior:** [08 · Desactivación](08-DESACTIVACION-Y-REVERSA.md) ·
**Siguiente:** [10 · Pruebas de aceptación](10-PRUEBAS-DE-ACEPTACION.md)
