Módulo 7: The Incident And Data Governance

Escribiendo una alerta y un runbook

Descripción

quarantine(), en la lección 3, separó las filas rotas de S04 en un lugar seguro. Pero un quarantined_df que nadie revisa es, en la práctica, casi tan invisible como el checkmark verde que abrió esta guía — la diferencia es que ahora el problema está guardado en algún lado, en vez de perdido, pero sigue sin llegarle a ninguna persona. Esta lección cierra ese ciclo con dos piezas: raise_alert(), una función que estructura una notificación completa sobre el incidente —sin llamar, nunca, a ningún sistema real de mensajería—, y un runbook.md escrito de punta a punta, con los seis pasos que cualquier persona en Kiosko debería poder seguir cuando esa alerta suene, sin necesitar memoria ni experiencia previa con este incidente específico.

Conexión con el módulo. Las lecciones 2 y 3 construyeron la detección y la contención del incidente de S04. Esta lección construye la comunicación y la respuesta: la parte que convierte "el sistema ya sabe que algo está mal" en "una persona específica ya sabe que algo está mal, y sabe exactamente qué hacer".

Una analogía: la alarma de incendios, no solo el detector de humo

Un detector de humo que se activa, pero no está conectado a ninguna alarma audible, cumple solo la mitad de su trabajo: sabe que hay humo, pero nadie más lo sabe hasta que alguien pase por casualidad y note el detector parpadeando. Una alarma de incendios completa tiene tres partes, no una: el sensor que detecta el problema (ya construido: build_failure_report(), check_freshness()), la sirena que hace que cualquiera en el edificio se entere de inmediato, sin tener que estar revisando el sensor activamente (raise_alert(), esta lección), y el protocolo de evacuación pegado en la pared —qué puerta usar, dónde reunirse, quién cuenta a las personas— que nadie tiene que inventar en el momento de pánico, porque ya está escrito de antemano (runbook.md, esta misma lección). Las tres partes son necesarias: un sensor sin sirena es inútil para cualquiera que no esté mirándolo; una sirena sin protocolo genera pánico sin dirección.

Ejemplo trabajado: raise_alert(), estructurada y determinista

raise_alert() no envía nada a ningún lado — construye y devuelve un diccionario de Python con toda la información que un sistema real de notificaciones (Slack, PagerDuty, un correo automático) necesitaría para actuar. Esta guía nombra esa integración real, pero nunca la implementa — el mismo patrón que ya usaron OpenLineage y Marquez en el módulo 6.

# alert_and_runbook.py -- leccion 4 del modulo 7
import json

import duckdb
import polars as pl
from datetime import datetime

PIPELINE_RUN_AT = "2026-08-16T09:00:00"


def check_freshness(df: pl.DataFrame, run_at: str, sla_hours: int, timestamp_col: str = "order_ts") -> dict:
    """Modulo 6, sin cambios."""
    latest_ts = df.select(pl.col(timestamp_col).max()).item()
    run_at_dt = datetime.fromisoformat(run_at)
    hours_since_latest = (run_at_dt - latest_ts).total_seconds() / 3600
    return {
        "check": "freshness",
        "latest_row_ts": str(latest_ts),
        "run_at": run_at,
        "sla_hours": sla_hours,
        "hours_since_latest": round(hours_since_latest, 2),
        "status": "PASS" if hours_since_latest <= sla_hours else "FAIL",
    }


def raise_alert(check_name: str, failure_count: int, sample: list[dict]) -> dict:
    """Estructura una alerta. NUNCA llama a un sistema real (Slack/PagerDuty/correo) -- solo la estructura."""
    return {
        "alert": "data_quality_incident",
        "pipeline": "kiosko_orders_s04",
        "check_name": check_name,
        "run_at": PIPELINE_RUN_AT,
        "severity": "high" if failure_count >= 5 else "medium",
        "failure_count": failure_count,
        "sample": sample[:3],
    }


