Libro 16 · Artefacto 6

Artefacto 6 · Monitorización · Fase 3 · la revisión

La red de seguridad

El medidor y la bitácora funcionaban en local. El siguiente paso era desplegarlos. Entre ambos momentos se metió, por costumbre ya, una revisión de código — y esa costumbre encontró dos despliegues rotos que todavía no habían ocurrido, un medidor con sesgo de supervivencia y un portero que chivaba secretos por el reloj. Este libro es el acta de esa revisión.

22 de julio de 2026 · 8 hallazgos · 6 arreglos de código + 1 de git · verificado con sondas sin coste

01Por qué se revisa lo que ya funciona

Todo lo que la revisión encontró estaba en código verificado: el medidor había contado tokens de verdad, la bitácora había cantado líneas JSON reales, los tres códigos de estado (200/401/400) estaban probados. Funcionaba. Y aun así salieron ocho hallazgos, dos de ellos capaces de tumbar el despliegue.

No es contradicción: es la diferencia entre probar y revisar. Las pruebas confirman que el camino que ejercitaste hace lo que esperas. La revisión pregunta por los caminos que no ejercitaste: ¿qué pasa cuando la petición revienta a mitad? ¿cuando el proveedor devuelve un campo vacío? ¿cuando este código corre en la nube y no en tu Mac? Son preguntas de ojos frescos — la misma filosofía que el harness del Artefacto 3, pero apuntando al código en vez de a los prompts: una segunda mirada sistemática antes de que el error cueste caro.

Las pruebas responden «¿hace lo que quiero?». La revisión responde «¿qué hará cuando pase lo que no quiero?».
La costumbre que ya es proceso Revisión antes de cada despliegue, siempre. En una empresa esto tiene nombre —pre-deploy code review— y suele ser obligatorio: nadie funde a producción sin que otros ojos pasen por encima. Aquí los ojos son un modelo, pero la disciplina es idéntica.

02La trampa que reincide: la lista blanca

El hallazgo más grave era viejo conocido. El Dockerfile copia a la imagen solo los ficheros nombrados uno a uno (la lista blanca del libro 06, que existe para que el .env jamás pueda colarse). Pero medidor.py es un módulo nuevo — y nadie lo había apuntado en la lista:

# Antes — la imagen se construye SIN el módulo nuevo:
COPY herramientas.py agente.py api.py prompts.py ./
# Después:
COPY herramientas.py agente.py api.py prompts.py medidor.py ./

Lo traicionero es cuándo habría fallado. La construcción de la imagen habría terminado en verde: copiar cuatro ficheros de cinco no es ningún error para Docker. El fallo habría esperado al arranque del contenedor en Cloud Run: import medidor → ModuleNotFoundError → el servicio nunca pasa el chequeo de salud → despliegue fallido.

Momento¿Qué habría dicho?¿Por qué?
En el Mac✓ funcionael fichero está en la carpeta
Construir la imagen✓ verdecopiar 4 de 5 ficheros no es error
Arrancar el contenedor✗ revientaimport medidor no encuentra nada

Es la tercera aparición de esta trampa en el roadmap, y por eso merece nombre: fallo de arranque, no de construcción (run-time, no build-time). Los fallos de construcción se ven solos; los de arranque solo se ven desplegando — o revisando antes de desplegar.

03El gemelo que nadie había fichado

La revisión destapó que la trampa tenía un hermano gemelo. medidor.py tampoco estaba en el índice de git: era un fichero «sin trackear». Consecuencia: un commit de los tres ficheros modificados que olvidara añadir el nuevo habría subido código que importa un módulo… que no viaja en el commit. La puerta de CI —que importa herramientas, que importa medidor— se habría puesto en rojo en todos los pushes.

Misma causa raíz, dos víctimas distintas. Y de ahí la lección general:

La idea que se lleva uno Un módulo nuevo no existe hasta que está dado de alta en todas las listas que empaquetan código: el índice de git (lo que viaja en el commit) y el COPY del Dockerfile (lo que viaja en la imagen). El fichero en la carpeta es solo la primera de tres altas.

El arreglo del gemelo no fue código: fue git add medidor.py, hoy, para que ningún commit futuro pueda dejarlo atrás.

04El medidor que solo contaba a los supervivientes

El hallazgo más interesante conceptualmente. La bitácora registraba el coste en las peticiones que terminaban — con éxito o por el guardarraíl de turnos. Pero cuando una petición reventaba a mitad (una herramienta que falla en el turno 4, tras siete llamadas al modelo ya pagadas), la línea del 500 salía sin un solo dato de coste. Para la consulta del DoD, esa petición costó $0.

Esto tiene nombre y es uno de los sesgos más famosos de la estadística: sesgo de supervivencia. En la Segunda Guerra Mundial, los aliados estudiaban los agujeros de bala de los bombarderos que volvían, para blindar esas zonas. Abraham Wald señaló el error: había que blindar donde los que volvían no tenían agujeros — porque los tocados ahí no volvían para contarlo. Un medidor que solo suma el gasto de las peticiones que terminan comete exactamente ese error: infradeclara el coste justo en los casos caros, los que fallan tras varias llamadas pagadas.

