Por qué las URLs relativas no cargan en tu mapa de calor de Matomo (y cómo arreglarlo)

Las capturas de los mapas de calor de Matomo se rompen cuando tu página usa rutas relativas. El renderizador las resuelve contra el host de Matomo en vez del tuyo, así que imágenes, hojas de estilo y fuentes dan 404. Te explico por qué pasa y cómo lo solucionamos.

Si has abierto un mapa de calor de Matomo y has encontrado iconos de imagen rota donde deberían estar tu logo y el hero, imágenes de fondo que faltan en cada sección, y en los peores casos un layout que parece haberse olvidado de que existe su CSS, los clics en sí están bien. Matomo los grabó en las coordenadas correctas. Lo que está roto es la captura de pantalla debajo de ellos. Todo lo que tiene una ruta relativa en la página no sobrevivió el viaje al renderizador de Matomo, así que te queda un mapa de calor flotando sobre un wireframe de lo que tus visitantes vieron de verdad.

La señal es consistente. Cualquier cosa con una URL completamente cualificada como https://yoursite.com/images/hero.jpg se renderiza bien. Cualquier cosa con una ruta relativa (/images/hero.jpg, ./logo.svg, assets/bg.png) está rota. Lo mismo aplica a hojas de estilo, fuentes, scripts y referencias CSS url(...) dentro de estilos inline. En cuanto detectas ese patrón, la causa es obvia.

El arreglo más rápido es una extensión gratuita de Chrome que mantenemos llamada Matomo Heatmap Helper. Recorre el DOM y convierte cada URL relativa a absoluta justo antes de cada captura, y después restaura las originales. El resto de este post explica cómo hacer lo mismo sin extensión, más unos cuantos arreglos permanentes que vale la pena implementar.

Cómo arreglarlo sin la extensión

Hay un snippet de consola que tapa el problema justo antes de cada captura, y un puñado de arreglos permanentes que puedes implementar en el sitio o en el build. Elige el que mejor encaje con las restricciones con las que trabajas.

Lista todas las URLs relativas de la página

Antes de cambiar nada, averigua qué es realmente relativo. Abre la página en Chrome, pulsa F12 para abrir DevTools, cambia a la pestaña Console y pega esto. Cubre <img src>, <img srcset>, <source srcset>, <source src>, <video poster> y background-image en estilos inline:

js
// Paste into the browser console. Lists every relative resource URL.
(() => {
  const isRel = u => u && !/^([a-z]+:|\/\/|data:|blob:|#)/i.test(u.trim());
  const out = [];
  document.querySelectorAll('img[src]').forEach(el =>
    isRel(el.getAttribute('src')) && out.push(['img.src', el.getAttribute('src'), el]));
  document.querySelectorAll('img[srcset], source[srcset]').forEach(el =>
    el.getAttribute('srcset').split(',').map(s => s.trim().split(/\s+/)[0])
      .forEach(u => isRel(u) && out.push(['srcset', u, el])));
  document.querySelectorAll('video[poster]').forEach(el =>
    isRel(el.getAttribute('poster')) && out.push(['video.poster', el.getAttribute('poster'), el]));
  document.querySelectorAll('source[src]').forEach(el =>
    isRel(el.getAttribute('src')) && out.push(['source.src', el.getAttribute('src'), el]));
  document.querySelectorAll('[style*="url("]').forEach(el => {
    const m = el.getAttribute('style').match(/url\(\s*['"]?([^'")]+)['"]?\s*\)/g) || [];
    m.forEach(raw => {
      const u = raw.match(/url\(\s*['"]?([^'")]+)/)[1];
      if (isRel(u)) out.push(['inline bg', u, el]);
    });
  });
  console.table(out.map(([t, u]) => ({ type: t, url: u })));
  return out;
})();

Eso te da la lista exacta de referencias que el renderizador de Matomo va a resolver mal. Si la tabla sale vacía y el mapa de calor sigue roto, el problema está en otro sitio (CORS en un CDN, licencias de fuentes, contenedores con scroll). En caso contrario, cada fila de esa tabla es algo que necesitas reescribir o mover a una URL absoluta.

El arreglo rápido: reescribir URLs relativas a absolutas justo antes de la captura

Este es el snippet que pegamos en la consola del navegador justo antes de lanzar la captura del mapa de calor de Matomo. Replica el relative-url-fixer de la extensión: recorre el DOM, resuelve cada URL de recurso relativa contra la ubicación actual de la página y escribe la versión absoluta de vuelta en el atributo. Para cuando Matomo serializa el DOM, cada referencia está completamente cualificada y el renderizador pide assets de tu origen en vez del de Matomo. Deliberadamente deja <a href> en paz, ya que reescribir enlaces de navegación rompería el routing de SPAs sin afectar a la captura.

js
// Paste into the browser console. Converts every relative resource URL to
// absolute so Matomo's server-side render resolves them against your origin
// instead of Matomo's. Skips <a href> deliberately, since changing nav links
// would break SPAs and doesn't affect the screenshot.
(() => {
  const base = location.href;
  const isRel = u => u && !/^([a-z]+:|\/\/|data:|blob:|#)/i.test(u.trim());
  const abs = u => { try { return new URL(u, base).href; } catch { return u; } };
  let count = 0;
 
  // 1. img[src], source[src], script[src], video[poster], stylesheets, icons, preloads
  document.querySelectorAll('img[src], source[src], script[src]').forEach(el => {
    const v = el.getAttribute('src');
    if (isRel(v)) { el.setAttribute('src', abs(v)); count++; }
  });
  document.querySelectorAll('video[poster]').forEach(el => {
    const v = el.getAttribute('poster');
    if (isRel(v)) { el.setAttribute('poster', abs(v)); count++; }
  });
  document.querySelectorAll('link[rel="stylesheet"], link[rel~="icon"], link[rel="preload"]').forEach(el => {
    const v = el.getAttribute('href');
    if (isRel(v)) { el.setAttribute('href', abs(v)); count++; }
  });
 
  // 2. img[srcset], source[srcset]. Rewrite each candidate and preserve descriptors.
  document.querySelectorAll('img[srcset], source[srcset]').forEach(el => {
    const srcset = el.getAttribute('srcset');
    const rewritten = srcset.split(',').map(part => {
      const trimmed = part.trim();
      const [u, ...rest] = trimmed.split(/\s+/);
      return isRel(u) ? [abs(u), ...rest].join(' ') : trimmed;
    }).join(', ');
    if (rewritten !== srcset) { el.setAttribute('srcset', rewritten); count++; }
  });
 
  // 3. CSS url() inside inline styles
  document.querySelectorAll('[style*="url("]').forEach(el => {
    const before = el.getAttribute('style');
    const after = before.replace(
      /url\(\s*['"]?([^'")]+)['"]?\s*\)/g,
      (whole, u) => isRel(u) ? `url("${abs(u)}")` : whole
    );
    if (after !== before) { el.setAttribute('style', after); count++; }
  });
 
  console.log(`Rewrote ${count} relative URLs to absolute. Now trigger your Matomo heatmap capture.`);
})();

Cuando aparezca la línea del log, captura de inmediato. La reescritura solo vive hasta la siguiente navegación. Ten en cuenta que este snippet solo ve atributos style="..." inline, no llamadas url(...) dentro de hojas de estilo externas. Para esas, mira el reescritor postbuild más abajo o usa el arreglo en tiempo de build.

Añadir una etiqueta <base> en <head>

El arreglo permanente más sencillo posible. Una línea al principio de <head>, y cada URL relativa de la página se resuelve contra tu dominio en vez de contra el host que esté renderizando el HTML:

html
<!-- Edit yoursite.com to your domain -->
<base href="https://yoursite.com/">

El problema es que <base> afecta a todas las URLs relativas del sitio, incluyendo los destinos de los enlaces. Las SPAs que dependen del routing relativo (React Router, <Link> de Next.js en ciertas configuraciones, cualquier cosa con pushState del lado del cliente contra rutas relativas) pueden funcionar mal en cuanto la añades. Prueba el flujo de navegación antes de publicar, o limita la etiqueta para que solo se renderice durante las capturas del mapa de calor (un parámetro de query que tu script de tracking activa, por ejemplo).

Si puedes implementar una etiqueta <base> sin romper la navegación de la SPA, esta es la respuesta y puedes dejar de leer.

Emitir URLs absolutas desde tu build

Si <base> no es una opción, configura tu build para que emita URLs absolutas para los assets desde el principio. La mayoría de generadores tienen un ajuste para esto. Next.js tiene assetPrefix en next.config.js. Hugo tiene baseURL en config.toml. Astro tiene site en astro.config.mjs. Gatsby usa pathPrefix junto con --prefix-paths en tiempo de build. Pon tu dominio de producción como valor, reconstruye, y cada etiqueta <img>, <link> y <script> que genere tu framework saldrá completamente cualificada. El renderizador de Matomo pedirá los archivos a tu origen y el mapa de calor funcionará.

El truco aquí es que las URLs absolutas en tiempo de build normalmente solo cubren los assets que el framework conoce. CSS escrito a mano (o que viene de un CMS, o escrito en un tema personalizado) muchas veces se cuela, y lo mismo pasa con las referencias url(...) dentro de hojas de estilo compiladas. Si has configurado el build correctamente y el mapa de calor sigue con fondos o fuentes de iconos que faltan, ese es el hueco que estás tocando.

Reescritor postbuild para CSS url()

Si tu generador se salta los url(...) dentro de las hojas de estilo compiladas, este es un pequeño script de Node que recorre el directorio de salida y reescribe cada ruta root-relativa a una absoluta:

js
// Edit SITE to your domain. Run with: node rewrite.js
import { readFileSync, writeFileSync } from 'fs';
import { globSync } from 'glob';
const SITE = 'https://yoursite.com';
for (const f of globSync('out/**/*.{html,css}')) {
  const out = readFileSync(f, 'utf8').replace(
    /url\(\s*['"]?(\/[^'")]+)['"]?\s*\)/g,
    (_, p) => `url(${SITE}${p})`
  );
  writeFileSync(f, out);
}

Conéctalo en tu CI como paso post-build. Está deliberadamente limitado a rutas root-relativas (rutas que empiezan por /), ya que reescribir ./foo o ../bar requiere saber dónde está la hoja de estilo en el árbol de salida. Si tienes muchas de esas, el arreglo en tiempo de build de arriba es mejor respuesta.

Por qué Matomo no puede resolver URLs relativas

La captura de pantalla de Matomo es un proceso en dos pasos. Primero, el tracker serializa tu DOM en vivo en HTML y lo envía. Después, tu servidor de Matomo vuelve a renderizar ese HTML para producir la captura que ves en la vista del mapa de calor. El HTML sigue conteniendo /images/hero.jpg exactamente como lo escribiste. Pero el renderizador no está corriendo en tu dominio, así que cuando el motor del navegador va a cargar esa imagen, resuelve la ruta relativa contra el host de Matomo. /images/hero.jpg se convierte en https://your-matomo-host/images/hero.jpg, que no existe. Lo mismo con hojas de estilo, fuentes y las llamadas url(...) dentro de estilos inline. El renderizador le pide al servidor de Matomo archivos de los que el servidor de Matomo no sabe nada, recibe 404s, y tú obtienes una captura con todo lo que tiene rutas relativas desaparecido.

Por esto también las URLs completamente cualificadas funcionan bien. Una vez que una URL tiene su propio esquema y host, el renderizador sabe exactamente dónde pedirla, y la petición va a tu origen como debería.

Qué está fallando realmente

Unos cuantos patrones que nos seguimos encontrando:

  • <img src="/images/hero.jpg"> y similares. El síntoma más visible: iconos de imagen rota donde deberían estar tu hero, logo o fotos de producto.
  • <link rel="stylesheet" href="/css/main.css"> con un href relativo. Toda la hoja de estilo da 404, así que la página se renderiza con los estilos por defecto del navegador. Este es el caso que la gente describe como "el mapa de calor tiene un aspecto completamente sin estilos."
  • style="background-image: url(/images/bg.png)" inline. Los fondos desaparecen, el layout parece correcto pero visualmente está roto.
  • Candidatos de srcset con rutas relativas dentro de <img srcset> o <source srcset>. El candidato que elige el renderizador de Matomo depende de su suposición de viewport, así que a veces una ruta se resuelve y otra no, y la rotura parece intermitente.
  • @font-face src: url("/fonts/inter.woff2") declarado dentro de un bloque <style> inline o una hoja de estilo que sí sobrevive. La fuente da 404 y caes al sistema de fuentes por defecto. (Esto se solapa con el post sobre fuentes que no cargan, pero aquí la causa es la resolución de rutas, no CORS.)
  • SVG <use href="/icons/sprite.svg#chevron">. El sprite nunca carga, cada icono desaparece, y el layout colapsa alrededor de los placeholders <svg> vacíos.
  • <link rel="preload" as="font" href="..."> con un href relativo. No rompe la captura por sí solo, pero es una señal útil de que otros assets de la misma familia probablemente también están rotos.

Si tu mapa de calor muestra placeholders de imagen rota, CSS que falta o un layout que no coincide con tu sitio en vivo, estás tocando al menos uno de estos. Normalmente más de uno en la misma página.

Esta es también la misma familia de problema que rompe fuentes, imágenes, contenedores con scroll y encabezados sticky en la misma captura. Hemos escrito un post más largo sobre capturas de mapas de calor de Matomo rotas que repasa el resto, más posts separados sobre por qué las fuentes no cargan y por qué las imágenes no cargan ya que cada uno tiene sus propias causas.

Qué haríamos nosotros

Si puedes implementar una etiqueta <base> sin romper la navegación de la SPA, esa es la respuesta. Una línea, cada URL relativa se resuelve correctamente, listo.

Si <base> entra en conflicto con tu routing, configura tu build para emitir URLs absolutas para los assets, y añade el reescritor CSS postbuild para las referencias url(...) que tu build no alcanza. Un poco más de trabajo, más completo, no pelea con tu framework.

Si ninguna de las dos es posible (plantillas gestionadas por un CMS que no puedes tocar, temas que no son tuyos, markup inyectado por terceros), la extensión Matomo Heatmap Helper de Chrome es lo que usamos en los sitios de clientes donde no podemos cambiar el stack. Ejecuta la misma lógica de reescritura que el snippet de arriba, más arreglos equivalentes para fuentes, imágenes, contenedores con scroll y encabezados sticky, en cada captura. Gratuita, de código abierto, código en GitHub.

Martez es el proyecto más amplio del que salió la extensión. Conecta Matomo con Meta Ads y Google Ads para que el ROAS, el CLV y la atribución estén junto a tus analíticas web en vez de en una hoja de cálculo aparte. Está en beta privada. Únete a la lista de espera si eso te resulta relevante.

La mayoría de problemas de URLs relativas se resuelven con una configuración de build. Vale la pena hacerlo una vez.

Por qué las URLs relativas no cargan en tu mapa de calor de Matomo (y cómo arreglarlo) - Martez Blog