Pourquoi vos images ne se chargent pas dans les heatmaps Matomo (et comment corriger ca)

Les captures d'ecran des heatmaps Matomo affichent des images cassees quand ton CDN, tes URLs signees ou tes hotes avec protection anti-hotlinking rejettent la requete du serveur Matomo. Les donnees de clics sont intactes. Voici pourquoi ca arrive et comment on gere ca.

Si tu as ouvert une heatmap Matomo et trouve ton image hero remplacee par un rectangle vide, tes photos de produits affichees comme des icones cassees, et les marqueurs de clics qui flottent sur du blanc — les clics eux-memes sont bons. Matomo les a enregistres aux bonnes coordonnees. Ce qui est casse, c'est la capture d'ecran en dessous. Les images ne sont pas passees quand le serveur Matomo a re-rendu la page, et tu te retrouves avec une heatmap qui ressemble a un wireframe de la page que tes visiteurs ont vraiment vue.

La correction la plus rapide, c'est une extension Chrome gratuite qu'on maintient, Matomo Heatmap Helper. Elle recupere chaque image cross-origin via le script de fond de l'extension (qui contourne les regles CORS de la page) et les encode en base64 directement avant chaque capture. Le reste de ce post explique comment faire la meme chose sans extension, plus quelques corrections permanentes qui valent le coup d'etre deployees.

Comment corriger ca sans l'extension

Il y a un snippet a coller dans la console qui masque le probleme juste avant chaque capture, et une poignee de corrections permanentes a deployer sur le site ou le CDN. Prends ce qui convient le mieux a tes contraintes.

Lister toutes les references d'images cross-origin

Avant de changer quoi que ce soit, identifie quelles images ne se chargent pas. Ouvre la page dans Chrome, appuie sur F12 pour ouvrir DevTools, passe sur la Console, et colle ca. Ca couvre <img src>, <img srcset>, <source srcset> dans <picture>, <video poster>, SVG <use href>, et les 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];
})();

Ca te donne la liste exacte des URLs que le renderer Matomo devra telecharger par lui-meme. Tout ce qui est servi depuis un hote que tu ne controles pas est un candidat a la casse.

La correction rapide : embarquer chaque image en base64 depuis la console

C'est le snippet qu'on colle dans la console du navigateur juste avant de declencher la capture heatmap de Matomo. Il parcourt toutes les references d'images sur la page (en couvrant les memes selecteurs que le snippet de listage), recupere chaque URL unique une seule fois, l'encode en base64, et recrit les references en inline. Le temps que Matomo serialise le DOM, les images sont integrees.

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

Attends de voir quelque chose comme : Embedded 8/8 unique URLs across 12 references. Now trigger your Matomo heatmap capture.

La limite : ce snippet tourne dans la page, donc il herite des regles CORS de la page. Si ton CDN envoie Access-Control-Allow-Origin: * (ou ton origine), le fetch fonctionne et c'est regle. Si ce n'est pas le cas, le fetch echoue pour ces URLs specifiques et les warnings t'indiquent exactement lesquelles. Pour celles-la, voir les corrections permanentes ci-dessous, ou saute directement a la section extension.

Heberger ou proxyfier via ton propre domaine

La reponse la plus propre pour presque tout le monde, exactement comme pour les polices. Same-origin contourne entierement le probleme, car le renderer Matomo telecharge l'image depuis ton domaine avec les memes regles d'acces que la page d'origine. Avec nginx, c'est deux lignes :

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;
}

Ensuite, reecris <img src="https://cdn.example.com/x.jpg"> en <img src="/cdn-proxy/x.jpg"> dans tes templates. C'est plus lourd que de toucher un en-tete CORS, mais c'est infaillible, et ca te donne en prime un niveau de controle sur le cache.

Ouvrir CORS sur le bucket ou la distribution

Si l'auto-hebergement ne vaut pas le coup mais que tu controles le CDN, ouvre simplement CORS. S3 attend ca dans la configuration CORS du bucket :

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

Cloudfront veut une policy de response-headers avec Access-Control-Allow-Origin: * attachee au comportement des images. Pense a mettre crossorigin="anonymous" sur les balises <img> aussi, sinon le navigateur n'incluera pas les en-tetes CORS dans la cle de cache et tu auras un comportement inconsistant entre les chargements frais et les hits de cache.

C'est aussi ce qui fait fonctionner le snippet d'integration ci-dessus depuis la console du navigateur, donc ca vaut le coup de le faire en premier si tu peux.

Mettre Matomo en liste blanche sur les hotes avec protection anti-hotlinking

Si l'hote bloque par Referer ou User-Agent (courant sur les marketplaces d'images, les CDN anti-scraping, et certaines configs nginx en auto-hebergement), ajoute le fetcher de captures de Matomo a la liste d'autorisation. Consulte les logs d'acces Matomo cote serveur pour voir la chaine User-Agent exacte qu'il envoie quand il telecharge pour les captures. Ajoute cette chaine, plus l'IP de ton serveur Matomo, a la regle qui rejette la requete.

C'est la bonne correction quand tu ne controles pas l'hote mais que tu controles la regle. C'est la mauvaise correction quand l'hote est un tiers qui le fait expres, comme Unsplash ou les CDN de photos de stock.

