何が起きたか
自社で運営している Next.js のサイト2つ(career-agent-blog と webasy)で、GA4 のカスタムイベントが エラーも出さずに 0 件 になっていました。
| サイト | 消えていたイベント | 気づいたきっかけ |
|---|---|---|
| career-agent-blog(ITコンサル転職メディア。資料ダウンロードが目標) | 記事内の CTA クリック(cta_click_*) |
資料 LP に「記事から来た」PV が 28 日で 15 あるのに、CTA クリックが 0 |
| webasy(webasy。ホームページ制作のサービスサイト) | 料金ページの表示(pricing_view) |
料金ページの PV が 28 日で 8 あるのに、pricing_view が 0 |
どちらも、コンソールにエラーは出ません。GA4 のリアルタイムで自分で試すと 届くこともある ので、「計測は動いている」と思い込みやすい。数字を突き合わせて初めて分かりました。
原因はどちらも「gtag() がまだ使えないタイミングでイベントを送っていた」ことです。ただ、壊れ方は 1 種類ではなく、最初の直し方も一度間違えました。この記事では次の順に書きます。
- 前提:
dataLayer・gtag()・gtag.jsの関係 - 壊れ方 2 つ(黙って捨てる/順番がずれる)と、その直し方
- 実際に「何ミリ秒の間、捨てられるのか」を測った方法
- 2 サイトそれぞれの直し方とテスト
- デプロイ後の確かめ方と、直した前後の数字
前提:dataLayer・gtag()・gtag.js は別物
GA4 の計測タグは、よく見ると 3 つの部品に分かれています。ここを分けて考えないと、この不具合は理解できません。
<!-- ① 本体(約 140KB)。読み込まれると dataLayer を順に処理して送信する -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script>
<!-- ② 初期化(数行)。dataLayer と gtag() を「定義するだけ」 -->
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){ dataLayer.push(arguments); }
gtag('js', new Date());
gtag('config', 'G-XXXX');
</script>
| 部品 | 中身 | いつ使えるか |
|---|---|---|
window.dataLayer |
ただの配列(待ち行列) | ② が動いたあと |
window.gtag() |
dataLayer.push(arguments) するだけの関数 |
② が動いたあと |
gtag.js |
待ち行列を読んで GA4 に送る本体 | ① の読み込みが終わったあと |
大事なのは、gtag() は ② さえ動けば呼べる ことです。本体 ① がまだ読み込まれていなくても、gtag('event', ...) は dataLayer に積まれ、本体が来たときに順番に送られます。
逆に言うと、② が動く前 にイベントを送ろうとすると、受け取る場所がありません。ここで「どう振る舞うか」が実装ごとに違い、今回は 2 通りの壊れ方をしていました。
壊れ方 1:gtag が無ければ黙って捨てる(career-agent-blog)
career-agent-blog のイベント送信は、よくある薄いラッパーでした。
export function sendGAEvent(name: string, params: GAEventParams = {}): void {
if (typeof window === 'undefined') return;
if (typeof window.gtag === 'function') {
window.gtag('event', name, params);
return;
}
// gtag が無いとき(開発環境・広告ブロック)はエラーにせず何もしない
}
「広告ブロックで gtag が無いときに落ちないように」という意図は正しいのですが、問題はタグの読み込み方でした。
// 修正前:①も②も lazyOnload
<Script src={`https://www.googletagmanager.com/gtag/js?id=${ID}`} strategy="lazyOnload" />
<Script id="ga-init" strategy="lazyOnload">{`/* ② の初期化 */`}</Script>
next/script の lazyOnload は「ページの読み込みが終わり、ブラウザが暇になってから」実行します。本体 ① を遅らせるのは表示速度(LCP)のために妥当ですが、数行しかない ② まで一緒に遅らせていた ため、その間 window.gtag が存在しません。
React の hydration が終わればボタンは押せるようになるので、「押せるのに、計測関数がまだ無い」時間帯 ができます。この間のクリックは sendGAEvent の中で静かに捨てられます。
壊れ方 2:dataLayer に先に積むと、config より前に並ぶ(webasy の最初の修正)
webasy では useEffect の中で pricing_view を送っていました。useEffect はマウント直後に動くので、② の初期化より先に呼ばれ、1 件も記録されていませんでした。
最初に入れた修正は「gtag が無ければ自分で dataLayer に積む」でした。
// 最初の修正(のちに取り下げ)
const w = window as { dataLayer?: unknown[] };
w.dataLayer = w.dataLayer ?? [];
// gtag と同じく arguments を積む(配列で積むと gtag.js が読まない)
(function () { w.dataLayer!.push(arguments); } as (...a: unknown[]) => void)('event', name, params);
これで「捨てる」はなくなりますが、新しい問題が出ます。この時点ではまだ ② の gtag('config', ...) が積まれていない ので、待ち行列の並びが次のようになります。
dataLayer = [
['event', 'pricing_view', {...}], ← 自分で先に積んだもの
['js', Date], ← ② の初期化
['config', 'G-XXXX'],
]
config より前にある event を gtag.js がどう扱うかは、公式に保証された挙動ではありません。送り先(測定 ID)が決まる前のイベントなので、確実に届くとは言えない。「捨てる」を「届くかどうか分からない」に変えただけでした。
なお、dataLayer.push(['event', ...]) のように 配列で積むと gtag.js は読みません。arguments オブジェクトで積む必要があり、ここも静かに壊れるポイントです(ESLint の prefer-rest-params に従って ...rest に書き換えると壊れます)。
直し方 1:gtag ができるまで手元で待つ(webasy の最終形)
最終的には、dataLayer に直接触らず、gtag() ができるまで自分の配列で待たせる 形にしました。
type Params = Record<string, unknown>;
type Gtag = (...args: unknown[]) => void;
function gtag(): Gtag | null {
const fn = (window as { gtag?: Gtag }).gtag;
return typeof fn === 'function' ? fn : null;
}
const pending: [string, Params | undefined][] = [];
let waiting: ReturnType<typeof setInterval> | null = null;
const WAIT_MS = 200;
const WAIT_TRIES = 50; // 最大 10 秒
function flush(): boolean {
const fn = gtag();
if (!fn) return false;
for (const [name, params] of pending.splice(0)) fn('event', name, params);
return true;
}
export function track(name: string, params?: Params) {
if (typeof window === 'undefined') return;
const fn = gtag();
// 待っているものが無ければ、すぐ送る
if (fn && pending.length === 0) { fn('event', name, params); return; }
pending.push([name, params]);
if (waiting || flush()) return;
let tries = 0;
waiting = setInterval(() => {
tries += 1;
if (flush() || tries >= WAIT_TRIES) {
if (tries >= WAIT_TRIES) pending.length = 0; // GA4 を読み込まない環境では捨てる
clearInterval(waiting!);
waiting = null;
}
}, WAIT_MS);
}
ポイントは 3 つです。
-
gtag()経由で送るので、必ず ② のconfigのあとに並ぶ。 待ち行列の順番の問題が起きません。 -
待っている間に来たイベントも、順番どおりに 1 回だけ送る。
fn && pending.length === 0の条件で、先に待っているものを追い越さないようにしています。 -
上限(10 秒)を超えたら捨てる。 開発環境・ステージング・広告ブロックでは
gtagが永遠に来ないので、メモリに積み続けないようにします。
直し方 2:初期化だけ先に動かす(career-agent-blog)
career-agent-blog はイベント送信の関数が多くの場所から呼ばれていたので、送信側ではなく 読み込み側 を直しました。
// 修正後:② の初期化だけ afterInteractive、① の本体は lazyOnload のまま
<Script id="ga-init" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${ID}');
`}
</Script>
<Script src={`https://www.googletagmanager.com/gtag/js?id=${ID}`} strategy="lazyOnload" />
数行の初期化を afterInteractive(hydration の直後)に上げるだけで、window.gtag が無い時間帯がほぼ無くなります。重い本体 ① は lazyOnload のままなので、表示速度への影響はありません。
本体が来る前のイベントは dataLayer に積まれますが、この場合は ② の config が先に積まれている ので、壊れ方 2 の順番の問題は起きません。
どちらの直し方を選ぶか
| 状況 | 直し方 |
|---|---|
| 初期化(②)のタイミングを自分で決められる | ② を afterInteractive に上げる(career-agent-blog) |
| 初期化が共通部品・別チームの持ち物で動かせない/マウント直後に送るイベントがある | 送信側で gtag ができるまで待つ(webasy) |
| どちらもやらない | 「押せるのに計測関数が無い」時間帯のイベントは消える |
両方やっても問題ありません。
実際に何ミリ秒、捨てられていたのか
コードを読んで「捨てられるはず」と推定しただけでは、0 件の理由として十分か分かりません。そこで本番で再現して測りました。
やり方は単純で、本番ページを iframe で開き、hydration 直後・gtag 定義前 に CTA をクリックします(遷移は止めておく)。同時に performance.now() で時刻を記録し、GA4 Data API の Realtime レポート(runRealtimeReport、直近 30 分)で届いた件数を数えます。
| 操作 | window.gtag |
Realtime の cta_click_download
|
|---|---|---|
| 読み込みから約 2.8 秒後(本体の読み込み後)にクリック | あり | +1(届いた) |
| hydration 直後(249ms)にクリック | undefined(定義は 283ms) | +0(届かない) |
クリック時点の dataLayer には js・config すら積まれておらず、イベントは跡形もありませんでした。
この計測環境(高速回線・キャッシュあり)では、捨てられる時間帯は 約 30ms でした。短く見えますが、lazyOnload は「ブラウザが暇になってから」なので、回線の遅いスマートフォンや初回訪問ではこの窓が伸びます。記事を開いてすぐ CTA を押す人ほど計測から漏れる、という偏り方をするのが厄介な点です。
なお、本番 GA4 にテストのイベントが入るので、URL に ?ga_check=... を付けて操作し、週次の分析ではその時刻のセッションを除外するようにしています。
テスト:fake timers で「待って、1 回だけ、順番どおり」を確かめる
webasy の待ち行列は、Vitest の fake timers で次の 3 つを確かめています。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { trackCtaClick, trackPricingView, ctaClickFromLink } from '@/lib/analytics/events';
let calls: unknown[][];
let w: { gtag?: (...a: unknown[]) => void };
beforeEach(() => {
calls = [];
w = { gtag: (...a: unknown[]) => { calls.push(a); } };
(globalThis as { window?: unknown }).window = w;
});
afterEach(() => {
vi.useRealTimers();
delete (globalThis as { window?: unknown }).window;
});
const names = () => calls.filter((a) => a[0] === 'event').map((a) => a[1]);
describe('gtag の用意より先のイベント', () => {
it('gtag ができるまで待ち、できたら1回だけ・順番どおりに送る', () => {
vi.useFakeTimers();
const gtag = w.gtag!;
delete w.gtag; // 初期化スクリプトがまだ動いていない
trackPricingView();
trackCtaClick(ctaClickFromLink('/contact', { origin: 'https://example.com', pathname: '/pricing' }, 'header')!);
vi.advanceTimersByTime(1000);
expect(calls).toEqual([]); // まだ送らない
w.gtag = gtag; // 初期化スクリプトが動いた
vi.advanceTimersByTime(200);
expect(names()).toEqual(['pricing_view', 'cta_click']);
vi.advanceTimersByTime(5000);
expect(names()).toHaveLength(2); // 二重に送らない
});
it('10秒たっても gtag ができなければ捨てる', () => {
vi.useFakeTimers();
const gtag = w.gtag!;
delete w.gtag;
trackPricingView();
vi.advanceTimersByTime(10_000);
w.gtag = gtag;
vi.advanceTimersByTime(1000);
trackPricingView(); // 新しいイベントはそのまま送られる
expect(names()).toEqual(['pricing_view']);
});
it('gtag があればすぐに送る', () => {
trackPricingView();
expect(calls).toEqual([['event', 'pricing_view', undefined]]);
});
});
1 本目が一番大事です。「待っている間に来た 2 つ目のイベントが、1 つ目を追い越さない」「gtag ができたあとに二重送信しない」を同時に見ています。
もう 1 つ、以前のテストは dataLayer の中身を見ていましたが、gtag() に何が渡されたかを見る形に変えました。dataLayer の中身を検証するテストは、壊れ方 2 を「正しい」と判定してしまう からです。
デプロイ後の確かめ方と、前後の数字
デプロイ後は、手元のブラウザで gtag を 1.5 秒遅らせても pricing_view が 1 回だけ送られることを確認し、Realtime レポートでも 1 件届くことを見ました。career-agent-blog は、本番の HTML に id:"ga-init" と strategy:"afterInteractive" が出ていることを確かめています。
GA4 Data API で取った前後の数字です(どちらも、サイトのホスト名で絞り込み)。
| 修正前 | 修正後 | |
|---|---|---|
webasy:料金ページの PV と pricing_view
|
PV 8 に対して 0(9/10〜10/7) | PV 1 に対して 1(10/9〜10/27) |
| career-agent-blog:記事から資料 LP に来た PV と CTA クリック | PV 15 に対して 0(9/18〜10/15) | PV 2 に対して 0(10/17〜10/27)、判定は 11 月 |
正直に書くと、どちらも母数が小さく、「直した」と言えるのは webasy の PV とイベントが 1 対 1 で一致するようになった ところまでです。career-agent-blog は修正後の期間に記事から LP へ来た人が 2 人しかおらず、まだ判定できません。そのため、判定基準を先に決めて記録しておきました。
- 成功:記事経由の LP の PV に対して、CTA クリックが 8 割以上
- 失敗:5 割未満(取りこぼし以外の原因があるので、リンクの経路を調べる)
- 判定不能:記事経由の LP の PV が 10 未満
30ms の窓を塞いだだけで 15 対 0 が説明しきれるとは考えていません。直したあとも比率が上がらなければ、計測していないリンクから LP に来ているなど、別の原因を疑います。計測の修正は「直したら数字が上がるはず」ではなく、「直したあとの数字で、次に疑う場所を決める」 ためのものです。
まとめ
- GA4 のタグは「待ち行列(
dataLayer)」「積む関数(gtag())」「送る本体(gtag.js)」の 3 部品。gtag()は初期化の数行さえ動けば使える。 - 初期化まで
lazyOnloadにすると、「ボタンは押せるのに計測関数が無い」時間帯ができ、その間のイベントは エラーなしで消える。 -
dataLayerに自分で先に積むとconfigより前に並ぶ。配列で積むと読まれない。どちらも静かに壊れる。 - 直し方は 2 つ。初期化だけ
afterInteractiveに上げる か、送信側で gtag ができるまで待つ(上限付き)。 - テストは
dataLayerの中身ではなくgtag()に何が渡ったか を見る。fake timers で「待つ・1 回だけ・順番どおり・上限で捨てる」を確かめる。 - 「エラーが出ない」「自分で試すと届く」は、計測が正しい証拠にならない。PV とイベントの件数を突き合わせる と見つかる。