Toda sesión de depuración empieza en el mismo sitio: abres DevTools. La consola tiene el error, la pestaña de red tiene la petición que falla y, en un minuto, sabes qué salió mal.
El problema es que DevTools está abierto en tu máquina, y el bug está pasando en la suya.
Esta guía trata de acortar esa distancia: instrumentar una página para que, cuando algo se rompa en el navegador de un usuario, recibas los mismos tres paneles que habrías abierto tú. Cubre qué parchear, en qué orden y las trampas concretas que convierten una implementación ingenua en una fuente de bugs nuevos.
Empieza por el problema del orden
Casi todas las guías para capturar la salida de la consola empiezan por el monkey patch. Es el sitio equivocado para empezar, porque lo difícil no es cómo parcheas, sino cuándo.
Tu instrumentación solo ve lo que pasa después de cargarse. Todo lo que se registró antes se ha perdido. Y los errores que más te interesan son, de forma desproporcionada, los tempranos: una petición de configuración que falló, un script de terceros que lanzó una excepción al inicializarse, un hydration mismatch. Para cuando tu bundle de reportes de bug se ha descargado, parseado e inicializado, la parte interesante de la línea de tiempo ya es historia.
Hay dos formas de resolverlo, y quieres las dos.
Carga primero la capa de captura. Ponla antes del bundle de tu aplicación, de forma síncrona, en <head>. Mantenla pequeña: ese es el argumento a favor de un stub de captura que instala los hooks y nada más, y que deja la interfaz, más pesada, para una carga diferida posterior.
Guarda en un buffer antes de tener dónde ponerlo. Los hooks deberían escribir en un ring buffer en memoria desde el primer milisegundo, mucho antes de que nadie haga clic en «reportar un bug». Cuando por fin se abre un reporte, el buffer ya está lleno de historial. Por eso se pueden capturar logs de antes de que se abriera el widget: el widget nunca fue lo que capturaba.
Parchear console sin romperlo
La mecánica es sencilla:
const BUFFER = [];
const LEVELS = ['log', 'info', 'warn', 'error', 'debug'];
for (const level of LEVELS) {
const original = console[level].bind(console);
console[level] = (...args) => {
BUFFER.push({ level, ts: Date.now(), args: serialize(args) });
original(...args);
};
}
En la práctica, aquí fallan cuatro cosas.
Rompes la atribución de origen de DevTools. Ahora la consola muestra cada línea como si saliera de tu wrapper y no del sitio de la llamada. Chrome y Firefox respetan el comentario //# sourceURL y las reglas de blackboxing, pero la solución fiable es capturar un stack trace en el momento de la llamada y guardar el frame de quien llamó junto a la entrada. De todos modos vas a querer ese frame en el reporte.
Te llamas a ti mismo en bucle. Si serialize lanza una excepción, tu manejador de errores escribe un log y tu logging está parcheado, tienes un bucle infinito que bloquea la pestaña por completo. Protégete con un flag de reentrada y haz que todo el cuerpo del hook no haga nada si algo dentro falla. Una instrumentación que tumba la página es peor que no tener instrumentación.
Mantienes objetos vivos. Meter los argumentos tal cual en un buffer guarda una referencia fuerte a cada objeto que se haya registrado: nodos del DOM, cuerpos de respuesta, árboles de componentes enteros. En una SPA que vive mucho tiempo es una fuga de memoria que crece mientras la pestaña siga abierta. Serializa en el momento a una representación en texto de tamaño acotado y suelta la referencia.
No eres el único que parchea. Los scripts de analítica, los rastreadores de errores y las herramientas de desarrollo de los frameworks también parchean console. Si guardas el original al cargar y alguien parchea después de ti, su wrapper queda por encima del tuyo y sigues viéndolo todo. Si parchearon antes, ves la salida de su wrapper. Las dos cosas funcionan; lo que no funciona es asumir que estás solo. Nunca restaures console[level] al original al desmontar: sobrescribirías el wrapper de quien lo envolvió después de ti. Restaura solo si sigues en lo alto de la cadena.
La serialización es la mayor parte del trabajo
JSON.stringify falla con referencias circulares, descarta sin avisar las funciones y undefined, lanza una excepción con BigInt y convierte los objetos Error en {}, que es justo el caso que más te importa.
Un serializador lo bastante bueno para reportes de bug tiene que manejar referencias circulares (sustitúyelas por un marcador), instancias de Error (nombre, mensaje y stack, de forma explícita), nodos del DOM (etiqueta, id y clases, no el subárbol), Map y Set, y límites de profundidad y longitud para que un objeto enorme no se coma el buffer entero. Trunca las cadenas largas con un marcador explícito, no en silencio. Una línea de log que dice "[truncated at 2000 chars]" le dice algo a quien desarrolla; una línea que simplemente se corta, no.
Errores que nunca registraste
La mayoría de los errores en producción nunca pasan por console.error desde tu código. Se lanzan, y otra cosa los maneja. Dos hooks globales cubren el hueco:
window.addEventListener('error', (e) => {
record({ kind: 'uncaught', message: e.message, filename: e.filename,
line: e.lineno, col: e.colno, stack: e.error?.stack });
}, true);
window.addEventListener('unhandledrejection', (e) => {
record({ kind: 'unhandled-rejection', reason: serialize(e.reason) });
});
Usa la fase de captura para error, así ves los eventos aunque algo más abajo detenga la propagación. Y ten en cuenta que el evento error también se dispara cuando falla la carga de un recurso (una imagen rota o un script que dio 404), y entonces el target es un elemento y no un objeto de error. Vale la pena registrarlos aparte: un bundle que falta explica muchísimos reportes de «la página sale en blanco».
Capturar la red
Dos APIs que envolver y una que usar como red de seguridad.
fetch es la sencilla. Envuélvela y registra el método, la URL y la hora de inicio, y después el código de estado y la duración cuando la promesa se resuelva. Registra también los rechazos: un fallo de red produce una promesa rechazada, no una respuesta, y «la petición nunca terminó» es un reporte muy distinto de «la petición devolvió 500».
XMLHttpRequest todavía importa. Muchas dependencias la usan por dentro, y si solo envuelves fetch te llegarán reportes en los que la petición que falla es invisible. Parchea open para guardar el método y la URL en la instancia, y después parchea send y escucha loadend.
PerformanceObserver con el tipo de entrada resource es la red de seguridad. Ve todas las peticiones que hizo el navegador, incluidas imágenes, hojas de estilo, fuentes, sendBeacon y cualquier cosa que lance un script que se guardó una referencia a fetch antes de que la parchearas. Te da tiempos y tamaño de transferencia, pero no códigos de estado ni cuerpos, así que complementa a los wrappers en lugar de sustituirlos.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
record({ url: entry.name, type: entry.initiatorType,
duration: entry.duration, size: entry.transferSize });
}
}).observe({ type: 'resource', buffered: true });
Lo importante es ese buffered: true: te entrega entradas de antes de que existiera el observer, y así recuperas parte de la línea de tiempo temprana que, si no, habrías perdido.
Los cuerpos son la parte peligrosa
Los cuerpos de petición y de respuesta son lo más útil que puedes adjuntar a un reporte de bug, y también lo que con más probabilidad convierte un reporte de bug en un incidente de seguridad. Ahí viajan tokens de autenticación, datos personales, enlaces para restablecer contraseñas y datos de pago.
El valor por defecto defendible es desactivado, con una activación explícita por proyecto. En BugDump esa activación es captureNetworkBodies, y a propósito no viene activada por defecto, aunque mejora los reportes. Si lo construyes tú, toma la misma decisión.
Cuando los cuerpos están activados, también hay trampas mecánicas. El cuerpo de un Response es un stream que solo se puede consumir una vez: tienes que hacer clone() antes de leerlo, o el propio await res.json() de la aplicación lanzará una excepción por un cuerpo ya consumido. Limita el tamaño que lees. Y lee de forma asíncrona para no bloquear el manejo que la propia aplicación hace de la respuesta.
Los datos sensibles se ocultan en el navegador
La mejor garantía de privacidad a tu alcance es que los datos sensibles nunca salgan de la máquina. Eso significa filtrar en el momento de la captura, en el cliente, no en el servidor después de subirlos.
Dos filtros cubren casi todo. Un filtro de consola descarta entradas por nivel o por patrón, útil para esa librería que registra un token de sesión en debug en cada render. Un filtro de red descarta o reescribe entradas por URL, y es la forma de excluir tu endpoint de autenticación, tu proveedor de pagos y los beacons de analítica que, si no, serían el 90% del log.
Bugdump.init({
apiKey: 'your-api-key',
networkFilter: (req) => !req.url.includes('/auth/'),
consoleFilter: (entry) => entry.level !== 'debug',
});
Aquí los predicados ganan a la configuración. Cualquier lista de permitidos basada en regex que diseñes estará mal para alguien; una función no está mal para nadie.
El tamaño, y saber cuándo parar
Un reporte de bug no es un pipeline de observabilidad. Capturas unos minutos de una sesión, no una flota.
Los ring buffers con límites estrictos, del orden de unos cientos de entradas de consola y otros tantos de red, casi siempre bastan, porque la ventana útil son los segundos antes de que el usuario se rindiera. Quédate con las entradas más recientes y descarta desde el principio. Registra cuántas descartaste, para que el reporte pueda decir «mostrando las últimas 200 de 4318 entradas» en lugar de dar a entender en silencio que el log era corto.
La misma lógica vale para lo que no construyes. Resolución de source maps, configuración del nivel de log por módulo, muestreo, políticas de retención, agregación: esos son problemas de un producto de monitorización de errores. Un reporte de bug tiene un autor que está ahí mismo, contándote qué intentaba hacer. Ese contexto hace la mayor parte del trabajo que en otros sitios hace la agregación.
La recomendación honesta
Todo lo anterior se puede construir en unos cientos de líneas. Vale la pena entenderlo aunque no lo construyas, porque estas son las propiedades que hay que comprobar en cualquier herramienta que uses: ¿se instala antes del bundle de la app? ¿Guarda en el buffer desde la carga? ¿Sobrevive a que otro lo parchee? ¿Los cuerpos vienen desactivados por defecto? ¿Puedes ocultar datos antes de subirlos?
BugDump hace todo esto con una sola etiqueta script, dibuja su interfaz dentro de un Shadow DOM para que nada se filtre en ninguna dirección y adjunta los logs de consola y de red capturados a lo que el usuario escriba en el cuadro del reporte. El plan Gratis cubre 500 reportes por proyecto, sin tarjeta.
Pero lo que importa es la capa de captura, la escriba quien la escriba. El resto de la herramienta es solo una forma de hacértela llegar.
Te puede interesar
Cómo ver los errores de consola de tus usuarios sin DevTools — buffers circulares, hooks de red y filtros, y cómo tenerlo todo con una etiqueta script.
Seguimiento de errores de JavaScript en apps web pequeñas: lo que de verdad necesitas — capturar lo que se rompe, agrupar las repeticiones, leer el stack en tu propio código y conservar el minuto anterior al fallo.
Repetición de sesión sin vigilancia — una grabación limitada a un reporte, en la que el usuario puede ocultar datos y que se desactiva con una línea.