CULTIVA IA  ·  IA-Ingenieria-MLOps  ·  Depuracion Sistematica
Informe de Depuracion — NutriSync API
● P1 Critico Incidente: INC-2026-0615-42 2026-06-15 · 07:30 – 10:14 UTC Duración: 2 h 44 min ✓ Resuelto
Estado
Cerrado
Fix desplegado en prod
MTTD
14 min
Tiempo hasta detectar
MTTR
2 h 44 min
Tiempo hasta resolver
Causa raiz
JOIN duplicado
LEFT JOIN sin DISTINCT
!
Contexto del incidente
Descripcion inicial recibida por el equipo de soporte
Error en logs (Datadog)
datadog · production
1ERROR [api] GET /api/v2/patients/history?clinic_id=42 2TypeError: Cannot read properties 3 of undefined (reading 'fullName') 4 at formatPatientRow (patient.ts:87:24) 5 at patients.map (history.ts:34:18) 6 at Layer.handle (router/layer.js:95:5) 7 8request_id: req-9f3c2a1b 9clinic_id: 42 user_id: 201
Contexto de negocio
Clinica afectada: Centro Salud Bienestar (Madrid) — 23 nutricionistas activos. Historial de pacientes inaccesible desde 07:30 UTC.
EntornoNode 20 · Express 4 · PG 15
Ruta afectadaGET /api/v2/patients/history
Clinics afectadasclinic_id: 42 (unica confirmada)
Ultimo deploy2026-06-14 22:05 UTC (anoche)
Tickets soporte7 tickets abiertos

