De logs de producción a pull requests

De logs de producción a pull requests
21 min read

Casi todos los equipos con los que trabajé llegaron al mismo lugar: los logs de todos los ambientes están centralizados, hay dashboards, hay alarmas. Y aun así, el último tramo lo camina una persona. Alguien abre la consola un lunes a la mañana, corre la query de errores, separa lo nuevo del ruido de siempre, abre el ticket, y con suerte escribe el fix.

Ese tramo, de "el gráfico está en rojo" a "hay un PR con dueño", es mecánico. Aburridamente mecánico. Y es el tipo de trabajo donde un LLM con acceso al código rinde: leer un stack trace, mapearlo al código, proponer una edición chica. Lo que sigue es la anatomía de un sistema que cierra ese loop corriendo desatendido contra producción. Lo armé sobre AWS, pero cada pieza tiene equivalente directo en Google Cloud, así que voy mostrando las dos.

Antes de arrancar, qué ordena todas las decisiones que vienen: seguridad y costo. Un agente que escribe código a partir de texto que vos no controlás es un problema de seguridad antes que de prompting. Y un loop de tool-use contra un modelo grande se te va de precio mucho antes de que se te vaya de calidad. Casi todo lo que parece paranoia o sobreingeniería en el diseño sale de una de esas dos cosas.

La forma del sistema: dos mitades y una cola

La primera decisión de diseño: partir el loop en dos procesos independientes y usar los issues de GitHub como la cola entre ellos. Las dos mitades actúan como una GitHub App con permisos mínimos.

logs de todos los ambientes


   ┌─────────────┐
   │  Triage     │  lee logs, clusteriza, entiende, cierra los benignos
   └─────────────┘
        │  abre issue

  ═══ issues de GitHub ═══   ← la cola: cada issue abierto es un pendiente
        │                     ↕ y acá interviene el humano:
        │  lee issues          cierra, saca labels, rechaza PRs
        ▼  abiertos
   ┌─────────────┐
   │  Resolución │  clona, edita, abre PR
   └─────────────┘


  PR / cerrado / needs-human

Lo que te da:

  • Cada mitad se deploya y se pausa por separado. El triage puede estar en producción mientras el que arregla sigue en dry-run.
  • El estado vive en los issues y no en una tabla propia. No hay nada que reconciliar entre corridas: "¿este error ya lo vi?" es una búsqueda en GitHub.
  • Una persona interviene en cualquier punto con el flujo que ya conoce: cerrar un issue, sacar un label, rechazar un PR. No hay una consola nueva que aprender, y cada una de esas acciones es una señal que el sistema respeta.

Un agente autónomo que no entiende "un humano ya decidió esto" es un agente que discute con tu equipo a toda hora.

Pieza 1: clusterizar del lado del servidor

El primer instinto es traer las líneas de error y agruparlas en el proceso. Es la decisión más cara: Logs Insights cobra por GB escaneado, así que traés y pagás un millón de líneas para terminar con los mismos ocho clusters.

En CloudWatch eso es una línea de query. El comando pattern agrupa mensajes con estructura parecida del lado del servidor y te devuelve un puñado de clusters, cada uno con su plantilla tokenizada (@pattern, donde los valores variables quedan como <Token-1>), el conteo de eventos que cayeron ahí (@sampleCount) y la proporción sobre el total (@ratio):

filter level="error" or @message like /(?i)(unhandledRejection|Task timed out|ECONNREFUSED)/
| pattern @message
| sort @sampleCount desc
| limit 25

Ojo con el filtro: si tus logs son JSON estructurado, level="error" agarra lo que tira tu código, pero no las líneas de plataforma (timeouts, procesos que se mueren, errores del runtime), que llegan como texto plano. Necesitás las dos mitades del or, y la lista de firmas de plataforma la vas a ir descubriendo mirando lo que se te escapa.

Y algo que hay que tener claro antes de diseñar el resto: pattern te da la plantilla, no una línea de log cruda. Para fingerprintear alcanza perfecto, pero si querés meter un stack de ejemplo en el issue necesitás una segunda query filtrando por ese cluster.

En Google Cloud hay dos caminos, y el primero es mejor que cualquier cosa que armes a mano.

Error Reporting ya hace el clustering. Agrupa por similitud de stack trace, mantiene el conteo y el primer/último visto, y expone todo por API: projects.groupStats.list te devuelve objetos ErrorGroupStats con count, affectedUsersCount, timestamps y un representative event. Si tus servicios ya reportan ahí, la pieza 1 es una llamada HTTP.

