Skip to content

Postmortems

AnderProgramming edited this page Jul 17, 2026 · 6 revisions

Un postmortem es un análisis estructurado de un incidente, diseñado no para culpar sino para aprender y prevenir recurrencias. Los postmortems documentados aquí son incidentes reales y simulados que ocurrieron durante el desarrollo y operación de Linker1.

Filosofía: Un incidente del que no se aprende es un incidente que volverá a ocurrir.


Formato de Postmortem

Cada postmortem sigue el formato estándar de la industria (inspirado en el SRE Book de Google):

  1. Resumen del Incidente
  2. Impacto
  3. Timeline
  4. Causa Raíz
  5. Resolución
  6. Lecciones Aprendidas
  7. Action Items

Postmortem #1: Telemetría silenciosamente perdida durante 48 horas

Resumen

Tras el primer despliegue exitoso a producción con OpenTelemetry habilitado, la aplicación funcionaba correctamente (200 en todas las rutas, health checks OK), pero ningún dato de telemetría llegaba a Grafana Cloud. El equipo no se dio cuenta hasta 48 horas después, cuando intentó consultar métricas en el dashboard.

Impacto

Dimensión Detalle
Duración ~48 horas sin telemetría
Usuarios afectados 0 (la app funcionaba correctamente)
Datos perdidos 48 horas de métricas, traces y logs
Severidad Media — sin impacto funcional, pero pérdida total de visibilidad operativa

Timeline

Hora Evento
T+0h Deploy a producción con OTEL_EXPORTER_OTLP_ENDPOINT configurado
T+0h App arranca correctamente, smoke tests pasan
T+0h El SDK intenta exportar con protocolo grpc (default)
T+0h Grafana Gateway rechaza con UNIMPLEMENTED (solo acepta http/protobuf)
T+0h El SDK descarta silenciosamente los batches fallidos y sigue operando
T+48h El equipo abre Grafana para revisar métricas — dashboard vacío
T+48.5h Investigación identifica el error del protocolo en logs internos del SDK
T+49h Fix: OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf hardcodeado en systemd
T+49h Redeploy. Telemetría comienza a llegar correctamente

Causa Raíz

El SDK de OpenTelemetry Java usa grpc como protocolo por defecto para el exportador OTLP. El OTLP Gateway de Grafana Cloud (otlp-gateway-*.grafana.net) solo acepta http/protobuf sobre HTTPS.

Cuando el SDK envía un batch vía gRPC, el gateway responde con UNIMPLEMENTED. El SDK lo trata como un error de transporte transitorio, descarta el batch, y reintenta con el siguiente batch — sin que esto afecte al thread principal de la aplicación ni se refleje en ningún log de nivel INFO o superior.

El error solo aparecía como log interno del SDK a nivel FINE (Java Logging) o DEBUG (si configurado), que no estaba habilitado en producción.

Resolución

  1. Se hardcodeó OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf en la unidad systemd
  2. Se aplicó el mismo cambio en deploy.sh, cloud-init.yaml, y pipeline.yml
  3. Se documentó el porqué en INSTRUMENTATION.md

Lecciones Aprendidas

Qué salió mal Qué aprendimos
Confiamos en los defaults del SDK sin verificar la compatibilidad con Grafana Siempre verifica end-to-end que la telemetría llega al destino
No teníamos alertas de "telemetría no recibida" Meta-monitoring: monitorea que tu monitoreo funcione
El error era completamente silencioso Los errores silenciosos son los más peligrosos — peor que un crash

Action Items

  • Hardcodear http/protobuf como protocolo OTLP
  • Documentar el porqué de la elección del protocolo
  • Configurar alerta en Grafana: "Si no se reciben métricas de linker1 en 10 minutos, alertar"

Postmortem #2: Despliegue roto por carácter % en token de Grafana

Resumen

Un despliegue automatizado falló porque el token de autenticación de Grafana Cloud contenía el carácter %. Al inyectarse en la unidad systemd como Environment="OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic abc%def...", systemd interpretó %d como un specifier (directorio de configuración) y rechazó la unidad como inválida. El servicio no arrancó.

Impacto

Dimensión Detalle
Duración ~30 minutos (detección + fix)
Usuarios afectados Todos (servicio caído)
Severidad Alta — downtime completo de la aplicación

