GA4イベント設計の沼から抜けた話:命名を固定し計測漏れと二重計測を止める
PVは伸びるのに何が効いたか分からない。GA4とPlausibleのイベント命名を固定し、計測漏れ・二重計測・同意管理(consent mode)まで実装で潰した記録。コピペで動くJS付き。
ダッシュボードを開いて、僕はしばらく固まりました。
3つのグラフに、それぞれ違う数字で「CTAクリック」が並んでいたんです。GA4ではcta_click、別のコンポーネントではclick_cta、フッターだけなぜかbutton_click。同じボタンを押した結果が、3つの名前でバラバラに記録されていました。どれが本当の数字か、誰にも分からない。
しかもPVは順調に伸びていました。なのに「どの記事が商品につながったか」だけは、いつまで経っても答えられない。理由は単純で、僕がPVだけ見て、肝心の行動(読了・CTA・問い合わせ・購入導線)をちゃんと測っていなかったからです。
アナリティクス実装は「タグを貼る作業」じゃありません。何を、どんな名前で、どこで一度だけ記録するかを決める設計の作業です。今日はその設計を、僕がやらかした計測漏れ・二重計測・同意まわりの失敗ごと、コピペで動くコードで残します。
この記事の要点
- アナリティクス実装の本体は「イベント命名の固定」。
cta_clickかclick_ctaかで揺れた瞬間、データは壊れる。最初に1枚の計測計画表を作る。 - GA4は標準イベント(
generate_lead等)に寄せ、独自イベントはsnake_caseで統一。Plausibleはゴール名と完全一致が必須。 - 計測漏れの二大原因は「同意前で送らない設計なのに同意UIがない」と「広告ブロッカーでタグが落ちる」。consent modeとサーバー側計測で補う。
- 二重計測はクリックと送信成功の両方で送る、ブラウザとサーバーで同じイベントを送る、で起きる。イベントごとに確定地点を1つに決める。
- 改善実験はA/Bテスト実装、出し分けはフィーチャーフラグ運用へ。計測はその土台。
なぜ「命名」がアナリティクスの9割なのか
イベントの名前は、あとから直すのが本当に高くつきます。
GA4は基本的に、過去にさかのぼってイベント名を変えてくれません。lead_submitで半年集めたデータをgenerate_leadに直すと、その日を境にグラフが分断されます。「先月と比べて問い合わせは増えた?」に答えられなくなる。名前のブレは、未来の自分から比較する力を奪う行為なんです。
だから僕は、コードを書く前に必ず1枚の表を作ります。Claude Codeに頼むときも、最初の指示はコードじゃなくてこの表です。
このサイトのアナリティクス実装計画を表で作ってください。
目的はPVだけでなく、読了・CTAクリック・問い合わせ・商品クリック・購入導線の改善です。
列は business_question, event_name, trigger, required_params, provider, decision。
GA4の標準イベントに寄せられるものは寄せ、独自イベントは snake_case で統一してください。
返ってくる表は、たとえばこうなります。これがプロジェクト全体の「正書法」になります。
| 知りたいこと | event_name | 発火条件 | 必須パラメータ | provider | 何を直すか |
|---|---|---|---|---|---|
| 最後まで読まれているか | article_read_complete | 本文末尾が70%以上見えた | slug, category, reading_time_sec | GA4/Plausible | 導入・見出し・内部リンク |
| CTAは押されているか | cta_click | 商品・研修・無料PDFのCTAを押した | slug, cta_id, cta_type, target_url | GA4/Plausible | CTAの位置と文言 |
| 問い合わせは完了したか | generate_lead | フォーム送信が成功した | form_id, lead_source, value, currency | GA4 | フォーム項目と訴求 |
| 収益導線になっているか | purchase_link_click | 商品・Gumroadリンクを押した | product_id, price, currency, slug | GA4 | 記事と商品の対応 |
| 流入元はどこか | campaign_landing | UTM付きで初回着地した | utm_source, utm_medium, utm_campaign | GA4 | 広告・SNSの出し方 |
GA4の標準イベントは公式のrecommended eventsで確認します。generate_leadやpurchaseのように寄せられるものは寄せると、GA4側の自動レポートにそのまま乗ってくれて楽です。
ここで一つ注意。Plausibleはゴール名がコードと完全一致でないと記録されません。GA4はsnake_case、Plausibleは管理画面のゴール名と一字一句同じ。この2つを別々に手書きすると、たいてい片方がズレます。だから次に、名前を1か所に閉じ込めます。
イベント契約:名前を1か所に閉じ込める
「イベント契約」と呼んでいるのは、イベント名・必須パラメータ・送信先を1ファイルにまとめた約束ごとです。
これを置くと、AIや別の自分が、別のファイルで勝手にclick_ctaを生やす事故が激減します。送る前に必須パラメータの欠けもチェックできる。アナリティクス実装で最初に書くべきは、おしゃれな計測コードじゃなくてこの地味な辞書です。
// event-plan.mjs — イベント命名の唯一の正解をここに集約する
import { pathToFileURL } from "node:url";
export const eventPlan = {
article_read_complete: {
required: ["slug", "category", "reading_time_sec"],
providers: ["GA4", "Plausible"],
},
cta_click: {
required: ["slug", "cta_id", "cta_type", "target_url"],
providers: ["GA4", "Plausible"],
},
generate_lead: {
required: ["form_id", "lead_source", "value", "currency"],
providers: ["GA4"],
},
purchase_link_click: {
required: ["product_id", "price", "currency", "slug"],
providers: ["GA4"],
},
campaign_landing: {
required: ["utm_source", "utm_medium", "utm_campaign"],
providers: ["GA4"],
},
};
// 送る前に「知らない名前」と「必須パラメータ漏れ」を弾く門番
export function validateEvent(name, params = {}) {
const contract = eventPlan[name];
if (!contract) return { ok: false, missing: ["known_event_name"] };
const missing = contract.required.filter(
(key) => params[key] === undefined || params[key] === ""
);
return { ok: missing.length === 0, missing };
}
// 単体実行すると、その場で契約どおりか確認できる
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
console.log(
validateEvent("cta_click", {
slug: "claude-code-analytics-implementation",
cta_id: "products_footer",
cta_type: "product",
target_url: "/products/",
})
);
// => { ok: true, missing: [] }
console.log(validateEvent("cta_click", { slug: "x" }));
// => { ok: false, missing: [ 'cta_id', 'cta_type', 'target_url' ] }
}
node event-plan.mjsで動きます。CIに組み込んでおけば、必須パラメータを忘れた計測が本番に出る前に止まります。/products/への商品クリックと/training/への相談クリックは、同じ「詳しく見る」でもcta_typeで分けて測る。改善の打ち手がまるで違うからです。
ブラウザ計測は1か所に寄せて同意で守る
次がブラウザ側。ここで一番やってはいけないのが、各コンポーネントから直接gtagを呼ぶことです。
直接呼びにすると、同意チェックの抜け、UTMの取りこぼし、空パラメータの混入が、呼び出し箇所の数だけ起きます。だから同意確認・UTM保存・パラメータ掃除・複数プロバイダー送信を、全部1つの層に押し込めます。
そして同意。GA4にはconsent modeという仕組みがあり、まず「既定では送らない」を宣言し、ユーザーが許可したらupdateで切り替えます。公式のconsent modeどおり、defaultはタグ読み込みより前に置くのが鉄則です。
// browser-analytics.js — 同意・UTM・送信を1か所に集約する
const CONSENT_KEY = "analytics_consent";
const UTM_KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"];
function inBrowser() {
return typeof window !== "undefined" && typeof localStorage !== "undefined";
}
function hasConsent() {
return inBrowser() && localStorage.getItem(CONSENT_KEY) === "granted";
}
// ページ読み込み直後、タグより前に呼ぶ。既定はすべて denied
export function initConsentDefaults() {
if (!inBrowser()) return;
window.gtag?.("consent", "default", {
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
analytics_storage: "denied",
});
}
// 同意バナーでOKされた瞬間に呼ぶ。ここで初めて計測が動き出す
export function setAnalyticsConsent(state) {
if (!inBrowser()) return;
localStorage.setItem(CONSENT_KEY, state); // "granted" or "denied"
window.gtag?.("consent", "update", {
analytics_storage: state,
ad_storage: "denied", // 広告計測はこのサイトでは使わない
});
}
// 空・null・undefined を捨て、boolean は数値に直して送る
function cleanParams(params = {}) {
return Object.fromEntries(
Object.entries(params)
.filter(([, v]) => v !== undefined && v !== null && v !== "")
.map(([k, v]) => [k, typeof v === "boolean" ? Number(v) : v])
);
}
// UTMは初回着地で保存し、以降の問い合わせまで持ち回る
export function readUtmParams() {
if (!inBrowser()) return {};
const current = new URLSearchParams(window.location.search);
const saved = JSON.parse(localStorage.getItem("landing_utm") || "{}");
const next = { ...saved };
for (const key of UTM_KEYS) {
const value = current.get(key);
if (value) next[key] = value;
}
localStorage.setItem("landing_utm", JSON.stringify(next));
return next;
}
// すべてのイベントはこの関数だけを通す
export function trackEvent(name, params = {}) {
if (!hasConsent()) return; // 同意がなければ何も送らない
const payload = cleanParams({ ...readUtmParams(), ...params });
window.gtag?.("event", name, payload);
window.plausible?.(name, { props: payload }); // ゴール名はGA4と同一に保つ
}
export function trackCtaClick({ slug, ctaId, ctaType, targetUrl }) {
trackEvent("cta_click", {
slug,
cta_id: ctaId,
cta_type: ctaType,
target_url: targetUrl,
});
}
Plausibleのカスタムイベントは公式のcustom event goalsのとおりplausible('EventName', { props })の形です。GA4のsnake_caseと同じ名前で送り、管理画面にも同じ名前のゴールを作る。これで2系統がズレません。
ポイントがもう一つ。読了はIntersectionObserverで本文末尾が見えた瞬間だけ送ります。フォームは送信ボタンのクリックではなく、送信が成功してからgenerate_leadを送る。クリックで送ると、入力ミスや通信失敗まで「問い合わせ成功」に化けます。これが次の二重計測の話につながります。
計測漏れと二重計測を止める
ここがアナリティクス実装で一番事故る場所です。僕の失敗を2方向に分けて書きます。
計測漏れ(送れているつもりで送れていない)
- 同意前に送らない設計にしたのに、同意バナー自体を置き忘れて全件ゼロ。最悪のパターンです。デフォルト
deniedは安全ですが、許可導線も同時に作らないと何も貯まりません。 - 広告ブロッカーや同意拒否で、ブラウザタグそのものが読み込まれない。実測で1〜2割は普通に欠けます。これはサーバー側計測で補います(後述)。
- SPAの画面遷移でPVが送られない。
history.pushStateに合わせて手で送る必要があります。
二重計測(1回の行動が2回記録される)
- クリック時と送信成功時の両方で送ってしまい、問い合わせが倍に見える。
- ブラウザとサーバーで同じイベントを送り、購入が2件に膨らむ。
- React等で同じハンドラが二重登録され、1クリックが2発になる。
対策はシンプルで、イベントごとに「確定地点」を1つだけ決める。generate_leadは「サーバーが保存に成功した瞬間」、purchase_link_clickは「ブラウザのクリック」。一覧表にして、同じイベントを2か所から送っていないか目で確認します。
| 起きること | 典型的な原因 | 確定地点をどこに置くか |
|---|---|---|
| 問い合わせが倍 | クリックと送信成功の両方で送信 | 送信成功時のサーバー側だけ |
| 購入が倍 | ブラウザとサーバーの両送り | どちらか一方に固定 |
| CTAが多すぎる | ハンドラの二重登録 | 1要素1リスナー、登録解除を徹底 |
| PVが半分 | SPA遷移で未送信 | ルート変更フックで明示送信 |
ブラウザで欠けるぶんは、サーバー側のGA4 Measurement Protocolで補います。実装時はMeasurement Protocolとvalidation serverで形を確認してください。
// ga4-server-event.mjs — 購入・問い合わせなど確定成果はサーバーから送る
import { pathToFileURL } from "node:url";
const { GA4_MEASUREMENT_ID, GA4_API_SECRET, GA4_DEBUG } = process.env;
if (!GA4_MEASUREMENT_ID || !GA4_API_SECRET) {
throw new Error("GA4_MEASUREMENT_ID and GA4_API_SECRET are required");
}
export async function sendGa4Event({ clientId, name, params = {} }) {
const endpoint = new URL(
GA4_DEBUG === "1"
? "https://www.google-analytics.com/debug/mp/collect"
: "https://www.google-analytics.com/mp/collect"
);
endpoint.searchParams.set("measurement_id", GA4_MEASUREMENT_ID);
endpoint.searchParams.set("api_secret", GA4_API_SECRET);
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ client_id: clientId, events: [{ name, params }] }),
});
if (!response.ok) {
throw new Error("GA4 request failed with status " + response.status);
}
// DEBUGモードならGA4が返す検証メッセージを必ず確認する
if (GA4_DEBUG === "1") {
const result = await response.json();
if (result.validationMessages?.length) {
throw new Error(JSON.stringify(result.validationMessages, null, 2));
}
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await sendGa4Event({
clientId: "555.1234567890",
name: "generate_lead",
params: { form_id: "training", lead_source: "article_footer", value: 1, currency: "USD" },
});
console.log("sent");
}
サーバーから送るgenerate_leadは、ブラウザからは送らない。これを徹底するだけで、二重計測の半分は消えます。
プライバシーを壊さない計測の線引き
同意管理は、法対応であると同時にデータの信頼性の話でもあります。
僕が決めているルールはシンプルです。個人情報はイベントに乗せない。メールアドレス、氏名、自由入力の問い合わせ本文は送らない。送るのはslug、cta_id、国コード、ステータス程度に絞ります。エッジ側でPV欠損を補うときも同じで、Cloudflare等に保存するのは匿名の集計データだけにします。
// cloudflare-worker.js — エッジで匿名のPVだけ拾い、個人情報は一切入れない
function json(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json" },
});
}
export default {
async fetch(request, env) {
if (request.method !== "POST") {
return json({ ok: false, error: "method_not_allowed" }, 405);
}
const event = await request.json().catch(() => null);
if (!event?.event_name || !event?.slug) {
return json({ ok: false, error: "event_name_and_slug_required" }, 400);
}
const country = request.cf?.country || request.headers.get("cf-ipcountry") || "XX";
env.ANALYTICS?.writeDataPoint({
blobs: [event.event_name, event.slug, event.cta_id || "", country],
doubles: [Number(event.value || 1)],
indexes: [String(event.slug).slice(0, 96)],
});
return json({ ok: true });
},
};
大事なのは、「同意が要るマーケティング計測」と「サーバー側の匿名集計」を混ぜて説明しないことです。前者はconsent modeで守り、後者は最初から個人を特定できない形にする。役割を分けておくと、ポリシー文も書きやすくなります。
タグの数も絞ります。計測を増やしすぎるとLCPやINPが悪化して、本末転倒です。僕はPlausibleを軽量なPV・ゴール確認、GA4を標準レポートと収益導線、Cloudflareをエッジ補完、と役割を固定しています。出し分けや実験を重ねる段階に来たら、フィーチャーフラグ運用で機能を制御し、A/Bテスト実装で勝ち負けを判定する。その判定の数字を支えるのが、ここまでのイベント設計です。
公開前後のチェックリスト
実装したら、公開の前後でこれを順に潰します。机上で正しく見えても、本番のDebugViewで初めて気づくズレが必ずあります。
- 計測計画表に、イベント名・発火条件・必須パラメータ・確定地点がそろっている
- GA4の標準イベントに寄せられるものは寄せた
- 同意バナーがあり、
defaultはタグ読み込み前に置いている - 同意前にブラウザイベントを送っていない
- UTMが初回着地で保存され、問い合わせまで持ち回れる
- フォームは送信成功時だけ
generate_lead、クリックでは送らない - 同じイベントをブラウザとサーバーの両方から送っていない
- Cloudflare等に個人情報を保存していない
- 公開後24時間以内に、GA4 DebugView・Realtime・Plausible Goalsで実数を確認する
よくある質問
Q. GA4とPlausible、どちらを入れるべき?
両方でも構いませんが役割を分けます。Plausibleはクッキーレスで軽く、PV・直帰・ゴールをサッと見るのに向きます。GA4は標準レポートが厚く、generate_leadやpurchaseで収益導線まで追えます。迷うなら、まず軽いPlausibleでPVと数個のゴールを置き、収益を細かく見たくなったらGA4を足すのが楽です。
Q. イベント名は結局どう決めればいい?
標準イベントがあるものは標準名に寄せ(generate_lead等)、独自イベントはsnake_caseの名詞+動詞で統一します。cta_clickはOK、clickCTAやCTA_ClickはNG。1ファイル(イベント契約)に全部書き、そこにない名前は使わない、という運用にすると揺れません。
Q. 同意モード(consent mode)は必須?
日本のサイトでも、GA4やGoogle広告を使うなら入れておくのが無難です。defaultで全部deniedにしてから、ユーザーの許可でupdateする。少なくとも「既定では送らない」状態を作っておけば、同意なしで個人データを集める事故は避けられます。
Q. 二重計測に気づくには?
GA4のDebugViewとRealtimeを開き、自分で1回だけ操作してみるのが一番速いです。1クリックでcta_clickが2件出たら二重登録、フォーム1送信でgenerate_leadが2件なら確定地点が2か所ある証拠です。リリース前に必ず手で1往復します。
Q. PVがアナリティクスと実感でズレる原因は? 多くは広告ブロッカーと同意拒否でブラウザタグが落ちているか、SPA遷移でPVを送り忘れているかです。前者はサーバー側やエッジで補い、後者はルート変更時に明示送信します。GSCの表示回数と突き合わせると、欠損の規模が見えてきます。
実際に試した結果
このやり方でPV・読了・CTA・問い合わせ・商品クリックを分けて測れるようにしたら、記事改善の判断が一気に速くなりました。
「検索では見られるのに読まれない記事」「読まれるのに商品へ進まない記事」「押されるのに問い合わせまで行かない記事」を、はっきり区別できるようになったからです。直す場所が、タイトルなのかCTA位置なのかフォームなのか、迷わず決まる。
そして実装の効きどころは、最後まで「命名の固定」でした。cta_clickに一本化し、確定地点を1つに決め、同意の既定をdeniedにした。派手な機能は何もありません。でも、この地味な3点を最初にやるかどうかで、半年後に読めるデータか、ただ増えていくゴミかが決まります。計測を整えたら、次はA/Bテスト実装で打ち手を検証するのがおすすめです。手を動かす土台が欲しい人は教材・テンプレート一覧も見てみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。