Root Cause Analysis:
Performance Score → NaN en equipos >50 miembros
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-scorecon 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 HEAD → git 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
| Aspecto | Funcionando (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 bisectconfirma 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