積み上げログ技術ブログ記事一覧へ

自主執筆記事

GA4が計測されない原因を283サイト分調べた|コード検索では絶対に分からない4つの落とし穴

「リポジトリにgtagが無い=GA4が動いていない」と思って283リポジトリをgrepしたら、154件が未計測という結果が出た。実際にサイトへアクセスして確かめ直したら、本当に困っていたのは5件だけだった。静的解析が嘘をつく理由と、GA4 Admin APIを使った確実な確認手順をまとめる。

運用・計測公開: 2026/08/13更新: 2026/08/13
GA4Googleアナリティクス運用Node.jsGitHub Actions

01 / 本文

結論:コードを検索してもGA4の稼働状況は分からない

自分が管理しているサイトのうち、どれがGoogleアナリティクス4(GA4)で計測できていて、どれができていないのか。それを知りたくて、手元の283リポジトリを一括で検索した。gtag(googletagmanager.com/gtag/jsG-で始まる測定IDを探す、いたって普通のやり方だ。

出てきた答えは「154件が計測できていない」だった。全体の54%。さすがに多すぎる。

結論から書くと、この数字は間違いだった。実際に各サイトへHTTPアクセスしてHTMLを確認し直したところ、本当に計測できていなかったのは5件だけだった。差の149件は、コードの中に測定IDが書かれていないだけで、実際にはきちんと計測できていた。

理由は単純で、多くのサイトは測定IDをソースコードに書かず、サーバーやCIの環境変数から注入しているからだ。当然、リポジトリをいくらgrepしても出てこない。

この記事では、なぜ静的解析が嘘をつくのか、代わりに何を見ればいいのか、そして実際に見つかった4種類の不具合とその直し方を記録しておく。

先に断っておくと、これは特殊な構成をしていたから起きた事故ではない。むしろ逆で、測定IDを環境変数に逃がすという「正しい作法」を守っているほど、コード検索では見えなくなる。秘密情報ではないのでリポジトリに直書きしても動くには動くが、開発環境と本番で別のプロパティに送りたい、という当たり前の要求を満たそうとすれば、自然と環境変数に行き着く。

つまりこの落とし穴は、設計がまともなプロジェクトほど深くなる。そこが厄介なところだった。

02 / 本文

前提整理:プロパティ・データストリーム・測定IDの関係

本題に入る前に、GA4の用語を整理しておく。ここが曖昧だと「どれを確認すればいいのか」が定まらない。

プロパティは、レポートの単位になる箱だ。「このサイトのアクセス解析」という一まとまりがこれにあたる。数値の集計も、期間比較も、すべてプロパティ単位で行われる。プロパティには123456789のような数字のIDが振られる。

データストリームは、そのプロパティにデータを流し込む経路のこと。ウェブサイト用、iOSアプリ用、Androidアプリ用があり、1つのプロパティに複数ぶら下げられる。ウェブのストリームを作ると、そこで初めて測定IDG-+英数10桁)が発行される。

つまり階層は「プロパティ → データストリーム → 測定ID」で、サイトのHTMLに書くのは一番下の測定IDだ。

ここを取り違えると妙なことになる。実際、私の調査でも「測定IDのつもりでプロパティIDを書いたメモ」が引っかかって、誤検知の原因になった。G-542370310のように、G-を付けたうえで数字9桁が続いていたら、それはほぼ確実にプロパティIDの書き間違いだ。測定IDは必ずG-+英数10桁なので、桁数と文字種で機械的に判別できる。

この構造を押さえておくと、Admin APIで何を取ればいいかも自明になる。プロパティ一覧を取り、各プロパティのデータストリームを取れば、「サイトURL ↔ 測定ID」の対応表が完成する。

03 / 本文

なぜ「コードにIDが無い=未計測」が成り立たないのか

きっかけは、判定結果を眺めていて見つけた1件の矛盾だった。あるキャンプ場比較サイトは、私の判定では「gtagのコードはあるが測定IDが見つからない=未計測」に分類されていた。ところが実際にブラウザで開いてみると、GA4のタグがしっかり出力されている。

