Matomoヒートマップで画像が表示されない原因と修正方法

MatomoヒートマップのスクリーンショットでCDN、署名付きURL、ホットリンク保護されたホストがMatomoサーバーからのフェッチを拒否すると、壊れた画像のプレースホルダーが表示されます。クリックデータ自体は問題ありません。原因と対処法を解説します。

Matomoのヒートマップを開いたらヒーロー画像が真っ白な長方形に変わっていて、商品写真が壊れたアイコンになっていて、クリックマーカーが白いスペースの上に浮いている——そんな状況でも、クリック自体は問題ありません。Matomoは正しい座標にクリックを記録しています。壊れているのはその下にあるスクリーンショットです。Matomoサーバーがページを再レンダリングしたときに画像が読み込めなかったので、実際の訪問者が見たページのワイヤーフレームみたいなヒートマップになってしまっているわけです。

一番手っ取り早い修正は、私たちが開発している無料のChrome拡張機能 Matomo Heatmap Helper を使う方法です。拡張機能のバックグラウンドスクリプト経由でクロスオリジン画像をフェッチする(ページ内のCORSルールを回避できる)ことで、各キャプチャの直前にbase64としてインライン化します。この記事の残りは、拡張機能なしで同じことをやる方法と、実際にリリースする価値のある恒久的な修正策についてです。

拡張機能を使わずに修正する方法

各キャプチャの直前に問題を回避するコンソールのスニペットと、サイトやCDNに対して実装できる恒久的な修正策がいくつかあります。置かれている制約に合わせて選んでください。

クロスオリジン画像参照を洗い出す

何かを変更する前に、どの画像が読み込まれていないかを確認しましょう。ChromeでページをひらいてF12でDevToolsを開き、Consoleに切り替えて以下を貼り付けます。<img src><img srcset><picture> 内の <source srcset><video poster>、SVGの <use href>、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];
})();

これでMatomoのレンダラーが自分でフェッチしなければならないURLの一覧が正確に得られます。自分でコントロールできないホストから配信されているものは、どれも壊れる可能性があります。

手っ取り早い修正:コンソールからすべての画像をbase64で埋め込む

これは、Matomoのヒートマップキャプチャをトリガーする直前にブラウザのコンソールに貼り付けるスニペットです。ページ上のすべての画像参照を巡回し(上のリスト用スニペットと同じセレクターを網羅)、ユニークなURLを一度ずつフェッチしてbase64エンコードし、参照をインラインに書き換えます。MatomoがDOMをシリアライズする時点では、画像がすでに埋め込まれている状態になります。

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

次のような表示が出るまで待ちます: Embedded 8/8 unique URLs across 12 references. Now trigger your Matomo heatmap capture.

注意点:このスニペットはページ内で実行されるため、ページのCORSルールを引き継ぎます。CDNが Access-Control-Allow-Origin: *(またはあなたのオリジン)を送っていれば、フェッチは成功します。送っていない場合、該当URLのフェッチは失敗し、警告メッセージでどのURLが問題かを教えてくれます。そちらについては下の恒久的な修正策を参照するか、拡張機能のセクションに進んでください。

自分のドメイン経由でセルフホストまたはプロキシする

フォントの問題と同様、ほぼすべての場合において最もクリーンな解決策です。同一オリジンであれば問題全体を回避できます。Matomoのレンダラーがあなたのドメインからページと同じアクセスルールで画像をフェッチできるからです。nginxのプロキシは2行で済みます:

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

あとはテンプレートの <img src="https://cdn.example.com/x.jpg"><img src="/cdn-proxy/x.jpg"> に書き換えるだけです。CORSヘッダーをいじるより手間はかかりますが、確実に動きますし、副次的にキャッシュコントロールのレイヤーも手に入ります。

バケットまたはディストリビューションでCORSを開放する

セルフホストは割に合わないけれどCDNを制御できる場合は、CORSを開放するだけです。S3ではバケットのCORS設定にこれを追加します:

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

Cloudfrontでは、画像用のビヘイビアに Access-Control-Allow-Origin: * を含むレスポンスヘッダーポリシーを適用します。<img> タグにも crossorigin="anonymous" を設定してください。そうしないとブラウザがCORSヘッダーをキャッシュキーに含めず、新規ロードとキャッシュヒット時で動作が一致しなくなります。

これはブラウザのコンソールから上の埋め込みスニペットを動かすためにも必要なので、できるなら最初にやっておく価値があります。

ホットリンク保護されたホストにMatomoをホワイトリスト登録する

ホストがRefererまたはUser-Agentで弾いている場合(画像マーケットプレイス、アンチスクレイピングCDN、一部のセルフホスト構成などでよくあります)、Matomoのスクリーンショットフェッチャーをアローリストに追加します。サーバーサイドのMatomoアクセスログを確認して、スクリーンショット取得時に送信される正確なUser-Agent文字列を調べてください。その文字列とMatomoサーバーのIPアドレスを、リクエストを弾いているルールに追加します。

ホストは制御できないけれどルールは制御できる、というケースに適した修正方法です。UnsplashやストックフォトCDNのように、ホストが意図的にやっている場合は対象外です。

ビルド時にファーストビューの画像をデータURIとしてインライン化する