Log Analytics es la opción si querés control sobre el agrupamiento o tus errores no llegan a Error Reporting. Corrés SQL sobre el log bucket y hacés el cluster con GROUP BY sobre una firma normalizada:

SELECT
  REGEXP_REPLACE(
    REGEXP_REPLACE(JSON_VALUE(json_payload.message), r'[0-9a-f-]{36}', 'UUID'),
    r'\d+', 'N'
  ) AS firma,
  COUNT(*) AS eventos,
  ANY_VALUE(JSON_VALUE(json_payload.message)) AS ejemplo
FROM `PROJECT_ID.LOCATION.BUCKET_ID.VIEW_ID`
WHERE timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 65 MINUTE)
  AND severity IN ('ERROR', 'CRITICAL', 'ALERT', 'EMERGENCY')
GROUP BY firma
HAVING eventos >= 3
ORDER BY eventos DESC

El FROM es la vista del log bucket, que sacás de la consola de Logging. Ese severity IN (...) es el equivalente del or de CloudWatch: filtrar sólo por ERROR te deja afuera CRITICAL, ALERT y EMERGENCY.

El HAVING eventos >= 3 filtra el error que pasó una vez y nunca más; sin eso el sistema abre issues por accidentes cósmicos. Y la ventana de 65 minutos para un job que corre cada hora: el solape de cinco minutos evita perder eventos en el borde, y el dedup absorbe lo repetido. Esa firma del SQL sirve sólo para agrupar dentro de la query — el identificador que va al issue se recalcula con la función de la próxima sección, así el mismo bug tiene la misma identidad sin importar el proveedor de logs.

Pieza 2: el fingerprint, la cabeza y no la cola

El proceso corre cada hora. Si abrís un issue por cluster sin más, el mismo error te abre 24 issues por día. La solución obvia es un fingerprint: un identificador estable, derivado del error, que sea el mismo en todas las corridas. Y acá está el problema que no se ve venir, que es el opuesto al spam por repetición: la fragmentación.

Si hasheás la muestra entera, partís un bug en decenas de issues. Un mensaje de excepción arrastra su stack trace, y la cola del stack varía entre invocaciones: números de línea dentro de dependencias, paths con hash, profundidad distinta según el camino async. Dos ocurrencias del mismo bug comparten la primera línea y difieren en todo lo que viene después. El resultado es peor que el spam: el spam repetido lo silenciás con un filtro, pero mil issues casi iguales te hacen apagar el sistema.

La regla es fingerprintear la cabeza, no la cola: clase de error y mensaje, con el stack cortado y los tokens volátiles neutralizados antes de hashear.

const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;