ソースを追うと理由はすぐ分かった。Laravel製のそのサイトは、ビューの中でこう書いていた。

つまり測定IDは.envにあり、その.envは当然.gitignoreされている。私がgrepしていたのはリポジトリの中身だから、見つからないのが当たり前だった。

同じ構造はどこにでもある。Next.jsならprocess.env.NEXT_PUBLIC_GA_ID、Astroならimport.meta.env、GitHub Actionsでビルドするならsecrets.*測定IDは「コードに書かないのが普通」であって、書いていないことは何の証拠にもならない。

静的解析で分かるのは「タグを出力する仕組みがあるか」まで。「実際に出力されているか」は、出力された結果を見るしかない。

{{-- resources/views/layouts/app.blade.php --}}
@if(config('services.ga4.id'))
  <script async src="https://www.googletagmanager.com/gtag/js?id={{ config('services.ga4.id') }}"></script>
  <script>
    window.dataLayer = window.dataLayer || [];
    function gtag(){dataLayer.push(arguments);}
    gtag('js', new Date());
    gtag('config', '{{ config('services.ga4.id') }}');
  </script>
@endif

{{-- config/services.php --}}
'ga4' => [
    'id' => env('GA4_MEASUREMENT_ID'),  // ← 値は .env にあり、Gitには入らない
],

04 / 本文

確実に調べる方法:GA4 Admin APIで棚卸しして、実サイトを叩く

やり直した手順はこうだ。コード側からではなく、GA4側から攻める。

まずGA4 Admin APIでアカウント配下のプロパティを全件取得する。サービスアカウントを作り、GA4の管理画面で「閲覧者」以上の権限を付けておけばいい。プロパティ一覧を取ったら、次に各プロパティのデータストリームを取得する。ここに測定ID(G-で始まる値)と、設定したサイトURLが入っている。

これで「本来どのドメインがどの測定IDで計測されるべきか」という正解表ができる。私の場合は190プロパティぶんの対応表になった。

あとは正解表のURLへ順にHTTPアクセスして、返ってきたHTMLに期待する測定IDが含まれているかを見るだけだ。

import { JWT } from "google-auth-library";

const key = JSON.parse(
  Buffer.from(process.env.GOOGLE_SERVICE_ACCOUNT_KEY_BASE64, "base64").toString("utf8"),
);
const client = new JWT({
  email: key.client_email,
  key: key.private_key,
  scopes: ["https://www.googleapis.com/auth/analytics.readonly"],
});

// 1. プロパティを全件取得(ページングに注意)
let pageToken, properties = [];
do {
  const res = await client.request({
    url: "https://analyticsadmin.googleapis.com/v1beta/properties",
    params: { filter: "parent:accounts/【アカウントID】", pageSize: 200, pageToken },
  });
  properties.push(...(res.data.properties ?? []));
  pageToken = res.data.nextPageToken;
} while (pageToken);

// 2. 各プロパティのデータストリームから測定IDとURLを取り出す
for (const p of properties) {
  const id = p.name.replace("properties/", "");
  const res = await client.request({
    url: `https://analyticsadmin.googleapis.com/v1beta/properties/${id}/dataStreams`,
  });
  for (const st of res.data.dataStreams ?? []) {
    const w = st.webStreamData;
    if (w) console.log(w.measurementId, w.defaultUri, p.displayName);
  }
}

05 / 本文

実測でわかった4つの状態

190件を実際に叩いた結果は、きれいに4つに分かれた。

計測中:118件。 正しい測定IDが出力されていた。全体の62%で、これは想定より多かった。静的解析では約半分がダメだと思い込んでいたので、単純に見立てを誤っていた。

重複プロパティ:23件。 サイト自体はきちんと計測できているのに、同じドメインに対してGA4プロパティを2つ3つ作ってしまっていて、片方にしかデータが入っていない状態。実害はないが、管理画面が見づらくなるし、どれを見ればいいのか自分でも分からなくなる。

タグなし:5件。 稼働しているのにタグが一切出ていない。これが本当に直すべきものだった。

