Perché le immagini non si caricano nella tua heatmap di Matomo (e come risolvere)

Gli screenshot delle heatmap di Matomo mostrano immagini rotte quando il tuo CDN, gli URL firmati o gli host con protezione hotlink rifiutano la richiesta dal server di Matomo. I dati dei clic sono a posto. Ecco perché succede e come lo gestiamo noi.

Se hai aperto una heatmap di Matomo e hai trovato l'immagine hero sostituita da un rettangolo bianco, le foto prodotto come segnaposto con immagine rotta e i marcatori di clic che fluttuano sul vuoto, sappi che i clic in sé sono a posto. Matomo li ha registrati alle coordinate giuste. Quello che è rotto è lo screenshot sottostante. Le immagini non sono passate quando il server di Matomo ha ri-renderizzato la pagina, e ti ritrovi con una heatmap che sembra un wireframe della pagina che i tuoi visitatori hanno visto davvero.

La soluzione più rapida è un'estensione gratuita per Chrome che manteniamo noi, si chiama Matomo Heatmap Helper. Recupera ogni immagine cross-origin tramite lo script in background dell'estensione (che aggira le regole CORS della pagina) e le incorpora come base64 subito prima di ogni cattura. Il resto di questo post spiega come fare la stessa cosa senza estensione, più qualche soluzione permanente che vale la pena mettere in produzione.

Come risolvere senza l'estensione

C'è uno snippet da console che tappa il problema subito prima di ogni cattura, e una manciata di soluzioni permanenti che puoi mettere nel sito o nel CDN. Scegli quella che si adatta ai tuoi vincoli.

Elenca tutti i riferimenti a immagini cross-origin

Prima di cambiare qualsiasi cosa, scopri quali immagini non si caricano. Apri la pagina in Chrome, premi F12 per aprire DevTools, passa alla Console e incolla questo. Copre <img src>, <img srcset>, <source srcset> dentro <picture>, <video poster>, SVG <use href> e CSS background-image:

js
// Paste into the browser console. Lists every cross-origin image-like reference.
(() => {
  const out = new Set();
  const isCross = u => { try { return new URL(u, location.href).origin !== location.origin && !u.startsWith('data:') && !u.startsWith('blob:'); } catch { return false; } };
  document.querySelectorAll('img[src]').forEach(el => isCross(el.src) && out.add(el.src));
  document.querySelectorAll('img[srcset], source[srcset]').forEach(el =>
    el.getAttribute('srcset').split(',').map(s => s.trim().split(/\s+/)[0]).forEach(u => isCross(u) && out.add(u)));
  document.querySelectorAll('video[poster]').forEach(el => isCross(el.poster) && out.add(el.poster));
  document.querySelectorAll('use').forEach(el => {
    const h = el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
    if (h && isCross(h)) out.add(h);
  });
  document.querySelectorAll('*').forEach(el => {
    const bg = getComputedStyle(el).backgroundImage;
    const m = bg && bg.match(/url\(['"]?([^'")]+)['"]?\)/);
    if (m && isCross(m[1])) out.add(m[1]);
  });
  console.log([...out]);
  return [...out];
})();

Ti restituisce la lista esatta degli URL che il renderer di Matomo dovrà recuperare da solo. Qualsiasi cosa servita da un host che non controlli è un candidato alla rottura.

La soluzione rapida: incorpora ogni immagine come base64 dalla console

Questo è lo snippet che incolliamo nella console del browser subito prima di triggerare la cattura della heatmap di Matomo. Scorre tutti i riferimenti alle immagini nella pagina (coprendo gli stessi selettori dello snippet di elenco sopra), recupera ogni URL univoco una volta sola, lo codifica in base64 e riscrive i riferimenti inline. Quando Matomo serializza il DOM, le immagini sono già incorporate.

