Perché gli URL relativi non si caricano nella heatmap di Matomo (e come risolvere)

Gli screenshot delle heatmap di Matomo si rompono quando la pagina usa percorsi relativi. Il renderer li risolve rispetto all'host di Matomo invece che al tuo, quindi immagini, fogli di stile e font vanno in 404. Ecco perché succede e come lo gestiamo.

Se hai aperto una heatmap di Matomo e hai trovato icone di immagini rotte al posto del logo e dell'hero, immagini di sfondo mancanti su ogni sezione e (nei casi peggiori) un layout che sembra aver dimenticato l'esistenza del suo CSS, i click in sé stanno bene. Matomo li ha registrati alle coordinate giuste. Quello che è rotto è lo screenshot sotto di loro. Qualsiasi cosa nella pagina con un percorso relativo non ha sopravvissuto al viaggio verso il renderer di Matomo, e ti ritrovi con una heatmap che fluttua sopra il wireframe di quello che i tuoi visitatori hanno effettivamente visto.

Il segnale è inequivocabile. Tutto ciò che ha un URL completo tipo https://yoursite.com/images/hero.jpg si renderizza bene. Tutto ciò che ha un percorso relativo (/images/hero.jpg, ./logo.svg, assets/bg.png) è rotto. Lo stesso vale per fogli di stile, font, script e riferimenti url(...) CSS dentro gli stili inline. Una volta che riconosci questo schema, la causa è ovvia.

La soluzione più rapida è un'estensione gratuita per Chrome che manteniamo, chiamata Matomo Heatmap Helper. Scansiona il DOM e converte ogni URL relativo in assoluto appena prima di ogni cattura, poi ripristina gli originali. Il resto di questo post spiega come fare la stessa cosa senza un'estensione, più qualche fix permanente che vale la pena distribuire.

Come risolvere senza l'estensione

C'è uno snippet da console che tappa il problema appena prima di ogni cattura, e una manciata di fix permanenti da distribuire con il sito o la build. Scegli quello che si adatta meglio ai vincoli con cui stai lavorando.

Elenca tutti gli URL relativi nella pagina

Prima di cambiare qualcosa, scopri cosa è effettivamente relativo. Apri la pagina in Chrome, premi F12 per aprire i DevTools, vai alla Console e incolla questo. Copre <img src>, <img srcset>, <source srcset>, <source src>, <video poster> e il background-image negli stili inline:

js
// Incolla nella console del browser. Elenca ogni URL di risorsa relativo.
(() => {
  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;
})();

Ti dà la lista esatta dei riferimenti che il renderer di Matomo risolverà male. Se la tabella è vuota ma la heatmap è ancora rotta, il problema è da qualche altra parte (CORS su un CDN, licenze font, container con scroll). Altrimenti, ogni riga di quella tabella è qualcosa che devi riscrivere o spostare su un URL assoluto.

Il fix rapido: riscrivi gli URL relativi in assoluti prima della cattura

Questo è lo snippet che incolliamo nella console del browser appena prima di triggerare la cattura della heatmap di Matomo. Rispecchia il relative-url-fixer dell'estensione: scansiona il DOM, risolve ogni URL di risorsa relativo rispetto alla posizione corrente della pagina e riscrive la versione assoluta nell'attributo. Nel momento in cui Matomo serializza il DOM, ogni riferimento è completo e il renderer scarica gli asset dalla tua origine invece che da quella di Matomo. Lascia deliberatamente stare <a href>, perché riscrivere i link di navigazione romperebbe il routing delle SPA senza influire sullo screenshot.

js
// Incolla nella console del browser. Converte ogni URL di risorsa relativo in
// assoluto così il render server-side di Matomo li risolve rispetto alla tua
// origine invece che a quella di Matomo. Salta deliberatamente <a href>,
// perché cambiare i link di navigazione romperebbe le SPA senza influire sullo 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], fogli di stile, icone, preload
  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]. Riscrivi ogni candidato e preserva i descrittori.
  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() dentro gli stili inline
  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 compare la riga di log, cattura subito. La riscrittura dura solo fino alla navigazione successiva. Nota che questo snippet vede solo gli attributi style="..." inline, non le chiamate url(...) dentro i fogli di stile esterni. Per quelli, vedi il rewriter postbuild più in basso oppure usa il fix in fase di build.

Aggiungi un tag <base> nel <head>

Il fix permanente più semplice che esiste. Una riga in cima al <head> e ogni URL relativo nella pagina si risolve rispetto al tuo dominio invece che a qualsiasi host stia renderizzando l'HTML:

html
<!-- Sostituisci yoursite.com con il tuo dominio -->
<base href="https://yoursite.com/">

L'insidia è che <base> influisce su tutti gli URL relativi dell'intero sito, compresi i link. Le SPA che si basano sul routing relativo (React Router, <Link> di Next.js in alcune configurazioni, qualsiasi cosa con pushState client-side su percorsi relativi) possono comportarsi male una volta aggiunto. Testa il flusso di navigazione prima di distribuire, oppure limita il tag in modo che si renderizzi solo durante le catture della heatmap (un parametro di query che il tuo script di tracciamento attiva, per esempio).

Se puoi distribuire un tag <base> senza rompere la navigazione SPA, questa è la risposta e puoi smettere di leggere.

Emetti URL assoluti dalla build

Se <base> è fuori dai giochi, configura la build in modo che emetta URL assoluti per gli asset fin dall'inizio. La maggior parte dei generatori ha un'opzione per farlo. Next.js ha assetPrefix in next.config.js. Hugo ha baseURL in config.toml. Astro ha site in astro.config.mjs. Gatsby usa pathPrefix insieme a --prefix-paths durante la build. Imposta il valore sul tuo dominio di produzione, ricostruisci, e ogni tag <img>, <link> e <script> che il tuo generatore emette esce già completo. Il renderer di Matomo scarica dalla tua origine e la heatmap funziona.

