wandres.dev
REDUX DEVTOOLS · time travel real

Depurar con acciones: reproducir la sesión del usuario

El informe de bug clásico es una narración imprecisa que el desarrollador debe traducir a pasos. Con un historial de acciones serializable el informe deja de narrarse y pasa a ejecutarse: el usuario exporta la secuencia exacta de lo que hizo, tú la importas y tu aplicación local recorre el mismo camino hasta el mismo estado roto. Esta lección construye ese circuito completo —exportar, importar, replegar, corregir y volver a plegar sobre el código nuevo—, muestra cómo capturar acciones en producción sin depender de que el usuario tenga la extensión instalada, y examina con rigor los tres obstáculos que impiden que la reproducción sea perfecta: el tiempo, la aleatoriedad y la parte del mundo que vive en el servidor y no en tu lista.

⏱ 19 min

El ciclo de vida de un bug reportado por un usuario consume, en casi cualquier equipo, más tiempo en la fase de reproducirlo que en la de arreglarlo. La razón es que el informe llega escrito en lenguaje natural —le di a guardar y se quedó en blanco— y el desarrollador tiene que reconstruir a partir de esa frase el camino exacto que llevó al fallo, sin saber qué filtros había activos, qué se cargó antes ni en qué orden. Un historial de acciones serializable elimina esa traducción de raíz. Si cada intención quedó registrada como un dato, el informe deja de ser una descripción y se convierte en un programa que puedes ejecutar. Esta lección enseña a montar ese circuito y, sobre todo, a saber cuándo miente.

🎯 Al terminar esta lección sabrás
  • Montar el circuito completo de exportar una sesión, importarla y replegarla en tu entorno local.
  • Capturar acciones en producción sin exigir que el usuario tenga la extensión instalada.
  • Iterar sobre un historial fijo para verificar una corrección sin repetir la interacción a mano.
  • Identificar los tres factores que rompen la reproducción fiel y las técnicas que los neutralizan.

El informe de bug que se ejecuta

La propiedad que hace esto posible ya la tienes: una acción es un objeto plano y serializable, y el estado es un pliegue determinista de la lista de acciones sobre el estado inicial. De ahí se deduce, sin ningún paso adicional, que la lista de acciones es una descripción completa y transferible de una sesión. Si te la llevas a otra máquina y la pliegas con el mismo reducer, obtienes el mismo estado. No es una aproximación: es la misma función aplicada a la misma entrada.

Las DevTools exponen exactamente eso con dos botones. El de exportar vuelca un fichero JSON con el estado inicial y la secuencia completa de acciones; el de importar lo carga en la extensión y lo repliega sobre tu aplicación. El resultado es que tu navegador local queda en el mismo estado en el que estaba el del usuario, con todo el historial disponible para recorrerlo hacia atrás y aislar el instante exacto en que algo se torció.

flowchart LR
U[usuario reproduce el fallo] --> E[exporta el historial a JSON]
E --> T[el fichero viaja en el ticket]
T --> D[el dev lo importa en local]
D --> P[se repliega con el mismo reducer]
P --> S[mismo estado roto en su maquina]
S --> F[corrige y vuelve a plegar]
F --> V[verifica sin repetir la interaccion]
style S fill:#f38ba8,color:#11111b
style V fill:#a6e3a1,color:#11111b

Merece la pena detenerse en el último tramo del diagrama, porque es donde está la ganancia real. Una vez importado el historial, corriges el reducer, guardas, y las DevTools vuelven a plegar la misma secuencia sobre el código nuevo. La entrada permanece idéntica y solo cambia la función bajo estudio, que es la definición misma de un experimento controlado. Sin este circuito, cada intento de corrección exige repetir a mano quince interacciones y confiar en haberlas repetido igual, que es donde se cuelan la mitad de las correcciones que parecían funcionar y no funcionaban.

💡
Pide el fichero, no la narración

El cambio organizativo que más rendimiento da es también el más barato: añadir al formulario de incidencias un campo para adjuntar el historial exportado. Para un usuario interno o un tester basta con enseñarle el botón. A partir de ahí, la conversación sobre el bug cambia de naturaleza: en vez de discutir si el usuario hizo o no cierto paso, ambos miran la misma lista de acciones y la discusión se acaba. Un ticket con historial adjunto se reproduce en segundos; uno sin él consume, de media, más tiempo en reproducir que en arreglar.

Capturar sin extensión: el registro propio

El circuito anterior tiene una dependencia incómoda: exige que la persona que sufre el bug tenga la extensión instalada y sepa usarla. Para usuarios internos es razonable; para usuarios reales en producción, no. La solución es no depender de la extensión y registrar las acciones tú mismo con un middleware minúsculo que mantiene un anillo de las últimas emitidas y lo adjunta al informe de error.

const MAX = 100
const anillo: unknown[] = []

