Artefacto 6 · Monitorización · Fase 3 · la revisión
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.
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?».
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 | ✓ funciona | el fichero está en la carpeta |
| Construir la imagen | ✓ verde | copiar 4 de 5 ficheros no es error |
| Arrancar el contenedor | ✗ revienta | import 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.
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:
El arreglo del gemelo no fue código: fue git add medidor.py, hoy, para que ningún commit futuro pueda dejarlo atrás.
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.
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.
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 {})
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.
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.
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.
| Término | Lo que significa aquí |
|---|---|
| pre-deploy code review | revisión sistemática antes de cada despliegue; aquí paró 2 despliegues rotos antes de existir |
| survivorship bias | medir solo lo que termina bien; el 500 ahora viaja con su coste_usd |
| build-time vs run-time failure | la imagen se construye en verde y el contenedor revienta al arrancar: el fallo que solo se ve desplegando |
| passive instrumentation | la telemetría observa y jamás lanza: guardas ante datos ausentes, .get() en vez de indexar a pelo |
| constant-time comparison | secrets.compare_digest: comparar secretos sin que el reloj chive (timing attack) |
| fail-closed | sin token configurado, nadie pasa — la comparación nueva conserva la misma política |
dobe-agente es el siguiente paso de la Fase 3 — commit, push (la puerta CI dará su veredicto) y a Cloud Run.coste_usd y el recuento de fallos — que ahora incluirá el coste de los fallos, gracias al arreglo de Wald.· · ·