Perché i font non si caricano nella tua heatmap di Matomo (e come risolvere)

Gli screenshot delle heatmap di Matomo ripiegano su Times New Roman o Arial quando i file @font-face vengono bloccati dal CORS, limitati ai domini consentiti dal tuo account, o caricati dopo la serializzazione del DOM. Ecco perché succede e come lo gestiamo noi.

Se hai aperto una heatmap di Matomo e hai trovato il tuo hero impostato in Times New Roman, i pulsanti in Arial e i marker dei clic spostati di qualche pixel rispetto agli elementi a cui appartengono, i clic in sé sono a posto. Matomo li ha registrati nelle coordinate corrette. Quello che è rotto è lo screenshot sottostante. I font del brand non si sono caricati quando il server Matomo ha ri-renderizzato la pagina, quindi è ripiegato sui font di sistema disponibili, e un font diverso significa metriche diverse, che significa spostamenti di layout.

La soluzione più rapida è un'estensione gratuita per Chrome che manteniamo noi, Matomo Heatmap Helper. Incorpora ogni font della pagina come base64 subito prima di ogni cattura della heatmap, senza modifiche ai fogli di stile. Il resto di questo post spiega come fare la stessa cosa senza estensione, e (più in basso) perché Matomo non riesce a caricare i tuoi font.

Come risolvere senza l'estensione

C'è uno snippet veloce da console che tappa il problema subito prima di ogni cattura, e alcune correzioni permanenti che puoi includere nel sito. Scegli quella che si adatta al tuo stack. C'è anche un pattern JS alla fine che rompe lo screenshot indipendentemente dalla correzione che scegli, vale la pena conoscerlo in ogni caso.

Diagnostica cosa sta fallendo

Prima di cambiare qualsiasi cosa, scopri quali font non si stanno caricando. Apri la pagina in Chrome, premi F12 per aprire DevTools, vai alla Console e incolla:

js
// 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}`));
});

Tutto quello con uno status diverso da loaded è un candidato. Poi DevTools → Network → filtra per "Font" e osserva se ci sono errori 403 o CORS sugli URL @font-face. Questo ti dice se stai guardando un problema di CDN, un problema di Adobe Fonts, o un problema di serializzazione.

La correzione rapida: incorpora ogni font come base64 dalla console

Questo è lo snippet che incolliamo nella console del browser subito prima di attivare la cattura della heatmap di Matomo. Scorre tutte le regole @font-face della pagina (inline, same-origin e cross-origin), recupera i binari dei font, li codifica in base64 e inietta un nuovo tag <style> con data URI self-contained. Quando Matomo serializza il DOM, i font sono già incorporati.

js
// 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.');
})();

Per confermare che l'iniezione sia andata a buon fine prima di scattare lo screenshot:

js
// 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.');

Questa è la via rapida. Funziona per catture una tantum e per i cicli di revisione in cui hai bisogno di uno screenshot pulito subito e non vuoi aspettare una modifica al codice. Per tutto quello che ricatturi regolarmente, usa una delle correzioni permanenti qui sotto.

Self-hosting dei font (la correzione canonica)

La risposta più pulita per quasi tutti. Scarica le varianti woff2 e woff che usi davvero da google-webfonts-helper, mettile in /public/fonts e usa un CSS come questo:

