Warum Bilder in deiner Matomo-Heatmap nicht laden (und wie du das behebst)

Matomo-Heatmap-Screenshots zeigen kaputte Bild-Platzhalter, wenn dein CDN, signierte URLs oder hotlink-geschützte Hosts den Abruf vom Matomo-Server blockieren. Die Klick-Daten sind in Ordnung. Hier erfährst du, warum das passiert und wie wir damit umgehen.

Wenn du eine Matomo-Heatmap geöffnet hast und dein Hero-Bild durch ein leeres Rechteck ersetzt wurde, deine Produktfotos als kaputte Platzhalter erscheinen und die Klick-Marker über weißem Leerraum schweben, sind die Klicks selbst in Ordnung. Matomo hat sie an den richtigen Koordinaten aufgezeichnet. Was kaputt ist, ist der Screenshot darunter. Die Bilder kamen beim serverseitigen Neu-Rendering der Seite nicht durch, sodass du eine Heatmap siehst, die wie ein Wireframe der Seite aussieht, die deine Besucher tatsächlich gesehen haben.

Der schnellste Fix ist eine kostenlose Chrome-Erweiterung, die wir entwickeln und pflegen: Matomo Heatmap Helper. Sie ruft jedes Cross-Origin-Bild über das Hintergrundskript der Erweiterung ab (das die seiteninternen CORS-Regeln umgeht) und bettet sie als Base64 direkt vor jeder Aufnahme ein. Der Rest dieses Beitrags erklärt, wie du dasselbe ohne Erweiterung erreichst, plus ein paar dauerhafte Fixes, die sich zu veröffentlichen lohnen.

Wie du es ohne die Erweiterung behebst

Es gibt einen Konsolen-Snippet, der das Problem direkt vor jeder Aufnahme überbrückt, und eine Handvoll dauerhafter Fixes, die du mit der Website oder dem CDN ausliefern kannst. Nimm den, der zu deinen Rahmenbedingungen passt.

Alle Cross-Origin-Bildreferenzen auflisten

Bevor du irgendwas änderst, finde heraus, welche Bilder nicht laden. Öffne die Seite in Chrome, drück F12 um die DevTools zu öffnen, wechsle zur Console und füg das hier ein. Es deckt <img src>, <img srcset>, <source srcset> innerhalb von <picture>, <video poster>, SVG <use href> und CSS background-image ab:

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

Das gibt dir die genaue Liste der URLs, die der Matomo-Renderer selbst abrufen muss. Alles, was von einem Host geliefert wird, den du nicht kontrollierst, ist ein Kandidat für Probleme.

Der schnelle Fix: Jedes Bild als Base64 aus der Konsole einbetten

Das ist der Snippet, den wir direkt vor dem Auslösen von Matomos Heatmap-Aufnahme in die Browser-Konsole einfügen. Er geht durch jede Bildreferenz auf der Seite (deckt dieselben Selektoren wie der Auflistungs-Snippet oben ab), ruft jede eindeutige URL einmal ab, kodiert sie als Base64 und schreibt die Referenzen inline um. Wenn Matomo das DOM serialisiert, sind die Bilder bereits eingebettet.

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

Warte, bis du so etwas siehst: Embedded 8/8 unique URLs across 12 references. Now trigger your Matomo heatmap capture.

Der Haken: Dieser Snippet läuft auf der Seite und erbt damit deren CORS-Regeln. Wenn dein CDN Access-Control-Allow-Origin: * sendet (oder deinen Origin), klappt der Abruf und du bist fertig. Wenn nicht, schlägt der Abruf für diese spezifischen URLs fehl und die Warnmeldungen zeigen dir genau, welche. Für die gibt es die dauerhaften Fixes weiter unten, oder du springst direkt zum Abschnitt mit der Erweiterung.

Self-Hosting oder Proxy über deine eigene Domain

Die sauberste Lösung für fast alle, genauso wie bei Schriftarten. Same-Origin umgeht das ganze Problem, weil der Matomo-Renderer das Bild von deiner Domain mit denselben Zugriffsregeln abruft wie die ursprüngliche Seite. Ein nginx-Proxy sind zwei Zeilen:

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