def main() -> None:
    con = duckdb.connect("kiosko.duckdb")
    df = con.sql("SELECT * FROM orders_s04").pl()

    # --- Alerta 1: el incidente de fila, resumido de las lecciones 2-3 ---
    # (failures y quarantined_df ya se calcularon en las lecciones anteriores;
    #  aqui se retoma solo el resumen que necesita raise_alert())
    quarantine_sample = [
        {"order_id": "ORD-9502", "dimension": "uniqueness", "detail": "order_id=ORD-9502"},
        {"order_id": "ORD-9503", "dimension": "completeness", "detail": "unit_price=None"},
        {"order_id": "ORD-9507", "dimension": "validity", "detail": "quantity=-1"},
        {"order_id": "ORD-9508", "dimension": "consistency", "detail": "product_id=P099 no existe en dim_product"},
        {"order_id": "ORD-9509", "dimension": "accuracy", "detail": "unit_price=60.0 se aleja 49.0x del precio de referencia (1.2)"},
        {"order_id": "ORD-9502", "dimension": "uniqueness", "detail": "order_id=ORD-9502"},
    ]
    row_alert = raise_alert(check_name="s04_full_gate", failure_count=6, sample=quarantine_sample)

    print("=== Alerta 1: incidente de fila (quarantine, lecciones 2-3) ===")
    print(json.dumps(row_alert, indent=2, ensure_ascii=False))

    # --- Alerta 2: el incidente de archivo (freshness, modulo 6) ---
    freshness_result = check_freshness(df, run_at=PIPELINE_RUN_AT, sla_hours=24)
    file_alert = raise_alert(check_name="freshness", failure_count=1, sample=[freshness_result])

    print("\n=== Alerta 2: incidente de archivo (freshness, modulo 6) ===")
    print(json.dumps(file_alert, indent=2, ensure_ascii=False))


if __name__ == "__main__":
    main()

Qué esperar (verificado corriendo python3 alert_and_runbook.py real, con kiosko.duckdb conteniendo orders_s04, polars==1.43.2):

=== Alerta 1: incidente de fila (quarantine, lecciones 2-3) ===
{
  "alert": "data_quality_incident",
  "pipeline": "kiosko_orders_s04",
  "check_name": "s04_full_gate",
  "run_at": "2026-08-16T09:00:00",
  "severity": "high",
  "failure_count": 6,
  "sample": [
    {
      "order_id": "ORD-9502",
      "dimension": "uniqueness",
      "detail": "order_id=ORD-9502"
    },
    {
      "order_id": "ORD-9503",
      "dimension": "completeness",
      "detail": "unit_price=None"
    },
    {
      "order_id": "ORD-9507",
      "dimension": "validity",
      "detail": "quantity=-1"
    }
  ]
}

=== Alerta 2: incidente de archivo (freshness, modulo 6) ===
{
  "alert": "data_quality_incident",
  "pipeline": "kiosko_orders_s04",
  "check_name": "freshness",
  "run_at": "2026-08-16T09:00:00",
  "severity": "medium",
  "failure_count": 1,
  "sample": [
    {
      "check": "freshness",
      "latest_row_ts": "2026-08-14 09:25:00",
      "run_at": "2026-08-16T09:00:00",
      "sla_hours": 24,
      "hours_since_latest": 47.58,
      "status": "FAIL"
    }
  ]
}

Lee estas dos alertas con cuidado, porque confirman algo importante sobre el diseño de raise_alert(): es una función genérica, capaz de estructurar tanto un incidente de fila (seis filas rotas, check_name="s04_full_gate") como un incidente de archivo completo (check_name="freshness", un solo elemento en sample, el resultado completo de check_freshness()). Fíjate en severity: la primera alerta es "high" porque failure_count=6 supera el umbral de 5; la segunda es "medium" porque failure_count=1 no lo supera — aunque, en términos de impacto real para el negocio, un archivo completo 47.58 horas tarde bien podría considerarse igual de grave que seis filas rotas. Esta es una simplificación deliberada de esta guía, y la Profundización de esta lección la discute a fondo. sample[:3] limita el tamaño de la muestra a un máximo de tres elementos —en la primera alerta, de seis fallas totales solo se incluyen las tres primeras—, una decisión común en sistemas de alertas reales: la alerta debe dar suficiente contexto para actuar, sin convertirse en un volcado completo de datos que nadie va a leer entero.