Timeline

Hora Evento
T+0m Pipeline ejecuta deploy-prod con nuevo token de Grafana
T+0m Script remoto escribe la unidad systemd con el token sin escapar
T+0m systemctl daemon-reload procesa la unidad
T+0m systemd rechaza la unidad: specifier %d inválido en Environment=
T+0m systemctl restart linker1.service falla
T+1m Pipeline detecta que el servicio no está activo, job falla
T+1m Job rollback se activa automáticamente
T+5m Rollback restaura la versión anterior con el token antiguo
T+5m Servicio restaurado
T+30m Fix implementado: systemd_escape_value() en los tres deployment paths

Causa Raíz

systemd trata el carácter % dentro de directivas Environment="..." como el inicio de un specifier (%h = home directory, %n = unit name, %d = config directory). Un token de Grafana Cloud que contiene % como parte de un string base64 se interpretó incorrectamente.

Además, el carácter " dentro de un valor Environment="..." termina prematuramente la asignación, corrompiendo el rest de la unidad.

Resolución

Implementación de systemd_escape_value() en los tres mecanismos de despliegue:

systemd_escape_value() {
  printf '%s' "$1" | sed -e 's/%/%%/g' -e 's/"/\\"/g'
}

Aplicado en:

  1. deploy.sh (despliegue manual)
  2. pipeline.yml (despliegue automatizado)
  3. infra/main.tf (Terraform / cloud-init)

Lecciones Aprendidas

Qué salió mal Qué aprendimos
Asumimos que un token es "solo un string" Cada capa tiene sus propias reglas de escape (shell, YAML, systemd)
Solo teníamos un deployment path con la protección Todos los paths deben ser consistentes
El rollback automático funcionó perfectamente La inversión en rollback automático se pagó sola en este incidente

Action Items

  • Implementar systemd_escape_value() en deploy.sh
  • Implementar escape en pipeline.yml
  • Implementar escape en Terraform (main.tf)
  • Agregar función systemd_unescape_value() para preservar valores en redeploys

Postmortem #3: Variable OTLP vacía impide arranque de la aplicación

Resumen

Al hacer un redeploy sin pasar explícitamente OTEL_EXPORTER_OTLP_ENDPOINT, la variable se escribió como una cadena vacía en la unidad systemd. El SDK de OpenTelemetry lanzó una ConfigurationException y la aplicación no arrancó.

Impacto

Dimensión Detalle
Duración ~15 minutos
Usuarios afectados Todos
Severidad Alta — downtime completo

Causa Raíz

El SDK de OpenTelemetry distingue entre:

  • Variable ausente: usa defaults internos (sin exportación, silencioso) ✅
  • Variable con valor vacío "": error de configuración, ConfigurationException

La línea Environment="OTEL_EXPORTER_OTLP_ENDPOINT=" (vacía pero presente) disparaba el error.

Resolución

Escritura condicional: la línea Environment= solo se agrega si hay un valor real.

if [ -n "$OTEL_EXPORTER_OTLP_ENDPOINT" ]; then
  echo "Environment=\"OTEL_EXPORTER_OTLP_ENDPOINT=$OTEL_EXPORTER_OTLP_ENDPOINT\"" | ...
fi

Lecciones Aprendidas

Qué salió mal Qué aprendimos
Tratamos "vacío" y "ausente" como equivalentes Semántica de ausencia: "no definido" ≠ "definido como vacío"
No validamos la unidad antes de restart Agregar systemd-analyze verify antes del restart

Action Items

  • Escritura condicional de variables OTLP y MySQL
  • Dump de diagnóstico (journalctl, systemd-analyze verify) si el servicio no arranca
  • Documentar la distinción en INSTRUMENTATION.md

Postmortem #4: Branch Protection bloquea todos los PRs a DEV

Resumen

Al configurar branch protection en DEV, se incluyeron como status checks requeridos los jobs del workflow pipeline.yml. Ese workflow solo se ejecuta en push a main, nunca en PRs a DEV. Resultado: todos los PRs a DEV quedaron permanentemente bloqueados (mergeStateStatus: BLOCKED).

Impacto

Dimensión Detalle
Duración ~1 hora (hasta que se detectó en el primer PR real)
PRs afectados PR #38 y todos los siguientes
Severidad Media — bloqueo del flujo de desarrollo, sin impacto en producción

