はじめに
個人運営している電気料金比較メディア enegent.jp で、GA4 のレポートを見ていたら、シミュレーター結果イベント simulator_result のカスタムパラメータ top_plan top_plan_type が全件 (not set) だった。lp_section_view.section も同じ。
「あれ、コード側は確かに sendGAEvent("simulator_result", { top_plan: ... }) で送ってるはずだが?」と疑い、コード/GTM/GA4 の 3 層を全 API で照会して整合を確認、最後に Playwright で本番サイトの実トラフィックをキャプチャしたところ、3 層すべて「正しく動いている」ことが確認できた。
ではなぜ (not set) なのか。真因は GA4 カスタムディメンションの「登録日」が、レポート対象期間より後だった。GA4 の仕様で、カスタムディメンションは登録時点以降のヒットにしか紐付かない。つまり登録前に発生したヒットは、param ストアに値が残っていても永久に (not set) のまま埋まらない。
調査に半日かかった。同じ罠にハマる人が次は半日溶かさずに済むよう、調査の全プロセスとチェック順を残す。
環境
- Next.js 15 + GA4 (
G-2F2LJCLBQ1) + GTM (GTM-KG3WWT4G) - 計測:
dataLayer.push({ event, ...params })→ GTM のeventSettingsTableで GA4 タグへ転送 → GA4 カスタムディメンションでレポート可視化 - 全 API は SA Impersonation で認証(個人 GCP は SA キー作成不可ポリシーのため)
何が起きたか
GA4 のレポートで、特定のカスタムイベントのパラメータが全件 (not set) になっていた。
=== 直近3日 simulator_result by top_plan ===
(not set) -> 21
=== 直近3日 simulator_result by top_plan_type ===
(not set) -> 21
=== 直近3日 lp_section_view by section ===
(not set) -> 74
ヒット件数は出ているのに、ディメンション値だけ全件 (not set)。コード側で
sendGAEvent("simulator_result", {
top_plan: `${topPlan.retailer_name} ${topPlan.plan_name}`,
top_plan_type: topPlan?.price_category || "",
verdict: data.verdict,
...
});
と確実に値を渡しているはずなので、絶対どこかで欠落している。3 層責務を整理して順に潰すことにした。
計測の3層責務
GA4 + GTM 構成では、計測は次の 3 層構造になっている。どの層でも値が欠落すれば結果は (not set)。
| 層 | 担当 | 主体 |
|---|---|---|
| 1. アプリ | dataLayer.push({event, params...}) |
フロントエンドコード |
| 2. GTM | DLV (Data Layer Variable) で値を読み、タグの eventSettingsTable で GA4 へ転送 |
GTM 設定 |
| 3. GA4 | 受信したパラメータをカスタムディメンションとしてレポート可視化 | GA4 管理画面 / Admin API |
これを各 API で順に検証していく。
仮説A: コード側の dataLayer.push が間違っている → 棄却
まず web/app/simulator/page.tsx を確認。
sendGAEvent("simulator_result", {
area: areaCode,
top_plan: topPlan ? `${topPlan.retailer_name} ${topPlan.plan_name}` : "none",
top_plan_type: topPlan?.price_category || "",
verdict: data.verdict,
// ...
});
sendGAEvent の中身も問題なし:
export function sendGAEvent(eventName: string, params?: GAEventParams): void {
if (typeof window === "undefined") return;
const w = window as unknown as { dataLayer?: unknown[] };
w.dataLayer = w.dataLayer || [];
const cleaned: Record<string, string | number | boolean> = {};
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null) continue;
cleaned[k] = v;
}
}
w.dataLayer.push({ event: eventName, ...cleaned });
}
LP の lp_section_view 側も <EventOnView event="lp_section_view" params={{ section: "hero" }}> で正しく渡している。コード側に欠落はない。
仮説B: GTM 側の DLV / タグ転送設定が抜けている → 棄却
GTM API 経由で対象 DLV と GA4 タグの転送設定を直接ダンプ。
svc = build("tagmanager", "v2", credentials=creds)
# DLV 定義
for v in variables:
if v["name"] in ["DLV - section", "DLV - top_plan", "DLV - verdict"]:
params = {p["key"]: p.get("value") for p in v.get("parameter", [])}
print(v["name"], params)
# 出力:
# DLV - section {'dataLayerVersion': '2', 'setDefaultValue': 'false', 'name': 'section'}
# DLV - top_plan {'dataLayerVersion': '2', 'setDefaultValue': 'false', 'name': 'top_plan'}
# DLV - verdict {'dataLayerVersion': '2', 'setDefaultValue': 'false', 'name': 'verdict'}
name=section で正しく dataLayer から値を読む設定。
GA4 タグの eventSettingsTable:
['section'] -> '{{DLV - section}}'
['top_plan'] -> '{{DLV - top_plan}}'
['top_plan_type'] -> '{{DLV - top_plan_type}}'
['verdict'] -> '{{DLV - verdict}}'
['area'] -> '{{DLV - area}}'
全部正しく登録されている。前回 GTM 側の罠(eventParameters 旧仕様 vs eventSettingsTable 新仕様)でハマった経験から、ここは念入りに見たが今回は問題なし。
仮説C: GA4 側のディメンションが User scope で登録されている → 棄却
GA4 のカスタムディメンションには Event scope と User scope がある。User scope だと「そのユーザーに最初に紐付いた値」しか持てず、イベントごとの値はレポートに出てこない。これだ!と思って Admin API で scope を確認。
client = AnalyticsAdminServiceClient(credentials=creds)
for cd in client.list_custom_dimensions(parent="properties/532106584"):
print(cd.parameter_name, CustomDimension.DimensionScope(cd.scope).name)
# 出力:
# section EVENT
# top_plan EVENT
# top_plan_type EVENT
# verdict EVENT
# area EVENT
# ... (全23件)
# 全23件のscope分布: EVENT: 23
全部 EVENT scope。これも問題なし。
仮説D: dataLayer push のタイミング問題 → Playwright で本番実測 → 棄却
ここまで設定は完璧に整っている。残るは「dataLayer に値が積まれた直後に GTM トリガーが発火する瞬間、DLV が値を読めていない」というタイミング問題の可能性。
これは実トラフィックを見ないと判断できない。Playwright で本番サイトをブラウザ操作し、/g/collect リクエストの中身(GA4 へ実際に送信されているペイロード)をキャプチャ。
from playwright.sync_api import sync_playwright
class GA4Capture:
def __init__(self):
self.events = []
def on_request(self, request):
url = request.url
if "google-analytics.com" not in url and "googletagmanager.com" not in url:
return
if "/collect" not in url and "/g/collect" not in url:
return
# クエリパラメータの ep.* がイベントパラメータ
# POST 本体にも複数イベントが改行区切りで入る
...
# 結果:
# lp_section_view → {"section": "hero", ...}
# lp_section_view → {"section": "trust", ...}
# lp_section_view → {"section": "faq", ...}
# simulator_result → {
# "top_plan": "TestRetailer TestPlan",
# "top_plan_type": "jepx_adjustment",
# "verdict": "switch_suggested",
# ...
# }
全パラメータが GA4 に正しく届いている。
ここで完全に詰んだ。「コードもGTMもGA4も全層正しく動いてる、リクエストも正しく届いている、なのに (not set)」。何が起きてるんだ?
仮説E(真因): ディメンション登録前のデータは永久に紐付かない
ふと思い出す。「2 日前にディメンションを追加登録した気がする」。
git log を見る。
2026-04-24 fix(ga4): top_plan / top_plan_type / step_no 等を含む7件のカスタムディメンションを追加
レポート対象期間は 2026-04-21〜23。ディメンション登録は 2026-04-24。
つまり、ディメンション登録より前のデータには、ディメンション値が紐付かない仕様。Playwright で 04-25 に発生させた新しいイベントを、04-25 のデータで Data API で再確認。
client = BetaAnalyticsDataClient(credentials=creds)
req = RunReportRequest(
property="properties/532106584",
date_ranges=[DateRange(start_date="2026-04-25", end_date="2026-04-25")],
dimensions=[Dimension(name="customEvent:section")],
metrics=[Metric(name="eventCount")],
dimension_filter=FilterExpression(filter=Filter(
field_name="eventName",
string_filter=Filter.StringFilter(value="lp_section_view")
)),
)
resp = client.run_report(req)
# 出力:
# 'hero' -> 4
# 'faq' -> 3
# 'how_to_use' -> 3
# 'result_preview' -> 3
# 'trust' -> 3
04-25 のデータでは正しい値が取れる。
GA4 のドキュメントには「カスタムディメンションは作成後のデータにのみ適用されます」と確かに書かれている。が、運用してると忘れる。GA4 の管理画面側で過去のヒットの param ストアの値を見る方法もないので、「データはあるはずなのに (not set)」の不気味さが先に立つ。
真因の理解
GA4 の内部モデルとしては、ヒット受信時に event_params テーブルに {key: section, value: hero} のレコードを保存している(BigQuery エクスポートを見るとわかる)。
カスタムディメンションは 「event_params.key = 'section' を section という名前のディメンションとしてレポートで使えるようにする」マッピング設定。このマッピングは「ディメンション作成日以降のヒットにのみ適用される」設計。
つまり:
- 設計上の理由: 過去全ヒットを再インデックスしないことで GA4 のクエリ性能を担保
- 結果: 登録前のヒットは BigQuery では値を持っているが、GA4 レポートでは
(not set)のまま
BigQuery エクスポートを使っていれば登録前のデータも救済できるが、無料枠運用だと使えないことが多い。
教訓: 「(not set)」を見たときのチェック順を組み替えた
これまで「(not set) が出たら送出側を疑え」というナレッジを個人メモに残していたが、今回の経験で逆だと判明。順番を組み替えた。
## 「(not set)」が出るときのチェック順
-1. GA4 にディメンションが登録されているか
-2. GTM またはアプリ側でイベントにパラメータが乗っているか(DebugView で実測)
-3. パラメータ名がスペルミスしていないか
+1. ディメンション登録日とレポート対象期間を比較
+ ── 登録時点以降のヒットにしか紐付かない。登録前のヒットは永久に (not set)
+2. GA4 にディメンションが登録されているか
+3. GTM またはアプリ側でイベントにパラメータが乗っているか
+ ── Playwright で /g/collect リクエストの ep.* を実測
+4. パラメータ名がスペルミスしていないか
「Playwright で送出側を実測」は強力で、3 層のうち 2 層(コード→dataLayer→GA4 collect)を一気に検証できる。今回も最初からこれをやれば、「送出側は問題ない」が 5 分で確定して、登録日仮説まで早く到達できた。
ただし、送出側の実測が通っていても登録日問題は解決しない。送出側は今送っている、ディメンションは今登録してある、でも過去のデータは永久に (not set)。だから順序として「登録日の確認」が先。
チェック順の使い方フローチャート
GA4 で param が (not set)
│
▼
Step 1: ディメンション登録日 < データ期間?
├─ NO (登録が後)→ 登録前のデータは永久に (not set)。再現できる新しいデータで確認しよう
└─ YES (登録が先)→ Step 2 へ
│
▼
Step 2: GA4 管理画面でディメンション登録あり?
├─ NO → 登録する。以降のデータから値が入る
└─ YES → Step 3 へ
│
▼
Step 3: Playwright で本番の /g/collect を実測。ep.* に値あり?
├─ NO → 送出側(コード or GTM)の問題。dataLayer / DLV / eventSettingsTable を順に確認
└─ YES → Step 4 へ
│
▼
Step 4: パラメータ名のスペルミスは?
└─ GA4 は大文字小文字を区別。ディメンションの parameter_name と完全一致しているか
まとめ
GA4 のカスタムディメンションは登録日以降のヒットにのみ適用される。当たり前のように書かれている仕様だが、運用していると忘れて「送出側が壊れた」と疑ってしまう。
- コード/GTM/GA4 全 API で検証 → 全層正しく動いていた
- Playwright で
/g/collectをキャプチャ → パラメータは正しく送出されていた - 真因はディメンションがレポート対象期間より後に登録されたこと
- 「登録日 vs データ期間の比較」を「(not set)」チェックの最優先項目に格上げ
GA4 を運用してて (not set) を見たら、まずディメンション一覧の登録日とレポート対象期間を見比べてください。それが原因なら 30 秒で解決、原因でないことが分かれば次のステップに進める。3 層 API 検証から始めて Playwright 実測まで進んでも辿り着けない罠なので、最初に潰しておくのが得です。
GA4 の BigQuery エクスポートを使っていれば、過去ヒットの event_params を BigQuery から直接拾えるので、登録前データも救済できます。無料枠運用でも GA4 → BQ エクスポートは無料枠の範囲内(10GB/月)に収まることが多いので、計測ベースの個人開発をしているなら早めに繋いでおくと良いです(私もこの後つなぐ予定)。
同じ罠で半日溶かす人が一人でも減ることを祈りつつ。