-
Notifications
You must be signed in to change notification settings - Fork 1
Postmortems
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.
Cada postmortem sigue el formato estándar de la industria (inspirado en el SRE Book de Google):
- Resumen del Incidente
- Impacto
- Timeline
- Causa Raíz
- Resolución
- Lecciones Aprendidas
- Action Items
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.
| 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 |
| 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 |
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.
- Se hardcodeó
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobufen la unidad systemd - Se aplicó el mismo cambio en
deploy.sh,cloud-init.yaml, ypipeline.yml - Se documentó el porqué en
INSTRUMENTATION.md
| 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 |
- Hardcodear
http/protobufcomo protocolo OTLP - Documentar el porqué de la elección del protocolo
- Configurar alerta en Grafana: "Si no se reciben métricas de
linker1en 10 minutos, alertar"
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ó.
| Dimensión | Detalle |
|---|---|
| Duración | ~30 minutos (detección + fix) |
| Usuarios afectados | Todos (servicio caído) |
| Severidad | Alta — downtime completo de la aplicación |
| 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 |
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.
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:
-
deploy.sh(despliegue manual) -
pipeline.yml(despliegue automatizado) -
infra/main.tf(Terraform / cloud-init)
| 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 |
- Implementar
systemd_escape_value()endeploy.sh - Implementar escape en
pipeline.yml - Implementar escape en Terraform (
main.tf) - Agregar función
systemd_unescape_value()para preservar valores en redeploys
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ó.
| Dimensión | Detalle |
|---|---|
| Duración | ~15 minutos |
| Usuarios afectados | Todos |
| Severidad | Alta — downtime completo |
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.
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| 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 |
- 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
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).
| 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 |
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].
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.
| 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 |
- 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
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.
| 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 |
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.
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.
| 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 |
- 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-pluginconServicesResourceTransformerpara merge correcto de SPI
| # | 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
- Blameless: los postmortems no buscan culpables, buscan mejoras sistémicas
- Action items concretos: cada postmortem produce acciones verificables
- Compartir el aprendizaje: los postmortems se publican para todo el equipo
- Mejora incremental: cada incidente deja al sistema más fuerte que antes
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.