Se você abriu um heatmap do Matomo e encontrou seu hero em Times New Roman, seus botões em Arial, e os marcadores de clique flutuando alguns pixels fora dos elementos a que pertencem, os cliques em si estão certos. O Matomo os registrou nas coordenadas corretas. O que está quebrado é a captura de tela por baixo. Suas fontes da marca não carregaram quando o servidor do Matomo re-renderizou a página, então ele caiu para as fontes do sistema que tinha disponíveis — e uma fonte diferente significa métricas diferentes, o que significa que o layout muda.
A correção mais rápida é uma extensão gratuita pro Chrome que a gente mantém chamada Matomo Heatmap Helper. Ela embute cada fonte da página como base64 logo antes de cada captura do heatmap, sem precisar alterar nenhum stylesheet. O resto deste post explica como fazer a mesma coisa sem extensão e, mais abaixo, por que o Matomo não consegue carregar suas fontes.
Como corrigir sem a extensão
Tem um snippet rápido de console que resolve o problema logo antes de cada captura, e algumas correções permanentes que você pode incluir no seu site. Escolha o que se encaixar melhor no seu stack. Tem também um padrão JavaScript no final que quebra a captura independente de qual correção você use — vale conhecer de qualquer jeito.
Diagnosticar o que está falhando
Antes de mudar qualquer coisa, descubra quais fontes não estão carregando. Abra a página no Chrome, pressione F12 pra abrir o DevTools, vá pro Console e cole:
// Lists every font on the page and whether it loaded
document.fonts.ready.then(() => {
[...document.fonts].forEach(f =>
console.log(`${f.family} ${f.weight} ${f.style} → ${f.status}`));
});Qualquer coisa com status diferente de loaded é candidata. Depois vá em DevTools → Network → filtre por "Font" e fique de olho em erros 403 ou CORS nas suas URLs de @font-face. Isso indica se o problema é no CDN, no Adobe Fonts ou na serialização.
A correção rápida: embuti cada fonte como base64 pelo console
Este é o snippet que a gente cola no console do navegador logo antes de disparar a captura do heatmap do Matomo. Ele percorre cada regra @font-face da página (inline, mesma origem e stylesheets cross-origin), busca os binários das fontes, codifica em base64 e injeta uma nova tag <style> com data URIs autossuficientes. Quando o Matomo serializa o DOM, as fontes já estão embutidas.
// Paste into the browser console. Embeds every @font-face on the page as base64.
(async () => {
const fontFaces = [];
const parseSrc = (srcValue, baseUrl) => {
const out = [];
const re = /url\(\s*['"]?([^'")\s]+)['"]?\s*\)(?:\s*format\(\s*['"]?([^'")\s]+)['"]?\s*\))?/gi;
let m;
while ((m = re.exec(srcValue)) !== null) {
const raw = m[1], format = m[2];
if (raw.startsWith('data:')) { out.push({ url: raw, format }); continue; }
try { out.push({ url: new URL(raw, baseUrl).href, format }); } catch {}
}
return out;
};
const collect = (rule, baseUrl) => {
if (rule instanceof CSSFontFaceRule) {
const s = rule.style;
const family = s.getPropertyValue('font-family').replace(/['"]/g, '').trim();
const src = s.getPropertyValue('src');
if (!family || !src) return;
fontFaces.push({
family,
weight: s.getPropertyValue('font-weight') || '',
style: s.getPropertyValue('font-style') || '',
display: s.getPropertyValue('font-display') || '',
unicodeRange: s.getPropertyValue('unicode-range') || '',
src: parseSrc(src, baseUrl),
});
} else if (rule.cssRules) {
for (const r of Array.from(rule.cssRules)) collect(r, baseUrl);
}
};
// 1. Walk accessible stylesheets via the CSSOM
const crossOriginUrls = [];
for (const sheet of Array.from(document.styleSheets)) {
const baseUrl = sheet.href || location.href;
try {
for (const rule of Array.from(sheet.cssRules)) collect(rule, baseUrl);
} catch {
if (sheet.href) crossOriginUrls.push(sheet.href);
}
}
// 2. Re-fetch cross-origin stylesheets as text and regex-parse @font-face blocks
const parseCssText = (txt, baseUrl) => {
const re = /@font-face\s*\{/gi;
let m;
while ((m = re.exec(txt)) !== null) {
const start = m.index + m[0].length;
let depth = 1, i = start;
while (depth > 0 && i < txt.length) {
if (txt[i] === '{') depth++; else if (txt[i] === '}') depth--;
i++;
}
if (depth !== 0) continue;
const body = txt.substring(start, i - 1);
const get = n => {
const r = new RegExp(n + '\\s*:\\s*([^;]+)', 'i').exec(body);
return r ? r[1].trim() : '';
};
const family = get('font-family').replace(/['"]/g, '').trim();
const src = get('src');
if (!family || !src) continue;
fontFaces.push({
family,
weight: get('font-weight'),
style: get('font-style'),
display: get('font-display'),
unicodeRange: get('unicode-range'),
src: parseSrc(src, baseUrl),
});
}
// Recurse into @import
const importRe = /@import\s+(?:url\(\s*['"]?([^'")\s]+)['"]?\s*\)|['"]([^'"]+)['"])/gi;
const imports = [];
while ((m = importRe.exec(txt)) !== null) {
const u = m[1] || m[2];
try { imports.push(new URL(u, baseUrl).href); } catch {}
}
return imports;
};
const visited = new Set();
const fetchCss = async (url) => {
if (visited.has(url)) return;
visited.add(url);
try {
const txt = await fetch(url).then(r => r.text());
const imports = parseCssText(txt, url);
for (const u of imports) await fetchCss(u);
} catch (e) { console.warn('CSS fetch failed:', url, e.message); }
};
for (const url of crossOriginUrls) await fetchCss(url);
if (fontFaces.length === 0) { console.warn('No @font-face rules found.'); return; }
console.log(`Found ${fontFaces.length} @font-face rules.`);
// 3. Fetch each unique font binary and base64-encode it
const uniqueUrls = [...new Set(
fontFaces.flatMap(f => f.src.filter(s => !s.url.startsWith('data:')).map(s => s.url))
)];
console.log(`Fetching ${uniqueUrls.length} font files...`);
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('Font fetch failed:', url, e.message); }
}));
// 4. Build new @font-face CSS using data URIs (with original URLs as fallback)
const css = fontFaces.map(f => {
const srcParts = f.src.map(s => {
const u = dataUris.get(s.url) || s.url;
return s.format ? `url("${u}") format("${s.format}")` : `url("${u}")`;
});
if (srcParts.length === 0) return '';
const props = [`font-family: "${f.family}"`, `src: ${srcParts.join(', ')}`];
if (f.weight) props.push(`font-weight: ${f.weight}`);
if (f.style) props.push(`font-style: ${f.style}`);
if (f.display) props.push(`font-display: ${f.display}`);
if (f.unicodeRange) props.push(`unicode-range: ${f.unicodeRange}`);
return `@font-face {\n ${props.join(';\n ')};\n}`;
}).filter(Boolean).join('\n\n');
// 5. Inject into <head> so Matomo's DOM serializer sees self-contained fonts
const style = document.createElement('style');
style.setAttribute('data-heatmap-font-fix', 'true');
style.textContent = css;
document.head.appendChild(style);
console.log(`Embedded ${dataUris.size}/${uniqueUrls.length} fonts across ${fontFaces.length} @font-face rules.`);
console.log('Now trigger your Matomo heatmap capture.');
})();Pra confirmar que a injeção realmente aconteceu antes de tirar a captura:
// Confirms the injected styles are in <head>
const tag = document.querySelector('style[data-heatmap-font-fix]');
console.log(tag
? `OK, injected ${(tag.textContent.match(/@font-face/g) || []).length} @font-face rules, `
+ `${(tag.textContent.match(/data:font/g) || []).length} use base64 data URIs.`
: 'No injected style tag found, re-run the embed snippet.');Esse é o caminho rápido. Funciona pra capturas pontuais e nos ciclos de revisão em que você precisa de uma captura limpa agora e não quer esperar uma mudança de código. Pra qualquer coisa que você vai re-capturar com frequência, use uma das correções permanentes abaixo.
Hospedar as fontes você mesmo (a correção definitiva)
A resposta mais limpa pra quase todo mundo. Baixe as variantes woff2 e woff que você realmente usa pelo google-webfonts-helper, coloque em /public/fonts e use um CSS assim:
/* Edit the family name and file paths to match your downloaded files */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/inter-400.woff2") format("woff2"),
url("/fonts/inter-400.woff") format("woff");
}Agora as fontes vêm do seu próprio domínio. Mesma origem da página, sem CORS pra negociar, o servidor do Matomo as busca sem problema e suas capturas renderizam na tipografia certa. É mais rápido também pra seus usuários reais, porque você removeu um DNS lookup e uma conexão de terceiro de cada carregamento de página. Na maioria dos casos são algumas horas de trabalho e um diff pequeno de CSS.
Corrigir CORS no seu CDN
Se preferir manter as fontes num CDN, configure o CDN pra retornar Access-Control-Allow-Origin: * nas respostas de fonte e adicione crossorigin no link de preload:
<!-- Edit the URL to your CDN's font path -->
<link rel="preload" as="font" type="font/woff2"
href="https://your-cdn.example.com/inter.woff2"
crossorigin="anonymous">Funciona pra Cloudflare, Fastly, AWS — qualquer coisa que você controle. Não funciona pra Adobe Fonts. A lista de domínios permitidos do Typekit é validada no lado do servidor e você não consegue alterá-la. Se você usa Typekit, hospedar você mesmo é o único caminho. O licenciamento da Adobe pra auto-hospedagem é uma conversa à parte.
Embutir no processo de build
Se você não controla o stylesheet mas controla o build, execute a mesma lógica que o snippet de console executa, só que no momento do build. Suba um navegador headless (Playwright ou Puppeteer), execute o código de embed na sua página compilada, capture o bloco <style> resultante e escreva no seu template HTML. A página já sai com as fontes embutidas. Sem fetch em runtime, sem colar nada no console antes de cada captura.
Esta é a mais pesada das três correções permanentes. Também cobre o caso em que o site de marketing está num CMS que não deixa você tocar no stylesheet mas permite scripts de build.
O problema da serialização
Mais uma coisa que pega muita gente. Fontes carregadas via JavaScript em runtime não sobrevivem à serialização do DOM pelo Matomo:
// BROKEN, won't survive Matomo's serialization
const f = new FontFace("Inter", "url(/fonts/inter.woff2)");
await f.load();
document.fonts.add(f);A fonte fica registrada no conjunto document.fonts do navegador, mas não está no HTML que o serializador lê. A captura vai cair pro padrão do sistema mesmo que a fonte esteja tecnicamente "carregada" no seu navegador. O mesmo vale pra fontes injetadas por frameworks JavaScript no momento da hidratação. Use uma tag <style> com regras @font-face, ou um <link rel="stylesheet"> normal. Qualquer coisa que o serializador consiga ver no HTML estático é válido. document.fonts.add() é invisível pra ele.
Por que o Matomo não consegue carregar suas fontes
A captura de tela do Matomo é um processo em duas etapas. Primeiro, o tracker serializa o seu DOM ao vivo em HTML e o envia pro servidor. Depois, seu servidor Matomo re-renderiza esse HTML pra produzir a captura que aparece na visualização do heatmap. Sem sessão de navegador, sem cookies, sem domínio. Só um arquivo HTML sendo renderizado a frio de um IP diferente.
Qualquer coisa nesse HTML que aponte pra um host de terceiro precisa passar pelo terceiro primeiro. Pras declarações @font-face, é aí que para.
O que está falhando de fato
Alguns padrões que a gente continua encontrando:
- O CDN que serve suas fontes não retorna cabeçalhos
Access-Control-Allow-Origin. O navegador tolera o caso de mesma origem sem eles. O servidor do Matomo, fazendo fetch de uma origem diferente, não tolera. - O Adobe Fonts (Typekit) valida os cabeçalhos
OrigineReferercontra os domínios que você adicionou à lista permitida na sua conta. O servidor do Matomo nunca está nessa lista, e o Typekit responde com 403. Não tem como adicionar o IP do Matomo também. fonts.gstatic.come alguns outros CDNs limitam ou bloqueiam diretamente renderizadores no lado do servidor, às vezes por user-agent, às vezes por reputação de IP. As fontes funcionam no seu navegador e não funcionam no renderer do Matomo.- Fontes carregadas com
new FontFace(...).load()e depoisdocument.fonts.add(...)simplesmente não estão no HTML serializado. O serializador lê o DOM, não o conjunto de FontFace em runtime.
Se a captura do seu heatmap está mostrando a fonte errada, você está enfrentando pelo menos um desses. Muitas vezes mais de um.
Esta também é a mesma família de problema que quebra imagens, containers com scroll e headers sticky na mesma captura. A gente escreveu um post mais longo sobre capturas de heatmap do Matomo quebradas que passa por todo o resto.
O que a gente faria na prática
Se você controla o stylesheet, hospede você mesmo. Resolve o heatmap, remove uma dependência de terceiro e deixa a página mais rápida pra seus usuários reais também.
Se não dá, embutir as fontes como data URIs base64 no seu stylesheet existente tem o mesmo efeito no renderer do Matomo, sem mudanças de infraestrutura.
Se nenhuma das duas for viável, a extensão Matomo Heatmap Helper pro Chrome é o que a gente usa em sites de clientes onde não consegue mexer no stack. Ela executa a mesma lógica de embed do snippet acima, mais correções equivalentes pra imagens, containers com scroll e headers sticky, em cada captura. Gratuita, open source, código no GitHub.
O Martez é o projeto maior de onde a extensão surgiu. 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 isso for relevante pra você.
As fontes geralmente têm solução no stylesheet. Vale fazer uma vez.