Pourquoi les URLs relatives ne se chargent pas dans ta heatmap Matomo (et comment corriger ca)

Les captures d'ecran des heatmaps Matomo cassent quand ta page utilise des chemins relatifs. Le moteur de rendu les resout par rapport a l'hote de Matomo plutot que le tien, donc images, feuilles de style et polices tombent en 404. Voici pourquoi ca arrive et comment on s'en sort.

Si tu as ouvert une heatmap Matomo et trouve des icones cassees a la place de ton logo et de ton hero, des images d'arriere-plan manquantes sur chaque section, et dans les pires cas une mise en page qui donne l'impression que la page a oublie que son CSS existait — les clics, eux, sont corrects. Matomo les a enregistres aux bonnes coordonnees. Ce qui est casse, c'est la capture d'ecran en dessous. Tout ce qui a un chemin relatif sur la page n'a pas survecu le voyage jusqu'au moteur de rendu de Matomo, et tu te retrouves avec une heatmap flottant au-dessus d'un wireframe de ce que tes visiteurs ont reellement vu.

Le signe qui ne trompe pas est systematique. Tout ce qui a une URL qualifiee comme https://yoursite.com/images/hero.jpg s'affiche bien. Tout ce qui a un chemin relatif (/images/hero.jpg, ./logo.svg, assets/bg.png) est casse. Meme chose pour les feuilles de style, les polices, les scripts et les references url(...) dans les styles inline. Une fois que tu reperes ce schema, la cause saute aux yeux.

Le correctif le plus rapide est une extension Chrome gratuite qu'on maintient, Matomo Heatmap Helper. Elle parcourt le DOM et convertit chaque URL relative en absolute juste avant chaque capture, puis restaure les originales apres. La suite de cet article explique comment faire la meme chose sans extension, plus quelques correctifs permanents qui valent le coup d'etre deployes.

Comment corriger ca sans l'extension

Il y a un snippet console qui panse le probleme juste avant chaque capture, et quelques correctifs permanents qu'on peut deployer avec le site ou le build. Choisis ce qui correspond aux contraintes dans lesquelles tu travailles.

Lister toutes les URLs relatives de la page

Avant de changer quoi que ce soit, repere ce qui est vraiment relatif. Ouvre la page dans Chrome, appuie sur F12 pour ouvrir les DevTools, passe sur la Console, et colle ca. Ca couvre <img src>, <img srcset>, <source srcset>, <source src>, <video poster>, et background-image dans les styles inline :

js
// Paste into the browser console. Lists every relative resource URL.
(() => {
  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;
})();

Ca te donne la liste exacte des references que le moteur de rendu Matomo va mal resoudre. Si le tableau revient vide et que la heatmap est toujours cassee, le probleme est ailleurs (CORS sur un CDN, licence de polices, conteneurs avec defilement). Sinon, chaque ligne du tableau est quelque chose que tu dois soit reecrire, soit passer en URL absolue.

Le correctif rapide : reecrire les URLs relatives en absolues juste avant la capture

C'est le snippet qu'on colle dans la console du navigateur juste avant de declencher la capture heatmap de Matomo. Il reproduit le comportement du relative-url-fixer de l'extension : il parcourt le DOM, resout chaque URL de ressource relative par rapport a l'emplacement courant de la page, et ecrit la version absolue dans l'attribut. Quand Matomo serialise le DOM, chaque reference est pleinement qualifiee et le moteur de rendu recupere les assets depuis ton origine plutot que celle de Matomo. Il laisse deliberement <a href> tranquille, puisque reecrire les liens de navigation casserait le routage SPA sans affecter la capture.

