Del stack trace al pull request: el ciclo de corrección con un agente de IA
Cómo expongo los errores de producción en JSON para un agente de IA (y dejo que un humano apruebe el PR).
Cada vez que aparecía un error de producción en la pantalla de soporte del sistema, el proceso era el mismo: abría la página, copiaba el mensaje de error, lo pegaba en la conversación con la IA y pedía un diagnóstico.
Funcionaba. Pero era lento, frágil y dependía de mí en cada eslabón.
Hasta que un FileNotFoundError en una pantalla de manuales operacionales me
hizo ver el problema real: no tenía forma de darle al agente de IA la
capacidad de descubrir los errores por sí solo. Solo veía lo que yo pegaba.
La pregunta que me guió fue simple:
¿Cómo exponer los errores de producción en JSON, de forma segura, para que un agente de IA cierre el ciclo de diagnóstico y corrección — con un humano revisando y aprobando el pull request antes del deploy?
El resultado es una API pequeña, con decisiones grandes. Voy a contar qué construí, por qué lo construí así y qué aprendí en el camino.
El problema: errores atrapados en una pantalla HTML
El sistema es una aplicación Django. Los errores de producción eran capturados por un middleware y guardados en una base de datos, pero la única forma de verlos era una pantalla HTML protegida por login de staff.
Antes de seguir, un contexto que explica el tamaño de la solución. La aplicación no corre en una nube grande. Por ahorro, está alojada on-premise, para uso interno, pero expuesta públicamente en internet para algunos usuarios que trabajan de forma remota. Eso significa dos cosas al mismo tiempo: los errores de producción llegan a usuarios reales del lado de afuera — y no hay presupuesto ni equipo para una plataforma de observabilidad. No tenemos APM, tracing distribuido ni dashboard de métricas. Lo que tenemos es la propia aplicación, la base de datos que ya usa y esta API de errores que vive dentro de ella. La limitación no es falta de tiempo: es el presupuesto.
Eso creaba tres límites:
- Ningún agente puede descubrir errores solo. Sin acceso programático, depende de que alguien pegue el texto.
- No se puede filtrar por deploy. Sin saber de qué publicación vino el error, era imposible responder “¿esto se rompió después de la última publicación?”.
- No hay seguimiento de lo que se corrigió. Nada marcaba un error como resuelto.
Lo que yo quería era lo contrario: el agente descubre, diagnostica y propone; el humano revisa, aprueba y publica.
La solución en una frase
Un middleware captura todo error HTTP 500+ y guarda un registro estructurado. Una API JSON, autenticada por token de entorno, expone esos registros al agente — con stack trace sanitizado, identificación del deploy y la posibilidad de marcar el error como corregido.
navegador ──▶ Django
├─ RequestErrorMiddleware → graba RequestError
├─ /support/errors → humano (HTML)
└─ /api/request_errors → agente de IA (JSON)
graph LR
Nav[Browser] ---|Request| Django[Django]
subgraph Django App
Django --> Middleware[RequestErrorMiddleware]
Django --> HTML_Route["/support/errors"]
Django --> JSON_Route["/api/request_errors"]
end
Middleware --> DB[(writes RequestError)]
HTML_Route -->|HTML| Human[Human]
JSON_Route -->|JSON| AIAgent[AI Agent]
Lo que el agente puede hacer
GET /api/request_errors/?status=AB&limit=20 # errores abiertos
GET /api/request_errors/?boot_id=<deploy> # lo que se rompió después del deploy X
GET /api/request_errors/{id} # detalle
PATCH /api/request_errors/{id} {"status":"CO"} # marcar como corregido
Cuatro operaciones. Pocas a propósito: la acción destructiva de limpiar el historial no existe en la API — solo en la pantalla humana.
Las decisiones que importan (y por qué importan)
1. Token de entorno, deshabilitado por defecto
La API global usa cookie de sesión. Un agente HTTP no tiene sesión. Entonces el
token va en un header (X-API-Token) y se compara en tiempo constante
(secrets.compare_digest).
El detalle más importante: sin la variable de entorno configurada, el endpoint responde 401. El estado por defecto es “no expone nada”. Seguridad por omisión, no por configuración.
2. El traceback no se corta por el final
Esta fue la decisión más interesante — y la que más me enseñó.
La primera versión del traceback se truncaba a 40 líneas cortando por el
final. El problema: el final del stack trace de Django es casi solo
django/db/backends, que es donde Django re-lanza el error, no donde
ocurrió.
Resultado: el agente recibía un montón de código de Django y no lograba hallar
la causa. El fix pasó a mantener los frames del proyecto (marcados por
/editora/ o /editor_site/), que quedan en el inicio del stack. Sin
ningún frame del proyecto, ahí sí cae a las últimas 40 líneas — donde está el
punto del raise.
Cómo cortas el stack trace es una decisión de producto. Entregar el stack equivocado es lo mismo que no entregar stack ninguno.
3. Lo que entra en el payload (y lo que sale)
El contexto de la solicitud se graba completo en la base de datos — la pantalla humana lo necesita. Pero en la entrega por la API, dos campos se eliminan:
session— la clave de sesión permite secuestrar la sesión de quien hizo la solicitud.body— carga los datos de la solicitud, posiblemente sensibles.
Grabación completa, entrega sanitizada. Son niveles de exposición diferentes para públicos diferentes.
4. boot_id: el filtro que cierra el ciclo
Cada error carga el boot_id del proceso que lo generó. Eso le permite al agente
responder a la pregunta más importante del ciclo de deploy:
“Después de que corregí y publiqué, ¿el error paró?”
Sin eso, la confirmación de la corrección depende de corazonadas.
5. Confiar en el secreto (de forma explícita)
El endpoint no verifica is_staff. Quien tenga el token ve los errores de
todos los usuarios. Es el mismo nivel de exposición de la pantalla de soporte,
pero vía secreto.
Es un compromiso consciente: el token es la barrera. Si se filtra, rotar la variable deshabilita el acceso inmediatamente.
El ciclo completo (lo que cambia en la práctica)
1. Agente: GET /api/request_errors/?status=AB
2. Agente: lee traceback + endpoint + boot_id + contexto
3. Agente: diagnostica la causa y escribe la corrección
4. Agente: abre un Pull Request
5. Humano: revisa y aprueba
6. Humano: hace el deploy
7. Agente: GET /api/request_errors/?boot_id=<nuevo_deploy> → confirma que paró
8. Agente: PATCH /api/request_errors/{id} {"status":"CO"}
El agente hace descubrimiento, diagnóstico y propuesta. El humano se queda con la decisión. Eso es lo que llamo automatización con supervisión: el trabajo repetitivo y de lectura sale de mis espaldas; la responsabilidad final sigue siendo mía.
En acción: una conversación con el agente
La teoría es linda. En la práctica, el ciclo empieza con una frase simple.
El prompt
verifique los errores en el ambiente de producción
Eso es todo. Ningún ID, ningún link, ningún copy-paste de stack trace.
Lo que el agente hace solo
El agente carga las variables de entorno (X_API_TOKEN, X_API_URL), arma las
llamadas y consulta la API directamente:
# 1. Errores abiertos — lo que necesita atención ahora
curl -s -H "X-API-Token: $X_API_TOKEN" \
"$X_API_URL/api/request_errors/?status=AB&limit=20"
# 2. Errores en análisis — ¿alguien ya miró?
curl -s -H "X-API-Token: $X_API_TOKEN" \
"$X_API_URL/api/request_errors/?status=AN&limit=10"
# 3. Todos los errores — panorama completo
curl -s -H "X-API-Token: $X_API_TOKEN" \
"$X_API_URL/api/request_errors/?limit=500"
La respuesta del agente
En segundos, devuelve un análisis estructurado:
Dashboard de Errores — Producción (Fly.io)
| Métrica | Valor |
|---|---|
| Total | 3 |
| 🟢 Abiertos | 0 |
| 🟡 En Análisis | 1 |
| 🔵 Corregidos | 2 |
| ID | Fecha | Excepción | Endpoint | Estado | Usuario | Boot ID |
|---|---|---|---|---|---|---|
| 3 | 15/10 11:50 | FileNotFoundError |
manual_operacao |
AN | guionardo | 1792075704 |
| 2 | 08/10 13:23 | OperationalError (database is locked) |
editora_pedidoitem_delete |
CO | Mara | 1790299626 |
| 1 | 09/09 10:39 | FieldError (icontains en FK) |
editora_contato_changelist |
CO | Akauana | 1788617182 |
Diagnóstico por error
#3 — FileNotFoundError
La vista
manual_operacaointentó abrir../../docs/MANUAL_OPERACIONAL.mddesde/app/editora/views/. En el container de Fly.io, el path relativo no resuelve. El archivo probablemente no fue incluido en la imagen Docker. Sugerencia: usar path absoluto via settings o incluir el archivo en el build.
#2 — database is locked
Lock de escritura en SQLite durante eliminación de
PedidoItempor el admin. El traceback se trunca en los frames del proyecto — en este caso, el relevante esdjango/db/backends/sqlite3/base.py:360, donde el lock explotó después de 30 segundos. Como fue un error aislado, indica transacción larga, no evento sistémico. Sugerencia: monitorear recurrencia; investigar transacciones enexclusoes.py.
#1 — FieldError
Búsqueda
q=luanaen el admin de Contato usóicontainsen ForeignKey. Django rechaza lookupicontainsdirecto en FK. Sugerencia: revisarsearch_fieldsdel admin, usarcliente__nome__icontains.
El punto que importa
El usuario no necesitó abrir una pantalla, no copió nada, no tradujo boot_id
à fecha de deploy. El agente hizo el puente entre la pregunta en lenguaje
natural y los comandos estructurados de la API. El humano recibió contexto,
hipótesis y acciones sugeridas — no un JSON crudo.
Lo que aprendí
- Exponer un error en JSON es exponer stack trace, nombre de archivo y contexto de usuario. No existe exposición cero — existe exposición controlada por secreto, con el mínimo en el payload.
- El detalle “aburrido” es el que rompe la automatización. El agente no falló por falta de inteligencia; falló porque el traceback entregado no tenía el frame del proyecto. La herramienta estaba mal, no el agente.
- Deshabilitar por defecto es una feature. Un endpoint que responde 401 sin configuración es un endpoint que nunca se filtra por olvido.
- La acción destructiva no pertenece a la API. Si el agente puede borrar historial, un error de diagnóstico se convierte en pérdida de datos.
- La confirmación objetiva vale oro. El
boot_idtransforma “creo que se resolvió” en “el error paró después de este deploy”. - El presupuesto es un requisito de diseño. No comprar una plataforma de observabilidad no fue pereza: fue decisión. Eso empujó la solución hacia dentro de la aplicación, donde cuesta casi nada y resuelve el problema real.
Limitaciones honestas
Nada de esto es perfecto, y vale la pena decirlo — y, siendo justo, son limitaciones coherentes con el presupuesto y el tamaño del proyecto, no falta de tiempo:
- Solo captura errores 500+. Los errores 4xx no entran (por decisión).
- La deduplicación usa
unique_together, así que un error recurrente no actualiza la fecha de la última ocurrencia — solo se guarda la primera. - No hay webhook: el agente necesita hacer polling.
- El campo
endpointguarda el nombre de la ruta, no el path — detalle que ya me agarró (y que documenté).
Sanitizando stack traces: qué cortar, qué mantener
Un lector preguntó exactamente lo que importa: “En el momento que automatizás ese ciclo, los secrets empiezan a filtrarse si no tenés cuidado. ¿Cómo decidís qué truncar sin perder los frames que apuntan al bug?”
La respuesta corta: grabación completa, entrega mínima.
El problema: el traceback de Django es un documento de mala fe
Mirá un traceback típico de excepción en producción. Las primeras líneas son
Django re-lanzando el error: asgiref, django/core/handlers/base.py,
django/db/backends. Después viene tu código. Después Django relanza de nuevo.
El error real está escondido en el medio de 80 líneas de framework.
La primera versión de la API cortaba las últimas 40 líneas. El agente recibía
casi sólo código de Django. Veía execute_sql, thread_handler, sync.py — y
no encontraba la línea de mi código. Entregar el stack equivocado es lo mismo
que no entregar stack ninguno.
La estrategia: mantener los frames del proyecto (que quedan arriba)
La decisión no fue “cortar por el principio” o “cortar por el final”. Fue: identificar lo que es mío y preservarlo.
En Django, los frames del tu proyecto quedan en la parte de arriba de la pila — antes de que Django empiece a re-lanzar. Entonces la lógica pasó a ser:
- Buscar frames del proyecto — líneas con
/editora/o/editor_site/ - Mantenerlos arriba, con la línea de código que está justo debajo
- ¿Sin frames del proyecto? Caer a las últimas 40 líneas — donde está el
raise
TRACEBACK_LINES = 40
MARCADORES_APP = ('/editora/', '/editor_site/')
def truncar_traceback(traceback: str, max_linhas: int = TRACEBACK_LINES) -> str:
if not traceback:
return ''
linhas = traceback.splitlines()
if len(linhas) <= max_linhas:
return traceback
# Encontrar todos los frames que son de nuestro proyecto
frames = [
i
for i, linha in enumerate(linhas)
if linha.lstrip().startswith('File ')
and any(m in linha for m in MARCADORES_APP)
]
# ¿Sin frames del proyecto? Tomar el final — donde está el raise
if not frames:
return '\n'.join(linhas[-max_linhas:])
# Coleccionar frame + la línea de código justo debajo
escolhidas = {0} # línea 0 = "Traceback (most recent call last):"
for i in frames:
escolhidas.add(i)
if i + 1 < len(linhas):
escolhidas.add(i + 1)
# Si pasó del límite, cortar por el principio (frames del proyecto)
if len(escolhidas) > max_linhas:
return '\n'.join(linhas[:max_linhas])
return '\n'.join(linhas[i] for i in sorted(escolhidas))
Cómo funciona en la práctica
Error #3 — FileNotFoundError: el traceback crudo tiene ~25 líneas. Las primeras 15 son de Django. La línea 16 es:
File "/app/editora/views/manual_operacao.py", line 44, in manual_operacao_view
with open(MANUAL_FILE, encoding='utf-8') as f:
La lógica mantiene sólo esas 2 líneas + el encabezado. El agente ve
inmediatamente: “Error en la vista manual_operacao, línea 44, intentando
abrir un archivo.”
Error #2 — database is locked: acá no hay frames del proyecto en el
traceback. La lógica cae al fallback de últimas 40 líneas. El agente recibe
django/db/backends/sqlite3/base.py:360 in execute — correcto, porque el bug no
está en nuestro código, está en la interacción con SQLite.
Dónde realmente viven los secrets
El lector acertó: el peligro no está en el stack trace, está en el request
context. El middleware graba el contexto completo — incluyendo session (permite
secuestro de sesión) y body (puede tener contraseñas). La API elimina:
CAMPOS_SENSIVEIS = ('session', 'body')
def limpar_contexto(contexto: dict | None) -> dict:
if not contexto:
return {}
return {k: v for k, v in contexto.items() if k not in CAMPOS_SENSIVEIS}
Lo que queda: method, params, user_agent, referer, headers — lo
suficiente para que el agente entienda “fue un POST del admin, CSRF token,
usuario Mara” sin exponer datos que permitan secuestro de sesión.
El principio general
| Capa | Qué guarda | Quién accede |
|---|---|---|
| Base de datos | Traceback completo + contexto con session/body | Pantalla humana de soporte |
| API | Traceback truncado (frames del proyecto) + contexto sin session/body | Agente de IA |
Grabación completa, entrega mínima. La base de datos almacena todo porque un humano en soporte puede necesitarlo. La API entrega sólo lo que un agente necesita para diagnosticar, y nada más.
Decidir cómo cortar el stack trace es una decisión de producto, no de ingeniería. Estás eligiendo: quién va a leer, qué es ruido, y qué es señal. El agente no falla por falta de inteligencia; falla porque la herramienta entregó el mapa equivocado.
Conclusión
Construir una API de errores para agentes de IA parecía un problema de “cómo expongo esto en JSON”. En la práctica, era un problema de cuánto exponer, para quién y con qué confirmación.
La feature es pequeña. Las decisiones, no. Y la ganancia real no fue “el agente descubrió un error” — fue dejar de ser el intermediario entre el error y la corrección.
Si trabajas con agentes de IA en producción, mi sugerencia es: empieza por la observabilidad. Un agente sin acceso estructurado a los errores es un agente adivinando. Y si el presupuesto es corto, recuerda: no hace falta una plataforma de observabilidad para darle al agente lo que necesita — a veces, una API de 100 líneas dentro de la propia aplicación resuelve.
¿Y tú? ¿Cómo descubren errores en producción tus agentes de IA? ¿Pegan stack traces a mano o ya tienen una API para eso? Cuéntame en los comentarios.