L'insidia qui è che gli URL assoluti in fase di build di solito coprono solo gli asset che il framework conosce. Il CSS scritto a mano (o proveniente da un CMS, o inserito in un tema personalizzato) spesso scivola via, così come i riferimenti url(...) dentro i fogli di stile compilati. Se hai configurato correttamente la build e nella heatmap mancano ancora sfondi o icon font, è lì che si apre il buco.

Rewriter postbuild per url() in CSS

Se il tuo generatore manca i url(...) dentro i fogli di stile compilati, questo è un piccolo script Node che scansiona la directory di output e riscrive ogni percorso root-relativo in assoluto:

js
// Modifica SITE con il tuo dominio. Esegui con: 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);
}

Aggiungilo alla CI come step post-build. È volutamente limitato ai percorsi root-relativi (quelli che iniziano con /), perché riscrivere ./foo o ../bar richiede di sapere dove si trova il foglio di stile nell'albero di output. Se ne hai molti, il fix in fase di build sopra è la risposta migliore.

Perché Matomo non riesce a risolvere gli URL relativi

La cattura degli screenshot di Matomo è un processo in due fasi. Prima, il tracker serializza il tuo DOM live in HTML e lo spedisce. Poi il tuo server Matomo ri-renderizza quell'HTML per produrre lo screenshot che vedi nella vista heatmap. L'HTML contiene ancora /images/hero.jpg esattamente come l'hai scritto tu. Ma il renderer non gira sul tuo dominio, quindi quando il motore browser prova a caricare quell'immagine, risolve il percorso relativo rispetto all'host di Matomo. /images/hero.jpg diventa https://your-matomo-host/images/hero.jpg, che non esiste. Lo stesso vale per fogli di stile, font e le chiamate url(...) dentro gli stili inline. Il renderer chiede al server di Matomo file che il server di Matomo non ha mai sentito nominare, riceve 404 in risposta, e ottieni uno screenshot con tutto ciò che ha un percorso relativo che manca.

Questo spiega anche perché gli URL completi funzionano bene. Una volta che un URL ha il proprio schema e host, il renderer sa esattamente dove scaricarlo, e la richiesta va alla tua origine come dovrebbe.

Cosa sta effettivamente fallendo

Alcuni pattern che continuiamo a incontrare:

  • <img src="/images/hero.jpg"> e simili. Il sintomo più visibile: icone di immagini rotte al posto dell'hero, del logo o delle foto prodotto.
  • <link rel="stylesheet" href="/css/main.css"> con un href relativo. L'intero foglio di stile va in 404, quindi la pagina si renderizza con i default del browser. È il caso che le persone descrivono come "la heatmap sembra completamente senza stile."
  • style="background-image: url(/images/bg.png)" inline. Gli sfondi spariscono, il layout sembra integro ma è visivamente rotto.
  • Candidati srcset con percorsi relativi dentro <img srcset> o <source srcset>. Quale candidato sceglie il renderer di Matomo dipende dalla sua assunzione sulla viewport, quindi a volte un percorso si risolve e un altro no, e il problema sembra intermittente.
  • @font-face src: url("/fonts/inter.woff2") dichiarato dentro un blocco <style> inline o un foglio di stile che sopravvive. Il font va in 404 e si cade indietro sui font di sistema. (Questo si sovrappone al post sui font che non si caricano, ma qui la causa è la risoluzione del percorso, non il CORS.)
  • SVG <use href="/icons/sprite.svg#chevron">. Lo sprite non si carica mai, ogni icona sparisce e il layout collassa attorno ai segnaposto <svg> vuoti.
  • <link rel="preload" as="font" href="..."> con un href relativo. Non rompe da solo lo screenshot, ma è un segnale utile che altri asset della stessa famiglia sono probabilmente rotti.

Se nella tua heatmap compaiono segnaposto di immagini rotte, CSS mancante o un layout che non corrisponde al tuo sito live, stai incontrando almeno uno di questi. Spesso più di uno sulla stessa pagina.

Questa è anche la stessa famiglia di problemi che rompe font, immagini, container con scroll e header sticky nello stesso screenshot. Abbiamo scritto un post più lungo sugli screenshot rotti delle heatmap di Matomo che tratta il resto, più post separati su perché i font non si caricano e perché le immagini non si caricano dato che ognuno ha le sue cause specifiche.

Cosa faremmo noi

Se puoi distribuire un tag <base> senza rompere la navigazione SPA, è la risposta. Una riga, ogni URL relativo si risolve correttamente, fatto.

Se <base> va in conflitto con il tuo routing, configura la build per emettere URL assoluti per gli asset e aggiungi il rewriter CSS postbuild per i riferimenti url(...) che la build non raggiunge. Un po' più di lavoro, più completo, non combatte col tuo framework.

Se nessuna delle due è raggiungibile (template gestiti da CMS che non puoi toccare, temi che non possiedi, markup iniettato da terze parti), l'estensione Chrome Matomo Heatmap Helper è quello che usiamo sui siti dei clienti dove non possiamo cambiare lo stack. Esegue la stessa logica di riscrittura dello snippet qui sopra, più fix equivalenti per font, 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 alla tua web analytics invece che in un foglio di calcolo separato. È in beta privata. Iscriviti alla lista d'attesa se ti è rilevante.

La maggior parte dei problemi con gli URL relativi si risolve con una modifica alla configurazione della build. Vale la pena farlo una volta sola.

Perché gli URL relativi non si caricano nella heatmap di Matomo (e come risolvere) - Martez Blog