到達不可:44件。 ドメインが応答しない。DNS未設定だったり、公開前だったり、止めたサイトだったり。プロパティを先に作って、サイトの公開が後回しになっているパターンが大半だった。

つまり「154件が問題」という最初の見立てのうち、実際に手を動かす価値があったのは5件。残りは思い込みか、放っておいても困らないものだった。調査の精度が低いと、やらなくていい仕事を149件分でっち上げてしまう。

ちなみに「到達不可」の44件は、放置していい種類の問題ではあるが、無視していいわけではない。プロパティだけ先に作ってサイトを公開しなかったものが混ざっているからだ。GA4のプロパティは作っただけでは何のコストもかからないので放置しがちだが、一覧が膨らむと本当に見るべきものが埋もれる。四半期に一度くらい、応答しないドメインのプロパティを棚卸しするだけでも管理画面はかなり見やすくなる。

06 / 本文

フレームワーク別・測定IDがどこから来るか

「コードに無いなら、どこにあるのか」を構成別に整理しておく。調査するとき、どこを見に行けばいいかの地図になる。

Laravel(Blade).envGA4_MEASUREMENT_IDconfig/services.php経由でビューが読む。本番の値はサーバー上の.envにあり、Gitには存在しない。設定キャッシュが効いていると.envを書き換えても反映されないので、変更後はphp artisan config:clearが要る。ここを忘れると「直したのに変わらない」で無駄に悩む。

Next.js(App Router)process.env.NEXT_PUBLIC_GA_IDをレイアウトで読み、next/scriptで出力するのが素直。NEXT_PUBLIC_接頭辞が付いた値はビルド時に埋め込まれるため、Vercelの環境変数を変えただけでは反映されず、再デプロイが必要になる。ここも引っかかりやすい。

Astroimport.meta.env.GA4_MEASUREMENT_ID。静的生成なら、これもビルド時に確定する。ローカルの.envを直しても、CIでビルドしているなら本番は変わらない。

静的HTML+CI生成:GitHub Actionsのsecretsvarsから環境変数として渡し、生成スクリプトがHTMLに書き込む。今回いちばん厄介だったのがこのパターンで、シークレットが無くてもビルドは通ってしまう。

共通して言えるのは、「ローカルで正しい」ことは「本番で正しい」ことを一切保証しないという点だ。ビルド時に値が確定する構成では、設定を直した後に必ずデプロイし直す必要がある。私も1件、.envを直して満足しかけたが、本番はCIビルドなので何も変わらないところだった。

07 / 本文

落とし穴1:CIのシークレット未設定は、デプロイが成功するので気づけない

「タグなし」5件のうち、最初に直したのは街の情報をまとめた静的サイトだった。GitHub Pagesにデプロイしている、よくある構成だ。

調べてみると、リポジトリのHTMLには測定IDがちゃんと書かれている。デプロイのワークフローも毎回成功している。それなのに本番にはタグが無い。

原因はワークフローの記述にあった。ビルド時に環境変数として測定IDを渡す作りになっていて、その値をGitHub Actionsのシークレットから取っていた。そしてそのシークレットが登録されていなかった

生成スクリプト側はif (!measurementId) return;のように、値が無ければ何も出力しない安全な作りになっていた。だからビルドは成功する。デプロイも成功する。Actionsの画面は緑のチェックマークが並ぶ。誰も異常に気づかない。

これが一番たちの悪いパターンだと思う。壊れているのに、どこにもエラーが出ない。 静かに何も起きないだけ。今回のように別の目的で棚卸しをしなければ、たぶん永久に気づかなかった。

対処はシークレットを登録してワークフローを再実行するだけで、作業自体は1分で終わった。原因の特定に時間がかかっただけだ。

- name: Generate pages
  run: npm run generate
  env:
    # このシークレットが未登録でも、ビルドは成功してしまう
    GOOGLE_ANALYTICS_MEASUREMENT_ID: ${{ secrets.GOOGLE_ANALYTICS_MEASUREMENT_ID }}

