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

Matomo-Heatmap-Screenshots fallen auf Times New Roman oder Arial zurück, wenn deine @font-face-Dateien durch CORS blockiert werden, auf erlaubte Domains deines Accounts beschränkt sind oder erst nach der DOM-Serialisierung geladen werden. Hier erklären wir, warum das passiert und wie wir damit umgehen.

Wenn du eine Matomo-Heatmap geöffnet hast und dein Hero in Times New Roman gesetzt ist, deine Buttons in Arial stehen und die Klick-Marker ein paar Pixel neben den Elementen schweben, zu denen sie eigentlich gehören: Die Klicks selbst sind in Ordnung. Matomo hat sie an den richtigen Koordinaten aufgezeichnet. Was kaputt ist, ist der Screenshot darunter. Deine Markenschriftarten wurden nicht geladen, als Matomos Server die Seite neu gerendert hat. Er ist deshalb auf die Systemschriften zurückgefallen, die er hatte. Und eine andere Schrift bedeutet andere Metriken, was wiederum Layout-Verschiebungen bedeutet.

Der schnellste Fix ist eine kostenlose Chrome-Erweiterung, die wir pflegen: Matomo Heatmap Helper. Sie bettet vor jeder Heatmap-Aufnahme jede Schriftart der Seite als Base64 ein, ohne dass Stylesheet-Änderungen nötig sind. Der Rest dieses Beitrags erklärt, wie du dasselbe ohne Erweiterung hinbekommst, und (weiter unten) warum Matomo deine Schriftarten überhaupt nicht laden kann.

Wie du es ohne die Erweiterung behebst

Es gibt ein schnelles Console-Snippet, das das Problem direkt vor jeder Aufnahme überbrückt, und ein paar dauerhafte Fixes, die du mit der Seite ausliefern kannst. Such dir aus, was zu deinem Stack passt. Am Ende gibt es außerdem ein JS-Muster, das Screenshots unabhängig davon kaputtmacht, welchen Fix du einsetzt – das solltest du auf jeden Fall kennen.

Diagnostizieren, was tatsächlich scheitert

Bevor du irgendetwas änderst, finde heraus, welche Schriftarten nicht laden. Öffne die Seite in Chrome, drück F12 für die DevTools, wechsel zur Console und füge Folgendes ein:

js
// Lists every font on the page and whether it loaded
document.fonts.ready.then(() => {
  [...document.fonts].forEach(f =>
    console.log(`${f.family} ${f.weight} ${f.style} → ${f.status}`));
});

Alles, was einen anderen Status als loaded hat, ist ein Kandidat. Dann: DevTools → Network → nach "Font" filtern und auf 403- oder CORS-Fehler bei deinen @font-face-URLs achten. Das zeigt dir, ob du ein CDN-Problem, ein Adobe-Fonts-Problem oder ein Serialisierungsproblem vor dir hast.

Der schnelle Fix: jede Schriftart als Base64 aus der Console einbetten

Das ist das Snippet, das wir in die Browser-Console einfügen, direkt bevor wir Matomos Heatmap-Aufnahme auslösen. Es geht jede @font-face-Regel auf der Seite durch (inline, same-origin und cross-origin Stylesheets), lädt die Schriftart-Binärdateien, kodiert sie als Base64 und injiziert ein neues <style>-Tag mit in sich geschlossenen Data-URIs. Wenn Matomo das DOM serialisiert, sind die Schriftarten bereits eingebettet.

