Google アナリティクス 4(GA4)の管理画面には、ユーザー数もイベント数も出ていました。リアルタイムレポートにも人が映っています。ブラウザのコンソールにエラーは1行もありません。
それなのに、自分で仕込んだカスタムイベントは 1件も送信されていませんでした。
原因は、Astro の define:vars という便利なディレクティブでした。この記事は、その1行が引き起こした事故と、そこから芋づる式に出てきた「例外を出さない不具合」たちの記録です。
この記事の対象読者
Astro や他の静的サイトジェネレータで、GA4 の計測タグを自分の手で埋め込んだことがあるフロントエンド開発者を想定しています。
具体的には、次のことができる方です。
-
gtag('event', ...)を書いてイベントを送った経験がある - ブラウザの開発者ツールで Network タブを開ける
-
npm run buildで生成されたdist/の中身を覗いたことがある
この記事で得られること
- スコープを作るフレームワーク機能と、公式スニペットを組み合わせたときに何が壊れるかの理解
- 計測が届かないときの、正しい切り分け順序
- ビルド成果物のインラインスクリプトを CI で自動検査する具体的な実装
この記事で扱わないこと
- Google タグマネージャー(GTM)を使った構成。この記事は gtag.js 直書き前提です
- GA4 のレポート分析手法やマーケティング的な解釈
- サーバーサイド計測(Measurement Protocol)の実装
検証環境
| 項目 | 内容 |
|---|---|
| サイト構成 | 診断コンテンツの静的サイト(全69ページ) |
| フレームワーク | Astro(output: "static") |
| ホスティング | オブジェクトストレージ + CDN |
| 計測 | GA4(gtag.js 直書き、GTM なし) |
| 自前イベント | 進行状況・完了・シェア・招待コピーなど計9種 |
Astro のバージョンによって define:vars の出力形式は変わり得ます。この記事の出力例は執筆時点のものです。必ずご自身のビルド成果物の HTML を開いて確認してください。 検査方法は第7章で扱います。
この記事全体を通して、GA4 の計測を店舗の監視カメラシステムにたとえて説明します。カメラ本体が gtag()、映像を溜める録画サーバーが dataLayer、それを本部へ運ぶ配送業者が gtag.js、そして本部のモニタールームが GA4 の管理画面です。
今回の事故は一言でいうと、カメラのスイッチが受付カウンターではなくバックヤードの鍵付きロッカーの中に置かれていた、という話です。
1. 症状 ─ モニターには映っているのに、録画だけが空だった
このセクションで分かること: 今回の不具合が、なぜ「異常」として認識されなかったのか。
DebugView に出てくるイベントは、次の3つだけでした。
page_viewuser_engagementscroll
いずれも GA4 が拡張計測で自動収集するものです。自前で書いたイベントは1件も出てきません。
そして重要なのは、エラーが1つも出ていないことです。コンソールは静かなものでした。
送信側のコードは、よくある防御的な書き方をしていました。
const track = (name, params) => {
if (typeof window.gtag === "function") {
window.gtag("event", name, params || {});
}
};
window.gtag が関数でなければ何もしない。安全な書き方に見えます。しかし今回は、この if がすべてのイベントを黙って捨てる蓋として機能していました。
監視カメラでいえば、スイッチが見つからないので撮影をあきらめ、しかも「撮れませんでした」という報告も上げない状態です。
2. 犯人 ─ define:vars はスクリプト全体を無名関数で包む
このセクションで分かること: window.gtag が undefined になっていた直接の原因。
書いていたコードはこれです。測定 ID を設定ファイルから渡すために define:vars を使っていました。
<script is:inline define:vars={{ id: SITE.gaMeasurementId }}>
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', id);
</script>
Google 公式のスニペットをほぼそのまま貼っただけです。ところが、生成された HTML はこうなっていました。
<script>(function(){const id = "G-XXXXXXX";
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', id);
})();</script>
(function(){ ... })(); ── 全体が即時実行関数(IIFE)で包まれています。
この結果、function gtag(){} は関数スコープの中だけに存在する関数になります。グローバルには一切生えません。つまり window.gtag は undefined のままです。
Astro のリポジトリにも、この挙動を前提とした報告が上がっています。
Astro Issue #14499(2025年10月)の原文:
When defining a
<script>with the define:vars directive all code get's wrapped in an IIFE including any imports.
和訳: define:vars ディレクティブを付けた <script> を定義すると、import 文も含めてすべてのコードが IIFE で包まれます。
公式ドキュメントにも、define:vars がバンドルを無効化してインライン展開になることは明記されています。
Astro 公式ドキュメントの原文:
Using
define:varson a<script>tag implies theis:inlinedirective, which means your scripts won't be bundled and will be inlined directly into the HTML.
和訳: <script> タグで define:vars を使うと is:inline ディレクティブが暗黙的に適用され、スクリプトはバンドルされず HTML に直接インライン展開されます。
ただし、IIFE で包まれるという挙動そのものは公式リファレンスに書かれていません。これは実装上の挙動であり、生成物を見ないと分かりません。ここが今回いちばん怖かった点です。
修正は1行
明示的に window へ生やすだけです。
window.gtag = function gtag(){ window.dataLayer.push(arguments); };
カメラのスイッチを、ロッカーの中から受付カウンターの上に出しました。それだけです。
3. なぜ気づけないのか ─ dataLayer はグローバル、gtag はそうでない
このセクションで分かること: 「GA は動いている」と誤認してしまう構造的な理由。
ここがこの記事の山場です。page_view は正常に届いてしまいます。
理由はスコープの非対称性にあります。
| 対象 | 書き方 | スコープ |
|---|---|---|
dataLayer |
window.dataLayer = ... |
グローバル |
gtag |
function gtag(){} |
IIFE の内側 |
gtag('config', id) は IIFE の中で実行されます。そして押し込んだ先の window.dataLayer はグローバルです。非同期で読み込まれた gtag.js は window.dataLayer を直接読みにいくので、config は何事もなく処理されます。
一方、各ページの送信側コードは IIFE の外にあります。外から window.gtag を探しても、そこには何もありません。
監視カメラのたとえに戻すと、録画サーバーは共用スペースにあるのに、カメラのスイッチだけが鍵付きロッカーの中にある状態です。初期設定を済ませた人はロッカーの中にいたので設定できましたが、あとから来たスタッフは誰もスイッチに触れません。
結果として、こう見えます。
- リアルタイムレポートにユーザーが出る
-
page_view/scroll/user_engagementが出る - 自前のイベントだけが、静かに全部消える
「GA は動いている」と誤認する条件が、これ以上ないほど完璧に揃っていました。
ここまでのまとめ
長くなってきたので、いったん整理します。
-
define:varsはスクリプト全体を IIFE で包む - 公式スニペットの
function gtag(){}は、その中で関数スコープに閉じる -
dataLayerはグローバルなのでconfigだけは通る - その結果、
page_viewは届き、自前イベントだけが消える - 送信側の
if (typeof window.gtag === "function")が例外を握りつぶす
スコープを作るフレームワーク機能と、素の <script> を前提に書かれた公式スニペットを組み合わせた瞬間に前提が崩れる ── これが今回の教訓の中心です。
以降は、この事故を追う過程で芋づる式に出てきた別の不具合と、それらをまとめて機械で検出する方法を扱います。
4. 芋づる式に出てきた3つの「例外が出ない」不具合
このセクションで分かること: 中心的な主張を支える、独立した3つの実例。
4-1. value は GA4 が「金銭的価値」として解釈するパラメータ
診断の回答値(-2 〜 +2)を、こう送っていました。
gtag("event", "quiz_progress", { question_index: idx + 1, value: v });
value は自由に使える名前ではありません。GA4 がイベントの金銭的価値として解釈する定義済みパラメータです。
Google アナリティクス ヘルプ(キーイベントの作成または変更)の原文:
To measure the revenue associated with an event, add the value and currency parameters to the event. ... The value parameter must be a number (e.g., 50) and must be accompanied by a currency parameter.
和訳: イベントに関連する収益を測定するには、value と currency のパラメータをイベントに追加します。value パラメータは数値である必要があり、currency パラメータと併せて指定する必要があります。
正確に言うと、value は「使用禁止の予約語」ではありません。GA4 が使用を禁じているのは ga_ firebase_ google_ gtag. といった接頭辞です。しかし value は標準の指標に自動的に吸い上げられるため、診断の回答値のような無関係な数字を入れると、レポート上の数字が静かに汚れます。
修正は名前を変えるだけです。
gtag("event", "quiz_progress", { question_index: idx + 1, answer_value: v });
危ないのは、自然な英単語であるほど衝突しやすいことです。 value currency transaction_id page_title page_location あたりは、命名前に一度リファレンスを引く価値があります。
監視カメラでいえば、「金額」と印字済みの伝票欄に、アンケートの5段階評価を書き込んでいたようなものです。
4-2. define:vars を付けない <script> は、サーバー側の変数を見られない
症状は「ボタンを押しても、うんともすんとも言わない」でした。GA にも出ないし、本来の機能であるクリップボードへのコピーも動きません。それでいてページは壊れず、見た目も変わりません。
問題のあったコード(クリックで展開)
---
const { code } = Astro.params; // サーバー側 frontmatter の変数
---
<button class="iv-btn" data-invite={text}>コピー</button>
<script>
document.querySelectorAll(".iv-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
window.gtag("event", "invite_copy", { code: code }); // code は存在しない
await navigator.clipboard.writeText(btn.dataset.invite); // ここまで到達しない
});
});
</script>
クリックした瞬間に ReferenceError: code is not defined が発生します。ただし例外はイベントハンドラの中で起きるため、ページ全体は壊れません。無反応なボタンが1つ生まれるだけです。
修正は、サーバー側の値を data-* 属性で渡すことです。これは Astro 公式が推奨している方法でもあります。
<button class="iv-btn" data-invite={text} data-self={code}>コピー</button>
window.gtag("event", "invite_copy", { user_code: btn.dataset.self });
ここで得た教訓は、計測とは別のところにあります。計測の不具合を追っていたら、機能そのものが壊れていました。 イベントが飛ばないという症状は、UI の故障を知らせるシグナルでもあるということです。
4-3. 測定 ID が空になると、タグごと消える
ある日を境に、GA のグラフが落ちました。原因は設定ファイルの設計です。
export const SITE = {
gaMeasurementId: "", // 空ならタグを埋め込まない
} as const;
バックアップから復元した際に、ID の入っていないツリーで上書きされ、HTML から GA タグが1行も出力されなくなっていました。これも当然、エラーは出ません。
測定 ID はページのソースに出力される公開情報であり、秘密ではありません。.env に置くと復元や環境移行のたびに失われます。API キーやシークレットとは扱いが違います。 コードに直書きしてバージョン管理に載せるのが正解です。
カメラの設置図面を、なくしても困らない付箋に書いていたようなものでした。
5. 切り分けの順番 ─ DebugView は最後に使う
このセクションで分かること: 「DebugView に出ない」から何を結論してよくて、何を結論してはいけないか。
DebugView は便利ですが、タブ単位で効く機能です。ページ遷移で追跡が切れます。診断完了で結果ページへ遷移した瞬間に追えなくなり、シェア系のイベントが永久に確認できませんでした。
対策として、デバッグフラグを sessionStorage に持たせました。ブラウザ拡張を入れずに DebugView が使えるので、これ自体が地味に便利です。
URL パラメータでデバッグモードを永続化するコード(クリックで展開)
var dbg = false;
try {
if (location.search.indexOf('ga_debug=0') !== -1) {
sessionStorage.removeItem('ga_debug');
} else if (location.search.indexOf('ga_debug') !== -1) {
sessionStorage.setItem('ga_debug', '1');
}
dbg = sessionStorage.getItem('ga_debug') === '1';
} catch (e) {
dbg = location.search.indexOf('ga_debug') !== -1;
}
window.gtag('config', id, dbg
? { content_group: cg, debug_mode: true }
: { content_group: cg });
そして、ここが本題です。
DebugView に出ないことと、送信されていないことは、まったく別の話です。
正しい切り分けはこの順番でした。
-
Network タブで
collectを絞り込む。google-analytics.com/g/collectが 204 を返していれば、送信は成功しています -
リアルタイムレポートを見る。
debug_modeは不要で、全タブのイベントが出ます - DebugView は今このタブを追うためのものと割り切る
実際、DebugView に出ないと騒いでいたイベントは、リアルタイムには全部出ていました。監視室の小窓を覗いて「映っていない」と言っていただけで、大画面には最初から映っていたわけです。
おまけ: 赤いエラーに釣られない
コンソールにこんなエラーが出ていましたが、これはサイトとは無関係でした。
Uncaught (in promise) Error: A listener indicated an asynchronous response
by returning true, but the message channel closed before a response was received
ブラウザ拡張のコンテンツスクリプトが出す定番のノイズです。切り分けの初手はシークレットウィンドウ(拡張機能が無効になる)と覚えておくと、無駄な時間を使わずに済みます。
6. 管理画面には「遡れない設定」がある
このセクションで分かること: 初日にやっておかないと取り返しがつかない設定。
コードを直したあと、管理画面側でも順序の罠を踏みました。
キーイベントは、受信済みのイベントにしか付けられない
- 初期状態のキーイベント一覧に並ぶ
purchaseなどは、GA4 が用意した候補であって、自分のイベントとは無関係です - 自分のイベントは「最近のイベント」タブに出てきます。反映まで最大24時間かかります
- 「イベントを作成」ボタンは、既存イベントから別のイベントを合成する機能です。名前を手打ちして登録するものではありません
つまり、先にイベントを発生させる。話はそれからです。
内部トラフィック除外は「テスト」に戻せない
- フィルタの状態は テスト → 有効 には進めますが、有効 → テスト には戻せません(行けるのは「無効」だけです)
- しかも有効の間、自分の動作確認データは丸ごと捨てられます
- 動作確認 → 一時的に「無効」→ 確認完了 → 「有効」、という順序が必要です
- GA4 が既定で用意する
Internal Trafficと、自分で作ったフィルタが二重に存在しがちです
従業員が映った映像を破棄する設定を有効にしたまま、自分でカメラの前を歩いて確認していた、という状況です。
遡れない設定が2つある
| 設定 | 初期値 | 遡及 |
|---|---|---|
| データ保持期間 | 2か月 | 後から延ばしても過去は復活しない |
| カスタムディメンション | 未登録 | 登録日以降のデータにしか適用されない |
データ保持期間の既定値は2か月です。ここを触らないまま3か月放置すると、最初の1か月のイベントデータは物理的に消えています。 録画テープの保存期間が2か月に設定されている状態と同じで、あとから延長しても消えたテープは戻りません。
7. 再発防止 ─ node:vm でビルド成果物を検査する
このセクションで分かること: ブラウザを起動せずに、インラインスクリプトの実行時エラーを CI で検出する方法。
ここからは実装の話です。これまで監視カメラの配線図を追ってきましたが、この章でやるのは開店前の録画テストにあたります。カメラの前で手を振って、その映像がちゃんと録画サーバーに残っているかを毎回自動で確かめる仕組みです。
静的サイトには大きな利点があります。ビルド後の HTML がファイルとして手元にあることです。ならば、そこからスクリプトを取り出して実行できます。
Node.js 標準の node:vm モジュールを使います。
Node.js 公式ドキュメントの原文:
The node:vm module is not a security mechanism. Do not use it to run untrusted code.
和訳: node:vm モジュールはセキュリティ機構ではありません。信頼できないコードの実行には使用しないでください。
今回動かすのは自分がビルドした自分のコードなので、この用途では問題ありません。
(A) window.gtag が本当に生えるかを検証する
検査スクリプト A(クリックで展開)
import { readFileSync } from "node:fs";
import { createContext, runInContext } from "node:vm";
const html = readFileSync("dist/index.html", "utf8");
const snippet = html.match(/<script>(\(function\(\)\{const id = "G-[^]*?)<\/script>/);
const box = {
location: { search: "" },
sessionStorage: {
_d: {},
getItem(k) { return this._d[k] ?? null; },
setItem(k, v) { this._d[k] = v; },
removeItem(k) { delete this._d[k]; },
},
Date,
};
box.window = box;
createContext(box);
runInContext(snippet[1], box);
console.assert(
typeof box.window.gtag === "function",
"window.gtag が生えていない",
);
box.window.gtag("event", "quiz_complete", { user_code: "TMPA" });
console.assert(
box.window.dataLayer.some((a) => a[0] === "event" && a[1] === "quiz_complete"),
"カスタムイベントが dataLayer に積まれない",
);
この検査の良いところは、修正前のコードに戻すと、この2項目だけが落ちて config は通ることです。つまり「page_view は届くのに自前イベントだけ消える」という今回の症状を、そのまま再現する検査になっています。
(B) クリックハンドラまで実行して、実行時エラーを検出する
4-2 で扱った ReferenceError は、読み込み時には起きません。押さないと落ちないタイプのバグです。
そこで DOM をモックして、addEventListener で登録されたハンドラを全部呼びます。
検査スクリプト B(クリックで展開)
const handlers = [];
const el = () => ({
dataset: new Proxy({}, { get: () => "X" }),
textContent: "",
style: {},
addEventListener: (_, fn) => handlers.push(fn),
querySelector: () => el(),
closest: () => el(),
appendChild() {},
});
const box = {
document: {
querySelectorAll: () => [el()],
querySelector: () => el(),
getElementById: () => el(),
createElement: () => el(),
},
navigator: { clipboard: { writeText: async () => {} } },
location: { search: "", href: "https://example.com/" },
console, setTimeout, Promise, JSON, Math, Date,
};
box.window = box;
box.window.gtag = () => {};
createContext(box);
runInContext(moduleScriptSource, box);
for (const fn of handlers) {
await fn(); // ここで ReferenceError が出る
}
実際の検出結果はこうでした。
NG 招待ページのスクリプトが実行時エラー: code is not defined
Playwright を入れずに、CI でクリック時の実行時エラーを捕まえられます。 静的サイト限定の手法ですが、費用対効果は非常に高いと感じています。
8. ついでに直した2つ
このセクションで分かること: 事故対応の副産物として入れた、レポートの実用性を上げる工夫。
コンテンツグループでページを畳む
69ページが URL 単位で並ぶと、傾向がまったく読めません。種別に畳んで送ります。
const contentGroup =
path === "/" ? "診断"
: path.startsWith("/types/") ? "図鑑"
: path.startsWith("/compat/") ? "相性"
: "その他";
window.gtag('config', id, { content_group: contentGroup });
content_group は GA4 が標準で持つパラメータなので、「ページとスクリーン」レポートでそのまま切り替えられます。カスタムディメンションの登録は不要です。カメラの映像を、店舗ごとではなくフロアごとにまとめて見るようなものです。
イベントに「あとで打ち手になる」パラメータを持たせる
件数だけ取っても、次の行動は決まりません。
window.gtag("event", "invite_copy", {
user_code: btn.dataset.self, // 自分のタイプ
other_code: btn.dataset.other, // 相手のタイプ
spot_title: btn.dataset.spot, // どの提案が刺さったか
});
シェアした人と来た人のコードを同じイベントに載せておくと、16×16 のクロス集計ができます。「誰のシェアから、どんな人が生まれたか」が分かる状態になりました。
9. トラブルシューティング表
| 症状 | 疑うべき原因 | 確認方法 | 対処 |
|---|---|---|---|
page_view は出るが自前イベントが出ない |
gtag がグローバルでない |
ブラウザのコンソールで typeof window.gtag
|
window.gtag = function... で明示的に生やす |
| ボタンが無反応。エラーも出ない | ハンドラ内で ReferenceError
|
ハンドラ内に console.log を置く |
サーバー側の値は data-* で渡す |
| ある日を境にデータが途絶えた | 測定 ID が空でタグ未出力 |
dist の HTML を grep "G-"
|
測定 ID をコードに直書きして管理下に置く |
| 数字が不自然に大きい |
value パラメータの誤用 |
イベントパラメータ名を確認 |
answer_value など独自名に変更 |
| DebugView に出ない | タブ単位の制約 | Network タブで collect を確認 |
リアルタイムレポートで代替する |
| キーイベントに自分のイベントがない | まだ受信されていない | 「最近のイベント」タブを確認 | 先にイベントを発生させ、最大24時間待つ |
| 自分の動作確認データが記録されない | 内部トラフィック除外が有効 | フィルタの状態を確認 | 確認中は一時的に「無効」にする |
| 過去のデータが2か月分しかない | データ保持期間が既定値 | 管理 → データ保持 | 14か月に変更(過去は復活しない) |
10. 用語集
| 用語 | 意味 |
|---|---|
dataLayer |
送信予定のデータを積むグローバルな配列。録画サーバーにあたる |
gtag() |
dataLayer にデータを積むための関数。カメラ本体にあたる |
gtag.js |
dataLayer を読んで GA4 へ送信する非同期スクリプト。配送業者にあたる |
| IIFE | 即時実行関数式。定義と同時に実行され、内部の変数を外から見えなくする |
define:vars |
サーバー側の変数をクライアントスクリプトへ渡す Astro のディレクティブ |
| 拡張計測 |
scroll や click などを自動収集する GA4 の機能 |
| キーイベント | 旧称コンバージョン。重要なイベントに付ける印 |
| コンテンツグループ | ページを種別にまとめる GA4 標準のディメンション |
node:vm |
隔離された V8 コンテキストでコードを実行する Node.js 標準モジュール |
11. 学習ロードマップ
第1段階: 手元の計測が本当に届いているか確かめる
Network タブで collect を絞り込み、204 が返っているかを見るところから始めてください。ここが確認できるだけで、切り分けの精度が段違いになります。
第2段階: ビルド成果物を読む習慣をつける
dist/ の HTML を開き、自分が書いた <script> が実際にどう出力されているかを確認します。フレームワークが何を書き換えているのかを知ることが、この記事の事故を防ぐ唯一の方法です。
第3段階: 検査を CI に載せる
第7章のスクリプトを npm run build の後段に組み込みます。監視カメラは「設置して終わり」ではなく、壊れたことに気づける状態まで作って初めて設置完了です。
12. まとめ ─ 今回の不具合に共通する構造
| # | 不具合 | 表向きの挙動 |
|---|---|---|
| 1 |
gtag がグローバルでない |
GA は動いて見える(page_view は届く) |
| 2 |
value パラメータの誤用 |
何も起きない(数字だけ汚れる) |
| 3 | サーバー変数の誤参照 | ボタンが無反応(ページは壊れない) |
| 4 | 測定 ID が空 | エラーなし(タグが出ないだけ) |
4つとも、例外が出ない。画面も壊れない。 これが今回いちばん恐ろしかった点です。
監視カメラは、電源ランプが点いているだけでは仕事をしていることになりません。録画サーバーに映像が残っていて、それを本部が受け取れて初めて機能します。計測も同じで、送信されたかどうかを機械で検証する仕組みがあって初めて完成します。
「入れて終わり」ではなく「壊れたことに気づける状態」まで作る。それが今回の一番の収穫でした。
参考文献
Astro 公式リファレンス。define:vars が is:inline を暗黙適用すること、および data-* による受け渡しを推奨していることが書かれています。
Astro の Issue #14499。define:vars を付けたスクリプトが import 文も含めて IIFE で包まれる旨の報告です。
Astro ロードマップの Discussion #1246。define:vars を script から廃止する提案と、data-* 属性を使った移行例が書かれています。
Google アナリティクス ヘルプ。value は数値であり currency と併せて指定する必要がある、という記述があります。
Google アナリティクス ヘルプ。データ保持期間の設定可能な値と、期間を超えたデータが削除される旨が書かれています。
Google アナリティクス ヘルプ。content_group パラメータと、標準レポートでの参照方法が書かれています。
GA4 Measurement Protocol リファレンス。使用できない予約イベント名・予約パラメータ名の一覧です。
Node.js 公式ドキュメント。node:vm の createContext / runInContext の仕様と、セキュリティ機構ではないという警告が書かれています。
関連記事
JavaScript の基礎から確認したい方はこちらをどうぞ。
型定義まわりで似た事故を防ぎたい場合は TypeScript の記事も参考になるはずです。
「実行環境」という言葉の整理には ランタイム の記事が役立ちます。
外部サービスとのやり取り全般については API の記事にまとめています。
最後までお読みいただきありがとうございました。技術記事の更新は X でも告知しています。