export const registro = (store) => (next) => (accion) => {
  anillo.push({ t: accion.type, p: accion.payload })
  if (anillo.length > MAX) anillo.shift()
  return next(accion)
}

export function sesionParaInforme() {
  return { acciones: anillo.slice(), version: APP_VERSION }
}

Tres decisiones de ese fragmento importan más de lo que parece. La primera es el anillo acotado: guardar la sesión entera es inviable en memoria y casi siempre innecesario, porque el contexto útil de un fallo cabe en las últimas decenas de acciones. La segunda es incluir la versión de la aplicación, sin la cual no sabrás con qué reducer plegar el historial cuando llegue. La tercera, y la más importante, es que ese payload va a salir de la máquina del usuario, de modo que aquí es donde empieza el problema de los datos sensibles que la última lección de este nivel trata en detalle: lo mínimo es filtrar o redactar los campos con información personal antes de que el anillo se envíe a ninguna parte.

🧾

Anillo de acciones

Las últimas N acciones en memoria, adjuntas automáticamente al informe de error. Barato, acotado y suficiente para casi todo.

🏷️

Versión y entorno

Sin saber qué reducer plegó ese historial, la reproducción no es fiable. Viaja siempre junto a las acciones.

🛡️

Redacción previa

Los campos personales se sustituyen antes de salir del dispositivo. Nunca después, nunca en el servidor.

🎬

Estado inicial

Sin el punto de partida, la lista de acciones no se puede plegar. Guarda una fotografía del arranque junto al anillo.

Del lado del desarrollador, importar ese registro casero no requiere nada especial: basta con despachar las acciones en orden sobre un store recién creado, opcionalmente detrás de una pantalla de reproducción que espere a que la aplicación esté montada. Si además marcas cada acción reproducida con una bandera, puedes hacer que tu middleware de efectos las ignore y evitar así que la reproducción vuelva a lanzar peticiones reales, que es el accidente más común de este montaje.

Los tres obstáculos de la reproducción fiel

La promesa del pliegue determinista tiene tres grietas conocidas, y conviene enunciarlas con precisión porque cada una tiene su remedio.

La primera es el tiempo. Cualquier estado calculado a partir de la hora actual —caducidades, saludos según la franja horaria, ordenaciones por antigüedad— produce un resultado distinto cuando lo repliegas mañana. El remedio es no leer nunca el reloj dentro del reducer: la hora entra como payload de la acción, calculada en el borde, y así queda congelada dentro del historial. La misma disciplina que hacía puro al reducer es la que hace reproducible a la sesión.

La segunda es la aleatoriedad. Los identificadores generados con azar hacen que el historial reproducido no coincida con el original, y las comparaciones o los índices basados en ellos se desalinean. El remedio es idéntico: generar el identificador fuera y meterlo en la acción. Redux Toolkit lo institucionaliza con el campo prepare de createSlice, que existe precisamente para alojar la impureza en el único lugar donde no rompe nada.

// El azar y el reloj se resuelven en prepare, no en el reducer.
const slice = createSlice({
  name: 'notas',
  initialState,
  reducers: {
    crear: {
      reducer: (state, accion) => { state.items.push(accion.payload) },
      prepare: (texto: string) => ({
        payload: { id: nanoid(), texto, creadaEn: Date.now() },
      }),
    },
  },
})

La tercera grieta es la más profunda y no tiene remedio completo: el servidor. Tu historial contiene las acciones que registraron las respuestas recibidas, pero el mundo remoto que las produjo ya cambió. Repliegas la sesión y obtienes el mismo estado local, sí, pero si tu reproducción vuelve a pedir datos, esos datos serán otros. La regla operativa es clara: reproduce plegando el historial, no relanzando las peticiones. Y para los bugs cuyo origen está en la respuesta del servidor, lo que necesitas capturar no es la interacción sino el payload exacto de la acción que recibió esa respuesta, que en tu historial ya está.

⚠️
Reproducir no es volver a ejecutar

El error de montaje más frecuente es confundir replegar el historial con relanzar la sesión. Si despachas las acciones sobre una aplicación con todos sus middleware de efectos activos, cada acción que originalmente disparó una petición volverá a dispararla: escribirás en la base de datos de producción, enviarás correos de verdad y contaminarás la analítica con una sesión fantasma. La reproducción segura pasa las acciones solo por el reducer, o marca cada acción reproducida para que la cadena de efectos la ignore. Antes de importar el historial de un usuario, verifica cuál de las dos cosas hace tu montaje.

De la sesión grabada al caso de prueba

Una sesión exportada tiene una segunda vida que casi nadie explota: convertirse en test de regresión. Como el pliegue es determinista, la prueba se escribe sola. Tomas el fichero, lo repliegas con el reducer actual y afirmas sobre el estado final; lo que era la descripción de un bug pasa a ser la garantía de que ese bug no vuelve.

import sesion from './sesiones/bug-1274.json'