El runbook: qué hace un humano cuando suena la alarma

Un runbook no es documentación general del sistema —eso ya lo tienen el DISEÑO de esta guía y los README de cada módulo—. Es un documento operativo, escrito para leerse durante un incidente, por alguien que puede no haber visto nunca este problema específico antes. Kiosko usa una estructura de seis pasos, una variación del ciclo de vida de incidentes que documentan tanto PagerDuty como el libro de SRE de Google (citados al final de esta lección), aplicada aquí, paso por paso, al incidente exacto de orders_2026-08-14.csv.

# Runbook: incidente de calidad de datos en `orders_s04`

## 1. Detectar
`build_failure_report()`, corrido el `2026-08-16T09:00:00` (`PIPELINE_RUN_AT`) sobre
`orders_2026-08-14.csv`, reportó 6 filas físicas con al menos un problema conocido.
`check_freshness()` reportó `FAIL` sobre el archivo completo (`47.58` horas sobre un
SLA de `24`). `raise_alert()` estructuró ambos hallazgos como dos alertas
independientes: `check_name="s04_full_gate"` (severity=high) y
`check_name="freshness"` (severity=medium).

## 2. Triage
Clasificar alcance e impacto antes de actuar. Dataset: `orders_s04`. Pipeline:
`kiosko_orders_s04`. ¿Bloquea algo aguas abajo? No todavía — `fact_orders` de
`data-modeling-for-analytics-guide` aún no se recalculó con estas filas; el
incidente está contenido antes de tocar el warehouse compartido del ecosistema.
Prioridad: revisar primero las filas de severidad alta (completeness, uniqueness,
accuracy — ver Ejercicio 2 de la lección 2), después las de severidad media
(validity, consistency).

## 3. Contener
`quarantine(df, failures)` ya separó las 6 filas limpias de las 6 rotas. Las
limpias (`ORD-9501`, `ORD-9504`, `ORD-9505`, `ORD-9506`, `ORD-9510`, `ORD-9511`)
pueden seguir el pipeline normal sin esperar la resolución del incidente. Las 6
rotas quedan en `quarantined_df`, fuera de cualquier tabla de negocio, sin
bloquear el archivo completo — la lección exacta que dejó foundations M7 con su
rechazo total.

## 4. Causa raíz
Por fila, la causa específica detrás de cada dimensión rota:
- `ORD-9503` (completeness): `unit_price` vacío -- probable error de captura en
  el punto de venta de S04, su primer día operando con el sistema real de Kiosko.
- `ORD-9502` x2 (uniqueness): retransmisión duplicada -- un reintento de red de
  la app de delivery reenvió la misma venta, siete minutos después.
- `ORD-9507` (validity): `quantity=-1` -- probable devolución registrada como una
  venta negativa, en vez de con un proceso de devoluciones dedicado.
- `ORD-9508` (consistency): `product_id=P099` no existe -- el catálogo local de
  S04 todavía no está sincronizado con `dim_product` del warehouse central.
- `ORD-9509` (accuracy): `unit_price=60.00` vs. referencia `1.20` -- el bug de
  dólares-a-centavos: el sistema de S04 probablemente registra el precio en una
  unidad distinta (centavos) a la que espera el contrato de Kiosko (dólares).

## 5. Arreglar
Corto plazo: las filas permanecen en cuarentena. Ningún valor se inventa ni se
corrige a mano -- el mismo principio que ya estableció el módulo 1 sobre
`validate_orders()` ("un diagnóstico describe, nunca corrige"), extendido aquí a
un sistema que además actúa, pero actúa moviendo filas, no adivinando valores.
Mediano plazo (fuera del alcance técnico de esta guía, nombrado en el cierre del
módulo 8): S04 necesita (a) deduplicación en el origen antes de reenviar una
venta, (b) que el contrato de datos del módulo 4 se aplique en el punto de
origen, no solo al llegar a Kiosko, y (c) una conversión de unidad de moneda
explícita en el sistema de captura de S04, antes de que el archivo salga hacia
Kiosko.