js
// Paste into the browser console. Embeds every cross-origin image reference
// as a base64 data URI. Works for any image host that allows CORS (most modern
// CDNs, S3 with CORS open, Unsplash, Cloudinary). Hot-link-protected hosts
// that block by Referer will fail. Fix the source there.
(async () => {
  const isCross = u => {
    try {
      if (!u || u.startsWith('data:') || u.startsWith('blob:') || u.startsWith('#')) return false;
      return new URL(u, location.href).origin !== location.origin;
    } catch { return false; }
  };
 
  // 1. Collect every cross-origin reference
  const refs = []; // {url, apply: dataUri => void}
 
  document.querySelectorAll('img[src]').forEach(el => {
    const url = el.getAttribute('src');
    if (isCross(url)) refs.push({ url, apply: d => el.setAttribute('src', d) });
  });
 
  document.querySelectorAll('img[srcset], source[srcset]').forEach(el => {
    const srcset = el.getAttribute('srcset');
    srcset.split(',').forEach(part => {
      const url = part.trim().split(/\s+/)[0];
      if (isCross(url)) {
        refs.push({ url, apply: d => {
          const cur = el.getAttribute('srcset') || '';
          el.setAttribute('srcset', cur.split(url).join(d));
        }});
      }
    });
  });
 
  document.querySelectorAll('video[poster]').forEach(el => {
    const url = el.getAttribute('poster');
    if (isCross(url)) refs.push({ url, apply: d => el.setAttribute('poster', d) });
  });
 
  document.querySelectorAll('use').forEach(el => {
    const url = el.getAttribute('href') || el.getAttributeNS('http://www.w3.org/1999/xlink', 'href');
    if (isCross(url)) {
      refs.push({ url, apply: d => {
        if (el.hasAttribute('href')) el.setAttribute('href', d);
        else el.setAttributeNS('http://www.w3.org/1999/xlink', 'href', d);
      }});
    }
  });
 
  document.querySelectorAll('*').forEach(el => {
    const bg = getComputedStyle(el).backgroundImage;
    if (!bg || bg === 'none') return;
    const m = bg.match(/url\(['"]?([^'")]+)['"]?\)/);
    if (m && isCross(m[1])) {
      const url = m[1];
      refs.push({ url, apply: d => el.style.setProperty('background-image', `url("${d}")`) });
    }
  });
 
  if (refs.length === 0) { console.warn('No cross-origin image references found.'); return; }
  console.log(`Found ${refs.length} cross-origin image references.`);
 
  // 2. Fetch each unique URL once and base64-encode
  const uniqueUrls = [...new Set(refs.map(r => r.url))];
  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('Image fetch failed:', url, e.message); }
  }));
 
  // 3. Apply every reference
  let applied = 0;
  for (const ref of refs) {
    const d = dataUris.get(ref.url);
    if (!d) continue;
    try { ref.apply(d); applied++; } catch (e) { console.warn('Apply failed:', ref.url, e); }
  }
 
  console.log(`Embedded ${dataUris.size}/${uniqueUrls.length} unique URLs across ${applied} references.`);
  console.log('Now trigger your Matomo heatmap capture.');
})();

Aspetta finché non vedi qualcosa tipo: Embedded 8/8 unique URLs across 12 references. Now trigger your Matomo heatmap capture.

L'inghippo: questo snippet gira nella pagina, quindi eredita le regole CORS della pagina. Se il tuo CDN manda Access-Control-Allow-Origin: * (o la tua origine), il fetch funziona e hai finito. Se non lo fa, il fetch fallisce per quegli URL specifici e i warning ti dicono esattamente quali. Per quelli, vedi le soluzioni permanenti qui sotto, oppure vai direttamente alla sezione sull'estensione.

Self-host o proxy attraverso il tuo dominio

La risposta più pulita per quasi tutti, come per i font. Same-origin aggira l'intero problema perché il renderer di Matomo recupera l'immagine dal tuo dominio con le stesse regole di accesso della pagina originale. Un proxy nginx sono due righe:

nginx
# Edit cdn.example.com to your CDN host
location /cdn-proxy/ {
  proxy_pass https://cdn.example.com/;
  proxy_set_header Host cdn.example.com;
}

Poi riscrivi <img src="https://cdn.example.com/x.jpg"> in <img src="/cdn-proxy/x.jpg"> nei tuoi template. Più pesante che toccare un header CORS, ma è blindato, e ti dà anche un livello di controllo sulla cache.

Apri il CORS sul bucket o sulla distribution

Se il self-hosting non vale la pena ma controlli il CDN, apri semplicemente il CORS. S3 vuole questo nella configurazione CORS del bucket:

json
[{
  "AllowedOrigins": ["*"],
  "AllowedMethods": ["GET"],
  "AllowedHeaders": ["*"]
}]

Cloudfront vuole una response-headers policy con Access-Control-Allow-Origin: * associata al behavior delle immagini. Imposta anche crossorigin="anonymous" sui tag <img>, altrimenti il browser non includerà gli header CORS nella cache key e avrai comportamenti incoerenti tra caricamenti freschi e cache hit.

È anche quello che fa funzionare lo snippet di incorporamento sopra dalla console del browser, quindi vale la pena farlo prima se puoi.

Se l'host blocca per Referer o User-Agent (comune per marketplace di immagini, CDN anti-scraping e alcune configurazioni self-hosted), aggiungi il fetcher di screenshot di Matomo alla lista di accesso. Controlla i log di accesso di Matomo lato server per vedere l'esatta stringa User-Agent che manda quando recupera per gli screenshot. Aggiungi quella stringa, più l'IP del tuo server Matomo, a qualunque regola stia rifiutando la richiesta.

Questa è la soluzione giusta quando non controlli l'host ma controlli la regola. È la soluzione sbagliata quando l'host è una terza parte che lo fa di proposito, come Unsplash o CDN di foto stock.

Incorpora le immagini above-the-fold come data URI a build time