Integrer les images above-the-fold en data URIs au moment du build

Le url-loader de Webpack, la query ?inline de Vite, ou des data URIs codees a la main dans tes templates. Meme idee que le snippet console, mais integree dans le build, donc chaque page est livree avec l'image hero deja embarquee et le renderer Matomo n'a rien a telecharger en cross-origin pour l'afficher.

Ca vaut le coup pour la poignee d'images qui comptent le plus pour les captures (hero, photos produit, tout ce qui est above the fold). Ca ne vaut pas le coup pour chaque miniature d'une page categorie.

Surveiller l'expiration des URLs signees

Probleme different, meme symptome. Si tu sers des images depuis S3 ou Cloudfront avec des URLs pre-signees (TTL d'une heure, d'une journee), la signature sera perimee le temps que le renderer Matomo s'en occupe. L'image etait accessible quand le visiteur l'a vue. Elle ne l'est plus.

Deux solutions : etendre le TTL au-dela de ta fenetre de capture heatmap (Matomo peut accuser un retard de plusieurs heures ou jours), ou proxyfier via ton propre domaine et laisser le proxy signer a la volee. La correction par proxy fait double emploi avec la correction same-origin ci-dessus.

Pourquoi Matomo ne peut pas charger tes images

Meme cause racine que la version du probleme avec les polices. La capture d'ecran de Matomo est un processus en deux etapes. D'abord, le tracker serialise ton DOM en direct en HTML et l'envoie. Ensuite, ton serveur Matomo re-rend ce HTML pour produire la capture d'ecran que tu vois dans la vue heatmap. Pas de session navigateur, pas de cookies, pas de Referer pointant vers ton domaine. Juste un fichier HTML rendu a froid depuis une IP differente.

Tout ce qui dans ce HTML pointe vers un hote tiers doit passer par ce tiers en premier. Pour les images, c'est generalement la que ca s'arrete.

Ce qui echoue concretement

Quelques patterns qu'on croise regulierement :

  • La protection anti-hotlinking sur le CDN, l'hote d'assets, ou une config nginx en auto-hebergement rejette les requetes avec le mauvais Referer ou sans Referer du tout. Le serveur Matomo n'envoie pas ton domaine comme Referer quand il telecharge pour une capture, donc la reponse est un 403 et l'image s'affiche comme un placeholder.
  • Les URLs pre-signees de S3 ou Cloudfront ont deja expire le temps que Matomo s'occupe de la capture. Le visiteur a vu une image valide, le renderer recoit un corps XML AccessDenied qu'aucun format image ne sait quoi faire.
  • Les CDN qui servent un contenu different (ou des 403) en fonction de l'en-tete Origin — souvent des setups anti-scraping ou des distributions avec restriction geo — rejettent l'IP de Matomo directement.
  • Les hotes d'images avec rate-limiting ou blocage par reputation d'IP. Unsplash, certains tiers de Cloudinary, tout ce qui limite par volume de requetes par IP. Le renderer de Matomo batchise les requetes, l'hote throttle, et la capture part avec la moitie de ses images manquantes.
  • Les references <picture> <source srcset> qui se resolvent vers une URL cross-origin differente du fallback <img src>. Le <img> peut passer, le <source> en haute resolution ne passe pas, et lequel Matomo choisit depend du viewport de son renderer.

Si ta heatmap affiche des placeholders d'images cassees, tu as au moins un de ces cas. Souvent plusieurs sur la meme page.

C'est aussi la meme famille de problemes qui casse les polices, les conteneurs avec defilement, et les headers sticky dans la meme capture. On a ecrit un post plus detaille sur les captures de heatmaps Matomo cassees qui couvre le reste, et un post a part sur pourquoi les polices ne se chargent pas dans tes heatmaps Matomo parce que celui-la revient presque aussi souvent.

Ce qu'on ferait concretement

Si tu controles le CDN ou l'hote, ouvre CORS et mets crossorigin="anonymous" sur les balises <img>. Le snippet console commence a fonctionner immediatement et une correction permanente est a une config CDN de distance.

Si tu controles les templates mais pas le CDN, proxyfie les images via ton propre domaine. Same-origin regle ca une bonne fois pour toutes et elimine une categorie de casse sur laquelle tu continuerais sinon de tomber.

Si aucune de ces options n'est accessible (hotes tiers, buckets verrouilles, URLs signees que tu ne detiens pas), l'extension Chrome Matomo Heatmap Helper est ce qu'on utilise sur les sites clients ou on ne peut pas toucher a la stack. Son script de fond telecharge les images en dehors du contexte CORS de la page, donc ca fonctionne meme sur les hotes qui bloquent le snippet in-page par Referer ou Origin. Gratuit, open source, code sur GitHub.

Martez est le projet plus large dont l'extension est issue. Il connecte Matomo avec Meta Ads et Google Ads pour que le ROAS, la CLV et l'attribution se retrouvent a cote de tes analytics web plutot que dans un tableur separe. Il est en beta privee. Rejoins la liste d'attente si ca te parle.

La plupart du temps, c'est une config CDN a faire. Ca vaut le coup de s'en occuper une fois.

Pourquoi vos images ne se chargent pas dans les heatmaps Matomo (et comment corriger ca) - Martez Blog