🚨
Incidente activo · P0 · Cliente afectado: Deloitte ES El endpoint /api/v2/teams/:teamId/performance-score devuelve { score: NaN } para equipos con >50 miembros activos en plan Pro/Enterprise desde el deploy del jueves v2.4.1. Deadline RCA: lunes 10:00 UTC.
NaN
Score devuelto
esperado: 0–200
30%
Usuarios afectados
Pro + Enterprise
v2.4.1
Deploy causante
PR #312 · jue 06-12
3
Causas raíz
encontradas
Fase 1 — Reproducción
📋
Checklist de Reproducción
¿Cuándo ocurre? ¿Consistentemente?
  • ¿Se puede reproducir? Sí — ejecutando GET /api/v2/teams/team_de123/performance-score con teamId de equipo Enterprise >50 miembros.
  • ¿Minimal reproduction? Sí — con 51 miembros activos donde 1 o más tienen member_stats.baseline = NULL.
  • ¿Documentados los pasos? Sí — ver script de reproducción abajo.
  • !
    ¿Siempre o a veces? Aparece SIEMPRE con equipos que tienen ≥1 miembro sin stats. La ventana de 03:00–07:00 UTC sin bug coincide con el job que precalcula las stats (cron diario).
  • ¿Otros pueden reproducirlo? Sí — reproducido en staging con dump anonimizado de prod.