Webpackの url-loader、Viteの ?inline クエリ、またはテンプレートへの手動エンコードが使えます。コンソールスニペットと同じ発想をビルドに焼き込む形で、ページがヒーロー画像を最初から埋め込んだ状態で配信されるため、Matomoのレンダラーはそれをクロスオリジンでフェッチする必要がなくなります。

スクリーンショットに最も影響する少数の画像(ヒーロー、商品写真、ファーストビューのすべて)に対してやる価値があります。カテゴリページのすべてのサムネイルに対してやる価値はありません。

署名付きURLの有効期限に注意する

別の問題ですが、症状は同じです。S3やCloudfrontからプリサインドURL(TTL1時間や1日)で画像を配信している場合、Matomoのレンダラーが動く頃には署名が古くなっています。訪問者が見たときは読み込めていた画像が、もう読み込めなくなっています。

対処は2つ:ヒートマップキャプチャウィンドウ(Matomoは数時間〜数日遅延することがある)を超えるようにTTLを延ばすか、自分のドメイン経由でプロキシしてプロキシ側でオンザフライで署名するか。プロキシは上の同一オリジン修正と兼ねて使えます。

Matomoが画像を読み込めない理由

フォントの問題と根本原因は同じです。Matomoのスクリーンショットキャプチャは2段階で動いています。まず、トラッカーがライブDOMをHTMLにシリアライズして送信します。次に、MatomoサーバーがそのHTMLを再レンダリングしてヒートマップビューに表示するスクリーンショットを生成します。ブラウザセッションなし、クッキーなし、あなたのドメインを指すRefererなし。別のIPから、HTMLファイルをコールドレンダリングするだけです。

そのHTML内でサードパーティホストを参照しているものは、まずサードパーティを通過しなければなりません。画像の場合、たいていそこで止まります。

実際に何が失敗しているか

よく遭遇するパターンをいくつか挙げます:

  • CDN、アセットホスト、またはセルフホストのnginx設定のホットリンク保護が、Refererが違う、またはRefererがないリクエストを弾く。Matomoのサーバーはスクリーンショット取得時にあなたのドメインをRefererとして送らないため、403が返って画像がプレースホルダーになる。
  • S3またはCloudfrontの署名付きURLが、Matomoがスクリーンショットを処理する時点ですでに期限切れ。訪問者には有効な画像が見えていたのに、レンダラーはどの画像フォーマットも処理できない AccessDenied のXMLボディを受け取る。
  • Origin ヘッダーに応じて異なるコンテンツ(または403)を返すCDN——よくあるのはアンチスクレイピング設定やジオ制限ディストリビューション——がMatomoのIPをそもそも弾く。
  • レートリミットまたはIPレピュテーションによるブロックをかける画像ホスト。Unsplash、一部のCloudinaryティア、IPあたりのリクエスト量で制限するもの全般。Matomoのレンダラーはリクエストをバッチ処理するため、ホスト側がスロットリングして、スクリーンショットが画像の半分欠けた状態で出来上がる。
  • <picture><source srcset> 参照が <img src> のフォールバックとは別のクロスオリジンURLに解決される。<img> は通過しても高解像度の <source> は通過せず、Matomoがどちらを選ぶかはレンダラーのビューポートによる。

ヒートマップに壊れた画像のプレースホルダーが出ている場合、これらのうち少なくとも一つに該当しています。同じページで複数同時に起きていることも珍しくありません。

これはフォント、スクロールコンテナ、スティッキーヘッダーが同じスクリーンショットで壊れる問題と同じ系統でもあります。Matomoヒートマップのスクリーンショットが壊れる問題についての詳細な記事もあり、そちらで残りの問題を解説しています。また、Matomoヒートマップでフォントが表示されない問題については別の記事を書きました。ほぼ同じくらいよく起きるので。

実際にどうするか

CDNまたはホストを制御できる場合は、CORSを開放して <img> タグに crossorigin="anonymous" を設定します。コンソールスニペットがすぐに動くようになり、恒久的な修正もCDNの設定変更一つで済みます。

テンプレートは制御できるがCDNは制御できない場合は、自分のドメイン経由で画像をプロキシします。同一オリジンにすることで問題が一度で解決し、他にも引っかかり続けていたはずの壊れ方の一類型をまるごと除去できます。

どちらも難しい場合(サードパーティホスト、変更できないバケット、自分では所有していない署名付きURL)、スタックを変更できないクライアントサイトで私たちが使っているのが Matomo Heatmap Helper のChrome拡張機能です。バックグラウンドスクリプトがページのCORSコンテキスト外で画像をフェッチするため、Refererまたはオリジンでページ内スニペットをブロックするホストでも動きます。無料、オープンソース、コードはGitHub上で公開しています

Martez は、この拡張機能が生まれた大きなプロジェクトです。MatomoをMeta AdsおよびGoogle Adsと接続し、ROAS、CLV、アトリビューションを別のスプレッドシートではなくWebアナリティクスの隣に置けるようにします。現在プライベートベータ中です。興味があればウェイトリストに参加してください

たいていの場合、CDNの設定変更一つで済みます。一度やっておく価値があります。

Related Articles

Matomoヒートマップで画像が表示されない原因と修正方法 - Martez Blog