function normalizar(mensaje) {
  // Cortar en el primer salto de línea, marco de stack o path absoluto:
  // la cabeza identifica al error, la cola cambia en cada invocación.
  // /var, /opt, /tmp y /home son las raíces donde caen los paths en
  // Lambda y en containers; ajustá la lista a tu runtime.
  const cabeza = String(mensaje).split(/\n|\s+at\s|\(?\/(?:var|opt|tmp|home)\//)[0];
  return cabeza
    .replace(UUID, ' ')
    .replace(/\b[0-9a-f]{12,}\b/gi, ' ')  // hashes, ids largos
    .replace(/\d+/g, ' ')                 // contadores, epochs, puertos
    .toLowerCase()
    .replace(/[^a-z ]+/g, ' ')
    .replace(/\s+/g, ' ')
    .trim()
    .slice(0, 100);                       // con la primera línea alcanza
}

const fingerprint = (servicio, contexto, mensaje) =>
  crypto.createHash('sha1')
    .update(`${servicio}|${contexto}|${normalizar(mensaje)}`)
    .digest('hex')
    .slice(0, 10);

Sí, borrar todos los dígitos fusiona HTTP 500 con HTTP 404. Es a propósito: el contexto que entra al hash (servicio más handler) ya separa lo que importa, y entre sub-agrupar de menos y fragmentar, prefiero lo primero.

Ese hash va como label en el issue (auto/fp:<hash>) y es la clave de dedup.

Buscá el label en todos los estados, no sólo en abiertos. Un issue cerrado a mano es una decisión humana: "esto ya lo sé, no me molestes". Si sólo mirás abiertos, lo reabrís cada hora. La contracara es una limitación que conviene tener presente: si el fix no sirvió, ese fingerprint ya quedó grabado como cerrado y el error no vuelve a reportarse solo. Hay que reabrir el issue a mano.

No lo scopees por ambiente. Es tentador meter el ambiente en el hash y es al revés: el fingerprint identifica al bug, y el bug es uno solo. Si el mismo error salta en staging y en producción quiero un issue, no dos, porque el fix también es uno: lo arreglo en la branch de desarrollo y sale a todos los ambientes. Dos issues para el mismo bug son dos triages pagados y dos PRs tocando la misma línea.

El ambiente sí importa, pero como contexto y no como identidad: va de label (env:prod, env:staging) y en el body del issue. Ahí sirve para priorizar, que es lo que de verdad aporta: un error que sólo aparece en staging suele ser un problema de configuración, y el mismo error en producción es urgente. Si algo se ve primero en staging y después en prod, el label nuevo se suma al issue que ya existía, y eso es la señal que querés ver.

Falta una pieza de plomería: de qué repo es cada error. Lo tengo como una tabla de reglas ordenada, que hace match por substring del nombre del servicio y lo rutea a un repo. Cuidado con el orden, porque pagos matchea pagos-batch si va primero. Y definí qué pasa cuando no matchea ninguna regla: en mi caso el cluster se descarta con un log, que es la decisión correcta cuando la mitad de los log groups de la cuenta son forwarders y jobs de monitoreo que no son de nadie.

Pieza 3: el modelo barato antes del modelo caro

Con el fingerprint ya tenés un sistema que abre tickets accionables, y eso solo justifica el esfuerzo. Pero el issue que sale es determinístico: título derivado del mensaje, stack de ejemplo, link a la query. Le falta lo que hace un buen triage humano: entender qué es esto.

Acá entra el primer LLM, y la decisión importante es dónde ponerlo: un loop de tool-use read-only sobre un modelo chico y barato, con acceso vía MCP a la documentación de los servicios y al código de los otros repos. Corre una vez por cluster nuevo y termina llamando la única tool que escribe algo, cuyo schema fuerza la respuesta estructurada:

{
  name: 'submit_triage',
  input_schema: {
    type: 'object',
    properties: {
      classification: { type: 'string', enum: ['bug', 'benign', 'needs_info'] },
      confidence: { type: 'string', enum: ['low', 'medium', 'high'] },
      title: { type: 'string' },
      analysis: { type: 'string' },
      likely_fix_area: { type: 'string' },  // archivo:línea si lo encontró
    },
    required: ['classification', 'confidence', 'title', 'analysis'],
  },
}

Cerca del límite de pasos hay que forzar la tool de submit (con tool_choice), o el loop se queda explorando el repo sin converger nunca. Y de las tres clasificaciones, la que paga todo el paso es benign.

Un rechazo de autenticación esperado, un error de validación que el cliente ya maneja, un timeout de un endpoint conocido: eso es ruido, y el triage lo identifica con contexto real en vez de adivinar desde el mensaje.

Las otras dos salidas son más simples: un bug sigue camino a la cola de fix, y un needs_info abre el issue con su análisis pero no entra a esa cola, porque le falta información para que alguien lo pueda arreglar. El likely_fix_area viaja en el body y le llega al agente de la próxima sección como punto de partida, sin garantía de que esté bien.

Y el detalle que hace que esto funcione: el benigno no se saltea, se abre y se cierra en el acto. Si lo salteás, el cluster nunca queda registrado, así que la próxima corrida lo ve "nuevo" y lo vuelve a analizar. Para siempre, cada hora. Abrirlo y cerrarlo con la razón en un comentario graba su fingerprint y deja el board de abiertos limpio. De paso mantiene al que arregla lejos (busca sólo is:open) y es reversible: si te equivocaste, lo reabrís.

Pieza 4: el agente que sólo edita archivos

La segunda mitad también corre cada hora: lee los issues abiertos y resuelve uno por uno.

Primero, el fan-out. Una sola invocación no vacía un backlog entre varios repos dentro del timeout que tengas. Un orquestador agendado lista los issues trabajables y dispara un worker por issue, cada uno con su propio presupuesto de tiempo. Va con un tope de paralelismo, para no reventar la API de GitHub ni tu cuota de tokens.

Y después, lo importante: el agente no tiene git. Sus tools son list_dir, read_file, grep y write_file/edit_file, todas sandboxeadas a un clone shallow del repo, más una última para declarar el veredicto. Nada de red, nada de shell, nada de gh; el grep lo implementa el worker, así que el modelo nunca ejecuta un comando. El modelo devuelve ediciones y nada más: el worker las aplica, decide si commitea, pushea y abre el PR. Por eso el token de la app nunca llega al modelo.

El veredicto tiene tres valores:

  • fix → el worker commitea las ediciones, pushea y abre el PR con Closes #N.
  • close → falsa alarma o ya resuelto. Comenta con evidencia archivo:línea y cierra.
  • needs_human → riesgoso o ambiguo. Postea un handoff completo (causa raíz, por qué no es auto-fixeable, qué haría falta) y le pone el label needs-human, que lo saca de la cola para siempre.

Sin esa tercera salida el agente fuerza una respuesta donde no la hay, y una respuesta forzada sobre código de producción no le sirve a nadie. Falta una red más: un veredicto ausente, o un fix sin cambios reales de archivo, se convierte en needs_human. Sin eso, un issue difícil re-corre el loop completo cada hora, sin progreso y sin señal.

Los tests no corren acá: los corre el CI del PR. Levantar los toolchains de todos los repos del alcance dentro del worker es impráctico.

Un detalle chico que cambia mucho la convivencia: cada issue y cada PR disparan una tarjeta al espacio de Google Chat del equipo, por webhook. A la mañana ves qué hizo sin entrar a ninguna consola, y el link te deja a un click del PR. Cómo se arman esas tarjetas queda para otro post.

Seguridad: el agente lee texto que no controlás

Un agente que lee input arbitrario y encima escribe código es otro tipo de bicho. El body de un issue puede llevar instrucciones inyectadas, y el diseño asume que en algún momento va a pasar.

Lo primero es que sólo se trabajan issues del bot. La cola se filtra por autor (author:app/tu-app). Si alcanzara con que el issue tenga el label, cualquiera con acceso al repo podría pasarle un body arbitrario a un agente que escribe código.

Después viene la denylist de paths, en dos capas. El agente no puede tocar .github/**, lockfiles, .npmrc, Dockerfiles ni archivos de infra; ni leer .env*, keys o pems. Y se chequea dos veces: en la capa de tools y otra vez al momento de commitear. La redundancia está puesta a propósito, porque una de las dos va a fallar algún día y no quiero que ese día sea el único control. Un cambio bloqueado tampoco se descarta en silencio: rutea el issue a needs-human marcado como posible inyección.

Conviene ver qué está frenando eso, concretamente. Un workflow de CI o un lockfile envenenado en una branch del mismo repo corre en el CI de ese repo, con sus secrets. Ese es el camino corto de "un agente editó un archivo" a "alguien tiene tus credenciales de deploy". Depende de cómo tengas configurados los triggers, y no querés que tu seguridad dependa de eso: el hardening más fuerte acá es pushear a un fork del bot, así el CI del PR corre sin acceso a los secrets del repo original. Con un asterisco, igual: si el repo destino tiene algún workflow con pull_request_target o workflow_run, esos corren con los secrets del repo base incluso para PRs que vienen de un fork. Es la vulnerabilidad clásica de Actions y el fork no te salva de ella.

Los paths, además, están sandboxeados en serio: cada uno se valida como string contra la raíz del clone y después se resuelve con realpath, que sigue los symlinks. Así un symlink plantado en el repo no puede escapar.

Y queda un gate que este código no controla. El único backstop contra un merge malo es branch protection con review obligatorio en los repos destino, con la app sin poder aprobar sus propios PRs y auto-merge apagado. Si el sistema puede escribir en toda la organización, tu superficie real es el repo peor configurado que tengas. Revisá cómo está configurado cada repo antes de prender esto, y arrancá con una allowlist angosta.

Costo: el 99% es input

Una corrida del agente es casi enteramente tokens de input: el system prompt, los schemas de las tools y cada archivo que leyó hasta ahora se reenvían en cada uno de los pasos del loop. Con decenas de round-trips, ese reprocesamiento es prácticamente toda la factura; la salida es ruido al lado.

La palanca es prompt caching: el proveedor se guarda el prefijo del prompt ya procesado y te lo cobra a una fracción si el próximo request arranca igual. Como el cache es siempre un prefijo, alcanza con dos breakpoints. Uno estático después del system prompt: como el orden de render es tools → system → messages, esa marca cubre las dos cosas juntas. Y uno rolling sobre el último bloque del último mensaje, que vas moviendo al final nuevo en cada paso y sacás del turno anterior.

Ese breakpoint móvil cachea todo el prefijo previo, así que el paso N+1 lee los pasos 1..N desde cache y sólo paga escribir lo que se agregó. Los números: una lectura de cache cuesta ~0.1× el input normal y escribirla ~1.25× con el TTL por defecto de cinco minutos, así que dos requests ya te dejan mejor parado (1.25 + 0.1 contra 2 sin cachear). Hay un mínimo de prefijo cacheable, entre 512 y 4096 tokens según el modelo, y un tope de cuatro breakpoints por request.

Verificalo, no lo asumas: logueá el usage de cada corrida y confirmá que las lecturas de cache dominan al input crudo. Si el input no baja, tenés un invalidador silencioso —un timestamp en el system prompt, un set de tools que cambia, un JSON serializado sin ordenar las claves— y el caching no está haciendo nada más que cobrarte el premium de escritura.

Los números concretos, para dar una referencia: un triage sale entre cinco y siete centavos, y una corrida de fix entre cincuenta centavos y ochenta. Lo que hace viable el sistema no es que el fix sea barato, es que el triage descarta la mayoría de los clusters antes de llegar ahí.

Cuando lo prendés, sale todo junto

Una expectativa para fijar antes de encender esto, o lo apagás la primera mañana: la primera corrida no abre dos issues. Abre decenas.

No es un bug, es el inventario. Los errores siempre estuvieron ahí; lo único que cambió es que ahora algo los lee todos, hora tras hora. Y no conozco sistema con tráfico real que no tenga ruido acumulado en los logs: reintentos que nadie mira, validaciones que fallan seguido y está perfecto que fallen, un endpoint de un tercero que timeoutea los martes.

La curva baja sola, y baja rápido: después de esa primera ola el volumen cae al ritmo real de errores nuevos, que es bastante más bajo de lo que uno imagina. Para que la primera semana sea llevadera, subí el umbral de eventos mínimos por cluster (el HAVING de más arriba) a 5 o 10 y bajalo cuando se calme, y prendelo en un ambiente o un repo antes que en toda la flota.

Lo que no está en el diagrama

Un mapa rápido de las piezas, para que quede claro que nada de esto está atado a un proveedor:

PiezaAWSGoogle Cloud
Clusterizar erroresLogs Insights patternError Reporting API / Log Analytics SQL
Correr cada horaEventBridge + LambdaCloud Scheduler + Cloud Run job
Fan-out acotadoasync invoke + reserved concurrencyPub/Sub + Cloud Run max-instances
Modelo sin API keyBedrock + rol IAMVertex AI + service account
Alarma sobre métrica propiaCloudWatch alarm + metric mathCloud Monitoring alerting policy
Alarma de gastoAWS BudgetsCloud Billing budget alert

Cerrar el loop resultó la parte fácil. Lo que hace que esto sobreviva desatendido son detalles de operación que no entran en ningún diagrama.

El sistema arrancó en dry-run: las dos mitades logueaban "abriría este issue / abriría este PR" sin escribir nada. Leés los logs una semana, confirmás que las decisiones tienen sentido, y después das vuelta el flag. Que sea un cambio de config de una línea y no de código es a propósito: se revisa en dos segundos y se revierte igual de rápido.

El chequeo que más me costó fue buscar PRs previos por head ref en cualquier estado, no sólo abiertos. Si alguien cerró el PR del agente y borró la branch, que es lo que pasa por default en muchos repos, un chequeo que dependa de que la branch exista no encuentra nada, y el agente vuelve a abrir el PR que un humano acababa de rechazar.

Y una corrida que falla no puede ser silenciosa. No hay dashboard para esto, así que hay una tarjeta roja al chat del equipo cuando algo explota y una alarma sobre la métrica de errores del proceso. Sumale una alarma de gasto sobre el modelo, porque el costo de un agente es invisible hasta que aparece en la factura.

Lo que me llevo

Lo que más me gusta de esto es que no hay nada exótico adentro. Una query de clustering que ya existía, un hash sha1, dos procesos agendados, una app de GitHub con permisos mínimos, un modelo autenticado por IAM. Lo difícil fue decidir qué hasheás, dónde ponés el modelo barato, qué archivos el agente no puede tocar, y cuándo levanta la mano para decir "esto lo mira un humano".

Comments

Share your thoughts and join the discussion

Loading comments…


Related Posts