reproduction.sh # 1. Crear equipo con 51 miembros (1 sin stats) export TEAM_ID="team_repro_01" psql $DATABASE_URL -c " INSERT INTO teams VALUES ('team_repro_01', 'Repro Team', 'enterprise'); INSERT INTO team_members SELECT gen_random_uuid(), 'team_repro_01', true FROM generate_series(1, 51); -- Solo 50 miembros tienen stats (el último NO): INSERT INTO member_stats (member_id, baseline, current_score) SELECT id, 75.0, 82.5 FROM team_members WHERE team_id = 'team_repro_01' LIMIT 50; " # 2. Hit the endpoint curl -s http://localhost:3000/api/v2/teams/$TEAM_ID/performance-score # Output esperado: {"score": NaN} ← BUG CONFIRMADO
Fase 2 — Recolección de Evidencias
📜
Línea de tiempo del incidente
Cronología hasta el RCA
🚀
Jue 2026-06-12 · 14:35 UTC
Deploy v2.4.1 en producción
PR #312 mergeado. Nadie revisó el LEFT JOIN sin null-guard.
📢
Jue 2026-06-12 · 18:12 UTC
Primeras quejas de Deloitte ES
Dashboard muestra "NaN%" en Score de Rendimiento del equipo Madrid.
📊
Vie 2026-06-13 · 03:00–07:00 UTC
Bug desaparece brevemente
Cron job calcula stats para TODOS los miembros. Ventana limpia.
🔍
Vie 2026-06-13 · 09:15 UTC
git bisect confirma PR #312
git bisect bad HEADgit bisect good v2.4.0 apunta al commit a3f9c12.
Lun 2026-06-15 · 08:00 UTC
RCA completado. Fix en staging
3 causas raíz identificadas. Parche listo para aprobación CTO.
📝
Logs de CloudWatch (extracto)
Error más frecuente en producción
CloudWatch · us-east-1 [ERROR] 2026-06-13T07:43:22.841Z performanceScoreService TypeError: Cannot read properties of undefined (reading 'baseline') at calculateTeamScore (performanceScoreService.ts:12) at processTicksAndRejections (internal/process/task_queues.js:95) [WARN] 2026-06-13T07:43:22.845Z teamId=team_de123 members=87, stats_missing=12 [ERROR] 2026-06-13T07:43:23.102Z Redis MISS key=perf:team:team_de123 ttl=300s, cache bypassed [DEBUG] Scores array: [82.5, 91.2, NaN, 78.3, NaN, NaN, 88.7, NaN, ...] → reduce → NaN propagation
Entorno: diferencias Working vs Broken
AspectoFuncionando (Free)Roto (Pro/Enterprise)
Miembros activos <50 >50
member_stats completos 100% 88–97%
Cache Redis Deshabilitada Habilitada (TTL 5min)
Cron stats job N/A (pocos miembros) Solo 50 miembros/batch
Fase 3 — Formación de Hipótesis
🧪
Hipótesis rankeadas por probabilidad
¿Qué cambió entre v2.4.0 y v2.4.1?
H1
NULL propagation en división — LEFT JOIN sin null-guard
El PR #312 cambió de INNER JOIN a LEFT JOIN para incluir miembros nuevos, pero no agregó COALESCE. Si baseline = NULL, la operación (current_score - NULL) / NULL * 100 devuelve NaN y se propaga por el reduce.
✔ CONFIRMADA
H2
División por cero cuando scores.length = 0
Si todos los miembros tienen stats NULL, el array resultante tiene longitud 0. sum / 0 = NaN. Caso extremo pero posible con equipos recién creados.
✔ CONFIRMADA
H3
Cron job de stats con batch limit de 50 miembros
El job nightly solo procesa 50 miembros por equipo. Equipos >50 siempre tienen los últimos X miembros sin stats hasta el siguiente ciclo. Explica la ventana 03:00–07:00 sin bug.
✔ CONFIRMADA
H4
Cache Redis guardando el valor NaN
Si el score NaN se cacheó en Redis (TTL 5min), las peticiones siguientes obtienen NaN aunque los datos se hayan corregido.
⚠ Secundaria
Fase 4 — Test & Verificación
Código original vs código corregido
Differential debugging — PR #312 incriminado
❌ Código v2.4.1 (roto)
export async function calculateTeamScore( teamId: string ): Promise<number> { const members = await db.query(` SELECT m.*, s.baseline, s.current_score FROM team_members m LEFT JOIN member_stats s ON s.member_id = m.id WHERE m.team_id = $1 AND m.active = true`, [teamId]); const scores = members.rows.map(m => ((m.current_score - m.baseline) / m.baseline) * 100 // ← NaN si baseline=null ); return scores.reduce( (sum, s) => sum + s, 0 ) / scores.length; // ← / 0 si scores vacío }
✅ Código v2.4.2 (fix)
export async function calculateTeamScore( teamId: string ): Promise<number> { const members = await db.query(` SELECT m.*, COALESCE(s.baseline, 0) AS baseline, COALESCE(s.current_score, 0) AS current_score FROM team_members m LEFT JOIN member_stats s ON s.member_id = m.id WHERE m.team_id = $1 AND m.active = true`, [teamId]); // Filtrar miembros sin stats válidas const validMembers = members.rows .filter(m => m.baseline > 0); if (validMembers.length === 0) return 0; const scores = validMembers.map(m => ((m.current_score - m.baseline) / m.baseline) * 100 ); return scores.reduce( (sum, s) => sum + s, 0 ) / scores.length; }
🧪
Verificación en staging completada Con el fix aplicado, 100 peticiones contra equipos de 51–200 miembros con stats incompletas devuelven scores numéricos válidos (0–200). Tests de regresión: 47/47 pasando. Cache Redis invalidada al detectar NaN.
Root Causes Identificadas
RC1
NULL propagation — LEFT JOIN sin COALESCE
El PR #312 cambió de INNER JOIN a LEFT JOIN para incluir miembros recién incorporados que aún no tienen registro en member_stats. Sin embargo, no se añadió COALESCE en las columnas baseline y current_score. En JavaScript, cualquier operación aritmética con null/undefined devuelve NaN, y NaN se propaga a través de Array.reduce() convirtiendo el resultado final en NaN.
LEFT JOIN
baseline = NULL
(score - NULL) / NULL
NaN
reduce propaga NaN
score = NaN
RC2
Cron job con batch limit de 50 — no escala a equipos >50
El job calculate_member_stats (diario, 03:00 UTC) tiene un LIMIT 50 hardcodeado por equipo, heredado de los tiempos en que el plan Free era el único. Los equipos Enterprise de hasta 200 miembros siempre tienen entre 0 y 150 miembros sin estadísticas calculadas entre ejecuciones del cron. Esto explica la ventana de 03:00–07:00 UTC sin bug: todos los teams estaban temporalmente completos.
RC3
Cache Redis sin validación de NaN — amplifica el impacto
Redis cachea el resultado del score con TTL de 5 minutos. Si el primer request devuelve NaN, ese valor se serializa a string "NaN" y se almacena. Los siguientes 5 minutos todos los usuarios del mismo equipo reciben el valor inválido incluso si los datos de base de datos se hubieran corregido. La validación de tipo debe hacerse antes de cachear.
Plan de Remediación
01
Hotfix: COALESCE + filtro null en calculateTeamScore
Añadir COALESCE(s.baseline, 0) en la query y filtrar miembros con baseline = 0 antes del map. Devolver 0 si no hay miembros con stats válidas.
Inmediato · Deploy tras aprobación CTO
02
Cron job: eliminar LIMIT 50 hardcodeado
Cambiar a procesamiento por chunks de 100 con LIMIT $chunkSize OFFSET $offset y loop hasta procesar todos los miembros del equipo.
Esta semana
03
Redis: validar antes de cachear
Añadir guard if (Number.isFinite(score)) antes de redis.set(). Si el score no es finito, no cachear y lanzar alerta a Sentry.
Esta semana
04
Test de regresión: equipos con stats incompletas
Añadir test con 51 miembros donde el último no tiene stats. Verificar que el endpoint devuelve un número finito y que Redis no cachea NaN. Añadir al CI/CD obligatorio.
Próximo sprint

Quick Checklist — Antes del próximo deploy
  • Reproducido en local con datos de staging anonimizados
  • git bisect confirma commit incriminado: a3f9c12 (PR #312)
  • Fix aplicado y verificado en staging: 47/47 tests pasando
  • Redis flush ejecutado en staging post-fix
  •  
    Aprobación CTO para deploy a producción
  •  
    Comunicación a Deloitte ES (ETA: lunes 10:30 UTC)
  •  
    Post-mortem asíncrono — completar template
  •  
    Añadir Number.isFinite() guard al cacher de Redis
  •  
    Refactorizar cron job con paginación dinámica