08 / 本文

落とし穴2:測定IDの貼り間違いは、他人のデータを汚染する

次に見つけたのは、静的解析では「登録済み」と判定されていた——つまり一見なんの問題もないサイトだった。

測定IDはきちんと.envに書かれている。書式も正しい。ただし、その測定IDは別のサイトのものだった

GA4の測定IDはG-+英数10桁で、見た目にはどれも同じような文字列だ。複数サイトを並行して立ち上げていると、コピー&ペーストで簡単に取り違える。しかも間違えたことに気づく手段が無い。データは送信され、GA4も正常に受け取り、エラーは一切出ない。

何が起きるかというと、A サイトのアクセスが B サイトのプロパティに混入する。Bのレポートには身に覚えのないページパスが並び、数字は水増しされる。Aの数字はゼロのまま。どちらのレポートも信用できなくなる。

今回は幸い、本番サーバー側の設定が正しかったので実被害は出ていなかった。ただしローカルの.envが間違っていたので、次にデプロイした瞬間に発生する状態だった。

この不具合は、測定IDとドメインの対応表を持っていないと絶対に見つからない。「IDが書いてあるかどうか」ではなく「書いてあるIDが正しいかどうか」を照合する必要がある。GA4 Admin APIで対応表を作った副産物として、たまたま発見できた。

見つけ方は単純で、対応表と実際の設定を突き合わせるだけでいい。GA4側から「このドメインの測定IDはこれ」という正解を持ってきて、各プロジェクトが持っている値と一致するかを機械的に比較する。一致しない、かつ他のドメインの正解と一致する場合は、ほぼ確実に貼り間違いだ。

09 / 本文

落とし穴3:プロパティを作りすぎる

23件見つかった「重複プロパティ」は、直接の実害こそ無いが、放っておくと確実に判断を誤らせる。

同じドメインにプロパティが2つあると、片方にはデータが入り、もう片方は永久にゼロのままになる。しばらく経ってから管理画面を開き、たまたま空の方を見て「このサイト、全然アクセス無いな」と誤解する。実際には別のプロパティに数十セッション入っている、ということが起こる。

原因は単純で、サイトを立ち上げるたびにプロパティを作り、以前作ったことを忘れて二度目を作ってしまう。名前も「キャンプ比較」と「キャンプ場比較(camp-hikaku.jp)」のように微妙に違うので、一覧を眺めても重複だと気づきにくい。

整理するときは、消す前に必ずデータの有無を確認する。Data APIで過去1年のセッション数を取れば、空かどうかは一発で分かる。

私はこの確認を入れたおかげで、消すつもりだった27件のうち2件に過去データが残っていることに気づいて、削除を取りやめた。数十セッションとはいえ、消せば二度と戻らない。

さらにもう一段の安全策として、「同じドメインに稼働中のプロパティが確実に残ること」を削除の条件に加えた。ドメインが応答しないサイトは、どちらが本番用か判定できないので触らない。結果、20件だけを削除し、7件は意図的に残した。

なおGA4のプロパティ削除はゴミ箱方式で、35日以内なら復元できる。とはいえ35日を過ぎれば完全に消えるので、確認してから消すに越したことはない。

// 消す前に「本当に空か」を確かめる
async function sessions(propertyId) {
  const res = await client.request({
    url: `https://analyticsdata.googleapis.com/v1beta/properties/${propertyId}:runReport`,
    method: "POST",
    data: {
      dateRanges: [{ startDate: "365daysAgo", endDate: "today" }],
      metrics: [{ name: "sessions" }, { name: "screenPageViews" }],
    },
  });
  const row = res.data.rows?.[0];
  return Number(row?.metricValues?.[0]?.value ?? 0);
}

// 0 でなければ削除しない。履歴は取り戻せない

10 / 本文

落とし穴4:判定結果そのものを疑う

最後に、自分で作った判定ロジックが出した「異常」を鵜呑みにして、直さなくていいものを壊しかけた話を書いておく。

私の静的解析は、3件を「設定が不完全」と判定した。ところが実物を確認したら、3件とも問題なかった。