js
// Paste into the browser console. Converts every relative resource URL to
// absolute so Matomo's server-side render resolves them against your origin
// instead of Matomo's. Skips <a href> deliberately, since changing nav links
// would break SPAs and doesn't affect the 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], stylesheets, icons, preloads
  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]. Rewrite each candidate and preserve descriptors.
  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() inside inline styles
  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.`);
})();

Quand la ligne de log apparait, capture immediatement. La reecriture ne vit que jusqu'a la prochaine navigation. Note que ce snippet ne voit que les attributs style="..." inline, pas les appels url(...) dans des feuilles de style externes. Pour ceux-la, voir le rewriter postbuild plus bas ou s'appuyer sur le correctif au moment du build.

Ajouter une balise <base> dans <head>

Le correctif permanent le plus simple qui existe. Une ligne en haut du <head>, et chaque URL relative de la page se resout par rapport a ton domaine plutot que par rapport a n'importe quel hote qui rend le HTML :

html
<!-- Edit yoursite.com to your domain -->
<base href="https://yoursite.com/">

Le piege, c'est que <base> affecte toutes les URLs relatives du site, y compris les cibles de liens. Les SPA qui s'appuient sur le routage relatif (React Router, Next.js <Link> dans certaines configurations, tout ce qui utilise pushState cote client avec des chemins relatifs) peuvent se mal comporter une fois qu'on l'ajoute. Teste le flux de navigation avant de deployer, ou conditionne la balise pour qu'elle ne s'affiche que pendant les captures heatmap (un parametre de requete que ton script de tracking active, par exemple).

Si tu peux deployer une balise <base> sans casser la navigation SPA, c'est la reponse et tu peux arreter de lire.

Emettre des URLs absolues depuis ton build

Si <base> est hors jeu, configure ton build pour emettre des URLs absolues pour les assets des le depart. La plupart des generateurs ont un reglage pour ca. Next.js a assetPrefix dans next.config.js. Hugo a baseURL dans config.toml. Astro a site dans astro.config.mjs. Gatsby utilise pathPrefix avec --prefix-paths au moment du build. Mets la valeur sur ton domaine de production, rebuild, et chaque balise <img>, <link> et <script> que ton generateur emet sort pleinement qualifiee. Le moteur de rendu Matomo recupere les assets depuis ton origine et la heatmap fonctionne.

Le bémol, c'est que les URLs absolues au moment du build ne couvrent generalement que les assets que le framework connait. Le CSS ecrit a la main (ou qui vient d'un CMS, ou inscrit dans un theme personnalise) passe souvent entre les mailles, tout comme les references url(...) dans les feuilles de style compilees. Si tu as correctement configure le build et que la heatmap a encore des arriere-plans manquants ou des polices d'icones absentes, c'est cette faille-la que tu colmates.

Rewriter postbuild pour les url() CSS

Si ton generateur rate les url(...) dans les feuilles de style buildees, voici un petit script Node qui parcourt le repertoire de sortie et reecrit chaque chemin root-relative en absolue :

js
// Edit SITE to your domain. Run with: 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);
}

Branche-le dans ton CI comme etape post-build. Il est deliberement limite aux chemins root-relatifs uniquement (chemins qui commencent par /), puisque reecrire ./foo ou ../bar necessite de savoir ou se trouve la feuille de style dans l'arbre de sortie. Si tu en as beaucoup, le correctif au moment du build ci-dessus est la meilleure reponse.

Pourquoi Matomo ne peut pas resoudre les URLs relatives

La capture d'ecran de Matomo est un processus en deux etapes. D'abord, le tracker serialise ton DOM live en HTML et l'envoie. Ensuite, ton serveur Matomo re-rend ce HTML pour produire la capture que tu vois dans la vue heatmap. Le HTML contient encore /images/hero.jpg exactement tel que tu l'as ecrit. Mais le moteur de rendu ne tourne pas sur ton domaine, donc quand le moteur du navigateur va charger cette image, il resout le chemin relatif par rapport a l'hote de Matomo. /images/hero.jpg devient https://your-matomo-host/images/hero.jpg, qui n'existe pas. Meme chose pour les feuilles de style, les polices, et les appels url(...) dans les styles inline. Le moteur de rendu demande au serveur de Matomo des fichiers que le serveur de Matomo n'a jamais vus, recoit des 404 en retour, et tu obtiens une capture avec tout ce qui est en chemin relatif manquant.

C'est aussi pourquoi les URLs pleinement qualifiees fonctionnent bien. Une fois qu'une URL a son propre scheme et son hote, le moteur de rendu sait exactement ou la recuperer, et la requete va vers ton origine comme elle le devrait.

Ce qui echoue concretement

Quelques schemas qu'on rencontre regulierement :

  • <img src="/images/hero.jpg"> et ses semblables. Le symptome le plus visible : des icones cassees a la place de ton hero, ton logo ou tes photos produit.
  • <link rel="stylesheet" href="/css/main.css"> avec un href relatif. La feuille de style entiere tombe en 404, la page se rend avec les valeurs par defaut du navigateur. C'est le cas que les gens decrivent comme "la heatmap n'a aucun style."
  • style="background-image: url(/images/bg.png)" inline. Les arriere-plans disparaissent, la mise en page a l'air correcte mais parait visuellement cassee.
  • Des candidats srcset avec des chemins relatifs dans <img srcset> ou <source srcset>. Le candidat que le moteur de rendu Matomo choisit depend de son hypothese de viewport, donc parfois un chemin se resout et un autre non, et le bug parait intermittent.
  • @font-face src: url("/fonts/inter.woff2") declare dans un bloc <style> inline ou une feuille de style qui survit. La police tombe en 404 et tu retombes sur les polices systeme. (Ca recoupe le post sur les polices qui ne se chargent pas, mais ici la cause est la resolution de chemin, pas le CORS.)
  • <use href="/icons/sprite.svg#chevron"> en SVG. Le sprite ne se charge jamais, chaque icone disparait, et la mise en page s'effondre autour des placeholders <svg> vides.
  • <link rel="preload" as="font" href="..."> avec un href relatif. Ca ne casse pas la capture en soi, mais c'est un bon indicateur que d'autres assets de la meme famille sont probablement casses aussi.

Si ta heatmap affiche des placeholders d'images cassees, du CSS manquant, ou une mise en page qui ne correspond pas a ton site live, tu es face a au moins l'un de ces cas. Souvent plusieurs en meme temps sur la meme page.

C'est aussi la meme famille de problemes qui casse les polices, les images, les conteneurs avec defilement et les en-tetes sticky dans la meme capture. On a ecrit un post plus complet sur les captures d'ecran de heatmaps Matomo cassees qui passe en revue le reste, plus des posts separes sur pourquoi les polices ne se chargent pas et pourquoi les images ne se chargent pas, puisque chacun a ses propres causes.

Ce qu'on ferait vraiment

Si tu peux deployer une balise <base> sans casser la navigation SPA, c'est la reponse. Une ligne, toutes les URLs relatives se resolvent correctement, affaire classee.

Si <base> entre en conflit avec ton routage, configure ton build pour emettre des URLs absolues pour les assets, et ajoute le rewriter CSS postbuild pour les references url(...) que ton build n'atteint pas. Un peu plus de travail, plus complet, ca ne se bat pas avec ton framework.

Si aucune de ces options n'est accessible (templates geres par un CMS que tu ne peux pas toucher, themes que tu ne possedes pas, markup injecte par des tiers), l'extension Chrome Matomo Heatmap Helper est ce qu'on utilise sur les sites clients ou on ne peut pas changer la stack. Elle execute la meme logique de reecriture que le snippet ci-dessus, plus des correctifs equivalents pour les polices, les images, les conteneurs avec defilement et les en-tetes sticky, a chaque capture. Gratuite, 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 soient cote a cote de tes analytics web plutot que dans un tableur a part. Il est en beta privee. Rejoins la liste d'attente si ca te parle.

La plupart des problemes d'URLs relatives sont a un reglage de build de distance. Ca vaut le coup de le faire une fois pour toutes.

Pourquoi les URLs relatives ne se chargent pas dans ta heatmap Matomo (et comment corriger ca) - Martez Blog