test('la sesion 1274 ya no deja el carrito vacio', () => {
  const estado = sesion.acciones.reduce(
    (acc, accion) => raiz(acc, accion),
    sesion.estadoInicial,
  )
  expect(estado.carrito.items).toHaveLength(3)
})

Este tipo de prueba tiene dos virtudes que los tests escritos a mano rara vez alcanzan. La primera es la fidelidad: la secuencia de entrada no la inventó nadie pensando en qué podría fallar, la produjo un usuario real haciendo su trabajo, con el orden raro y los pasos redundantes que un test sintético nunca reproduce. La segunda es el coste de redacción, prácticamente nulo, lo cual importa porque el motivo real por el que muchos equipos no cubren sus regresiones no es no saber, sino que escribir el caso costaba más que el arreglo.

Hay dos cautelas. Una sesión larga produce un test frágil, porque cualquier cambio legítimo del dominio lo rompe; conviene recortar el historial al tramo mínimo que reproduce el fallo antes de convertirlo en prueba, usando el propio deslizador para encontrar el primer instante relevante. Y el fichero, si vino de un usuario real, contiene sus datos, así que antes de entrar en el repositorio debe pasar por la misma redacción que exige la última lección del nivel: un caso de prueba versionado es un archivo permanente y público dentro de tu organización.

📝
Los mejores casos de prueba no los escribe el equipo

La consecuencia organizativa de este patrón es que la fuente principal de casos de prueba de un sistema maduro deja de ser la imaginación de sus desarrolladores y pasa a ser el uso real. Cada incidencia reportada deja tras de sí una secuencia que se puede recortar, sanear y archivar como prueba, de modo que la cobertura crece exactamente por donde el sistema demostró ser frágil, que es justo donde una batería escrita a priori nunca acierta a mirar. Es la forma más barata de convertir el dolor de producción en una garantía permanente.

Un bug reproducible deja de ser un misterio y pasa a ser una tarea

La distancia entre un equipo que arregla bugs y uno que los persigue no está en la habilidad de sus programadores, sino en si sus fallos se pueden reproducir a voluntad. Un bug que no se reproduce obliga a razonar por conjetura: se lee el código buscando lo que podría haber pasado, se aventura una corrección, se despliega y se espera a ver si el usuario vuelve a quejarse, un ciclo cuya duración se mide en días y cuya tasa de acierto es deprimente. Un bug reproducible se ataca con el método normal de la ingeniería: se observa, se acota por bisección, se corrige y se verifica, y el ciclo se mide en minutos. Lo notable de Redux es que no ofrece una herramienta para conseguir esa reproducibilidad, sino que la reproducibilidad es un corolario de su forma: al obligar a que toda intención sea un dato plano y a que el estado sea un pliegue puro de esos datos, hizo que la sesión completa de un usuario fuese, sin esfuerzo adicional, un valor transferible. Esta es la razón por la que las tres grietas de esta lección —el reloj, el azar y el servidor— son exactamente las tres impurezas del sistema y no otras: la reproducción falla solo donde la pureza falla, y cada técnica para arreglarla consiste en empujar la impureza hacia el borde para que quede congelada dentro de la acción en lugar de recalcularse dentro del reducer. Quien entiende esto deja de ver prepare como una comodidad de Redux Toolkit y lo ve como lo que es, el lugar institucional donde el sistema aloja lo que no puede repetirse, y descubre que la disciplina que parecía burocrática estaba comprando, todo este tiempo, la capacidad de que un informe de bug fuera ejecutable en lugar de narrado. Cuando la sesión de tu usuario cabe en un fichero, dejas de depender de su memoria y de tu suerte, y esa independencia vale más que casi cualquier optimización que puedas hacerle a tu aplicación.

⚔️ Monta el circuito de reproducción de extremo a extremo
  1. Provoca un fallo real en tu aplicación, exporta el historial desde las DevTools y ábrelo en un editor. Identifica el estado inicial y cuenta cuántas acciones fueron necesarias.
  2. Importa ese fichero en una instancia limpia y confirma que llegas al mismo estado roto. Si no llegas, busca qué información quedó fuera del historial: ahí está tu estado oculto.
  3. Escribe el middleware de anillo con las últimas cincuenta acciones y engánchalo a tu informador de errores. Verifica que el payload que sale del dispositivo no contiene ningún dato personal.
  4. Marca las acciones reproducidas con una bandera y haz que tu cadena de efectos las ignore. Comprueba con la pestaña de red que reproducir una sesión no lanza ni una sola petición.
  5. Busca en tus reducers cualquier lectura del reloj o del generador de azar y muévela a prepare. Repliega un historial antiguo antes y después del cambio y compara los estados resultantes.
  6. Convierte la sesión exportada en un test que pliegue el historial y afirme sobre el estado final. Rompe a propósito el reducer y confirma que el test detecta la regresión.