# Antes — el 500 se anotaba, pero con la cartera «a cero»:
except Exception:
    bitacora(500, t0)
    raise
# Después — lo gastado hasta el crash viaja en la línea:
except Exception:
    bitacora(500, t0, {"uso": medidor.resumen(MODELO)})
    raise

¿Y por qué funciona pedirle el resumen al medidor después del crash? Porque el ContextVar del libro 15 sigue vivo: la excepción sube desde el bucle hasta la API por el mismo hilo, y el carril de esa petición conserva todo lo registrado hasta el momento del fallo. El aislamiento por petición que montamos para la concurrencia resulta que también es lo que permite cerrar la cuenta de una petición muerta.

Verificado con sonda La línea del 500 simulado salió con coste_usd: 0.00027 — el gasto acumulado antes del fallo. Antes del arreglo, ese mismo caso habría cantado una línea sin campos de coste.

05El observador no tumba lo observado

Dos hallazgos compartían el mismo principio de fondo. Primero: registrar() asumía que el usage de la API nunca llega vacío — si algún día llega None, el medidor habría reventado la petición entera con un 500. Segundo: la bitácora leía las claves del resultado a pelo (resultado["uso"]) — un dict sin esa clave habría convertido un éxito ya pagado en un error, después de que el agente hiciera todo su trabajo.

# La guarda del medidor: si no hay carril O no hay dato, no cuenta — jamás rompe.
if datos is None or usage is None:
    return
# La bitácora lee con .get(): observa lo que hay, no exige lo que falta.
linea.update(resultado.get("uso") or {})
La idea que se lleva uno La instrumentación es pasiva por contrato: un medidor que tumba la petición que está midiendo deja de ser un medidor y pasa a ser un bug. Ante un dato ausente, la telemetría calla y sigue; nunca lanza. (El mismo primum non nocere de los médicos, aplicado a la observabilidad.)

06Escribir para el endpoint que aún no existe

Hallazgo pequeño con moraleja de diseño: la bitácora llevaba la ruta "/preguntar" cableada dentro. Con un solo endpoint, funciona; pero el roadmap ya tiene previsto un segundo (el copiloto de llamadas de filtro, extra del roadmap), y ese endpoint habría heredado en sus logs una ruta que miente — mezclando los números de los dos servicios en las consultas del DoD.

def bitacora(estado, t0, resultado=None, ruta="/preguntar"):
    # cada endpoint declara la suya; las consultas no se mezclan

La regla de altitud: cuando un dato va a variar por quien llama, es un parámetro, no una constante enterrada. No hizo falta un middleware ni una abstracción — un argumento con valor por defecto es exactamente la cantidad de generalidad que este problema pide, ni más ni menos.

07El portero que chivaba por el reloj

El hallazgo de seguridad. Comparar el token con != tiene un defecto invisible: la comparación de cadenas corta en el primer byte distinto. Un token que empieza bien tarda un pelín más en rechazarse que uno que empieza mal — y esa diferencia, medida muchas veces, permite inferir el secreto byte a byte. Se llama ataque de temporización (timing attack).

# Antes — corta al primer byte distinto (el reloj chiva):
if x_token != APP_TOKEN or not APP_TOKEN:
# Después — tarda LO MISMO acierte o falle:
if not APP_TOKEN or not secrets.compare_digest(x_token.encode(), APP_TOKEN.encode()):

secrets.compare_digest es la función de la librería estándar pensada exactamente para esto: comparar secretos en tiempo constante. ¿Es explotable de verdad un token largo a través de Internet, con el ruido de red por medio? Muy difícilmente. Pero el idioma correcto cuesta una línea, y en una revisión de una API con token, un revisor profesional lo señalará siempre — mejor tenerlo puesto que explicarlo.

08El contrato que mentía por omisión

El hallazgo más humilde: el docstring de _correr_bucle enumeraba las claves del dict que devuelve — y no mencionaba uso, la clave nueva. En un proyecto cualquiera sería una nimiedad; en uno cuyo propósito declarado es documentación didáctica viva, un contrato que omite es un contrato que miente. Una línea, arreglado.

La lección menor pero real: cuando cambia lo que devuelve una función, cambia su docstring en el mismo gesto. La documentación que se actualiza «luego» es la que nunca se actualiza.

09Las palabras que se lleva el CV

TérminoLo que significa aquí
pre-deploy code reviewrevisión sistemática antes de cada despliegue; aquí paró 2 despliegues rotos antes de existir
survivorship biasmedir solo lo que termina bien; el 500 ahora viaja con su coste_usd
build-time vs run-time failurela imagen se construye en verde y el contenedor revienta al arrancar: el fallo que solo se ve desplegando
passive instrumentationla telemetría observa y jamás lanza: guardas ante datos ausentes, .get() en vez de indexar a pelo
constant-time comparisonsecrets.compare_digest: comparar secretos sin que el reloj chive (timing attack)
fail-closedsin token configurado, nadie pasa — la comparación nueva conserva la misma política

10Lo que queda abierto

· · ·