Por que URLs relativas não carregam no seu heatmap do Matomo (e como corrigir)

As capturas de tela dos heatmaps do Matomo quebram quando sua página usa caminhos relativos. O renderizador resolve esses caminhos em relação ao host do Matomo, não ao seu, então imagens, stylesheets e fontes retornam 404. Veja por que isso acontece e como a gente lida com isso.

Se você abriu um heatmap do Matomo e encontrou ícones de imagem quebrada onde o logo e o hero deveriam estar, imagens de fundo sumindo em cada seção, e nos casos mais graves um layout que parece que a página esqueceu que tem CSS — os cliques em si estão certos. O Matomo registrou tudo nas coordenadas corretas. O que quebrou foi a captura de tela embaixo deles. Qualquer coisa na página com um caminho relativo não sobreviveu à viagem até o renderizador do Matomo, e você fica com um heatmap flutuando sobre o wireframe do que seus visitantes realmente viram.

O sintoma é bem consistente. Qualquer coisa com uma URL completa como https://yoursite.com/images/hero.jpg renderiza normal. Qualquer coisa com um caminho relativo (/images/hero.jpg, ./logo.svg, assets/bg.png) está quebrada. O mesmo vale para stylesheets, fontes, scripts e referências url(...) do CSS dentro de estilos inline. Assim que você percebe esse padrão, a causa fica óbvia.

A correção mais rápida é uma extensão gratuita pra Chrome que a gente mantém chamada Matomo Heatmap Helper. Ela percorre o DOM e converte cada URL relativa pra absoluta logo antes de cada captura, depois restaura as originais. O restante deste post mostra como fazer a mesma coisa sem extensão, mais algumas correções permanentes que valem a pena subir pra produção.

Como corrigir sem a extensão

Tem um snippet de console que resolve o problema bem antes de cada captura, e algumas correções permanentes que você pode subir junto com o site ou no build. Escolha o que se encaixa melhor nas suas restrições.

Liste todas as URLs relativas da página

Antes de mudar qualquer coisa, descubra o que é relativo de verdade. Abra a página no Chrome, aperte F12 pra abrir o DevTools, vá pra aba Console e cole isso. O snippet cobre <img src>, <img srcset>, <source srcset>, <source src>, <video poster> e background-image em 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;
})();

Isso te dá a lista exata de referências que o renderizador do Matomo vai resolver de forma errada. Se a tabela voltar vazia e o heatmap ainda estiver quebrado, o problema está em outro lugar (CORS num CDN, licenciamento de fonte, containers com scroll). Caso contrário, cada linha da tabela é algo que você precisa reescrever ou mover pra uma URL absoluta.

A correção rápida: reescrever URLs relativas pra absolutas antes da captura

