Si has abierto un heatmap de Matomo y has encontrado tu hero en Times New Roman, tus botones en Arial y los marcadores de clics flotando un par de píxeles fuera de los elementos a los que pertenecen, los propios clics están bien. Matomo los grabó en las coordenadas correctas. Lo que está roto es la captura de pantalla que hay debajo. Tus fuentes de marca no cargaron cuando el servidor de Matomo volvió a renderizar la página, así que recurrió a las fuentes del sistema que tenía disponibles, y una fuente distinta significa métricas distintas, lo que significa que el layout se desplaza.
La solución más rápida es una extensión gratuita para Chrome que mantenemos nosotros, Matomo Heatmap Helper. Incrusta cada fuente de la página en base64 justo antes de cada captura del heatmap, sin necesidad de cambios en las hojas de estilo. El resto de este artículo explica cómo hacer lo mismo sin extensión y, más abajo, por qué Matomo no puede cargar tus fuentes en primer lugar.
Cómo arreglarlo sin la extensión
Hay un snippet de consola que tapa el problema justo antes de cada captura, y unos cuantos arreglos permanentes que puedes incluir en el sitio. Elige el que mejor encaje con tu stack. Al final también hay un patrón JS que rompe la captura independientemente del arreglo que uses, así que merece la pena conocerlo de todos modos.
Diagnostica qué está fallando exactamente
Antes de cambiar nada, averigua qué fuentes no están cargando. Abre la página en Chrome, pulsa F12 para abrir DevTools, cambia a la pestaña Console y pega esto:
// 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}`));
});Todo lo que tenga un estado que no sea loaded es candidato. Luego ve a DevTools → Network → filtra por "Font" y fíjate si hay errores 403 o CORS en tus URLs de @font-face. Eso te dirá si estás ante un problema de CDN, de Adobe Fonts o de serialización.
El arreglo rápido: incrustar cada fuente como base64 desde la consola
Este es el snippet que pegamos en la consola del navegador justo antes de lanzar la captura del heatmap de Matomo. Recorre cada regla @font-face de la página (hojas de estilo inline, del mismo origen y de origen cruzado), descarga los binarios de las fuentes, los codifica en base64 e inyecta una etiqueta <style> nueva con URIs de datos autocontenidas. Cuando Matomo serializa el DOM, las fuentes ya están integradas.
// 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.');
})();Para confirmar que la inyección aterrizó correctamente antes de hacer la 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.');Eso es el camino rápido. Funciona para capturas puntuales y para los ciclos de revisión donde necesitas una captura limpia ahora mismo y no quieres esperar a un cambio en el código. Para todo lo que vayas a volver a capturar con regularidad, incluye uno de los arreglos permanentes que vienen a continuación.
Alojar las fuentes tú mismo (el arreglo canónico)
La respuesta más limpia para casi todo el mundo. Descarga las variantes woff2 y woff que realmente usas de google-webfonts-helper, déjalas en /public/fonts y sirve un CSS como este:
/* 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");
}Ahora las fuentes vienen de tu propio dominio. Mismo origen que la página, sin CORS que negociar, el servidor de Matomo las descarga sin problema y tus capturas se renderizan con la tipografía correcta. Además es más rápido para tus usuarios reales, porque eliminas una resolución DNS y una conexión a terceros de cada carga de página. La mayoría de las veces son un par de horas de trabajo y un diff de CSS pequeño.
Arreglar CORS en tu CDN
Si prefieres mantener las fuentes en un CDN, configúralo para que devuelva Access-Control-Allow-Origin: * en las respuestas de fuentes y añade crossorigin en el enlace 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">Esto funciona con Cloudflare, Fastly, AWS y cualquier cosa que controles. No funciona con Adobe Fonts. La lista de dominios permitidos allí se aplica en el servidor y no puedes modificarla. Si usas Typekit, el autoalojamiento es el único camino. El tema de las licencias de Adobe para el autoalojamiento es otra conversación.
Incrustar el embed en tu build
Si no controlas la hoja de estilos pero sí controlas el build, ejecuta la misma lógica que ejecuta el snippet de consola, pero en tiempo de build. Levanta un navegador sin interfaz (Playwright o Puppeteer), ejecuta el código de incrustación contra tu página ya construida, captura el bloque <style> resultante y escríbelo en tu plantilla HTML. La página se sirve con las fuentes ya incrustadas. Sin fetch en tiempo de ejecución, sin pegado en consola antes de cada captura.
Este es el más pesado de los tres arreglos permanentes. También cubre el caso en que el sitio de marketing está en un CMS que no te deja tocar la hoja de estilos pero sí te permite ejecutar un script de build.
El problema de la serialización
Una cosa más que le pilla a la gente. Las fuentes cargadas mediante JS en tiempo de ejecución no sobreviven a la serialización del DOM de Matomo:
// BROKEN, won't survive Matomo's serialization
const f = new FontFace("Inter", "url(/fonts/inter.woff2)");
await f.load();
document.fonts.add(f);La fuente se registra en el conjunto document.fonts del navegador, pero no está en el HTML que lee el serializador. La captura recurrirá a las fuentes del sistema aunque la fuente esté técnicamente "cargada" en tu navegador. Lo mismo pasa con las fuentes inyectadas por frameworks JS en tiempo de hidratación. Usa una etiqueta <style> con reglas @font-face, o un <link rel="stylesheet"> normal. Cualquier cosa que el serializador pueda ver en el HTML estático está bien. document.fonts.add() es invisible para él.
Por qué Matomo no puede cargar tus fuentes
La captura de pantalla de Matomo es un proceso en dos pasos. Primero, el tracker serializa tu DOM en vivo como HTML y lo envía. Luego, tu servidor de Matomo vuelve a renderizar ese HTML para producir la captura que ves en la vista del heatmap. Sin sesión de navegador, sin cookies, sin dominio. Solo un archivo HTML renderizándose en frío desde una IP diferente.
Todo lo que hay en ese HTML que apunte hacia fuera a un host de terceros tiene que superar primero al tercero en cuestión. Para las declaraciones @font-face, es ahí donde se acaba el camino.
Qué está fallando exactamente
Unos cuantos patrones que nos seguimos encontrando:
- El CDN que sirve tus fuentes no devuelve cabeceras
Access-Control-Allow-Origin. El navegador tolera el caso del mismo origen sin ellas. El servidor de Matomo, que hace la petición desde un origen distinto, no. - Adobe Fonts (Typekit) valida las cabeceras
OriginyReferercontra los dominios que has añadido a la lista de permitidos en tu cuenta. El servidor de Matomo nunca está en esa lista, y Typekit responde con 403. Tampoco hay forma de añadir la IP de Matomo. fonts.gstatic.comy otros CDNs limitan la tasa o directamente bloquean los renderizadores del lado del servidor, a veces por user-agent, a veces por reputación de IP. Las fuentes funcionan en tu navegador y no en el renderizador de Matomo.- Las fuentes cargadas con
new FontFace(...).load()y luegodocument.fonts.add(...)no están en el HTML serializado para nada. El serializador lee el DOM, no el conjunto FontFace en tiempo de ejecución.
Si la captura de tu heatmap muestra la fuente incorrecta, estás tocando al menos uno de estos casos. A menudo más de uno.
Esta es también la misma familia de problema que rompe imágenes, contenedores con scroll y encabezados sticky en la misma captura. Hemos escrito un artículo más largo sobre capturas rotas en heatmaps de Matomo que recorre el resto.
Lo que haríamos nosotros
Si controlas la hoja de estilos, aloja las fuentes tú mismo. Arregla el heatmap, elimina una dependencia de terceros y además hace más rápida la carga de página para tus usuarios reales.
Si no puedes, incrusta las fuentes como URIs de datos en base64 en tu hoja de estilos existente. Mismo efecto en el renderizador de Matomo, sin cambios de infraestructura.
Si ninguna de las dos opciones es alcanzable, la extensión Matomo Heatmap Helper para Chrome es lo que usamos en los sitios de clientes donde no podemos cambiar el stack. Ejecuta la misma lógica de incrustación que el snippet anterior, más arreglos equivalentes para 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 tu analítica web en vez de en una hoja de cálculo separada. Está en beta privada. Únete a la lista de espera si te resulta relevante.
Las fuentes normalmente se pueden arreglar en una hoja de estilos. Vale la pena hacerlo una vez.