Se você abriu um heatmap do Matomo e encontrou sua imagem hero substituída por um retângulo em branco, suas fotos de produto aparecendo como ícones quebrados e os marcadores de clique flutuando sobre espaço em branco, não se preocupe com os cliques — eles estão certos. O Matomo os registrou nas coordenadas certas. O que quebrou foi a captura de tela por baixo deles. As imagens não passaram quando o servidor do Matomo re-renderizou a página, então você fica com um heatmap que parece um wireframe da página que seus visitantes realmente viram.
A correção mais rápida é uma extensão gratuita do Chrome que mantemos chamada Matomo Heatmap Helper. Ela busca cada imagem cross-origin pelo script de background da extensão (o que contorna as regras de CORS da página) e as incorpora como base64 antes de cada captura. O resto deste post explica como fazer a mesma coisa sem extensão, além de algumas correções permanentes que valem a pena implementar.
Como corrigir sem a extensão
Tem um snippet de console que resolve o problema antes de cada captura, e algumas correções permanentes que você pode implementar no site ou no CDN. Escolha a que melhor se encaixa nas suas restrições.
Liste todas as referências de imagem cross-origin
Antes de mudar qualquer coisa, descubra quais imagens não estão carregando. Abra a página no Chrome, aperte F12 pra abrir o DevTools, vá pra aba Console e cole isso. Cobre <img src>, <img srcset>, <source srcset> dentro de <picture>, <video poster>, SVG <use href> e 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];
})();Isso dá a lista exata de URLs que o renderizador do Matomo vai ter que buscar por conta própria. Qualquer coisa servida de um host que você não controla é candidata a quebrar.
A correção rápida: embutir cada imagem como base64 pelo console
Este é o snippet que colamos no console do navegador antes de acionar a captura de heatmap do Matomo. Ele percorre cada referência de imagem na página (cobrindo todos os mesmos seletores do snippet de listagem acima), busca cada URL única uma vez, codifica em base64 e reescreve as referências inline. Quando o Matomo serializa o DOM, as imagens já estão incorporadas.
// 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.');
})();Agora aguarde até ver algo assim: Embedded 8/8 unique URLs across 12 references. Now trigger your Matomo heatmap capture.
O porém: esse snippet roda na página, então herda as regras de CORS dela. Se o seu CDN enviar Access-Control-Allow-Origin: * (ou sua origem), a busca funciona e pronto. Se não enviar, a busca falha pra essas URLs específicas e os avisos dizem exatamente quais são. Pra esses casos, veja as correções permanentes abaixo, ou pule pra seção da extensão.
Hospede no seu domínio ou use proxy
A resposta mais limpa pra quase todo mundo, assim como é com fontes. Mesma origem elimina o problema todo porque o renderizador do Matomo busca a imagem do seu domínio com as mesmas regras de acesso da página original. Um proxy nginx são duas linhas:
# Edit cdn.example.com to your CDN host
location /cdn-proxy/ {
proxy_pass https://cdn.example.com/;
proxy_set_header Host cdn.example.com;
}Depois reescreva <img src="https://cdn.example.com/x.jpg"> para <img src="/cdn-proxy/x.jpg"> nos seus templates. É mais trabalhoso do que ajustar um cabeçalho CORS, mas é à prova de falhas e, de quebra, dá uma camada de controle de cache.
Abra o CORS no bucket ou na distribuição
Se hospedar no seu domínio não compensa mas você controla o CDN, basta abrir o CORS. No S3, adicione isso na configuração de CORS do bucket:
[{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"]
}]No Cloudfront, crie uma política de cabeçalhos de resposta com Access-Control-Allow-Origin: * vinculada ao behavior de imagens. Coloque também crossorigin="anonymous" nas tags <img>, senão o navegador não inclui os cabeçalhos CORS na chave de cache e você vai ter comportamento inconsistente entre carregamentos novos e hits de cache.
Isso também é o que faz o snippet de incorporação funcionar pelo console do navegador, então vale fazer primeiro se for possível.
Coloque o Matomo na lista de permissões em hosts com proteção anti-hotlink
Se o host bloqueia por Referer ou User-Agent (comum em marketplaces de imagens, CDNs anti-scraping e algumas configurações auto-hospedadas), adicione o fetcher de captura do Matomo na lista de permissões. Veja os logs de acesso do Matomo no lado do servidor pra descobrir o User-Agent exato que ele envia ao buscar pra capturas. Adicione essa string, mais o IP do seu servidor Matomo, na regra que está recusando a requisição.
Essa é a correção certa quando você não controla o host mas controla a regra. É a correção errada quando o host é um terceiro fazendo isso de propósito, como o Unsplash ou CDNs de banco de imagens.
Incorpore imagens acima da dobra como data URIs no build
O url-loader do Webpack, a query ?inline do Vite, ou data URIs codificados à mão nos seus templates. Mesma ideia do snippet de console, mas embutida no build, então cada página já sai com a imagem hero incorporada e o renderizador do Matomo não precisa buscar nada cross-origin pra desenhá-la.
Vale fazer pra aquelas poucas imagens que mais importam pras capturas (hero, fotos de produto, tudo acima da dobra). Não vale fazer pra cada thumbnail numa página de categoria.
Fique de olho no vencimento de URLs assinadas
Problema diferente, sintoma igual. Se você serve imagens do S3 ou Cloudfront com URLs pré-assinadas (TTL de uma hora, um dia), a assinatura vai estar vencida quando o renderizador do Matomo for atuar. A imagem era carregável quando o visitante viu. Já não é mais.
Duas saídas: estender o TTL além do período de captura de heatmap (o Matomo pode atrasar horas ou dias), ou usar proxy pelo seu domínio e deixar o proxy assinar na hora. A correção de proxy também resolve o problema de mesma origem acima.
Por que o Matomo não consegue carregar suas imagens
Mesma causa raiz que a versão do problema com fontes. A captura de tela do Matomo é um processo em duas etapas. Primeiro, o tracker serializa o DOM ao vivo em HTML e manda pro servidor Matomo. Depois, o servidor Matomo re-renderiza esse HTML pra produzir a captura que você vê na visualização do heatmap. Sem sessão de navegador, sem cookies, sem Referer apontando pro seu domínio. Só um arquivo HTML sendo renderizado a frio de um IP diferente.
Qualquer coisa nesse HTML que aponta pra um host de terceiros precisa passar pelo terceiro primeiro. Pra imagens, é geralmente aí que trava.
O que está falhando de verdade
Alguns padrões que encontramos com frequência:
- Proteção anti-hotlink no CDN, no host de assets ou numa configuração nginx auto-hospedada rejeita requisições com o Referer errado ou sem Referer nenhum. O servidor do Matomo não envia seu domínio como Referer ao buscar pra capturas, então a resposta é um 403 e a imagem aparece como placeholder.
- URLs pré-assinadas do S3 ou Cloudfront já expiraram quando o Matomo chega na captura. O visitante viu uma imagem válida, o renderizador vê um corpo XML
AccessDeniedque nenhum formato de imagem sabe o que fazer. - CDNs que servem conteúdo diferente (ou 403s) com base no cabeçalho
Origin, geralmente setups anti-scraping ou distribuições com restrição geográfica, rejeitam o IP do Matomo diretamente. - Hosts de imagem com rate limit ou bloqueio por reputação de IP. Unsplash, alguns planos do Cloudinary, qualquer coisa que controla por volume de requisições por IP. O renderizador do Matomo faz requisições em lote, o host corta, a captura sai com metade das imagens faltando.
- Referências
<picture><source srcset>que resolvem pra uma URL cross-origin diferente do fallback<img src>. O<img>pode passar, o<source>de maior resolução não, e qual o Matomo escolhe depende do viewport do renderizador.
Se o seu heatmap está mostrando placeholders de imagem quebrada, você está batendo em pelo menos um desses. Frequentemente mais de um na mesma página.
Esse também é o mesmo tipo de problema que quebra fontes, containers com scroll e headers sticky na mesma captura. Escrevemos um post mais longo sobre capturas de tela quebradas do Matomo Heatmap que cobre o resto, e um post separado sobre por que as fontes não carregam no seu heatmap do Matomo já que esse aparece quase tão frequente.
O que faríamos na prática
Se você controla o CDN ou o host, abra o CORS e coloque crossorigin="anonymous" nas tags <img>. O snippet de console começa a funcionar imediatamente e uma correção permanente é uma config de CDN de distância.
Se você controla os templates mas não o CDN, use proxy das imagens pelo seu domínio. Mesma origem resolve de uma vez por todas e elimina uma classe de quebra que você continuaria tropeçando.
Se nenhum dos dois é alcançável (hosts de terceiros, buckets travados, URLs assinadas que você não controla), a extensão Matomo Heatmap Helper do Chrome é o que usamos em sites de clientes onde não conseguimos mudar o stack. O script de background dela busca as imagens fora do contexto de CORS da página, então funciona mesmo em hosts que bloqueiam o snippet inline por Referer ou Origin. Gratuita, open source, código no GitHub.
O Martez é o projeto maior do qual a extensão saiu. Ele conecta o Matomo com Meta Ads e Google Ads pra que ROAS, CLV e atribuição fiquem ao lado da sua análise web em vez de numa planilha separada. Está em beta privado. Entre na lista de espera se for relevante pra você.
Na maioria das vezes, é uma config de CDN de distância. Vale fazer uma vez.