Il url-loader di Webpack, il query ?inline di Vite, o data URI codificati a mano nei tuoi template. Stessa idea dello snippet da console ma integrata nella build, così ogni pagina viene consegnata con l'immagine hero già incorporata e il renderer di Matomo non deve recuperare nulla cross-origin per renderizzarla.

Vale la pena farlo per la manciata di immagini che contano di più per gli screenshot (hero, foto prodotto, qualsiasi cosa above the fold). Non vale la pena farlo per ogni thumbnail su una pagina di categoria.

Attenzione alla scadenza degli URL firmati

Problema diverso, stesso sintomo. Se servi immagini da S3 o Cloudfront con URL pre-firmati (TTL di un'ora, un giorno), la firma sarà scaduta quando il renderer di Matomo si mette al lavoro. L'immagine era caricabile quando il visitatore l'ha vista. Non lo è più.

Due uscite: estendi il TTL oltre la finestra di cattura della heatmap (Matomo può ritardare di ore o giorni), oppure proxia attraverso il tuo dominio e lascia che il proxy firmi al volo. Il fix con proxy vale anche come soluzione same-origin vista sopra.

Perché Matomo non riesce a caricare le tue immagini

Stessa causa radice del problema con i font. La cattura degli screenshot di Matomo è un processo in due fasi. Prima, il tracker serializza il tuo DOM in HTML e lo spedisce. Poi il tuo server Matomo ri-renderizza quell'HTML per produrre lo screenshot che vedi nella vista heatmap. Nessuna sessione browser, nessun cookie, nessun Referer che punta al tuo dominio. Solo un file HTML renderizzato a freddo da un IP diverso.

Qualsiasi cosa in quell'HTML che punta verso un host di terze parti deve passare da quella terza parte prima. Per le immagini, di solito è lì che si ferma.

Cosa sta fallendo davvero

Alcuni pattern che troviamo continuamente:

  • La protezione hotlink sul CDN, sull'asset host o in una configurazione nginx self-hosted rifiuta le richieste con il Referer sbagliato o senza Referer. Il server di Matomo non manda il tuo dominio come Referer quando recupera per uno screenshot, quindi la risposta è un 403 e l'immagine viene renderizzata come segnaposto.
  • Gli URL pre-firmati di S3 o Cloudfront sono già scaduti quando Matomo arriva allo screenshot. Il visitatore ha visto un'immagine valida, il renderer vede un body XML AccessDenied che nessun formato immagine sa cosa farsene.
  • CDN che servono contenuto diverso (o 403) in base all'header Origin, spesso configurazioni anti-scraping o distribution geo-ristrette, rifiutano l'IP di Matomo direttamente.
  • Host di immagini con rate limit o con l'IP in blacklist per reputazione. Unsplash, certi tier di Cloudinary, qualsiasi cosa che limiti per volume di richieste per IP. Il renderer di Matomo accumula richieste, l'host le throttla, lo screenshot arriva con metà delle immagini mancanti.
  • Riferimenti <picture> <source srcset> che si risolvono in un URL cross-origin diverso dal fallback <img src>. L'<img> potrebbe passare, il <source> ad alta risoluzione no, e quale Matomo sceglie dipende dalla viewport del suo renderer.

Se la tua heatmap mostra segnaposto con immagini rotte, stai incappando in almeno uno di questi. Spesso più di uno sulla stessa pagina.

È anche la stessa famiglia di problemi che rompe font, container con scroll e header sticky nello stesso screenshot. Abbiamo scritto un post più lungo sugli screenshot rotti delle heatmap di Matomo che passa in rassegna il resto, e un post separato su perché i font non si caricano nella tua heatmap di Matomo perché quello viene fuori quasi altrettanto spesso.

Cosa faremmo noi

Se controlli il CDN o l'host, apri il CORS e imposta crossorigin="anonymous" sui tag <img>. Lo snippet da console inizia a funzionare subito e una soluzione permanente è a una configurazione CDN di distanza.

Se controlli i template ma non il CDN, proxia le immagini attraverso il tuo dominio. Same-origin risolve una volta per tutte e rimuove una classe di rotture in cui altrimenti continueresti a inciampare.

Se nessuna delle due opzioni è raggiungibile (host di terze parti, bucket chiusi, URL firmati che non possiedi), l'estensione Chrome Matomo Heatmap Helper è quella che usiamo noi sui siti dei clienti dove non possiamo cambiare lo stack. Il suo script in background recupera le immagini fuori dal contesto CORS della pagina, quindi funziona anche sugli host che bloccano lo snippet inline per Referer o Origin. Gratuita, open source, codice su GitHub.

Martez è il progetto più grande da cui è nata l'estensione. Connette Matomo con Meta Ads e Google Ads così ROAS, CLV e attribuzione stanno vicino alla tua web analytics invece che in un foglio di calcolo separato. È in private beta. Iscriviti alla lista d'attesa se ti è utile.

La maggior parte delle volte, è a una configurazione CDN di distanza. Vale la pena farlo una volta.

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