## 6. Postmortem
Blameless -- el estándar que documenta el libro de SRE de Google (citado abajo):
el objetivo no es señalar a S04 como "la tienda que mandó datos malos", es
identificar la causa sistémica. Y la causa sistémica, en este caso, es clara:
el contrato de datos del módulo 4 se escribió *después* de que S04 empezara a
vender, no antes -- S04 nunca tuvo la oportunidad de validar su primer archivo
contra un contrato, porque ese contrato todavía no existía cuando lo mandó.
Acción de seguimiento: todo productor de datos nuevo en Kiosko debería tener un
contrato y una compuerta de calidad activa *antes* de su primer archivo real,
no después -- exactamente la pregunta que ya adelantó el módulo 4, lección 7
("¿debería S04 poder escribir sin un contrato?"), ahora respondida con la
evidencia completa de este incidente.

Qué esperar. Este runbook.md no se "corre" — se lee, durante un incidente real. Su verificación no es una salida de terminal, es que cada paso responda, con evidencia ya producida por este módulo, la pregunta que le corresponde: Detectar cita los números exactos de build_failure_report() y check_freshness(); Triage clasifica sin inventar información nueva; Contener cita el resultado literal de quarantine(); Causa raíz nombra las cinco causas específicas, una por dimensión; Arreglar distingue con precisión qué es responsabilidad de esta guía y qué queda para el ecosistema; Postmortem cierra con una acción de seguimiento concreta, no una frase genérica de "hay que mejorar".

Diagrama: de la detección a la respuesta completa

flowchart TD
    A["build_failure_report()\n+ check_freshness()"] --> B["raise_alert()\nx2: fila + archivo"]
    B --> C["runbook.md"]
    C --> D["1. Detectar"]
    D --> E["2. Triage"]
    E --> F["3. Contener\n(quarantine, ya hecho)"]
    F --> G["4. Causa raiz"]
    G --> H["5. Arreglar\n(corto y mediano plazo)"]
    H --> I["6. Postmortem\nblameless"]
    I --> J["Accion de seguimiento:\ncontrato ANTES del\nprimer archivo, no despues"]

Profundización: por qué severity de raise_alert() es una simplificación consciente

Vale la pena ser honesto sobre una limitación del diseño de raise_alert() en esta lección: severity se calcula con una sola regla —"high" si failure_count >= 5, "medium" en cualquier otro caso—, sin distinguir qué tipo de falla ocurrió ni cuánto impacto de negocio tiene. La Alerta 2 de esta lección es un buen ejemplo del límite de esa regla: un archivo completo que llega casi dos días tarde (47.58 horas sobre un SLA de 24) recibe severity="medium", exactamente la misma etiqueta que tendría una sola fila con un problema menor, solo porque failure_count=1 en ambos casos.

Un sistema de alertas de producción real casi nunca usa un solo número como criterio de severidad — combina, típicamente, el número de filas afectadas, el porcentaje que representan del total, si el problema toca una columna financiera (como unit_price, en el caso de ORD-9509) o una puramente descriptiva, y si el archivo completo, no solo una fila, está en riesgo de no llegar al warehouse a tiempo. Esta guía elige la versión más simple, deliberadamente, por la misma razón que ya explicó el módulo 5 sobre check_price_baseline(): cada regla de esta guía tiene que ser explicable en una sola frase, sin volverse un sistema de puntuación complejo que nadie pueda auditar a simple vista. Un sistema de severidad más sofisticado —que sí distinguiera el caso de freshness como crítico— es una extensión razonable, y el Ejercicio 2 de esta lección construye una versión mejorada.

Errores comunes

