trace y assert: descubrir quién llama y qué invariante se rompió
La traza de pila bajo demanda, las aserciones que solo hablan cuando fallan, y la técnica de envolver una función ajena para averiguar quién la usa.
Dos de las preguntas más frecuentes de la depuración no se responden con un valor sino con un origen: quién ha llamado a esta función, y en qué momento dejó de cumplirse la condición que yo daba por sentada. console.trace y console.assert responden a una cada una, cuestan una línea, y son probablemente las dos funciones más infrautilizadas de la API. La segunda parte de esta lección es la técnica que las combina y que resuelve el caso más difícil: instrumentar código que no es tuyo.
- Obtener la pila de llamadas en un punto arbitrario sin detener la ejecución.
- Escribir aserciones que documenten invariantes y que solo hablen cuando se rompen.
- Envolver una función ajena para descubrir quién la llama y con qué argumentos.
- Interceptar la asignación de una propiedad para cazar quién la modifica.
console.trace
Imprime la pila de llamadas desde el punto donde se ejecuta hasta la raíz, sin interrumpir nada. Acepta argumentos igual que log, que se muestran encima de la pila.
function guardar(datos) {
console.trace('guardar llamado con', datos.id);
// ...
}
Responde a la pregunta “quién llama a esto” en el caso general, incluidas las llamadas indirectas, las que pasan por una librería y las que vienen de un manejador de eventos. Y como respeta la lista de scripts ignorados, la pila que muestra es legible en vez de ser veinte marcos de framework.
Su ventaja sobre poner un breakpoint es que no detiene la ejecución, así que sirve para código que se ejecuta muchas veces y para código sensible al tiempo. Su desventaja es que solo da la pila, no el estado.
Cuando lo que quieres es contar y saber de dónde, se combinan.
function guardar(datos) {
console.groupCollapsed('guardar', datos.id);
console.trace();
console.groupEnd();
}
Con el grupo plegado, cada llamada ocupa una línea y la pila está a un clic.
console.assert
Recibe una condición y, solo si es falsa, emite un error con el resto de argumentos y con traza de pila. Si es verdadera, no hace absolutamente nada.
function calcularTotal(lineas) {
console.assert(Array.isArray(lineas), 'lineas deberia ser un array, es', typeof lineas);
const total = lineas.reduce((s, l) => s + l.precio * l.cantidad, 0);
console.assert(total >= 0, 'total negativo', { total, lineas });
return total;
}
La diferencia con un if seguido de un console.error es sutil y importante: una aserción documenta una invariante. Leer el código de arriba dice, sin ningún comentario, que quien lo escribió espera un array y espera que el total no sea negativo. Cuando alguien rompa esa expectativa dentro de seis meses, la consola lo dirá con la pila incluida.
Dos advertencias. La primera: console.assert no interrumpe la ejecución. El código sigue con el valor malo. Si necesitas parar, lanza una excepción. La segunda: como cualquier llamada a la consola, los argumentos se evalúan siempre, así que una aserción con una comprobación cara se paga en cada llamada aunque la condición se cumpla.
Las aserciones son especialmente valiosas en los puntos de frontera: al recibir datos de una API, al entrar en una función pública de un módulo, al leer de almacenamiento. Son exactamente los sitios donde un dato con la forma equivocada se cuela y produce un error tres capas más abajo, en un sitio donde ya no se puede saber de dónde vino.
Instrumentar código que no es tuyo
Aquí está la técnica que resuelve el caso difícil. Cuando el comportamiento raro está dentro de una librería, o cuando quieres saber quién llama a un método del navegador, no puedes poner una traza dentro. Puedes envolverlo desde fuera.
// Quien llama a este metodo, con que argumentos, y desde donde
function espiar(objeto, metodo) {
const original = objeto[metodo];
objeto[metodo] = function (...args) {
console.groupCollapsed(`${metodo}(`, ...args, ')');
console.trace();
console.groupEnd();
return original.apply(this, args);
};
return () => { objeto[metodo] = original; };
}
// Ejemplos que funcionan pegados tal cual
const parar1 = espiar(history, 'pushState');
const parar2 = espiar(Element.prototype, 'scrollIntoView');
const parar3 = espiar(window, 'requestAnimationFrame');
Cada llamada devuelve una función que restaura el original, lo cual importa: dejar el navegador con métodos parcheados produce comportamientos raros más adelante.
Los objetivos que más rinden con esta técnica: history.pushState y replaceState para descubrir quién cambia la URL, Element.prototype.scrollIntoView para descubrir quién mueve el scroll, EventTarget.prototype.addEventListener para descubrir quién registra manejadores, localStorage.setItem para descubrir quién escribe una clave, y window.fetch para todo lo de red.
Cazar quién modifica una propiedad
La variante más potente. Cuando una propiedad de un objeto cambia y no sabes quién, se sustituye por un par de accesores que avisan.
// Detiene la ejecucion cuando alguien asigna a esta propiedad
function vigilar(objeto, propiedad) {
let valor = objeto[propiedad];
Object.defineProperty(objeto, propiedad, {
configurable: true,
enumerable: true,
get() { return valor; },
set(nuevo) {
console.trace(`${propiedad}:`, valor, '->', nuevo);
debugger; // quita esta linea si solo quieres la traza
valor = nuevo;
}
});
}
vigilar(window, 'miEstadoGlobal');
vigilar(document.body.style, 'overflow'); // quien bloquea el scroll del body
El segundo ejemplo es un caso real muy frecuente: en una aplicación con modales, cuando el scroll del cuerpo se queda bloqueado después de cerrar uno, esa línea encuentra al culpable en la primera reproducción.
La técnica tiene un límite que conviene conocer: solo funciona sobre propiedades de datos normales. Si la propiedad ya es un accesor, o si el objeto es un Proxy, o si está congelado con Object.freeze, la redefinición falla o cambia la semántica. Y para propiedades de nodos del DOM gestionadas por el motor, el resultado puede ser inconsistente.
La idea que atraviesa toda esta lección merece enunciarse aparte porque es un cambio de mentalidad, no una técnica: en JavaScript, casi todo se puede envolver, y eso significa que casi nada es una caja negra. El código minificado de una librería de terceros, un método del propio navegador, un manejador registrado por una extensión, una función pasada como callback: todos son valores accesibles desde la consola, y todos se pueden sustituir por una versión que informa antes de delegar en la original. La consecuencia práctica es que la frase “no puedo depurar esto porque está dentro de la librería” es casi siempre falsa, y la alternativa —leer el código minificado, o clonar el repositorio y compilar con símbolos— es muchísimo más cara que las diez líneas de arriba. Hay tres reglas que hacen la técnica segura y sin ellas se vuelve peligrosa. La primera: siempre delega en el original y devuelve lo que devuelva, porque una envoltura que se come el valor de retorno o que pierde el this rompe la aplicación de formas que luego confundes con el bug que buscabas; por eso el patrón usa apply con this y con los argumentos completos. La segunda: guarda siempre la forma de deshacerlo, y deshazlo antes de sacar conclusiones, porque una envoltura activa es una intervención y las intervenciones contaminan. La tercera, la que más gente ignora: una envoltura cambia la identidad de la función, así que cualquier código que compare funciones por referencia —un removeEventListener con el manejador original, un mapa de callbacks, una comprobación de igualdad en un memo— deja de funcionar. Si al instrumentar aparece un comportamiento nuevo que no estaba antes, esa es la primera sospecha. Con esas tres reglas, la técnica es la más versátil de todo el repertorio y la que convierte el “no sé por dónde empezar” en una pregunta concreta con respuesta en la consola.