1
Paso 1 — Reproducir
Confirmar que el fallo ocurre de forma fiable antes de tocar nada
STOP-THE-LINE activado a las 07:44 UTC. Se congelo cualquier tarea en curso. Se preservaron los logs completos del error (Datadog snapshot, request ID: req-9f3c2a1b).
Comandos de reproduccion (entorno staging)
terminal · staging
$ # 1. Simular peticion en staging con clinic_id=42 $ curl -s "https://staging.nutrisync.io/api/v2/patients/history?clinic_id=42" \ -H "Authorization: Bearer $STAGING_TOKEN" | jq . $ # RESULTADO: 500 Internal Server Error — TypeError confirmado ✓ $ # 2. Probar con clinic_id=1 (otra clinica) $ curl -s ".../history?clinic_id=1" -H "Authorization: Bearer $STAGING_TOKEN" $ # RESULTADO: 200 OK con ENTRADAS DUPLICADAS — sintoma distinto ✓ $ # 3. Aislar test especifico $ npm test -- --testPathPattern="history.test" --runInBand --verbose
Reproduccion exitosa. Error 100% reproducible en staging con clinic_id=42. Clinicas sin notas de sesion adicionadas muestran duplicados. Clinica 42 tiene ademas un paciente con datos de sesion nulos que activa el TypeError.
2
Paso 2 — Localizar
Identificar la capa y el componente exacto donde falla
Arbol de decision — Localizacion
¿Que capa falla? ├── UI/Frontend NO · La consola del navegador no muestra errores propios ├── API/Backend SI ● server logs confirman 500 en /api/v2/patients/history │ ├── Controller NO · history.ts:34 solo hace patients.map() — no es su bug │ ├── Formatter SI PARCIAL · patient.ts:87 accede a undefined.fullName │ └── Query/DAO CAUSA RAIZ ● getPatientHistory() devuelve filas con patient=undefined ├── Database NO · Los datos en PG estan correctos, el schema no cambio └── Test itself NO · Los tests cubren el escenario y fallan correctamente
git bisect — Commit que introdujo el bug
terminal
$ git bisect start $ git bisect bad # main actual — roto $ git bisect good a3f9c12 # commit previo al deploy de anoche $ git bisect run npm test -- --testPathPattern="history.test" # Bisect identifico: # commit c7d84b1 — "feat: add session_notes to patient history view" # Autor: maria.chen@nutrisync.io — 2026-06-14 21:58 UTC $ git show c7d84b1 -- src/db/queries/history.sql
Bug localizado: El commit c7d84b1 anadio un LEFT JOIN a session_notes sin DISTINCT, generando filas duplicadas. Ademas, el JOIN no protege contra NULL en session_notes.patient_id, haciendo que el formateador reciba un objeto patient undefined.
3
Paso 3 — Reducir
Caso minimo que reproduce el fallo
Query SQL — Caso minimo de reproduccion
minimal_repro.sql
1-- Setup minimo: 1 clinica, 1 paciente, 2 session_notes 2INSERT INTO clinics (id, name) VALUES (99, 'Test Clinic'); 3INSERT INTO patients (id, full_name, clinic_id) VALUES (1, 'Juan Lopez', 99); 4INSERT INTO session_notes (id, patient_id, note) VALUES (1, 1, 'Nota A'), (2, 1, 'Nota B'); 5 6-- Query BUGGY (commit c7d84b1) — devuelve 2 filas en vez de 1 7SELECT p.*, sn.note 8FROM patients p 9LEFT JOIN session_notes sn ON sn.patient_id = p.id BUG 10WHERE p.clinic_id = 99; 11 12-- Resultado: Juan Lopez (x2) — duplicado porque tiene 2 notas
patient.ts · formatPatientRow
84function formatPatientRow(row: RawRow): PatientDTO { 85 const { patient, sessionNote } = separateJoinedRow(row); 86 87 return { 88 fullName: patient.fullName, // ← TypeError cuando patient es undefined 89 lastSession: sessionNote?.date, 90 }; 91}
4
Paso 4 — Corregir la causa raiz
Fix de la query SQL + ajuste del formateador (no del sintoma en UI)
Sintoma descartado: Una solucion incorrecta habria sido hacer [...new Set(patients)] en el frontend o un .filter(Boolean) en el controlador. Eso ocultaria el bug sin corregirlo. Se corrige la query.
Diff aplicado — history.sql
src/db/queries/history.sql
- SELECT p.id, p.full_name, p.created_at, - sn.note, sn.created_at AS note_date - FROM patients p - LEFT JOIN session_notes sn ON sn.patient_id = p.id - WHERE p.clinic_id = $1 - ORDER BY p.created_at DESC; + SELECT DISTINCT ON (p.id) + p.id, p.full_name, p.created_at, + sn.note, sn.created_at AS note_date + FROM patients p + LEFT JOIN session_notes sn + ON sn.patient_id = p.id AND sn.id = ( + SELECT MAX(id) FROM session_notes + WHERE patient_id = p.id + ) + WHERE p.clinic_id = $1 + ORDER BY p.id, p.created_at DESC;
src/formatters/patient.ts · linea 87
- fullName: patient.fullName, + fullName: patient?.fullName ?? '[Paciente sin datos]',
Logica: DISTINCT ON (p.id) mas subconsulta de la ultima nota garantiza exactamente una fila por paciente. El optional chaining en el formateador es defensa en profundidad (fallback seguro), no el fix principal.
5
Paso 5 — Proteger contra recurrencia
Test de regresion anadido al suite
Test de regresion — history.test.ts
src/__tests__/history.test.ts
// REGRESION: INC-2026-0615-42 — duplicados por JOIN sin DISTINCT describe('GET /api/v2/patients/history', () => { it('devuelve 1 fila por paciente aunque tenga multiples session_notes', async () => { // Arrange: 1 paciente con 3 notas de sesion const patient = await createPatient({ clinicId: 99 }); await createSessionNote({ patientId: patient.id, note: 'Consulta 1' }); await createSessionNote({ patientId: patient.id, note: 'Consulta 2' }); await createSessionNote({ patientId: patient.id, note: 'Consulta 3' }); // Act const res = await request(app) .get('/api/v2/patients/history?clinic_id=99') .set('Authorization', `Bearer ${token}`); // Assert: exactamente 1 entrada, no 3 expect(res.status).toBe(200); expect(res.body.patients).toHaveLength(1); expect(res.body.patients[0].lastNote).toBe('Consulta 3'); // ultima }); it('no lanza TypeError cuando patient tiene session_notes nulas', async () => { const patient = await createPatient({ clinicId: 42, noNotes: true }); const res = await request(app) .get('/api/v2/patients/history?clinic_id=42') .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.patients[0].fullName).toBeDefined(); }); });
Validacion: Ambos tests fallan contra el codigo con bug (commit c7d84b1) y pasan con el fix aplicado. Proteccion garantizada contra regresion futura.
6
Paso 6 — Verificar end-to-end
Suite completa + build + check manual en staging antes de desplegar
Resultados de verificacion
Verificacion Comando Resultado Duracion
Test de regresion especifico npm test -- --grep "INC-2026-0615" ✓ 2/2 passed 1.2 s
Suite completa npm test ✓ 847/847 passed 43 s
Build TypeScript npm run build ✓ 0 errores 8 s
Check manual staging curl staging/history?clinic_id=42 ✓ 200 OK · sin duplicados manual
Check clinic_id=1 (control) curl staging/history?clinic_id=1 ✓ 200 OK · sin duplicados manual
Deploy a produccion git push origin fix/inc-0615-history-join ✓ Desplegado 10:14 UTC deploy

T
Cronologia del incidente
Secuencia de eventos desde la deteccion hasta la resolucion
07:30 UTC
Bug introducido en produccion
El deploy nocturno (22:05 UTC) con commit c7d84b1 activa el bug al primer acceso matutino de la clinica 42.
!
07:44 UTC
Alerta Datadog → STOP-THE-LINE
Error rate de /history supera umbral del 5%. Oncall notificado. Se congela todo trabajo en curso. Logs preservados.
07:58 UTC
Reproduccion + Localizacion
Bug reproducido en staging. git bisect identifica commit c7d84b1. Query SQL confirmada como causa raiz.
08:20 UTC
Fix desarrollado + tests escritos
Query corregida con DISTINCT ON. Formateador reforzado con optional chaining. 2 tests de regresion anadidos y validados.
10:14 UTC
Deploy a produccion — Incidente cerrado
847 tests pasan. Build limpio. Deploy exitoso. Clinica 42 confirma funcionamiento normal. INC-2026-0615-42 cerrado.
Checklist de verificacion final
Todos los criterios de cierre del incidente
  • Causa raiz identificada y documentada — JOIN sin DISTINCT en history.sql, commit c7d84b1
  • Fix corrige la causa raiz, no el sintoma (no se aplico deduplicacion en UI)
  • Test de regresion existe — 2 tests nuevos que fallan sin el fix y pasan con el
  • Suite completa pasa — 847/847 tests, 0 regresiones introducidas
  • Build exitoso — TypeScript sin errores de tipos ni compilacion
  • Escenario original verificado end-to-end — historial clinic_id=42 muestra datos correctos y sin duplicados
  • Post-mortem programado — revision de proceso de code review para detectar JOINs sin DISTINCT en PRs futuras
L
Lecciones y acciones de mejora
Derivadas del analisis de causa raiz
Racionalizacion encontradaRealidad aplicadaAccion de mejora
"Los duplicados son del frontend, filtro ahi" La query devuelvia datos incorrectos. El frontend no debe compensar bugs de backend. Regla en code review: toda query con JOIN debe tener test de cardinalidad.
"El test de history no cubria este caso" No habia test para un paciente con multiples notas. El gap era de cobertura. Anadir test fixture de "1 paciente con N notas" al setup de integracion.
"Funciono en staging antes del merge" Staging no tenia datos con multiples session_notes por paciente. Generar seed realista con datos polifonicos en staging (mı́nimo 3 notas/paciente).
"Es un bug menor, lo arreglo rapido sin bisect" Sin bisect se habria tardado 3x mas en localizar el commit culpable. Incluir git bisect en el runbook de incidentes P1/P2.