Si has abierto un heatmap de Matomo y has encontrado tu imagen hero reemplazada por un rectangulo en blanco, tus fotos de producto mostrando iconos de imagen rota y los marcadores de clic flotando sobre espacio blanco, los clics en si mismos estan bien. Matomo los registró en las coordenadas correctas. Lo que esta roto es la captura de pantalla que hay debajo. Las imagenes no pasaron cuando el servidor de Matomo volvió a renderizar la pagina, asi que te quedas con un heatmap que parece un wireframe de la pagina que tus visitantes vieron de verdad.
La solucion mas rapida es una extension gratuita de Chrome que mantenemos llamada Matomo Heatmap Helper. Obtiene cada imagen de origen cruzado a traves del script de fondo de la extension (lo que esquiva las reglas CORS dentro de la pagina) y las incrusta como base64 justo antes de cada captura. El resto de este post explica como hacer lo mismo sin una extension, mas algunas soluciones permanentes que merece la pena implementar.
Como arreglarlo sin la extension
Hay un fragmento de consola que tapa el problema justo antes de cada captura, y un puñado de soluciones permanentes que puedes implementar en el sitio o en el CDN. Elige la que mejor encaje con tus restricciones.
Lista todas las referencias de imagenes de origen cruzado
Antes de cambiar nada, averigua que imagenes no estan cargando. Abre la pagina en Chrome, pulsa F12 para abrir DevTools, cambia a la pestaña Console y pega esto. Cubre <img src>, <img srcset>, <source srcset> dentro de <picture>, <video poster>, SVG <use href> y CSS background-image:
// Paste into the browser console. Lists every cross-origin image-like reference.
(() => {
const out = new Set();
const isCross = u => { try { return new URL(u, location.href).origin !== location.origin && !u.startsWith('data:') && !u.startsWith('blob:'); } catch { return false; } };
document.querySelectorAll('img[src]').forEach(el => isCross(el.src) && out.add(el.src));
document.querySelectorAll('img[srcset], source[srcset]').forEach(el =>
el.getAttribute('srcset').split(',').map(s => s.trim().split(/\s+/)[0]).forEach(u => isCross(u) && out.add(u)));
document.querySelectorAll('video[poster]').forEach(el => isCross(el.poster) && out.add(el.poster));
document.querySelectorAll('use').forEach(el => {
const h = el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
if (h && isCross(h)) out.add(h);
});
document.querySelectorAll('*').forEach(el => {
const bg = getComputedStyle(el).backgroundImage;
const m = bg && bg.match(/url\(['"]?([^'")]+)['"]?\)/);
if (m && isCross(m[1])) out.add(m[1]);
});
console.log([...out]);
return [...out];
})();Eso te da la lista exacta de URLs que el renderizador de Matomo tendra que obtener por su cuenta. Cualquier cosa servida desde un host que no controlas es candidata a fallar.
La solucion rapida: incrusta cada imagen como base64 desde la consola
Este es el fragmento que pegamos en la consola del navegador justo antes de lanzar la captura del heatmap de Matomo. Recorre cada referencia de imagen en la pagina (cubriendo todos los mismos selectores que el fragmento de listado anterior), obtiene cada URL unica una vez, la codifica en base64 y reescribe las referencias en linea. Para cuando Matomo serializa el DOM, las imagenes ya estan integradas.
// Paste into the browser console. Embeds every cross-origin image reference
// as a base64 data URI. Works for any image host that allows CORS (most modern
// CDNs, S3 with CORS open, Unsplash, Cloudinary). Hot-link-protected hosts
// that block by Referer will fail. Fix the source there.
(async () => {
const isCross = u => {
try {
if (!u || u.startsWith('data:') || u.startsWith('blob:') || u.startsWith('#')) return false;
return new URL(u, location.href).origin !== location.origin;
} catch { return false; }
};
// 1. Collect every cross-origin reference
const refs = []; // {url, apply: dataUri => void}
document.querySelectorAll('img[src]').forEach(el => {
const url = el.getAttribute('src');
if (isCross(url)) refs.push({ url, apply: d => el.setAttribute('src', d) });
});
document.querySelectorAll('img[srcset], source[srcset]').forEach(el => {
const srcset = el.getAttribute('srcset');
srcset.split(',').forEach(part => {
const url = part.trim().split(/\s+/)[0];
if (isCross(url)) {
refs.push({ url, apply: d => {
const cur = el.getAttribute('srcset') || '';
el.setAttribute('srcset', cur.split(url).join(d));
}});
}
});
});
document.querySelectorAll('video[poster]').forEach(el => {
const url = el.getAttribute('poster');
if (isCross(url)) refs.push({ url, apply: d => el.setAttribute('poster', d) });
});
document.querySelectorAll('use').forEach(el => {
const url = el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
if (isCross(url)) {
refs.push({ url, apply: d => {
if (el.hasAttribute('href')) el.setAttribute('href', d);
else el.setAttributeNS('http://www.w3.org/1999/xlink', 'href', d);
}});
}
});
document.querySelectorAll('*').forEach(el => {
const bg = getComputedStyle(el).backgroundImage;
if (!bg || bg === 'none') return;
const m = bg.match(/url\(['"]?([^'")]+)['"]?\)/);
if (m && isCross(m[1])) {
const url = m[1];
refs.push({ url, apply: d => el.style.setProperty('background-image', `url("${d}")`) });
}
});
if (refs.length === 0) { console.warn('No cross-origin image references found.'); return; }
console.log(`Found ${refs.length} cross-origin image references.`);
// 2. Fetch each unique URL once and base64-encode
const uniqueUrls = [...new Set(refs.map(r => r.url))];
const dataUris = new Map();
await Promise.all(uniqueUrls.map(async url => {
try {
const blob = await fetch(url, { mode: 'cors' }).then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.blob();
});
const dataUri = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result);
r.onerror = rej;
r.readAsDataURL(blob);
});
dataUris.set(url, dataUri);
} catch (e) { console.warn('Image fetch failed:', url, e.message); }
}));
// 3. Apply every reference
let applied = 0;
for (const ref of refs) {
const d = dataUris.get(ref.url);
if (!d) continue;
try { ref.apply(d); applied++; } catch (e) { console.warn('Apply failed:', ref.url, e); }
}
console.log(`Embedded ${dataUris.size}/${uniqueUrls.length} unique URLs across ${applied} references.`);
console.log('Now trigger your Matomo heatmap capture.');
})();Ahora espera hasta que veas algo como: Embedded 8/8 unique URLs across 12 references. Now trigger your Matomo heatmap capture.
La pega: este fragmento corre dentro de la pagina, asi que hereda las reglas CORS de la pagina. Si tu CDN envia Access-Control-Allow-Origin: * (o tu origen), la peticion funciona y listo. Si no lo hace, la peticion falla para esas URLs concretas y los avisos te dicen exactamente cuales son. Para esas, consulta las soluciones permanentes mas abajo, o salta directamente a la seccion de la extension.
Sirve las imagenes desde tu propio dominio o usa un proxy
La solución mas limpia para casi todo el mundo, igual que con las fuentes. Mismo origen esquiva el problema entero porque el renderizador de Matomo obtiene la imagen de tu dominio con las mismas reglas de acceso que la pagina original. Un proxy de nginx son dos lineas:
# Edit cdn.example.com to your CDN host
location /cdn-proxy/ {
proxy_pass https://cdn.example.com/;
proxy_set_header Host cdn.example.com;
}Luego reescribe <img src="https://cdn.example.com/x.jpg"> como <img src="/cdn-proxy/x.jpg"> en tus plantillas. Mas laborioso que tocar una cabecera CORS, pero es a prueba de balas, y de paso te da una capa de control sobre el cacheado.
Abre CORS en el bucket o la distribucion
Si alojar las imagenes propias no vale la pena pero controlas el CDN, simplemente abre CORS. S3 necesita esto en la configuracion CORS del bucket:
[{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"]
}]Cloudfront necesita una politica de cabeceras de respuesta con Access-Control-Allow-Origin: * vinculada al comportamiento de las imagenes. Añade tambien crossorigin="anonymous" en las etiquetas <img>, o el navegador no incluira las cabeceras CORS en la clave de cache y tendras comportamiento inconsistente entre cargas frescas y aciertos de cache.
Esto tambien es lo que hace que el fragmento de incrustacion anterior funcione desde la consola del navegador, asi que merece la pena hacerlo primero si puedes.
Pon a Matomo en la lista blanca de los hosts con proteccion contra hotlinking
Si el host bloquea por Referer o User-Agent (habitual en marketplaces de imagenes, CDNs anti-scraping y algunos setups autoalojados), añade el fetcher de capturas de Matomo a la lista de permitidos. Revisa los logs de acceso de Matomo desde el lado del servidor para ver la cadena exacta de User-Agent que envia al obtener capturas. Añade esa cadena, mas la IP de tu servidor de Matomo, a la regla que esta rechazando la peticion.
Esta es la solucion correcta cuando no controlas el host pero si controlas la regla. Es la solucion equivocada cuando el host es un tercero que lo hace a proposito, como Unsplash o los CDNs de fotos de stock.
Incrusta las imagenes destacadas como URIs de datos en tiempo de compilacion
El url-loader de Webpack, la query ?inline de Vite, o URIs de datos codificados a mano en tus plantillas. Misma idea que el fragmento de consola pero integrado en la compilacion, asi que cada pagina llega con la imagen hero ya incrustada y el renderizador de Matomo no necesita obtener nada de origen cruzado para pintarla.
Vale la pena hacerlo para el puñado de imagenes que mas importan para las capturas (hero, fotos de producto, todo lo que esta above the fold). No vale la pena hacerlo para cada miniatura de una pagina de categoria.
Vigila la caducidad de las URLs firmadas
Problema distinto, mismo sintoma. Si sirves imagenes desde S3 o Cloudfront con URLs pre-firmadas (TTL de una hora, un dia), la firma puede estar caducada para cuando el renderizador de Matomo se ponga en marcha. La imagen era accesible cuando el visitante la vio. Ya no lo es.
Dos salidas: ampliar el TTL mas alla de la ventana de captura del heatmap (Matomo puede tardar horas o dias), o hacer un proxy a traves de tu propio dominio y dejar que el proxy firme al vuelo. La solucion del proxy tambien vale como solucion de mismo origen que vimos antes.
Por que Matomo no puede cargar tus imagenes
La misma causa raiz que la version de este problema con las fuentes. La captura de pantalla de Matomo es un proceso en dos pasos. Primero, el tracker serializa tu DOM en vivo en HTML y lo envia. Luego, tu servidor de Matomo renderiza ese HTML para producir la captura que ves en la vista del heatmap. Sin sesion de navegador, sin cookies, sin Referer apuntando a tu dominio. Solo un archivo HTML renderizandose en frio desde una IP diferente.
Todo lo que hay en ese HTML apuntando hacia fuera a un host de terceros tiene que pasar el filtro del tercero primero. Con las imagenes, ahi es donde suele pararse todo.
Que es lo que esta fallando exactamente
Algunos patrones con los que nos seguimos encontrando:
- La proteccion contra hotlinking en el CDN, el host de assets, o una configuracion de nginx autoalojada rechaza peticiones con el Referer equivocado o sin Referer. El servidor de Matomo no envia tu dominio como Referer cuando esta obteniendo para una captura, asi que la respuesta es un 403 y la imagen se renderiza como placeholder.
- Las URLs pre-firmadas de S3 o Cloudfront ya han caducado para cuando Matomo llega a la captura. El visitante vio una imagen valida, el renderizador ve un cuerpo XML de
AccessDeniedque ningun formato de imagen sabe que hacer con el. - Los CDNs que sirven contenido diferente (o 403s) segun la cabecera
Origin, a menudo setups anti-scraping o distribuciones con restriccion geografica, rechazan la IP de Matomo directamente. - Hosts de imagenes con rate limiting o bloqueo por reputacion de IP. Unsplash, algunos tiers de Cloudinary, cualquier servicio que controle por volumen de peticiones por IP. El renderizador de Matomo agrupa peticiones, el host las limita, y la captura llega con la mitad de las imagenes faltando.
- Referencias
<picture><source srcset>que resuelven a una URL de origen cruzado diferente a la del fallback<img src>. El<img>puede pasar, el<source>de mayor resolucion no, y cual elige Matomo depende del viewport de su renderizador.
Si tu heatmap muestra placeholders de imagen rota, estas sufriendo al menos uno de estos. A menudo mas de uno en la misma pagina.
Esta es tambien la misma familia de problemas que rompe las fuentes, los contenedores con scroll y los encabezados sticky en la misma captura. Hemos escrito un post mas largo sobre capturas de heatmaps de Matomo rotas que repasa el resto, y un post aparte sobre por que las fuentes no cargan en tu heatmap de Matomo ya que ese aparece casi igual de seguido.
Que hariamos nosotros
Si controlas el CDN o el host, abre CORS y pon crossorigin="anonymous" en las etiquetas <img>. El fragmento de consola empieza a funcionar de inmediato y una solucion permanente esta a un cambio de configuracion del CDN.
Si controlas las plantillas pero no el CDN, haz un proxy de las imagenes a traves de tu propio dominio. Mismo origen lo resuelve de una vez por todas y elimina una categoria de roturas con las que de otra forma seguirias tropezando.
Si ninguna de las dos opciones esta a tu alcance (hosts de terceros, buckets bloqueados, URLs firmadas que no son tuyas), la extension Matomo Heatmap Helper de Chrome es lo que usamos en los sitios de clientes donde no podemos cambiar el stack. Su script de fondo obtiene las imagenes fuera del contexto CORS de la pagina, asi que funciona incluso en hosts que bloquean el fragmento dentro de la pagina por Referer u Origin. Gratuita, de codigo abierto, codigo en GitHub.
Martez es el proyecto mas amplio del que surgio la extension. Conecta Matomo con Meta Ads y Google Ads para que el ROAS, el CLV y la atribucion esten junto a tu analitica web en vez de en una hoja de calculo aparte. Esta en beta privada. Apuntate a la lista de espera si eso te resulta relevante.
La mayoria de las veces, es un cambio de configuracion del CDN. Vale la pena hacerlo una vez.