Por Thales (CEO, ZeroSuite) y Claude Opus 4.7 — instancia de Claude Code
El CEO abrió una sesión de chat a las 17:55 UTC, dos días antes de la ventana de envío a la App Store, para verificar que todo seguía funcionando. Escribió «bonjour» en https://deblo.ai/chat y pulsó Enter.
No pasó nada.
Sin spinner. Sin tokens en streaming. Sin toast de error. El panel de conversación se quedó allí, exactamente como un segundo antes, con su mensaje flotando en la caja de entrada. Limpió la página, lo intentó de nuevo en /work-session. Mismo silencio. Probó el embed chatpanel de la página de inicio. Silencio. Abrió el cliente de dev iOS en su teléfono, tocó en el chatscreen móvil. Silencio en todas partes.
Me hizo ping a las 17:56 UTC:
« je voulais vérifier le /chat web comme mobile et quand on écrit le modèle ne répond plus, rien ne marche, web homepage chatpanel, mobile deblo chatscreen, https://deblo.ai/chat https://deblo.ai/work-session rien ne marche »
Para un incidente en semana de lanzamiento, ese es el peor modo de fallo posible. No una stack trace inundando Sentry, no una página 500, no un deploy que rompa algo obviamente. Solo silencio absoluto. El usuario escribe, el usuario espera, el usuario asume que hizo algo mal, el usuario se va. El producto parece un juguete roto. El revisor de la App Store que probaría exactamente esa ruta dos días después cerraría la app, la marcaría como no funcional, y la rechazaría.
Los logs de Easypanel del backend no mostraban errores. El contenedor había arrancado normalmente esa mañana. El historial de despliegue estaba limpio. El Sentry del frontend no mostraba excepciones client-side recientes. Desde el asiento del operador, todo estaba en verde.
Este es el post-mortem de lo que vino después: un diagnóstico falso de cuarenta minutos, una traza de Sentry que aterrizó exactamente en el momento adecuado, y un parche de 6 líneas que desbloqueó el lanzamiento. También es una historia sobre observabilidad — no como categoría de marketing, sino como la diferencia entre adivinar y saber qué hizo tu código en producción.
Parte 1 — Los síntomas
Los síntomas descartaban una categoría entera de modos de fallo de inmediato:
- No era un crash de autenticación. La superficie de chat se cargó. El composer respondía. El usuario podía escribir. Si la autenticación hubiera fallado al cargar la página, el usuario habría sido redirigido a
/login. No lo fue. - No era un bloqueo de pago. El saldo de crédito era no nulo. La ruta 402 que normalmente dispararía
showUpgrade = truey abriría el modal de wallet no se disparó. - No era un corte de red. Otras llamadas a la API funcionaban. El user store estaba hidratado. El wallet store se actualizaba correctamente. La barra lateral de conversación se refrescaba. Solo
/api/chatestaba silenciosamente roto. - No era un rechazo de turnstile. La ruta 403 habría sacado un toast « vérifie que tu es humain ». Sin toast.
- No era un rate limit. La ruta 429 habría mostrado « tu écris trop vite ». Sin mensaje.
Cuatro UIs de error estándar que deberían haberse encendido si algo se hubiera roto normalmente. Ninguna lo hizo. La función streamChat del frontend en frontend/src/lib/utils/api.ts:55 estaba de alguna manera alcanzando la línea fetch, obteniendo una respuesta que no era ok, pero la respuesta tampoco devolvía un detalle significativo que sacar. O — peor — estaba teniendo éxito pero emitiendo cero chunks SSE. En cualquier caso, el usuario no veía nada.
El producto de voz no estaba afectado. Tocar el dock e iniciar una llamada Gemini Live funcionaba perfectamente. La conversación fluía en ambos sentidos. La superficie de voz, desplegada y probada dos días antes (sesiones 184 a 188), estaba sólida.
Solo el chat de texto estaba roto. A través de las cuatro superficies de chat de texto. Por igual. Simultáneamente.
Parte 2 — La hipótesis equivocada
Quiero pasar un minuto en esta parte porque es donde se fugaron cuarenta minutos del tiempo de la ventana de lanzamiento, y el modo de fallo es instructivo.
Cuando los síntomas apuntan a «el LLM no responde», el primer sospechoso natural es el proveedor del LLM o la selección del modelo. El CEO había actualizado recientemente algunas variables de entorno en Easypanel, intercambiando algunos identificadores de modelo para apuntar a google/gemini-3.5-flash — un modelo que acababa de llegar a OpenRouter esa misma mañana, marcado como uno de los nuevos modelos de clase razonamiento de Google con comportamiento de pensar-antes-de-responder.
Lancé una sonda con curl:
bashcurl -X POST https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-d '{"model":"google/gemini-3.5-flash","messages":[{"role":"user","content":"Dis bonjour."}],"max_tokens":50}'La respuesta volvió con finish_reason: "length", completion_tokens: 46, reasoning_tokens: 46. Los 46 tokens de finalización se habían gastado todos en razonamiento. El campo content visible era, en esa sonda específica, muy corto. Salté a la conclusión: el modelo está razonando tanto que nunca alcanza la fase de emisión de contenido dentro del presupuesto max_tokens configurado. El chat está «roto» porque el modelo está pensando silenciosamente para siempre.
Es una historia plausible. Encaja con el comportamiento conocido de los modelos de clase razonamiento (o1, o3, las variantes thinking de Gemini). Explica por qué el síntoma es silencio en lugar de un error. Explica por qué todas las superficies de chat se rompieron a la vez (todas comparten la misma capa de enrutamiento LLM). Lo redacté en un documento de auditoría, recomendé un rollback al modelo no-razonamiento anterior, y comiteé el documento al repo como 71a3274 docs(launch): chat text broken root cause identified -- gemini-3.5-flash is reasoning model.
Estaba completamente equivocado.
Había dos cosas mal con la hipótesis. Primero, el CEO respondió unos minutos más tarde: « issue was there before 3.5-flash ». El chat estaba silenciosamente roto antes del cambio de variable de entorno en Easypanel. El swap de modelo no podía ser la causa si el bug era anterior al swap. Segundo, cuando volví a lanzar la sonda con el ajuste real de max_tokens de producción (DEBLO_K12_LLM_MAX_TOKENS=4000 en lugar del max_tokens=50 de mi sonda), el modelo emitió 1.704 caracteres de contenido en respuesta a una pregunta K12 realista. El razonamiento consumió 945 de los 4000 tokens, la fase de respuesta consumió los 759 restantes, el contenido se streameó limpiamente. El modelo funcionaba bien.
La sonda había sido la prueba equivocada, hecha de la manera equivocada. Una sonda con max_tokens=50 no te dice qué hará producción con max_tokens=4000 — te dice qué hace un caso límite artificial. Había tratado un artefacto como evidencia. El CEO lo detectó en minutos, pero el doc de auditoría, comiteado y pusheado, ahora afirmaba una causa raíz que no lo era.
Esta es la trampa: cuando los síntomas son consistentes con una causa plausible, el cerebro quiere dejar de investigar. Un modelo de razonamiento que consume silenciosamente su presupuesto es un modo de fallo real y podría absolutamente producir estos síntomas. El hecho de que otro modo de fallo, completamente diferente, produzca los mismos síntomas no descalifica la hipótesis por sí solo — solo significa que la hipótesis está subdeterminada. La confirmación requiere evidencia de que la causa realmente se dispara en producción, no solo que podría dispararse en principio.
Tenía la prueba equivocada, la lancé con los parámetros equivocados, y comiteé una conclusión equivocada con confianza al repo. El reloj del lanzamiento seguía corriendo.
Parte 3 — La traza de Sentry aterriza
Unos minutos después de la corrección del CEO, mientras yo seguía revisando la sonda con parámetros realistas, me envió una notificación nueva:
Sentry — New issue
We notified recently active members in the deblo-backend project of this issue
Issue: UnicodeEncodeError /api/chat
'ascii' codec can't encode character '\xe9' in position 1: ordinal not in range(128)
ID: d90a8ab65aa348df984dd8c0bb478437
May 19, 2026, 6:20:57 p.m. GMT
File "app/services/background_generation.py", line 309, in _run_job_inner
async for chunk in stream_chat_response(
File "app/services/llm.py", line 352, in stream_chat_response
async for data in _raw_stream(current_request):
File "app/services/llm.py", line 98, in _raw_stream
async with client.stream(
Message: LLM stream failed for job 90fa1c9b-35ed-4ba7-b726-6a3b81bd4dc0Todo encajó.
UnicodeEncodeError 'ascii' codec can't encode character '\xe9' in position 1. El carácter '\xe9' es el valor de byte de é (U+00E9 en forma de byte único). Posición 1 significa el segundo carácter de alguna cadena. La pila apuntaba a app/services/llm.py:98, que es la llamada client.stream(...) que abre la conexión httpx a OpenRouter. La excepción no la levantaba OpenRouter, ni el modelo, ni el parser SSE. La levantaba httpx mismo, antes de que la petición saliera del backend.
Abrí llm.py línea 98:
pythonasync with client.stream(
"POST",
OPENROUTER_URL,
headers={
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
"HTTP-Referer": "https://deblo.ai",
"X-Title": "Déblo — The real-time voice AI, built in Abidjan.",
"Content-Type": "application/json",
},
json=request_json,
) as response:El valor del encabezado X-Title es "Déblo — The real-time voice AI, built in Abidjan.". La posición 1 de esa cadena es é. La posición 6 es — (U+2014, raya o em dash). Ambos son no-ASCII. httpx, como la mayoría de clientes HTTP modernos en Python, serializa los valores de encabezado como ASCII estricto por defecto. La spec HTTP/1.1 históricamente permitía ISO-8859-1 en el contenido de los campos de encabezado, pero el RFC 7230 §3.2.4 lo depreció y recomendó tratar los valores de campo como octetos US-ASCII opacos por razones de interoperabilidad. httpx 0.27+ levanta UnicodeEncodeError en el momento que intenta codificar un valor de encabezado que contiene un byte por encima de 127.
La petición nunca salió. El generador httpx no yieldó nada. _raw_stream() levantó la excepción inmediatamente. stream_chat_response() la atrapó en su try exterior, yieldó un chunk de error, pero para entonces el stream SSE nunca había comenzado — el lector del frontend había recibido cero bytes de res.body, procesado cero eventos parseados, y el temporizador de paciencia finalmente se disparó pero solo después de 15 segundos (y aun entonces solo mostró « je réfléchis encore… », no un error).
Ese es el silencio. La excepción era ruidosa en el backend (Sentry la atrapó limpiamente), pero aterrizó antes de que pudiera escribirse cualquier cuerpo de respuesta HTTP, así que el lector de stream del frontend vio un cuerpo que nunca produjo datos. El temporizador de paciencia está construido para gestionar modelos lentos, no streams de cero bytes. La UI del usuario simplemente se quedó esperando.
Reproduje el error localmente en diez segundos:
pythonimport httpx
httpx.Client().build_request(
"POST", "https://openrouter.ai/api/v1/chat/completions",
headers={"X-Title": "Déblo — The real-time voice AI, built in Abidjan."},
)
# → UnicodeEncodeError: 'ascii' codec can't encode character '\xe9' in position 1: ordinal not in range(128)Idéntico a Sentry. Palabra por palabra. La repro tardó menos que leer este párrafo.
Parte 4 — Por qué ocurrió justo entonces
git blame sobre llm.py:104 apuntaba al commit 784dc91, fechado tres días antes:
commit 784dc91
chore(branding): align OpenRouter X-Title + frontend copy with launch master v2.0
- "X-Title": "Deblo.ai -- AI tutor for African students from CP to Terminale..."
+ "X-Title": "Déblo — The real-time voice AI, built in Abidjan."El commit había sido parte de una alineación de marca pre-lanzamiento. El X-Title anterior era un párrafo solo-ASCII que se leía como una descripción SEO. El nuevo X-Title era el eslogan de marca del documento Launch Master v2.0 — el posicionamiento en una línea que ZeroSuite había finalizado para el envío a la App Store, el hero del sitio web, el press kit. « Déblo — The real-time voice AI, built in Abidjan. » Era el eslogan de marca correcto. Era el valor de encabezado equivocado.
El autor del commit (una sesión anterior, también Claude Code) había grepeado la base de código por X-Title y reemplazado cada instancia. Había siete:
backend/app/services/llm.py:104— la ruta principal del chatbackend/app/services/memory.py:78, 250, 336— tres puntos de llamada en la sumarización de conversaciónbackend/app/routes/voice_tools.py:541— function calling del agente de vozbackend/app/services/embedding.py:50— embeddings RAGbackend/app/services/daily_suggestions.py:211— jobs en background de sugerencias diarias
Siete literales de cadena idénticos, todos copiados y pegados desde la misma fuente de marca, todos conteniendo los mismos dos caracteres no-ASCII. Reemplazarlos todos de una sola vez (que es lo que hizo el commit) activó siete bombas de tiempo simultáneas. No había ninguna prueba que ejercitara la llamada HTTP a OpenRouter con una petición saliente real — las pruebas locales mockeaban el cliente, el CI no corría pruebas de red, y el entorno de staging no veía tráfico real antes de que main se desplegara. El commit de branding verificó visualmente (el nuevo título se veía bien en el diff), pasó verify-deblo (que comprueba build, typecheck y svelte-check pero no la codificación de encabezados), y salió a producción.
Siguieron tres días de rotura silenciosa. La sumarización de conversación dejó de funcionar — la tabla AIMemory dejó de crecer. Los embeddings RAG fallaron silenciosamente — la tabla documents tenía nuevos archivos subidos con chunks_indexed: 0 porque cada llamada de embedding levantaba y se la tragaba el wrapper fire-and-forget. Los jobs de sugerencias diarias dejaron de correr. Ninguno de estos fallos tenía superficie visible para el usuario: la ausencia de memoria es invisible, los resultados RAG vacíos parecen «ningún documento relevante», y las sugerencias diarias están en background. Así que nada apareció en los dashboards de operador.
El producto de chat en sí también era casi invisible — el usuario escribe, no pasa nada, el usuario asume que la IA está pensando y espera. Con poco tráfico durante la preparación del lanzamiento, muy pocos usuarios realmente tocaron la ruta rota, así que el buzón de soporte se quedó callado. Lo único que lo atrapó fue el smoke test pre-envío del CEO, que no estaba en su agenda hasta esa tarde.
Si no hubiera hecho el smoke test a las 17:55 UTC, el revisor de la App Store lo habría encontrado al enviar. El revisor habría anotado «función principal de chat no funcional» y rechazado. El lanzamiento se habría retrasado una semana como mínimo. El eslogan de marca que se suponía debía ser la primera cosa que el mundo viera habría sido la cosa que rompió nuestro producto.
Parte 5 — El parche
El parche fueron seis líneas de diff a través de siete archivos. Reemplazar "Déblo — The real-time voice AI, built in Abidjan." por "Deblo - The real-time voice AI, built in Abidjan." (sustituir é → e, raya → guion). ASCII puro, totalmente serializable, semánticamente idéntico para un lector, visible en el dashboard de OpenRouter exactamente como se pretendía.
diff- "X-Title": "Déblo — The real-time voice AI, built in Abidjan.",
+ "X-Title": "Deblo - The real-time voice AI, built in Abidjan.",Aplicado a través de los siete sitios en un solo commit. Pusheado a main. Easypanel auto-desplegó en 1m 47s. El CEO corrió el smoke test de las cuatro superficies (/chat, /work-session, chatpanel de la página de inicio, chatscreen móvil). Las cuatro PASARON al primer intento. Sentry mostró cero nuevos eventos de UnicodeEncodeError después del timestamp del despliegue. El lanzamiento se desbloqueó.
El parche tardó aproximadamente tres minutos desde «veo la traza de Sentry» hasta «commit pusheado». La parte difícil no era el parche. Era encontrar el bug.
Un matiz que vale la pena señalar sobre la elección de la cadena de reemplazo: tenemos una regla global en nuestra base de código que dice nunca quitar acentos franceses para adaptarse a las limitaciones del teclado del usuario. La motivación es que Déblo es un producto educativo para estudiantes africanos francófonos, y un tutor cuyas cadenas de UI eliminaran los acentos enseñaría a los niños ortografía equivocada. La regla vive en CLAUDE.md y se aplica a través de plantillas, etiquetas de UI, mensajes de commit y cadenas dirigidas al usuario.
El encabezado X-Title no está dirigido al usuario. Es un valor de encabezado que solo aparece en el dashboard de OpenRouter, en nuestros propios logs, y en herramientas de traza de API — todas superficies de lado admin. La regla «no quitar acentos» trata sobre educación y percepción del usuario, no sobre serialización HTTP. Elegir ASCII para el encabezado no es una violación de la regla; es elegir la codificación correcta para el transporte correcto. El eslogan de marca que los usuarios ven — en el sitio web, en la descripción de la App Store, en el onboarding — sigue siendo « Déblo — The real-time voice AI, built in Abidjan. » con diacríticos completos. La versión que viaja por el formato de cable HTTP recibe el downgrade ASCII.
Esta es una pequeña distinción semántica pero importa como precedente. Reglas como «nunca quites acentos» necesitan acotación — se aplican a superficies dirigidas al usuario, no a uso arbitrario de cadenas. Una interpretación general nos prohibiría usar ASCII en cualquier ruta de código, incluidas aquellas donde el protocolo de cable lo requiere explícitamente. El encuadre correcto es ortografía correcta en las superficies donde la ortografía es el producto, codificación correcta en las superficies donde la codificación es el transporte.
Parte 6 — Por qué la traza de Sentry fue el único camino a la verdad
Cuarenta minutos de investigación, dos hipótesis falsas, un parche correcto. Lo que volcó la investigación no fue una relectura de código, no una sonda más profunda, no una traza manual más exhaustiva — fue un evento de error con una pila apuntando a client.stream(...) y un mensaje que contenía \xe9 position 1.
Vale la pena detenerse aquí un momento, porque la lección más amplia se generaliza mucho más allá de este incidente.
Los dos modos de fallo que consideré antes de que aterrizara la traza de Sentry — «el modelo está razonando silenciosamente, nunca llega al contenido» y «el system prompt contiene un carácter que rompe la serialización JSON» — eran ambos plausibles y ambos consistentes con todos los síntomas observables. También eran ambos erróneos. No había manera de distinguir entre ellos, o entre cualquiera de ellos y la causa real, usando solo los síntomas. El comportamiento visible para el usuario era idéntico en los tres escenarios: escribe, no pasa nada.
Lo que una stack trace de producción te da que ninguna cantidad de razonamiento hacia adelante puede igualar es un registro específico, marcado en el tiempo, de qué ruta de código se ejecutó realmente y dónde falló. Colapsa el espacio de búsqueda de «todo lo que podría estar roto» a «esta línea exacta, en esta función exacta, con este tipo de excepción exacto, en este momento exacto». La investigación pasa de generativa (tengo que imaginar qué podría salir mal) a discriminativa (puedo leer qué salió mal).
Sin ese registro, la única manera de discriminar entre las hipótesis plausibles es probar cada una independientemente en producción. Rollback del modelo. Ver si ayuda. (No habría ayudado, en este caso — el modelo estaba bien, la petición nunca lo alcanzó.) Quitar el system prompt. Ver si ayuda. (Tampoco habría ayudado.) Cada prueba es un ciclo de despliegue, más tiempo de observación, más posiblemente un rollback. Al tempo de semana de lanzamiento, incluso un ciclo desperdiciado es caro; cuatro serían catastróficos.
La traza de error nos dio la respuesta en cero ciclos de despliegue. Apuntó a la línea exacta, nombró el carácter exacto, e hizo que la reproducción local fuera un ejercicio de diez segundos. El diseño del parche siguió en tres minutos.
Esto es lo que la infraestructura de observabilidad se gana el sueldo haciendo. No las cosas vistosas para demo — dashboards bonitos, lenguajes de consulta, alertas personalizadas. Eso está bien. El valor real es el caso aburrido: cuando algo se rompe silenciosamente en producción, un registro de error completo, estructurado, buscable, existe y está a una consulta de distancia. Los dashboards no son el producto; los dashboards son la consecuencia de tener un almacén estructurado de eventos. El lenguaje de consulta importa menos que el hecho de que los eventos estén ahí para ser consultados.
Para nuestro stack, usamos Sentry. Lo usamos porque atrapó este bug en 24 horas de funcionamiento roto (el primer evento se disparó a las 18:20:57 UTC, unos minutos antes de que el CEO escalara), produjo una stack trace que nombraba el archivo y línea exactos, y enrutó una notificación a un canal que ambos vigilábamos. El coste de operarlo queda eclipsado por el coste de una caída bloqueante para el lanzamiento atrapada dos horas más tarde en lugar de dos días más tarde. No somos leales a la marca; somos leales a la propiedad — eventos de error estructurados, capturados cerca de la fuente, buscables en tiempo real. Varias herramientas proporcionan esto. Elige una. Instálala en el día cero, no en el día cien. La decisión de cablearlo lleva una hora. La decisión de no cablearlo no lleva ninguna decisión, por eso tantos proyectos lo posponen hasta que se queman.
El consejo más visto en esta categoría es «configura el seguimiento de errores antes de configurar la analítica». La analítica te dice qué hicieron los usuarios. El seguimiento de errores te dice qué hizo tu código. Cuando algo se rompe silenciosamente, la analítica te dirá que los usuarios dejaron de interactuar — lo cual es cierto e inútil. El seguimiento de errores te dirá por qué. La asimetría de valor es suficientemente grande como para que el orden importe.
Parte 7 — Lo que salió bien en el proceso
Tres cosas funcionaron correctamente en la respuesta a este incidente, a pesar del tropiezo de la hipótesis falsa:
El CEO escaló a la persona correcta en el momento correcto. Cuando me envió los síntomas, no dijo «el chat está roto, arréglalo». Envió las acciones de usuario exactas, las superficies exactas afectadas, y el estado exacto de los logs del backend (contenedor Easypanel corriendo normalmente). Cuando volví con una hipótesis equivocada, no la aceptó — envió una sola frase (« issue was there before 3.5-flash ») que descalificó la teoría del swap de modelo con un solo dato temporal. No necesitaba saber la respuesta correcta para saber que la mía era incorrecta.
La traza de Sentry le llegó a él antes que a mí. Las notificaciones de Sentry estaban enrutadas al canal que él vigilaba. Copió el cuerpo completo de la notificación a nuestra sesión a los pocos minutos de dispararse. Si el enrutamiento hubiera sido solo a mí, o a un canal de Slack de baja prioridad que nadie vigilaba, la traza se habría quedado sin leer y la investigación habría continuado por el camino equivocado. Dónde aterriza la notificación de error importa tanto como que aterrice.
El parche se aplicó a través de los siete sitios de copia y pega en un solo commit. Una vez identificada la causa raíz, la tentación natural es arreglar el que se disparó (la ruta de chat en llm.py) y enviarlo. No lo hicimos. Grepeamos por X-Title a través de todo el backend, encontramos los siete sitios, y los parcheamos en el mismo commit. Los otros seis también estaban silenciosamente rotos — embeddings, sumarización, sugerencias diarias, herramientas de voz — y los arreglos parciales dejan minas. Seis minutos de grep ahorran seis incidentes futuros.
Los dos primeros son sobre personas y enrutamiento. El tercero es sobre disciplina. Juntos acortaron el tiempo de arreglar-y-enviar post-traza de «incierto» a «8 minutos desde el email de Sentry hasta el auto-despliegue completo de Easypanel».
Parte 8 — Lo que esta sesión enseña sobre la confianza pre-lanzamiento
Algunas conclusiones que pueden generalizarse más allá de Déblo y más allá de los crunches pre-lanzamiento.
Los fallos silenciosos son los peores fallos. Una página de error 500 es mala pero recuperable — el usuario sabe que algo se rompió, el operador ve el tráfico, el sistema registra el evento. El fallo silencioso — sin error, sin spinner, sin señal — es el modo de fallo que vence todas las demás salvaguardas. El usuario asume que escribió algo mal. El operador ve métricas normales de carga de página. El sistema no registra ninguna excepción en las peticiones que sirve salvo la que murió antes de poder escribir un cuerpo de respuesta. Construye para el fallo silencioso haciendo que tus rutas de error sean más ruidosas que tus rutas de éxito. Si tu código puede devolver un stream de cero bytes que el frontend trata como «sigue cargando», tienes una superficie de fallo silencioso. Ciérrala.
Una hipótesis plausible no es una prueba. «El modelo es un modelo de razonamiento y el razonamiento consume el presupuesto de tokens» es un modo de fallo perfectamente real. Pasa. Explica los síntomas. Hasta tiene un parche que funcionaría para ese escenario (subir max_tokens, cambiar de modelo, poner reasoning.effort=low). Y era completamente irrelevante para el bug real. La lección es distinguir hipótesis que son consistentes con la evidencia de hipótesis que están realmente instanciadas. La traza de error de producción es el discriminador. Hasta que la tengas, tus hipótesis son a lo sumo candidatas; tratarlas como conclusiones desperdicia ciclos de despliegue.
Las sondas con parámetros artificiales producen resultados artificiales. Mi sonda inicial usaba max_tokens=50 porque quería una respuesta rápida. Con ese presupuesto, un modelo de razonamiento puede legítimamente quedarse sin espacio antes de emitir contenido. Pero producción corre con max_tokens=4000, y con ese presupuesto el mismo modelo emite 1700 caracteres de contenido sin problema. La sonda dio una respuesta correcta a la pregunta equivocada. Prueba con parámetros de producción, o tu prueba no es una prueba de producción.
Los bugs de copiar y pegar se propagan, los arreglos de copiar y pegar no. El commit de branding copió y pegó la misma cadena en siete puntos de llamada en una sola operación. Así es como rompió siete rutas a la vez. Grepear la cadena y arreglar cada sitio es el mismo tipo de operación — y, crucialmente, el correcto mismo tipo de operación. Cuando un bug es una propagación de copiar-pegar, el arreglo es una propagación de grep-y-reemplazo. No arregles un sitio y envíes; volverás a arreglar los demás en el siguiente incidente.
Verifica lo que tus herramientas realmente verifican. Nuestro CI pre-despliegue (verify-deblo) corre build de frontend, svelte-check de frontend, pytest de backend y type-check de backend. Ninguna de estas pruebas ejercita la petición HTTP real que va a OpenRouter. La excepción httpx con la que dimos solo se dispara cuando corre el codificador de formato de cable, que nuestra suite de pruebas mockea para que desaparezca. La lección no es «añade una prueba de integración para cada llamada a API externa» — sería exagerado. La lección es saber qué superficies cubre tu verificación y cuáles no, y hacer las brechas explícitas. Nuestra brecha era «encabezados de API externos». Ahora la tenemos en la lista.
La consistencia de marca y la consistencia de protocolo de cable son problemas diferentes. Está bien — incluso es deseable — que el eslogan de marca use diacríticos y rayas. Esos caracteres llevan información tipográfica que importa en contextos visibles para el usuario. No está bien poner esos caracteres en valores de encabezado HTTP, porque el formato de cable HTTP es más restringido que el renderizado de Markdown. Las dos restricciones no están en conflicto; se aplican a superficies diferentes. Mapea el uso de tus activos de marca a las restricciones de codificación de cada superficie explícitamente, no por reflejo de copiar y pegar.
Parte 9 — Lo que hice bien y lo que no pude ver
Esto lo escribe Claude Code.
Dónde fui útil en esta sesión:
- Cruzar las referencias de los siete puntos de llamada
X-Titleen paralelo y parchearlos todos en un solo commit. El riesgo de «arreglar solo la ruta del chat» era real y lo atrapé antes de pushear el parche. GrepearX-Titlea través debackend/appy leer cada sitio para confirmar que la misma cadena rota estaba presente — rápido para mí, propenso a errores para un humano bajo el estrés de la semana de lanzamiento. - La reproducción local de httpx. Traducir «la pila de producción dice UnicodeEncodeError en httpx.Client.stream» a un snippet de Python de cuatro líneas que reproduce la excepción fue un ejercicio de diez segundos que confirmó la causa raíz definitivamente. Una vez la reproducción estaba en la mano, el parche dejaba de ser una hipótesis; era una transformación conocida.
- La lista de comprobación de smoke-gate post-parche. Después de pushear el parche, escribí las seis superficies que el CEO necesitaba verificar (web
/chatinvitado, web/chatauth K12, web/work-sessionauth Pro, chatpanel de página de inicio invitado, chatscreen móvil auth, Sentry cero nuevos eventos después del timestamp de despliegue). Tener la lista escrita antes de que se completara el despliegue significaba cero ambigüedad sobre qué aspecto tiene el hecho.
Dónde necesité a Thales:
- La corrección de la hipótesis falsa. Comiteé
71a3274 docs(launch): chat text broken root cause identified -- gemini-3.5-flash is reasoning modelcon alta confianza. El CEO la descalificó con una frase (« issue was there before 3.5-flash »). Sin esa corrección, habría aconsejado un rollback de variable de entorno en Easypanel que no habría arreglado nada. El ciclo desperdiciado habría costado otros 20-30 minutos como mínimo, durante los cuales la ventana de envío a la App Store ardía. - El reenvío de la traza de Sentry. El evento de error se disparó a las 18:20:57 UTC. El CEO copió el cuerpo completo de la traza a la sesión a los pocos minutos. Si no hubiera vigilado el canal de notificaciones de Sentry, la traza se habría quedado sin leer, y la investigación habría continuado. Él era la capa de enrutamiento entre Sentry y yo, y el enrutamiento era tan portante como la herramienta misma.
- La decisión de acotar el parche a una sustitución de cadena solo-ASCII en lugar de implementar una solución más elaborada (codificación latin-1, encoded-word RFC 2047, middleware httpx personalizado). Brevemente había considerado cada una. Cortó por lo sano con la decisión correcta: el X-Title es un encabezado, el encabezado son solo metadatos, ASCII es la respuesta correcta barata. Cinco líneas de diff en lugar de cincuenta. El alcance correcto en el momento correcto, especialmente bajo presión de lanzamiento.
Dónde casi envío lo equivocado:
- El documento de auditoría comiteado-luego-equivocado
71a3274es el artefacto más vergonzoso de esta sesión. Existe enmain. Añadí una sección «hipótesis invalidada, esto es lo que realmente pasó» debajo después de que surgiera la verdad, pero el contenido erróneo original sigue ahí para que lectores futuros se rompan la cabeza. La lección es que pushear una conclusión antes de que la conclusión esté confirmada crea escombros arqueológicos que alguien en tres meses leerá y creerá. No comitees conclusiones que todavía son hipótesis. Comitea investigaciones como investigaciones, y conclusiones como conclusiones. - El documento de auditoría había recomendado un rollback específico de variable de entorno como parche. Si ese documento hubiera sido leído por un ingeniero de guardia a las 03:00 UTC durante un incidente diferente, habría seguido la recomendación y no habría arreglado el bug real. El coste de una recomendación equivocada en un doc público no es cero; solo está diferido.
El patrón es consistente con sesiones anteriores: puedo moverme rápido en ejecución, paralelizar a través de puntos de llamada, correr reproducciones y parches a alto rendimiento. Los movimientos estratégicos — saber a qué hipótesis confiar, qué traza escalar, qué alcance elegir para el parche — siguen viniendo de un CEO con memoria de producto, contexto de mercado, y la disciplina de empujar de vuelta a agentes confiados-pero-equivocados. El rendimiento de una sesión de debug se comprime; el criterio de cuándo dejar de perseguir una hipótesis no. Todavía no.
Conclusión
Una sola raya en un solo valor de encabezado HTTP rompió todo nuestro producto de chat a través de cuatro superficies durante aproximadamente 24 horas. El bug era invisible a cada salvaguarda que teníamos — ninguna prueba lo atrapó, ningún smoke check lo ejercitó, ninguna alerta de monitoreo lo notó. Salió a la superficie solo porque un humano hizo una verificación al producto dos días antes del envío, y se resolvió solo porque una stack trace de producción apuntó a la línea exacta de código que falló.
La lección más profunda no trata de Unicode en encabezados, aunque ese es el desliz específico que vale la pena comprometer a memoria muscular. La lección más profunda trata de la epistemología del debug bajo presión: una hipótesis no es una conclusión, una sonda no es una traza, y un parche no es seguro hasta que el modo de fallo haya sido nombrado y visto, no meramente postulado y consistente. Casi enviamos un parche para el bug equivocado porque el parche equivocado habría sido consistente con los síntomas. La consistencia es necesaria pero no suficiente. La traza de producción — el registro específico, marcado en el tiempo, archivo-línea-nombrado de lo que tu código realmente hizo — es el único artefacto que cierra la brecha entre «podría ser» y «es».
Esto es lo que las herramientas de observabilidad se ganan el sueldo haciendo. No los dashboards. No la alertas. El caso base aburrido: cuando algo se rompe silenciosamente, existe un evento de error estructurado y está a una consulta de distancia. Cablealo antes de cablear cualquier otra cosa de cara al cliente. La decisión de añadirlo lleva una hora. La decisión de no añadirlo no parece una decisión, por eso te cuesta un lanzamiento cuando llega el día.
El chat de Déblo está de vuelta en línea. El parche salió en el commit bc93ffb. Easypanel redesplegó en menos de dos minutos. Las cuatro superficies pasaron el smoke test al primer intento. Sentry ha registrado cero nuevos eventos de UnicodeEncodeError desde entonces. La ventana de envío a la App Store está abierta, el eslogan de marca sigue leyéndose « Déblo — The real-time voice AI, built in Abidjan. » en cada lugar donde un humano lo leerá, y el codificador de formato de cable recibe la versión ASCII que necesita en cada lugar donde una máquina lo leerá.
La raya está de vuelta donde le corresponde. Solo que no en nuestros encabezados HTTP.
Esta pieza fue escrita en colaboración por Thales (CEO de ZeroSuite, construyendo Déblo y VeoStudio desde Abiyán, Costa de Marfil) y Claude Opus 4.7 — instancia Claude Code corriendo en macOS. El incidente que describe tuvo lugar el 19 de mayo de 2026 (log de sesión phase-13-audit-chat-text-broken-2026-05-19.md). El parche está en el commit bc93ffb en main en el monorepo deblo.ai. Los siete puntos de llamada parcheados fueron: backend/app/services/llm.py:104, backend/app/services/memory.py:78, 250, 336, backend/app/routes/voice_tools.py:541, backend/app/services/embedding.py:50, backend/app/services/daily_suggestions.py:211. La traza de Sentry que abrió la investigación tenía el ID d90a8ab65aa348df984dd8c0bb478437. El bug bloqueante para el lanzamiento había estado en producción desde el commit 784dc91 (16 de mayo de 2026), tres días antes del descubrimiento. El documento original de auditoría con hipótesis falsa se conserva en el repo en session-logs/gemini-session-logs/phase-13-audit-chat-text-broken-2026-05-19.md con una sección de invalidación anotada, como registro de qué aspecto tenía el razonamiento antes de que la traza saliera a la superficie.