1件目は、測定IDらしき文字列がJSONデータの中の作業メモの一文に含まれていただけ。タグの設定ではなかった。

2件目は、.env.exampleのコメントに書かれた記入例# 例: G-XXXXXXXXXX)を拾っていた。

3件目は、JavaScriptにG-XXXXXXXXXXというプレースホルダが書かれていたが、その直後にif (id && id !== "G-XXXXXXXXXX")というガードがあり、未設定なら送信しない正しい実装だった。

もし確認せずに「一括修正」していたら、動いているものを壊していた。正規表現は文脈を読まない。コメントも、サンプルも、メモ書きも、条件分岐の中の比較対象も、全部同じ文字列として拾う。

自動判定は候補を絞るためのものであって、答えではない。件数が多いときほど「まとめて直したい」誘惑が強くなるが、そこで1件ずつ開く手間を惜しむと、静かな不具合を自分で作り込むことになる。

11 / 本文

この方法の限界:HTMLを取るだけでは見えないもの

実測に切り替えれば万能かというと、そうではない。取得したHTMLを見るだけの手法には、はっきりした穴がある。

JavaScriptで後から差し込むタグは検出できない。 Googleタグマネージャー経由でGA4を配信している場合、HTMLにあるのはGTMのコンテナタグだけで、測定IDはGTMの管理画面側にある。同様に、同意管理ツールが同意後にタグを注入する構成でも、初回のHTMLには何も出ない。この場合はHTMLではなく、ヘッドレスブラウザで実際にJavaScriptを実行し、googletagmanager.comへの通信が飛ぶかを見る必要がある。

タグが出ていても届いているとは限らない。 広告ブロッカーや同意管理で送信自体が止まることはあるし、測定IDが正しくてもプロパティ側でデータフィルタや内部トラフィック除外が効いていれば、レポートには現れない。「タグが出ている」と「数字が入っている」は別の話だ。確実を期すなら、実測に加えてData APIでセッション数を見ればいい。今回の重複プロパティの判定はまさにそれで、両方を突き合わせて初めて確信が持てた。

取得できないことと壊れていることは違う。 私の調査でも44件が到達不可だったが、その大半は未公開ドメインで、異常ではない。接続タイムアウトは自分側のネットワーク事情でも起きるので、一度失敗しただけで異常と決めつけないほうがいい。時間をおいて再試行し、それでもダメなものだけを対象にする。

要するに、実測もまた完璧な手段ではない。ただ、静的解析より本番の実態に一段近いというだけだ。どこまで確かめたいかに応じて、HTML取得 → ヘッドレスブラウザ → Data APIでの実データ確認、と精度を上げていけばいい。

12 / 本文

一度直して終わりにしない:実測を定期実行する

今回の作業でいちばん怖かったのは、壊れてから気づくまでの期間が誰にも分からないことだった。

シークレットが未設定だったサイトは、いつからそうだったのか特定できない。ワークフローの実行履歴はすべて成功していて、失敗の記録が無いからだ。数か月分のアクセスデータが丸ごと失われている可能性があるが、失われたことを証明する材料すら残っていない。

だから対策は「直す」ことより「次に壊れたときすぐ気づく」ことに置いたほうがいい。幸い、今回書いた実測スクリプトはそのまま監視に転用できる。

設計はこうなる。GA4 Admin APIで対応表を取得し、各ドメインへHTTPアクセスし、期待する測定IDが出ていなければ検知する。応答しないドメインは「未公開」として別扱いにし、通知の対象から外す。これを週に一度動かして、異常があればIssueを立てるかSlackに流す。

実測に必要な時間は、190ドメインで数分程度だった。並列度を12に絞ってもこの速さなので、コストを気にする必要はほぼない。

重要なのは、判定基準を「タグが出ているか」に固定することだ。コードの状態や設定ファイルの中身ではなく、外から見た結果だけを見る。そうすれば、原因が環境変数だろうとCIだろうとDNSだろうと、等しく検知できる。

13 / 本文

