Als je een Matomo heatmap hebt geopend en je hero-afbeelding is vervangen door een leeg vlak, je productfoto's als kapotte-afbeeldingsplaceholders verschijnen, en de klikmarkeringen over witruimte zweven: de klikken zelf zijn prima. Matomo heeft ze op de juiste coördinaten vastgelegd. Wat kapot is, is de screenshot eronder. De afbeeldingen kwamen niet door toen de Matomo-server de pagina opnieuw renderde, waardoor je een heatmap hebt die eruitziet als een wireframe van de pagina die je bezoekers écht zagen.
De snelste fix is een gratis Chrome-extensie die we onderhouden, genaamd Matomo Heatmap Helper. Die haalt elke cross-origin afbeelding op via het achtergrondscript van de extensie (waarmee de in-page CORS-regels worden omzeild) en brengt ze inline als base64 aan vlak voor elke opname. De rest van dit artikel beschrijft hoe je hetzelfde doet zonder extensie, plus een paar permanente fixes die het waard zijn om door te voeren.
Hoe je het repareert zonder de extensie
Er is een consolefragment dat het probleem tijdelijk oplost vlak voor elke opname, en een handvol permanente fixes die je op de site of de CDN kunt doorvoeren. Kies wat het beste past bij de beperkingen waar je mee te maken hebt.
Lijst alle cross-origin afbeeldingsverwijzingen op
Voordat je iets verandert, zoek je uit welke afbeeldingen niet laden. Open de pagina in Chrome, druk op F12 om DevTools te openen, schakel over naar de Console en plak dit. Het dekt <img src>, <img srcset>, <source srcset> binnen <picture>, <video poster>, SVG <use href>, en CSS background-image:
// 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];
})();Dat geeft je de exacte lijst van URL's die de Matomo-renderer zelf zal moeten ophalen. Alles wat wordt geserveerd vanaf een host die je niet beheert, is een kandidaat voor problemen.
De snelle fix: elke afbeelding als base64 inbedden vanuit de console
Dit is het fragment dat we in de browserconsole plakken vlak voordat we Matomo's heatmap-opname triggeren. Het loopt langs elke afbeeldingsverwijzing op de pagina (dezelfde selectors als het lijstfragment hierboven), haalt elke unieke URL één keer op, codeert die als base64 en herschrijft de verwijzingen inline. Tegen de tijd dat Matomo de DOM serialiseert, zijn de afbeeldingen ingebakken.
// 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.');
})();Wacht nu tot je zoiets ziet als: Embedded 8/8 unique URLs across 12 references. Now trigger your Matomo heatmap capture.
Het addertje: dit fragment draait in de pagina, dus het erft de CORS-regels van de pagina. Als je CDN Access-Control-Allow-Origin: * stuurt (of jouw origin), werkt de fetch en ben je klaar. Als dat niet zo is, mislukt de fetch voor die specifieke URL's en vertellen de waarschuwingen je precies welke dat zijn. Zie voor die gevallen de permanente fixes hieronder, of sla door naar het gedeelte over de extensie.
Zelf hosten of proxyen via je eigen domein
De schoonste oplossing voor bijna iedereen, net als bij lettertypen. Same-origin omzeilt het hele probleem, want de Matomo-renderer haalt de afbeelding op van je eigen domein met dezelfde toegangsregels als de originele pagina. Een nginx-proxy is twee regels:
# Edit cdn.example.com to your CDN host
location /cdn-proxy/ {
proxy_pass https://cdn.example.com/;
proxy_set_header Host cdn.example.com;
}Herschrijf vervolgens <img src="https://cdn.example.com/x.jpg"> naar <img src="/cdn-proxy/x.jpg"> in je templates. Iets zwaarder dan een CORS-header aanpassen, maar het is kogelvrij en geeft je er meteen een laag cache-controle bij.
CORS openzetten op de bucket of distributie
Als zelf hosten het niet waard is maar je de CDN wel beheert, zet dan gewoon CORS open. S3 wil dit in de CORS-configuratie van de bucket:
[{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"]
}]Cloudfront wil een response-headers-policy met Access-Control-Allow-Origin: * gekoppeld aan het image behavior. Zet ook crossorigin="anonymous" op de <img>-tags, anders neemt de browser de CORS-headers niet mee in de cache-sleutel en krijg je inconsistent gedrag tussen verse laadpogingen en cache-hits.
Dit zorgt er ook voor dat het inbeddfragment hierboven vanuit de browserconsole werkt, dus het is de moeite waard om als eerste te doen als je kunt.
Matomo op hot-link-beveiligde hosts op de whitelist zetten
Als de host blokkeert op Referer of User-Agent (gebruikelijk bij afbeeldingmarktplaatsen, anti-scraping CDN's en sommige zelf-gehoste setups), voeg je Matomo's screenshot-fetcher toe aan de allowlist. Bekijk Matomo's toegangslogs vanaf de serverzijde om de exacte User-Agent-string te zien die het verstuurt bij het ophalen voor screenshots. Voeg die string, plus het IP-adres van je Matomo-server, toe aan de regel die het verzoek afwijst.
Dit is de juiste fix als je de host niet beheert, maar de regel wel. Het is de verkeerde fix als de host een derde partij is die dit bewust doet, zoals Unsplash of stock-foto CDN's.
Afbeeldingen boven de vouw als data-URI's inbedden tijdens de build
Webpack's url-loader, Vite's ?inline-query, of handmatig gecodeerde data-URI's in je templates. Hetzelfde idee als het consolefragment, maar ingebakken in de build, zodat elke pagina wordt geleverd met de hero-afbeelding al ingebed en de Matomo-renderer niets cross-origin hoeft op te halen om die te renderen.
De moeite waard voor het handjevol afbeeldingen dat er het meest toe doet voor screenshots (hero, productfoto's, alles boven de vouw). Niet de moeite waard voor elke thumbnail op een categoriepagina.
Let op vervallen signed-URL's
Een ander probleem, hetzelfde symptoom. Als je afbeeldingen serveert vanuit S3 of Cloudfront met pre-signed URLs (TTL van een uur, een dag), is de handtekening al verlopen tegen de tijd dat Matomo's renderer actie onderneemt. De afbeelding was bereikbaar toen de bezoeker hem zag. Nu niet meer.
Twee uitwegen: verleng de TTL voorbij je heatmap-opnamevenster (Matomo kan uren of dagen achterlopen), of proxy via je eigen domein en laat de proxy on-the-fly ondertekenen. De proxy-fix werkt tegelijk als de same-origin-fix hierboven.
Waarom Matomo je afbeeldingen niet kan laden
Dezelfde grondoorzaak als de lettertypevariant van dit probleem. Matomo's screenshot-opname is een proces in twee stappen. Eerst serialiseert de tracker je live DOM naar HTML en stuurt die weg. Dan rendert je Matomo-server die HTML tot de screenshot die je in de heatmapweergave ziet. Geen browsersessie, geen cookies, geen Referer die naar je domein wijst. Gewoon een HTML-bestand dat koud wordt gerenderd vanaf een ander IP-adres.
Alles in die HTML dat naar een derde-partijhost wijst, moet eerst langs die derde partij. Voor afbeeldingen is dat meestal waar het stopt.
Wat er precies misgaat
Een paar patronen die we steeds tegenkomen:
- Hot-link-beveiliging op de CDN, de asset-host of een zelf-gehoste nginx-configuratie wijst verzoeken af met de verkeerde Referer of helemaal geen Referer. Matomo's server stuurt je domein niet als Referer bij het ophalen voor een screenshot, dus het antwoord is een 403 en de afbeelding wordt als placeholder gerenderd.
- Pre-signed URLs van S3 of Cloudfront zijn al verlopen tegen de tijd dat Matomo aan de screenshot toekomt. De bezoeker zag een geldige afbeelding, de renderer ziet een
AccessDeniedXML-body waar geen enkel afbeeldingsformaat raad mee weet. - CDN's die andere content (of 403's) serveren op basis van de
Origin-header, vaak anti-scraping-setups of geo-beperkte distributies, blokkeren Matomo's IP volledig. - Rate-limited of IP-reputation-geblokkeerde afbeeldingshosts. Unsplash, bepaalde Cloudinary-tiers, alles wat limiteert op aanvraagvolume per IP. Matomo's renderer bundelt verzoeken, de host throttelt, de screenshot wordt verstuurd met de helft van de afbeeldingen mist.
<picture><source srcset>-verwijzingen die naar een andere cross-origin URL verwijzen dan de<img src>-fallback. De<img>komt misschien door, de hogere-resolutie<source>niet, en welke Matomo pakt hangt af van de viewport van zijn renderer.
Als je heatmap kapotte-afbeeldingsplaceholders toont, heb je met minimaal één van deze te maken. Vaak meer dan één op dezelfde pagina.
Dit is ook dezelfde familie van problemen die lettertypen, scrollcontainers en sticky headers in dezelfde screenshot breekt. We hebben een langere post over kapotte Matomo heatmap-screenshots die de rest behandelt, en een aparte post over waarom lettertypen niet laden in je Matomo heatmap omdat dat bijna even vaak voorkomt.
Wat we zelf zouden doen
Als je de CDN of de host beheert, zet CORS open en zet crossorigin="anonymous" op de <img>-tags. Het consolefragment werkt meteen en een permanente fix is één CDN-configuratie verwijderd.
Als je de templates beheert maar niet de CDN, proxy de afbeeldingen via je eigen domein. Same-origin lost het eenmalig op en verwijdert een klasse van fouten waar je anders blijft over struikelen.
Als geen van beide haalbaar is (derde-partijhosts, afgesloten buckets, signed URLs die je niet beheert), is de Matomo Heatmap Helper Chrome-extensie wat we gebruiken op clientsites waar we de stack niet kunnen aanpassen. Het achtergrondscript haalt de afbeeldingen op buiten de CORS-context van de pagina, dus het werkt zelfs op hosts die het in-page fragment blokkeren op Referer of Origin. Gratis, open source, code op GitHub.
Martez is het grotere project waar de extensie uit voortkomt. Het verbindt Matomo met Meta Ads en Google Ads zodat ROAS, CLV en attributie naast je webanalytics staan in plaats van in een aparte spreadsheet. Het zit in private beta. Zet je op de wachtlijst als dat relevant voor je is.
Meestal is dit één CDN-configuratie verwijderd. De moeite waard om één keer goed te doen.