Esperar que raise_alert() haga una llamada de red real, o intentar "completarla" con una integración a Slack. Qué pasa: alguien, después de ver el JSON estructurado de esta lección, busca agregar requests.post(SLACK_WEBHOOK_URL, json=alert) para que la alerta realmente llegue a algún lado. Por qué pasa: una alerta que no notifica a nadie de verdad se siente incompleta. Cómo detectarlo: revisa si tu versión de raise_alert() importa requests, slack_sdk, o cualquier librería de red. Cómo corregirlo: esta guía nombra la integración real como una posibilidad, nunca la construye — el mismo patrón exacto que ya practicaron OpenLineage/Marquez (módulo 6) y Great Expectations/Soda (módulo 2): nombrar con precisión, sin instalar. Conectar raise_alert() a un sistema real de notificaciones —Slack, PagerDuty, un correo transaccional— es una extensión legítima para un proyecto propio, pero queda fuera del alcance de esta guía a propósito, para mantenerla en $0 y sin credenciales de terceros.

Pensar que un runbook.md genérico —sin los números exactos del incidente— es suficiente. Qué pasa: alguien escribe un runbook con pasos abstractos ("Detectar: revisar los logs del pipeline", "Contener: aislar los datos problemáticos"), sin ninguna cifra ni order_id concretos. Por qué pasa: un runbook "reusable" para cualquier incidente futuro se siente más eficiente que uno escrito para un caso específico. Cómo detectarlo: si tu runbook podría aplicarse, sin cambiar una sola palabra, a un incidente completamente distinto en otra tienda de Kiosko, probablemente es demasiado genérico para ser útil en el momento de una emergencia real. Cómo corregirlo: el runbook de esta lección cita cifras exactas (47.58 horas, 6 filas, ORD-9502 específicamente) porque un runbook vivo, escrito durante o inmediatamente después de un incidente real, sirve como referencia concreta para el próximo incidente parecido — la plantilla general (los seis encabezados: Detectar, Triage, Contener, Causa raíz, Arreglar, Postmortem) sí es reusable; el contenido de cada sección, no.

Ejercicios

Ejercicio 1 — Corre alert_and_runbook.py tú mismo, desde cero. En una carpeta nueva, con kiosko.duckdb conteniendo orders_s04 (módulo 2), corre python3 alert_and_runbook.py. Confirma que ves exactamente las dos alertas, con severity="high" para la primera y severity="medium" para la segunda.

Ver solución

Si orders_s04 tiene las doce filas exactas del módulo 2, la salida debería reproducir exactamente la de esta lección: la Alerta 1 con failure_count: 6, severity: "high"; la Alerta 2 con failure_count: 1, severity: "medium", y hours_since_latest: 47.58 dentro de su sample. Si tu resultado difiere en hours_since_latest, revisa primero que PIPELINE_RUN_AT siga siendo exactamente "2026-08-16T09:00:00", sin ningún cambio accidental.

Ejercicio 2 — Mejora raise_alert() para que la Alerta 2 (freshness) reciba severity="high". Basándote en la Profundización de esta lección, modifica la lógica de severidad para que un check de tipo "freshness" sea siempre "high", sin importar failure_count — argumentando que un archivo completo tarde es, casi siempre, más grave que unas pocas filas rotas.

Ver solución
def raise_alert(check_name: str, failure_count: int, sample: list[dict]) -> dict:
    if check_name == "freshness":
        severity = "high"
    else:
        severity = "high" if failure_count >= 5 else "medium"
    return {
        "alert": "data_quality_incident",
        "pipeline": "kiosko_orders_s04",
        "check_name": check_name,
        "run_at": PIPELINE_RUN_AT,
        "severity": severity,
        "failure_count": failure_count,
        "sample": sample[:3],
    }

file_alert = raise_alert(check_name="freshness", failure_count=1, sample=[freshness_result])
print(file_alert["severity"])

Salida esperada:

high

Con este cambio, cualquier alerta de freshness —sin importar cuántas "filas" técnicamente reporte, siempre 1, el archivo completo— queda clasificada como "high" de forma explícita, en vez de depender de un número que nunca refleja bien su gravedad real. Este ejercicio es un buen ejemplo de por qué vale la pena revisar, de vez en cuando, si una regla "simple y explicable en una frase" (el estándar que fijó el módulo 5) sigue produciendo el resultado correcto a medida que el sistema cubre casos nuevos — la regla original no estaba mal, solo incompleta para un tipo de check que no existía cuando se escribió por primera vez.