Causa Raíz

GitHub mantiene un PR como BLOCKED si tiene status checks requeridos que nunca se reportan. Los jobs de pipeline.yml (Deploy to Production, Validate Production, Rollback) solo existen como checks cuando el workflow corre, y ese workflow tiene trigger on: push: branches: [main], no on: pull_request: branches: [DEV].

Resolución

Se removieron los checks de pipeline.yml de la configuración de branch protection de DEV, dejando solo los 5 checks de ci.yml que sí corren en PRs.

Lecciones Aprendidas

Qué salió mal Qué aprendimos
Configuramos branch protection sin verificar qué workflows corren en PRs Un check requerido que nunca se ejecuta bloquea todo
No probamos el flujo con un PR real antes de aplicar las reglas Test-first aplica a configuración, no solo a código

Action Items

  • Corregir la lista de checks requeridos
  • Documentar qué workflows corren en qué contextos en BRANCH_PROTECTION.md
  • Verificar con un PR de prueba antes de considerar la configuración completa

Postmortem #5: Driver JDBC de MySQL silenciosamente eliminado por fat JAR

Resumen

Al agregar el conector MySQL para el healthcheck, la feature parecía funcionar localmente con mvn exec:java, pero fallaba silenciosamente en producción con el fat JAR: la conexión MySQL lanzaba "No suitable driver found" a pesar de que mysql-connector-j estaba en pom.xml.

Impacto

Dimensión Detalle
Duración Detectado en la primera ejecución del fat JAR
Funcionalidad afectada Health check (/healthz) reportaba unhealthy
Severidad Baja — la app funcionaba, solo el healthcheck fallaba

Causa Raíz

maven-assembly-plugin fusiona los archivos SPI (META-INF/services/java.sql.Driver) de todas las dependencias con estrategia "last wins". Tanto sqlite-jdbc como mysql-connector-j tienen ese archivo. El plugin mantuvo el de SQLite y descartó el de MySQL, eliminando su auto-registro JDBC.

Resolución

Registro manual explícito antes de crear la conexión:

Class.forName("com.mysql.cj.jdbc.Driver");

Con un comentario extenso explicando el porqué, para que futuros desarrolladores no lo eliminen pensando que es código legacy.

Lecciones Aprendidas

Qué salió mal Qué aprendimos
Asumimos que las dependencias se empaquetan sin pérdida Los build tools hacen decisiones silenciosas con archivos duplicados
El problema no existía en desarrollo (mvn exec:java) Siempre testea con el artefacto final (fat JAR), no solo en modo desarrollo

Action Items

  • Agregar Class.forName() explícito con comentario
  • Smoke test de CI inicia la app desde el fat JAR (no mvn exec:java)
  • Evaluar migrar a maven-shade-plugin con ServicesResourceTransformer para merge correcto de SPI

Resumen de Incidentes

# Incidente Severidad MTTD MTTR Impacto en usuarios
1 Protocolo OTLP incorrecto Media 48h 1h Ninguno
2 Carácter % en token systemd Alta 1min 5min Downtime 5min
3 Variable OTLP vacía Alta 1min 15min Downtime 15min
4 Branch protection bloquea PRs Media 1h 10min Ninguno (dev flow)
5 Driver JDBC eliminado por fat JAR Baja Inmediato 30min Healthcheck degradado

MTTD = Mean Time To Detect · MTTR = Mean Time To Resolve


Cultura de Postmortem

Principios que seguimos

  1. Blameless: los postmortems no buscan culpables, buscan mejoras sistémicas
  2. Action items concretos: cada postmortem produce acciones verificables
  3. Compartir el aprendizaje: los postmortems se publican para todo el equipo
  4. Mejora incremental: cada incidente deja al sistema más fuerte que antes

Lo que cambió después de estos incidentes

Antes de los postmortems, el equipo confiaba en que "si la app arranca, todo está bien". Después de estos incidentes, internalizamos que:

  • Arrancar ≠ funcionar correctamente
  • Funcionar correctamente ≠ exportar telemetría
  • CI verde ≠ deploy exitoso
  • Deploy exitoso ≠ sistema observable

Cada capa requiere su propia validación. La resiliencia no es un estado, es un proceso continuo.

Clone this wiki locally