Dann schreibst du <img src="https://cdn.example.com/x.jpg"> in deinen Templates zu <img src="/cdn-proxy/x.jpg"> um. Aufwändiger als einen CORS-Header zu setzen, aber kugelsicher, und du bekommst nebenbei noch eine Caching-Kontrollschicht dazu.

CORS am Bucket oder an der Distribution öffnen

Wenn Self-Hosting nicht lohnt, du aber das CDN kontrollierst, öffne einfach CORS. S3 erwartet das in der Bucket-CORS-Konfiguration:

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

Cloudfront möchte eine Response-Headers-Policy mit Access-Control-Allow-Origin: *, die an das Image-Behavior angehängt wird. Setz außerdem crossorigin="anonymous" an den <img>-Tags, sonst bezieht der Browser die CORS-Header nicht in den Cache-Key ein und du bekommst inkonsistentes Verhalten zwischen Erstladen und Cache-Hits.

Das bringt auch den Einbettungs-Snippet oben zum Laufen, also ist es sinnvoll, das zuerst anzugehen, wenn es möglich ist.

Wenn der Host nach Referer oder User-Agent blockt (verbreitet bei Bild-Marktplätzen, Anti-Scraping-CDNs und manchen selbst gehosteten Setups), füge Matomos Screenshot-Fetcher zur Allowlist hinzu. Schau dir Matomos Zugriffslogs auf Serverseite an, um den genauen User-Agent-String zu sehen, den er beim Abruf für Screenshots sendet. Füg diesen String plus die IP deines Matomo-Servers zu der Regel hinzu, die die Anfrage ablehnt.

Das ist der richtige Fix, wenn du den Host nicht kontrollierst, aber die Regel schon. Es ist der falsche Fix, wenn der Host ein Drittanbieter ist, der das absichtlich macht, wie Unsplash oder Stock-Foto-CDNs.

Above-the-fold-Bilder beim Build als Data-URIs einbetten

Webpacks url-loader, Vites ?inline-Query oder händisch kodierte Data-URIs in deinen Templates. Gleiche Idee wie der Konsolen-Snippet, aber in den Build eingebaut, sodass jede Seite mit dem bereits eingebetteten Hero-Bild ausgeliefert wird und der Matomo-Renderer nichts Cross-Origin abrufen muss, um es darzustellen.

Lohnt sich für die paar Bilder, die für Screenshots am wichtigsten sind (Hero, Produktfotos, alles above the fold). Nicht für jedes Thumbnail auf einer Kategorieseite.

Ablauf von signierten URLs im Blick behalten

Anderes Problem, gleiche Symptome. Wenn du Bilder von S3 oder Cloudfront mit pre-signierten URLs auslieferst (TTL eine Stunde, ein Tag), ist die Signatur abgelaufen, wenn Matomos Renderer aktiv wird. Das Bild war aufrufbar, als der Besucher es sah. Jetzt nicht mehr.

Zwei Wege raus: TTL so weit verlängern, dass es über dein Heatmap-Aufnahmefenster hinausgeht (Matomo kann um Stunden oder Tage verzögert sein), oder über deine eigene Domain proxyen und den Proxy on the fly signieren lassen. Der Proxy-Fix ist gleichzeitig auch der Same-Origin-Fix von oben.

Warum Matomo deine Bilder nicht laden kann

Gleiche Grundursache wie bei der Schriftarten-Variante dieses Problems. Matomos Screenshot-Aufnahme läuft in zwei Schritten ab. Zuerst serialisiert der Tracker dein Live-DOM in HTML und schickt es weg. Dann rendert dein Matomo-Server dieses HTML, um den Screenshot zu erzeugen, den du in der Heatmap-Ansicht siehst. Kein Browser-Session, keine Cookies, kein Referer, der auf deine Domain zeigt. Nur eine HTML-Datei, die kalt von einer anderen IP gerendert wird.