css
/* 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");
}

I font ora vengono dal tuo dominio. Stessa origine della pagina, nessun CORS da negoziare, il server Matomo li scarica senza problemi e i tuoi screenshot si renderizzano con il carattere giusto. È anche più veloce per i tuoi utenti reali, perché hai eliminato una risoluzione DNS e una connessione a terze parti da ogni caricamento di pagina. Nella maggior parte dei casi sono un paio d'ore di lavoro e una piccola modifica al CSS.

Correggere il CORS sul CDN

Se preferisci tenere i font su un CDN, configura il CDN per restituire Access-Control-Allow-Origin: * nelle risposte dei font, e aggiungi crossorigin sul link di preload:

html
<!-- 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">

Funziona con Cloudflare, Fastly, AWS, qualsiasi cosa tu controlli. Non funziona con Adobe Fonts. La lista di domini consentiti lì è applicata lato server e non puoi modificarla. Se usi Typekit, il self-hosting è l'unica via. Le licenze Adobe per il self-hosting sono un discorso a parte.

Incorporare l'embed nel processo di build

Se non controlli il foglio di stile ma controlli la build, esegui la stessa logica dello snippet da console, ma al momento della build. Avvia un browser headless (Playwright o Puppeteer), esegui il codice di embed sulla tua pagina compilata, cattura il blocco <style> risultante e scrivilo nel tuo template HTML. La pagina viene servita con i font già incorporati. Nessun fetch a runtime, nessun incolla-nella-console prima di ogni cattura.

Questa è la più pesante delle tre correzioni permanenti. Copre anche il caso in cui il sito marketing è su un CMS che non ti lascia toccare il foglio di stile ma ti permette di eseguire uno script di build.

Il problema della serializzazione

Un'ultima cosa che frega le persone. I font caricati tramite JS a runtime non sopravvivono alla serializzazione del DOM di Matomo:

js
// BROKEN, won't survive Matomo's serialization
const f = new FontFace("Inter", "url(/fonts/inter.woff2)");
await f.load();
document.fonts.add(f);

Il font è registrato nel set document.fonts del browser, ma non è nell'HTML che legge il serializzatore. Lo screenshot ricadrà sui font di sistema anche se il font è tecnicamente "loaded" nel tuo browser. Lo stesso vale per i font iniettati dai framework JS al momento dell'idratazione. Usa un tag <style> con regole @font-face, o un normale <link rel="stylesheet">. Qualsiasi cosa il serializzatore possa vedere nell'HTML statico va bene. document.fonts.add() è invisibile per lui.

Perché Matomo non riesce a caricare i tuoi font

La cattura degli screenshot di Matomo è un processo in due fasi. Prima, il tracker serializza il DOM live in HTML e lo invia. Poi, il server Matomo ri-renderizza quell'HTML per produrre lo screenshot che vedi nella vista heatmap. Nessuna sessione browser, nessun cookie, nessun dominio. Solo un file HTML renderizzato a freddo da un IP diverso.

Tutto quello che in quell'HTML punta verso un host di terze parti deve superare il vaglio del terzo prima di arrivare. Per le dichiarazioni @font-face, è lì che si ferma.

Cosa sta fallendo davvero

Alcuni pattern che continuiamo a incontrare:

  • Il CDN che serve i tuoi font non restituisce gli header Access-Control-Allow-Origin. Il browser tollera il caso same-origin senza di essi. Il server Matomo, che recupera da un'origine diversa, no.
  • Adobe Fonts (Typekit) valida gli header Origin e Referer rispetto ai domini che hai inserito nella lista consentita del tuo account. Il server Matomo non è mai in quella lista, e Typekit risponde con 403. Non c'è modo di aggiungere l'IP di Matomo.
  • fonts.gstatic.com e alcuni altri CDN limitano o bloccano direttamente i renderer lato server, a volte per user agent, a volte per reputazione dell'IP. I font funzionano nel tuo browser e non nel renderer di Matomo.
  • I font caricati con new FontFace(...).load() e poi document.fonts.add(...) non sono nell'HTML serializzato. Il serializzatore legge il DOM, non il set FontFace a runtime.

Se il tuo screenshot della heatmap mostra il font sbagliato, stai incappando in almeno uno di questi. Spesso in più di uno.

Questa è anche la stessa famiglia di problemi che rompe immagini, container con scroll e header sticky nello stesso screenshot. Abbiamo scritto un post più lungo sugli screenshot rotti delle heatmap di Matomo che affronta tutto il resto.

Cosa faremmo noi

Se controlli il foglio di stile, fai self-hosting. Risolve la heatmap, rimuove una dipendenza da terze parti e rende le pagine più veloci per i tuoi utenti reali.

Se non puoi, incorpora i font come data URI base64 nel tuo foglio di stile esistente. Stesso effetto sul renderer di Matomo, nessuna modifica all'infrastruttura.

Se nessuna delle due è praticabile, l'estensione Chrome Matomo Heatmap Helper è quella che usiamo sui siti dei clienti dove non possiamo cambiare lo stack. Esegue la stessa logica di embed dello snippet qui sopra, più correzioni equivalenti per immagini, container con scroll e header sticky, ad ogni cattura. Gratuita, open source, codice su GitHub.

Martez è il progetto più grande da cui è nata l'estensione. Collega Matomo con Meta Ads e Google Ads così ROAS, CLV e attribuzione stanno accanto alle tue analytics web invece che in un foglio di calcolo separato. È in beta privata. Iscriviti alla lista d'attesa se è rilevante per te.

I font di solito si sistemano nel foglio di stile. Vale la pena farlo una volta sola.

Perché i font non si caricano nella tua heatmap di Matomo (e come risolvere) - Martez Blog