Ejercicio 3 — Argumenta si el paso de Postmortem del runbook debería escribirse inmediatamente después de Contener, o solo después de que el incidente esté completamente resuelto. El runbook de esta lección lista Postmortem como el sexto y último paso. En 2-3 frases, considerando la definición de "postmortem blameless" del libro de SRE de Google (citado abajo), argumenta cuándo debería escribirse realmente ese paso.

Ver solución

El postmortem debería escribirse después de que el incidente esté resuelto o suficientemente contenido, no en paralelo con Contener — la razón tiene que ver con la calidad del análisis, no solo con el orden cronológico. Escribir un postmortem mientras el incidente todavía está activo mezcla dos tipos de trabajo con urgencias distintas: contener un problema requiere decisiones rápidas, a veces con información incompleta; un postmortem blameless, según lo describe el libro de SRE de Google, requiere tiempo para investigar la causa sistémica completa —no solo el síntoma inmediato— sin la presión de "resolver ya". Escribirlo demasiado pronto arriesga capturar solo la causa superficial (un archivo llegó con un producto inexistente) en vez de la causa sistémica real (el contrato se escribió después del primer archivo, no antes) — precisamente la diferencia que el runbook de esta lección sí logra capturar, al escribirse con el incidente ya contenido y con tiempo para el análisis completo.

Resumen y siguiente paso

En esta lección cerraste la primera mitad del módulo 7: raise_alert(), una función genérica capaz de estructurar tanto un incidente de fila como uno de archivo completo, corrida de verdad sobre las dos alertas reales de S04 — sin llamar nunca a un sistema externo, nombrando esa integración real como una extensión legítima fuera del alcance de esta guía. Y escribiste un runbook.md completo, con los seis pasos —Detectar, Triage, Contener, Causa raíz, Arreglar, Postmortem— aplicados, palabra por palabra, con las cifras exactas del incidente real de S04, cerrando con un postmortem blameless que identifica la causa sistémica, no un culpable.

Antes de avanzar deberías poder: explicar por qué raise_alert() nunca hace una llamada de red real; citar los seis pasos del runbook de memoria, con al menos un dato concreto de S04 por paso; y argumentar cuándo debería escribirse el paso de Postmortem respecto al resto del incidente.

Con esto, Kiosko ya sabe qué hacer cuando un check falla en producción. Falta la segunda mitad de este módulo, una pregunta completamente distinta: una vez que los datos están adentro, ¿quién puede verlos? La lección 5 introduce customers, la primera tabla de esta guía con información que identifica a una persona real, y ACCESS_POLICY, el diccionario que decide qué columna ve cada rol de Kiosko.

Recursos

  • PagerDuty — "Incident Response Documentation" (la guía pública de PagerDuty sobre el ciclo de vida de un incidente: antes, durante y después — la base de la estructura de seis pasos de este runbook, adaptada al vocabulario de calidad de datos de esta guía). response.pagerduty.com. En inglés.
  • Google — "Site Reliability Engineering", capítulo 15, "Postmortem Culture: Learning from Failure" (la definición exacta de postmortem blameless que cita el paso 6 de este runbook: "a written record of an incident, its impact, the actions taken to mitigate or resolve it, the root cause(s), and the follow-up actions"). sre.google/sre-book/postmortem-culture. En inglés.
  • Python — documentación oficial, módulo json (json.dumps, usado para imprimir las alertas de esta lección de forma legible y determinista). docs.python.org/3/library/json.html. En inglés.
  • Módulo 4, lección 7, de esta misma guía ("¿Debería S04 poder escribir sin un contrato?") — fuente de la pregunta que el paso de Postmortem de esta lección responde con evidencia completa. src/guides/data-reliability-and-governance-guide/workbook/module-04-data-contracts-as-versioned-artifacts/es/07-should-s04-be-allowed-to-write-without-a-contract.md. En español.
  • DISEÑO de esta guía. src/guides/data-reliability-and-governance-guide/DISENO.md. En español.