js
// Paste into the browser console. Embeds every @font-face on the page as base64.
(async () => {
  const fontFaces = [];
 
  const parseSrc = (srcValue, baseUrl) => {
    const out = [];
    const re = /url\(\s*['"]?([^'")\s]+)['"]?\s*\)(?:\s*format\(\s*['"]?([^'")\s]+)['"]?\s*\))?/gi;
    let m;
    while ((m = re.exec(srcValue)) !== null) {
      const raw = m[1], format = m[2];
      if (raw.startsWith('data:')) { out.push({ url: raw, format }); continue; }
      try { out.push({ url: new URL(raw, baseUrl).href, format }); } catch {}
    }
    return out;
  };
 
  const collect = (rule, baseUrl) => {
    if (rule instanceof CSSFontFaceRule) {
      const s = rule.style;
      const family = s.getPropertyValue('font-family').replace(/['"]/g, '').trim();
      const src = s.getPropertyValue('src');
      if (!family || !src) return;
      fontFaces.push({
        family,
        weight: s.getPropertyValue('font-weight') || '',
        style: s.getPropertyValue('font-style') || '',
        display: s.getPropertyValue('font-display') || '',
        unicodeRange: s.getPropertyValue('unicode-range') || '',
        src: parseSrc(src, baseUrl),
      });
    } else if (rule.cssRules) {
      for (const r of Array.from(rule.cssRules)) collect(r, baseUrl);
    }
  };
 
  // 1. Walk accessible stylesheets via the CSSOM
  const crossOriginUrls = [];
  for (const sheet of Array.from(document.styleSheets)) {
    const baseUrl = sheet.href || location.href;
    try {
      for (const rule of Array.from(sheet.cssRules)) collect(rule, baseUrl);
    } catch {
      if (sheet.href) crossOriginUrls.push(sheet.href);
    }
  }
 
  // 2. Re-fetch cross-origin stylesheets as text and regex-parse @font-face blocks
  const parseCssText = (txt, baseUrl) => {
    const re = /@font-face\s*\{/gi;
    let m;
    while ((m = re.exec(txt)) !== null) {
      const start = m.index + m[0].length;
      let depth = 1, i = start;
      while (depth > 0 && i < txt.length) {
        if (txt[i] === '{') depth++; else if (txt[i] === '}') depth--;
        i++;
      }
      if (depth !== 0) continue;
      const body = txt.substring(start, i - 1);
      const get = n => {
        const r = new RegExp(n + '\\s*:\\s*([^;]+)', 'i').exec(body);
        return r ? r[1].trim() : '';
      };
      const family = get('font-family').replace(/['"]/g, '').trim();
      const src = get('src');
      if (!family || !src) continue;
      fontFaces.push({
        family,
        weight: get('font-weight'),
        style: get('font-style'),
        display: get('font-display'),
        unicodeRange: get('unicode-range'),
        src: parseSrc(src, baseUrl),
      });
    }
    // Recurse into @import
    const importRe = /@import\s+(?:url\(\s*['"]?([^'")\s]+)['"]?\s*\)|['"]([^'"]+)['"])/gi;
    const imports = [];
    while ((m = importRe.exec(txt)) !== null) {
      const u = m[1] || m[2];
      try { imports.push(new URL(u, baseUrl).href); } catch {}
    }
    return imports;
  };
 
  const visited = new Set();
  const fetchCss = async (url) => {
    if (visited.has(url)) return;
    visited.add(url);
    try {
      const txt = await fetch(url).then(r => r.text());
      const imports = parseCssText(txt, url);
      for (const u of imports) await fetchCss(u);
    } catch (e) { console.warn('CSS fetch failed:', url, e.message); }
  };
  for (const url of crossOriginUrls) await fetchCss(url);
 
  if (fontFaces.length === 0) { console.warn('No @font-face rules found.'); return; }
  console.log(`Found ${fontFaces.length} @font-face rules.`);
 
  // 3. Fetch each unique font binary and base64-encode it
  const uniqueUrls = [...new Set(
    fontFaces.flatMap(f => f.src.filter(s => !s.url.startsWith('data:')).map(s => s.url))
  )];
  console.log(`Fetching ${uniqueUrls.length} font files...`);
 
  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('Font fetch failed:', url, e.message); }
  }));
 
  // 4. Build new @font-face CSS using data URIs (with original URLs as fallback)
  const css = fontFaces.map(f => {
    const srcParts = f.src.map(s => {
      const u = dataUris.get(s.url) || s.url;
      return s.format ? `url("${u}") format("${s.format}")` : `url("${u}")`;
    });
    if (srcParts.length === 0) return '';
    const props = [`font-family: "${f.family}"`, `src: ${srcParts.join(', ')}`];
    if (f.weight)       props.push(`font-weight: ${f.weight}`);
    if (f.style)        props.push(`font-style: ${f.style}`);
    if (f.display)      props.push(`font-display: ${f.display}`);
    if (f.unicodeRange) props.push(`unicode-range: ${f.unicodeRange}`);
    return `@font-face {\n  ${props.join(';\n  ')};\n}`;
  }).filter(Boolean).join('\n\n');
 
  // 5. Inject into <head> so Matomo's DOM serializer sees self-contained fonts
  const style = document.createElement('style');
  style.setAttribute('data-heatmap-font-fix', 'true');
  style.textContent = css;
  document.head.appendChild(style);
 
  console.log(`Embedded ${dataUris.size}/${uniqueUrls.length} fonts across ${fontFaces.length} @font-face rules.`);
  console.log('Now trigger your Matomo heatmap capture.');
})();

Um zu prüfen, ob die Injektion tatsächlich gelandet ist, bevor du den Screenshot machst:

js
// Confirms the injected styles are in <head>
const tag = document.querySelector('style[data-heatmap-font-fix]');
console.log(tag
  ? `OK, injected ${(tag.textContent.match(/@font-face/g) || []).length} @font-face rules, `
    + `${(tag.textContent.match(/data:font/g) || []).length} use base64 data URIs.`
  : 'No injected style tag found, re-run the embed snippet.');

Das ist der schnelle Weg. Er funktioniert für einmalige Aufnahmen und für Review-Zyklen, bei denen du jetzt einen sauberen Screenshot brauchst und nicht auf eine Code-Änderung warten willst. Für alles, was du regelmäßig neu aufnimmst, setz lieber einen der dauerhaften Fixes unten um.

Schriftarten selbst hosten (der kanonische Fix)

Die sauberste Lösung für fast alle. Lad die woff2- und woff-Varianten, die du tatsächlich verwendest, von google-webfonts-helper herunter, leg sie unter /public/fonts ab und liefere CSS wie dieses aus:

css
/* Edit the family name and file paths to match your downloaded files */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/inter-400.woff2") format("woff2"),
       url("/fonts/inter-400.woff") format("woff");
}

