TL;DR
- Astro v6 + View Transitions (
<ClientRouter />) 構成のブログに@astrojs/partytownを使って GA4 を導入したが、本番でgoogle-analytics.com/g/collectリクエストが一切発火しなかった - Partytown の Service Worker は active、Console エラーもなし、スクリプトタグも HTML に埋め込まれているのに計測が走らない
- 原因は Partytown + GA4 + View Transitions の組み合わせに複数の既知不具合があること、加えて main thread で
window.dataLayerを先に定義していたため Partytown の forward proxy が効かなかったこと - Partytown を外して main thread で gtag.js を async ロードする標準構成に変更することで解決
はじめに
このブログは Astro v6(AstroPaper v5 テンプレート)で作られていて、Cloudflare Workers の静的アセット配信機能で動いています。せっかくならアクセス解析を入れようと思い、Google Analytics 4 (GA4) の導入に取り組みました。
パフォーマンス最適化を重視して、最初は @astrojs/partytown を使って gtag.js を Web Worker にオフロードする方針を取りました。メインスレッドから重たいトラッキングコードを追い出せるため、Lighthouse スコアにも優しい構成です。
しかし、デプロイしても GA4 のリアルタイムレポートに一切アクセスが計上されないという問題に直面しました。
症状
本番環境(https://read-uncommitted.com/)で以下を確認しました。
- ✅
<script src="https://www.googletagmanager.com/gtag/js?id=G-..."></script>が HTML に埋め込まれている - ✅
<script type="text/partytown">でgtag('config', ...)を実行するコードも入っている - ✅ DevTools の Application → Service Workers に Partytown の Service Worker が active として表示されている
- ✅ Console にエラーなし
- ❌
google-analytics.com/g/collectリクエストが Network タブに一切表示されない - ❌ GA4 リアルタイムレポートに計上されない
スクリプトはロードされている、Service Worker も動いている、なのにデータが飛ばない。見た目上は「動いているはず」なのに計測が走らないという、切り分けが難しい症状でした。
実装していた内容
以下が最初の実装です(抜粋)。
astro.config.ts:
integrations: [
sitemap({ ... }),
partytown({
config: {
forward: ["dataLayer.push"],
},
}),
],
src/layouts/Layout.astro(PUBLIC_GA_ID がセットされていれば埋め込む):
<!-- main thread: gtag スタブ + astro:page-load リスナー -->
<script is:inline>
window.dataLayer = window.dataLayer || [];
window.gtag = function () {
window.dataLayer.push(arguments);
};
document.addEventListener("astro:page-load", function () {
window.gtag("event", "page_view", {
page_path: location.pathname + location.search,
page_title: document.title,
send_to: "G-XXXXXXXXXX",
});
});
</script>
<!-- Worker (Partytown): gtag.js 本体と初期化 -->
<script
type="text/partytown"
async
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script type="text/partytown">
window.dataLayer = window.dataLayer || [];
window.gtag = function () {
window.dataLayer.push(arguments);
};
gtag("js", new Date());
gtag("config", "G-XXXXXXXXXX", { send_page_view: false });
</script>
考え方としては次のような分担でした。
- Worker 側:
gtag.js本体のロードと初期化(configでsend_page_view: falseにして自動ページビューを抑止) - Main thread 側:
astro:page-loadで View Transitions 遷移を検知し、明示的にpage_viewイベントを発火 - Partytown の
forward: ["dataLayer.push"]で main thread のdataLayer.pushを worker に転送する
Partytown 公式ドキュメントの Google Tag Manager ガイドにほぼ準拠した形です。理屈の上では動くはずでした。
調査
Claude Code に手伝ってもらって、以下の観点で調査しました。
1. Partytown の forward メカニズム
forward: ["dataLayer.push"] は、main thread の window.dataLayer.push を Partytown が独自のフォワーディングプロキシに差し替える仕組みです。main thread のコードが dataLayer.push(...) を呼ぶと、Partytown がその呼び出しをシリアライズして Service Worker / Worker 側に送信し、Worker 内の dataLayer に反映されます。
ここで重要なのが Partytown のローダーが動く前に window.dataLayer = [] を定義すると、Partytown がプロキシを貼れないケースがあるという点です。関連 issue としては QwikDev/partytown#27 があります。
私のコードは main thread 側で最初に window.dataLayer = window.dataLayer || []; window.gtag = function () {...}; を定義していたため、window.gtag のクロージャが古い dataLayer 参照を持ち続け、Partytown が後から貼ったプロキシには届かない という状態になっていました。
2. page_path は GA4 では非推奨
実装で使っていた page_path は Universal Analytics (UA) 時代のパラメータで、GA4 では page_location(フルURL)が正式です。GA4 は page_location 未指定の場合 location.href で自動補完しますが、send_page_view: false + manual event の構成では config の初期化完了タイミング次第で event が正しく処理されない可能性があります。
参考: page_path vs page_location in GA4
3. Partytown + GA4 + View Transitions の既知問題
さらに調べると、この組み合わせに対する既知の不具合が複数見つかりました。
- QwikDev/partytown#599: 「
page_view/on_loadイベントが GA4 に届かない」—dataLayer.pushは走るが collect リクエストが発火しない。未解決 - QwikDev/partytown#165: 「GTM が history 変更を検知しない」(SPA ナビゲーション問題)
- withastro/astro#14282: 「Astro ClientRouter が GA のナビゲーション検知を壊す」— ClientRouter が
history.pushState.bind(history)をキャッシュするため、gtag が行う history API の monkey-patch が無効化される
複数の技術ブログ(Rico Sta. Cruz、Garrett Digital)も 2026 年時点で「Astro の View Transitions と Partytown + GA4 は組み合わせるな」という結論を出しています。
つまり、実装の不具合と Partytown 側の既知不具合が重なっていたのが真の原因でした。
修正
結論として、この環境では Partytown を外して main thread で gtag.js を async ロードする素朴な構成に切り替えました。async 属性により初期描画はブロックされず、Lighthouse スコアへの実質的な影響も軽微です。
src/layouts/Layout.astro(修正後):
{
PUBLIC_GA_ID && (
<>
<script
is:inline
async
src={`https://www.googletagmanager.com/gtag/js?id=${PUBLIC_GA_ID}`}
/>
<script
is:inline
set:html={`
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
window.gtag = gtag;
gtag("js", new Date());
gtag("config", ${JSON.stringify(PUBLIC_GA_ID)}, { send_page_view: false });
document.addEventListener("astro:page-load", function () {
gtag("event", "page_view", {
page_location: location.href,
page_title: document.title,
});
});
`}
/>
</>
)
}
ポイントは次の通りです。
- Partytown のラッパーを完全に排除(
type="text/partytown"なし) page_path→page_locationに修正(GA4 ネイティブ)send_page_view: falseと手動page_viewは維持:初回ロードとastro:page-load(View Transitions 含む)の両方を一つのハンドラで処理して二重計測を回避astro:page-loadは初回ロードでも発火するので、ClientRouter 遷移 + 初回の両方をカバーできる
astro.config.ts からは @astrojs/partytown import と integration 登録を削除し、依存パッケージも pnpm remove @astrojs/partytown で片付けました。
検証
修正後、本番デプロイして以下を確認しました。
curl https://read-uncommitted.com/ | grep gtagで GA スクリプトとpage_location、send_page_viewが HTML に埋め込まれていること- ブラウザ DevTools の Network タブで
https://www.google-analytics.com/g/collect?...リクエストが発火していること - トップ→記事へのナビゲーションで 遷移ごとに新しい collect リクエストが飛ぶこと(View Transitions 経由の計測)
- GA4 リアルタイムレポートに自分のアクセスが計上されること
無事、GA4 のリアルタイムビューにアクセスが表示されるようになりました。
教訓
- 「見た目上は動いている」ときほど疑うべきは外部ライブラリ(+ SPA との組み合わせ)の既知不具合。Console エラーも Service Worker の active 表示も、トラフィックが飛んでいる保証にはならない
- 初期化タイミングと closure が絡むバグはデバッグが難しい。今回の
window.dataLayerの先行定義は、Partytown 公式の GA 例に沿っているつもりでも、微妙にパターンが違っていた(公式例は bootstrap を partytown scope 内で完結させている) - Partytown は高性能なトラッキングオフロード手段ではあるが、SPA ルーティング + GA4 との組み合わせには罠がある。ブログ程度の規模で Lighthouse 数点を気にするより、まず確実に計測できるシンプル構成を選んだほうが投資対効果が高い(by Claude)
- GA4 の
page_pathは UA 時代の遺物。新規実装ではpage_locationを使うべし
とまあ、色々書いていますが、ほとんどClaude Codeに助けてもらいました。特にGithub Issuesの調査などリサーチが途中で走り出すので結構驚きです。フロントエンドは普段やっていないので、こういう機会で都度学べるのは良いですね。