Alles in diesem HTML, das nach außen zu einem Drittanbieter-Host zeigt, muss erst an dem Drittanbieter vorbei. Bei Bildern ist das meistens, wo es hakt.

Was tatsächlich fehlschlägt

Ein paar Muster, auf die wir immer wieder stoßen:

  • Hotlink-Schutz am CDN, dem Asset-Host oder einer selbst gehosteten nginx-Konfiguration lehnt Anfragen mit falschem oder fehlendem Referer ab. Matomos Server sendet deine Domain nicht als Referer beim Screenshot-Abruf, also kommt ein 403 zurück und das Bild wird als Platzhalter gerendert.
  • Pre-signierte URLs von S3 oder Cloudfront sind bereits abgelaufen, wenn Matomo mit dem Screenshot anfängt. Der Besucher sah ein gültiges Bild, der Renderer sieht einen AccessDenied-XML-Body, mit dem kein Bildformat etwas anfangen kann.
  • CDNs, die abhängig vom Origin-Header unterschiedliche Inhalte (oder 403s) ausliefern, oft Anti-Scraping-Setups oder geo-beschränkte Distributionen, lehnen Matomos IP direkt ab.
  • Rate-begrenzte oder IP-reputationsblockierte Bild-Hosts. Unsplash, manche Cloudinary-Tiers, alles, das nach Anfragevolumen pro IP begrenzt. Matomos Renderer bündelt Anfragen, der Host drosselt, der Screenshot liefert ohne die Hälfte seiner Bilder.
  • <picture> <source srcset>-Referenzen, die zu einer anderen Cross-Origin-URL auflösen als der <img src>-Fallback. Das <img> kommt vielleicht durch, die höher aufgelöste <source> nicht, und welche Matomo nimmt, hängt vom Viewport seines Renderers ab.

Wenn deine Heatmap kaputte Bild-Platzhalter zeigt, triffst du mindestens eines davon. Oft mehrere gleichzeitig auf derselben Seite.

Das ist auch dieselbe Problemfamilie, die Schriftarten, Scroll-Container und fixierte Header im selben Screenshot zerschießt. Wir haben einen längeren Beitrag zu kaputten Matomo-Heatmap-Screenshots geschrieben, der den Rest durchgeht, und einen separaten Beitrag darüber, warum Schriftarten in deiner Matomo-Heatmap nicht laden, weil das fast genauso häufig vorkommt.

Was wir tatsächlich tun würden

Wenn du das CDN oder den Host kontrollierst, öffne CORS und setz crossorigin="anonymous" an den <img>-Tags. Der Konsolen-Snippet funktioniert sofort und ein dauerhafter Fix ist eine CDN-Konfiguration entfernt.

Wenn du die Templates, aber nicht das CDN kontrollierst, proxye die Bilder über deine eigene Domain. Same-Origin löst es ein für alle Mal und beseitigt eine Fehlerklasse, über die du sonst immer wieder stolpern würdest.

Wenn beides nicht erreichbar ist (Drittanbieter-Hosts, gesperrte Buckets, signierte URLs, die dir nicht gehören), ist die Chrome-Erweiterung Matomo Heatmap Helper das, was wir auf Kundenseiten nutzen, wo wir den Stack nicht ändern können. Ihr Hintergrundskript ruft die Bilder außerhalb des CORS-Kontexts der Seite ab, sodass es auch auf Hosts funktioniert, die den seiteninternen Snippet nach Referer oder Origin blockieren. Kostenlos, Open Source, Code auf GitHub.

Martez ist das größere Projekt, aus dem die Erweiterung hervorgegangen ist. Es verbindet Matomo mit Meta Ads und Google Ads, sodass ROAS, CLV und Attribution neben deinen Web-Analytics sitzen statt in einer separaten Tabelle. Es befindet sich in der Private Beta. Trag dich in die Warteliste ein, wenn das relevant für dich ist.

Meistens ist das eine CDN-Konfiguration entfernt. Einmal machen, dann erledigt.

Warum Bilder in deiner Matomo-Heatmap nicht laden (und wie du das behebst) - Martez Blog