Jetzt kommen die Schriftarten von deiner eigenen Domain. Gleicher Origin wie die Seite, kein CORS, das verhandelt werden muss, Matomos Server lädt sie problemlos, und deine Screenshots rendern in der richtigen Schrift. Als Bonus ist es auch schneller für deine echten Nutzer, weil du einen DNS-Lookup und eine Drittanbieter-Verbindung aus jedem Seitenaufruf entfernst. In den meisten Fällen sind das ein paar Stunden Arbeit und ein kleines CSS-Diff.

CORS auf deinem CDN fixen

Wenn du die Schriftarten lieber auf einem CDN belässt, konfiguriere das CDN so, dass es auf Schriftart-Antworten Access-Control-Allow-Origin: * zurückgibt, und füg crossorigin zum Preload-Link hinzu:

html
<!-- Edit the URL to your CDN's font path -->
<link rel="preload" as="font" type="font/woff2"
      href="https://your-cdn.example.com/inter.woff2"
      crossorigin="anonymous">

Das funktioniert für Cloudflare, Fastly, AWS und alles, was du selbst kontrollierst. Für Adobe Fonts funktioniert es nicht. Die Domain-Allowlist dort wird serverseitig erzwungen und lässt sich nicht ändern. Wer Typekit verwendet, kommt am Selbst-Hosten nicht vorbei. Adobes Lizenzierung für Self-Hosting ist ein eigenes Thema.

Das Einbetten in den Build integrieren

Wenn du das Stylesheet nicht kontrollierst, aber den Build schon, führ dieselbe Logik, die das Console-Snippet ausführt, zur Build-Zeit aus. Starte einen Headless-Browser (Playwright oder Puppeteer), führe den Einbettungs-Code gegen deine fertig gebaute Seite aus, capture den resultierenden <style>-Block und schreib ihn in dein HTML-Template. Die Seite wird mit bereits eingebetteten Schriftarten ausgeliefert. Kein Runtime-Fetch, kein Console-Einfügen vor jeder Aufnahme.

Das ist der aufwendigste der drei dauerhaften Fixes. Er deckt aber auch den Fall ab, dass die Marketing-Seite auf einem CMS liegt, das keine Stylesheet-Änderungen erlaubt, aber Build-Skripte zulässt.