副産物:棚卸しは他の不具合も一緒に釣り上げる

GA4の調査として始めた作業だったが、終わってみると計測以外の問題もいくつか出てきた。棚卸しの副産物として書き留めておく。

同じリポジトリが2箇所にクローンされていた。 283プロジェクトのうち63件が重複配置で、そのうち20件は2つのコピーでGA4の設定状態が食い違っていた。古い方のコピーで作業を続ければ、直したはずの設定を巻き戻すことになる。実際、片方だけが最新で、もう片方が数コミット遅れているケースがいくつもあった。

公開しているはずのドメインが404を返していた。 GA4のプロパティは正常に設定されているのに、サイト本体が応答しない。計測の話とは無関係だが、調べていなければ気づかなかった。

デプロイ保護が有効なままのプロジェクトがあった。 本番デプロイにSSO認証がかかっていて、外部からアクセスできない状態だった。これも「タグが出ていない」として検知されたおかげで発覚した。

こうした発見はどれも、GA4を調べようとして偶然引っかかったものだ。外形監視は、意図した対象以外の異常も一緒に釣り上げてくれる。サイトを実際に取得しに行くという行為そのものに、副次的な健全性チェックの効果がある。

数を増やして運用していると、個々のサイトを開いて確認する機会は自然と減る。定期的に全部を叩きに行く仕組みを持っておくと、こういう「静かな異常」を拾える。

14 / 本文

明日から使えるチェック手順

同じことを調べたい人のために、順番だけまとめておく。特別なツールは要らず、サービスアカウントとNode.jsがあれば動く。

1. GA4側の正解表を作る。 Admin APIでプロパティとデータストリームを取得し、「ドメイン → 測定ID」の対応表を作る。ここが出発点になる。コード側から始めると私と同じ間違いをする。

2. 実サイトを取得して照合する。 各ドメインへHTTPアクセスし、HTMLに期待する測定IDが含まれるかを見る。ここで「計測中/別IDが出ている/タグなし/到達不可」の4つに分かれる。

3. 「別IDが出ている」を最優先で調べる。 重複プロパティなら実害は小さいが、他サイトのIDを貼っているならデータ汚染が起きている。対応表と突き合わせれば区別できる。

4. 「タグなし」の原因はデプロイ側を疑う。 コードに書いてあるのに出ていないなら、環境変数・シークレット・古いビルドのどれかだ。CIが成功していても安心材料にはならない。

5. 消す前にデータの有無を確認する。 Data APIでセッション数を見る。0でなければ消さない。

所要時間は、スクリプトを書く時間を含めても半日かからなかった。190ドメインへのHTTPアクセスは並列度を12くらいにして数分で終わる。

15 / 本文

まとめ:静的解析は「候補出し」、実測が「答え」

今回いちばんの収穫は、GA4の設定を直したことではなく、自分の調査手法が間違っていたと気づけたことだった。

コードを検索するのは速いし、網羅的に見える。だからそれで分かった気になってしまう。けれど実際には、コードは「そう動くはずの設計」を表しているだけで、「実際にそう動いているか」は何ひとつ保証しない。環境変数、CIの設定、デプロイのタイミング、DNS——本番の挙動を決める要素の多くは、リポジトリの外にある。

サイトを1つ2つしか持っていなければ、ブラウザで開いて確かめれば済む話だ。数が増えるほど「開いて確かめる」が現実的でなくなり、代わりに静的解析へ逃げたくなる。だが数が増えるほど、静かに壊れたまま放置される確率も上がる。

自動化すべきは判定ロジックではなく、実測のほうだった。190ドメインを叩いて回るスクリプトは50行ほどで書けたし、これは定期実行にも向いている。次はこれをGitHub Actionsで週次実行して、「タグなし」が出たらIssueを立てるようにするつもりだ。

「デプロイが成功している」は「サイトが正しく動いている」の証明にならない。今回いちばん身に沁みたのはそこだった。

コメント

この記事へのコメント

お名前だけで投稿できます。アカウント登録やログインは不要です。 いただいたコメントは確認のうえ公開します。