Esse é o snippet que a gente cola no console do navegador logo antes de acionar a captura de heatmap do Matomo. Ele espelha o relative-url-fixer da extensão: percorre o DOM, resolve cada URL relativa de recurso em relação à localização atual da página e grava a versão absoluta de volta no atributo. Na hora que o Matomo serializa o DOM, toda referência já está completamente qualificada e o renderizador busca os assets da sua origem em vez da do Matomo. Ele deixa <a href> de lado de propósito, já que reescrever links de navegação quebraria o roteamento de SPA sem afetar a captura de tela.

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.`);
})();

Quando a linha de log aparecer, faça a captura imediatamente. A reescrita só dura até a próxima navegação. Note que esse snippet enxerga apenas atributos style="..." inline, não chamadas url(...) dentro de stylesheets externos. Para esses, veja o reescritor pós-build mais abaixo, ou use a correção no momento do build.

Adicione uma tag <base> no <head>

A correção permanente mais simples possível. Uma linha no topo do <head> e cada URL relativa da página passa a resolver em relação ao seu domínio em vez do host que estiver renderizando o HTML:

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

O porém é que <base> afeta todas as URLs relativas do site, incluindo links de navegação. SPAs que dependem de roteamento relativo (React Router, <Link> do Next.js em algumas configurações, qualquer coisa com pushState no lado cliente usando caminhos relativos) podem se comportar mal depois de adicionar isso. Teste o fluxo de navegação antes de subir pra produção, ou limite a tag pra que ela só apareça durante as capturas de heatmap (um parâmetro de query que o script de rastreamento ativa, por exemplo).

Se você consegue adicionar uma tag <base> sem quebrar a navegação do SPA, essa é a resposta e você pode parar de ler.

Emita URLs absolutas a partir do build

Se <base> não é uma opção, configure seu build pra emitir URLs absolutas pra assets desde o início. A maioria dos geradores tem uma configuração pra isso. Next.js tem assetPrefix no next.config.js. Hugo tem baseURL no config.toml. Astro tem site no astro.config.mjs. Gatsby usa pathPrefix junto com --prefix-paths no momento do build. Defina o valor pro seu domínio de produção, faça o rebuild, e cada tag <img>, <link> e <script> que o gerador emitir vai sair completamente qualificada. O renderizador do Matomo busca a partir da sua origem e o heatmap funciona.

O problema aqui é que URLs absolutas geradas no build normalmente cobrem só os assets que o framework conhece. CSS escrito à mão (ou vindo de um CMS, ou de um tema customizado) costuma escapar, e também as referências url(...) dentro de stylesheets compilados. Se você configurou o build corretamente e o heatmap ainda tem fundos ou fontes de ícone faltando, é esse gap que você está enfrentando.

Reescritor pós-build para url() no CSS

Se o seu gerador deixa passar url(...) dentro de stylesheets compilados, esse é um script Node pequeno que percorre o diretório de saída e reescreve cada caminho root-relativo pra um absoluto:

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);
}

Conecte-o ao seu CI como uma etapa pós-build. Ele é intencionalmente limitado a caminhos root-relativos (caminhos que começam com /), já que reescrever ./foo ou ../bar exige saber onde o stylesheet está na árvore de saída. Se você tem muitos desses, a correção no momento do build acima é a melhor resposta.

Por que o Matomo não consegue resolver URLs relativas

A captura de tela do Matomo é um processo em duas etapas. Primeiro, o tracker serializa seu DOM ativo em HTML e envia. Depois, seu servidor Matomo re-renderiza esse HTML pra produzir a captura que você vê na visualização do heatmap. O HTML ainda contém /images/hero.jpg exatamente como você escreveu. Mas o renderizador não está rodando no seu domínio, então quando o motor do navegador tenta carregar essa imagem, ele resolve o caminho relativo em relação ao host do Matomo. /images/hero.jpg vira https://seu-host-matomo/images/hero.jpg, que não existe. O mesmo vale para stylesheets, fontes e as chamadas url(...) dentro de estilos inline. O renderizador pede ao servidor do Matomo por arquivos que ele nunca viu, recebe 404s de volta, e você fica com uma captura em que tudo com caminho relativo está faltando.

É também por isso que URLs completamente qualificadas funcionam normal. Uma vez que uma URL tem seu próprio scheme e host, o renderizador sabe exatamente onde buscar e a requisição vai pra sua origem como deveria.

O que está falhando de verdade

Alguns padrões que a gente continua encontrando:

  • <img src="/images/hero.jpg"> e similares. O sintoma mais visível: ícones de imagem quebrada onde deveriam estar o hero, o logo ou as fotos de produto.
  • <link rel="stylesheet" href="/css/main.css"> com href relativo. O stylesheet inteiro retorna 404, então a página renderiza com os padrões do navegador. Esse é o caso que as pessoas descrevem como "o heatmap parece completamente sem estilo."
  • style="background-image: url(/images/bg.png)" inline. Os fundos somem, o layout parece OK mas está visivelmente quebrado.
  • Candidatos de srcset com caminhos relativos dentro de <img srcset> ou <source srcset>. Qual candidato o renderizador do Matomo escolhe depende da suposição de viewport dele, então às vezes um caminho resolve e outro não, e a quebra parece intermitente.
  • @font-face src: url("/fonts/inter.woff2") declarado dentro de um bloco <style> inline ou de um stylesheet que sobrevive. A fonte retorna 404 e você cai de volta pras fontes do sistema. (Isso se sobrepõe ao post sobre fontes que não carregam, mas aqui a causa é resolução de caminho, não CORS.)
  • <use href="/icons/sprite.svg#chevron"> no SVG. O sprite nunca carrega, cada ícone some e o layout colapsa em torno dos placeholders <svg> vazios.
  • <link rel="preload" as="font" href="..."> com href relativo. Não quebra a captura por si só, mas é um sinal útil de que outros assets da mesma família provavelmente também estão quebrados.

Se o seu heatmap está mostrando placeholders de imagem quebrada, CSS faltando ou um layout que não bate com o seu site ao vivo, você está enfrentando pelo menos um desses. Com frequência mais de um na mesma página.

Essa também é a mesma família de problema que quebra fontes, imagens, containers com scroll e headers sticky na mesma captura. A gente escreveu um post mais completo sobre capturas de tela quebradas do heatmap do Matomo que aborda o restante, além de posts separados sobre por que as fontes não carregam e por que as imagens não carregam, já que cada um tem suas próprias causas.

O que a gente faria na prática

Se você consegue adicionar uma tag <base> sem quebrar a navegação do SPA, essa é a resposta. Uma linha, cada URL relativa resolve corretamente, pronto.

Se <base> conflita com seu roteamento, configure o build pra emitir URLs absolutas pra assets e adicione o reescritor CSS pós-build pras referências url(...) que o build não alcança. Um pouco mais de trabalho, mais completo, não briga com o framework.

Se nenhuma das opções é viável (templates gerenciados por CMS que você não pode tocar, temas que não são seus, markup injetado por terceiros), a extensão Matomo Heatmap Helper pra Chrome é o que a gente usa em sites de clientes onde não consegue mudar a stack. Ela roda a mesma lógica de reescrita do snippet acima, mais correções equivalentes pra fontes, imagens, containers com scroll e headers sticky, em cada captura. Gratuita, open source, código no GitHub.

O Martez é o projeto maior do qual a extensão nasceu. 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 em uma planilha separada. Está em beta privado. Entre na lista de espera se isso for relevante pra você.

A maioria dos problemas de URL relativa é uma configuração de build. Vale fazer uma vez.

Por que URLs relativas não carregam no seu heatmap do Matomo (e como corrigir) - Martez Blog