Die Serialisierungs-Falle

Noch ein Punkt, der viele überrascht. Schriftarten, die zur Laufzeit via JS geladen werden, überleben Matomos DOM-Serialisierung nicht:

js
// BROKEN, won't survive Matomo's serialization
const f = new FontFace("Inter", "url(/fonts/inter.woff2)");
await f.load();
document.fonts.add(f);

Die Schriftart ist im document.fonts-Set des Browsers registriert, aber nicht im HTML, das der Serializer liest. Der Screenshot fällt auf Systemschriften zurück, auch wenn die Schriftart im Browser technisch gesehen „geladen" ist. Dasselbe gilt für Schriftarten, die von JS-Frameworks zur Hydration-Zeit injiziert werden. Verwende ein <style>-Tag mit @font-face-Regeln oder ein normales <link rel="stylesheet">. Alles, was der Serializer im statischen HTML sehen kann, funktioniert. document.fonts.add() ist für ihn unsichtbar.

Warum Matomo deine Schriftarten nicht laden kann

Matomos Screenshot-Erfassung 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. Keine Browser-Session, keine Cookies, keine Domain. Nur eine HTML-Datei, die kalt von einer anderen IP gerendert wird.

Alles in diesem HTML, das auf einen Drittanbieter-Host zeigt, muss zuerst am Drittanbieter vorbeikommen. Bei @font-face-Deklarationen ist genau da Schluss.

Was tatsächlich scheitert

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

  • Das CDN, das deine Schriftarten ausliefert, gibt keine Access-Control-Allow-Origin-Header zurück. Der Browser toleriert den Same-Origin-Fall auch ohne sie. Matomos Server, der von einem anderen Origin abruft, tut das nicht.
  • Adobe Fonts (Typekit) validiert die Origin- und Referer-Header gegen die Domains, die du in deinem Account auf die Allowlist gesetzt hast. Matomos Server steht nie auf dieser Liste, und Typekit antwortet mit 403. Matomos IP lässt sich dort auch nicht hinzufügen.
  • fonts.gstatic.com und einige andere CDNs begrenzen serverseitige Renderer durch Rate-Limiting oder blockieren sie ganz, manchmal nach User-Agent, manchmal nach IP-Reputation. Die Schriftarten funktionieren in deinem Browser und nicht in Matomos Renderer.
  • Schriftarten, die mit new FontFace(...).load() und dann document.fonts.add(...) geladen werden, sind im serialisierten HTML gar nicht vorhanden. Der Serializer liest das DOM, nicht das Runtime-FontFace-Set.

Wenn dein Heatmap-Screenshot die falsche Schrift zeigt, triffst du mindestens eines dieser Probleme. Oft mehrere gleichzeitig.

Das gehört auch zur gleichen Problemfamilie, die in demselben Screenshot Bilder, Scroll-Container und fixierte Header kaputtmacht. Wir haben einen längeren Beitrag zu fehlerhaften Matomo-Heatmap-Screenshots geschrieben, der den Rest durchgeht.

Was wir tatsächlich tun würden

Wenn du das Stylesheet kontrollierst, host selbst. Das behebt die Heatmap, entfernt eine Drittanbieter-Abhängigkeit und macht auch für deine echten Nutzer die Seite schneller.

Wenn das nicht geht, bette die Schriftarten als Base64-Data-URIs direkt in dein bestehendes Stylesheet ein. Gleiche Wirkung auf Matomos Renderer, keine Infrastrukturänderungen.

Wenn beides nicht erreichbar ist, ist der Chrome-Extension Matomo Heatmap Helper das, was wir auf Client-Seiten einsetzen, auf denen wir den Stack nicht anfassen können. Sie führt dieselbe Einbettungslogik wie das Snippet oben aus, plus entsprechende Fixes für Bilder, Scroll-Container und fixierte Header, bei jeder Aufnahme. Kostenlos, Open Source, Code auf GitHub.

Martez ist das größere Projekt, aus dem die Erweiterung entstanden 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 auf der Warteliste ein, wenn das relevant für dich ist.

Die Schriftarten lassen sich meistens in einem Stylesheet beheben. Das einmalige Erledigen lohnt sich.

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