我們自家營運的兩個 Next.js 網站(career-agent-blog 與 webasy)中,GA4 的自訂事件竟然在沒有任何錯誤訊息的情況下變成 0 筆。
注意到事件消失的契機
career-agent-blog(IT 顧問轉職媒體。目標是資料下載)
文章內的 CTA 點擊(cta_click_*)
資料 LP 的「從文章來的」PV 在 28 天內有 15,但 CTA 點擊是 0
webasy(webasy。網站製作服務網站)
價格頁面的顯示(pricing_view)
價格頁面在 28 天內有 8 次 PV,但 pricing_view 是 0
兩個網站在 Console 都沒有錯誤。即使在 GA4 即時報表裡自己測試,有時候也會送得到,所以很容易誤以為「計量正常」。直到把數字對起來,才發現問題。
兩邊的原因都是「在 gtag() 還不能用的時機就送出事件」。不過,壞掉的方式不只一種,而且一開始的修法我也試錯了一次。這篇文章會依照以下順序說明:
dataLayer、gtag()、gtag.js 的關係GA4 的追蹤標籤,仔細看其實可以拆成 3 個部分。若不把它們分開思考,就無法理解這個 bug。
<!-- ① 本體(約 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,等本體到來後再依序送出。
反過來說,如果在 ② 動作之前就想送事件,就沒有地方可以接收。這時候「會怎麼表現」取決於實作方式,而這次剛好遇到兩種壞掉方式。
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 裡被安靜地丟掉。
webasy 是在 useEffect 裡送 pricing_view。useEffect 會在掛載後立刻執行,因此它比 ② 的初始化更早被呼叫,結果完全沒有記錄到。
第一次下的修正是「如果沒有 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'],
]
gtag.js 對於排在 config 前面的 event 要如何處理,官方並沒有保證。因為這是送到尚未決定目的地(measurement ID)之前的事件,所以不能說一定會送達。這只是把「丟掉」變成「不確定會不會到」。
另外,像 dataLayer.push(['event', ...]) 這樣用陣列去推,gtag.js 其實不會讀。必須以 arguments 物件的形式推進去,這也是會安靜壞掉的地方(如果照 ESLint 的 prefer-rest-params 改成 ...rest,反而會壞)。
最後我們沒有直接碰 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 之後。 不會發生等待佇列順序錯亂的問題。fn && pending.length === 0 的條件,避免後來的事件超車。gtag 可能永遠不會來,不能一直把事件堆在記憶體裡。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.gtagRealtime 的 cta_click_download從載入約 2.8 秒後(本體載入完成後)點擊有定義+1(有送達)hydration 直後(249ms)點擊undefined(在 283ms 才定義)+0(沒送到)點擊當下的 dataLayer 裡連 js、config 都還沒被放進去,事件完全沒有留下痕跡。
在這個測量環境(高速網路、快取已存在)中,被丟掉的時間窗約為 30ms。看起來很短,但 lazyOnload 的意思是「瀏覽器有空之後才執行」,所以在網路較慢的手機或首次造訪時,這個視窗會變長。越是文章一打開就立刻按 CTA 的人,越容易漏計,這正是麻煩的地方。
另外,因為正式 GA4 會收到測試事件,所以我們會在 URL 上加上 ?ga_check=... 再操作,並在每週分析時排除該時刻的 session。
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 可用,之後只送一次,而且順序正確', () => {
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]]);
});
});
第一個測試最重要。它同時檢查了「等待期間進來的第二個事件不會超車第一個事件」以及「gtag 出現後不會重複送出」。
另外,之前的測試是看 dataLayer 裡的內容,後來改成檢查傳給 gtag() 的內容。因為如果看 dataLayer,很可能會把壞掉方式 2 誤判成正確。
部署之後,我們在本機瀏覽器把 gtag 延遲 1.5 秒,確認 pricing_view 仍然只會送一次,Realtime 報表也確實收到 1 筆。career-agent-blog 則確認正式 HTML 中有 id:"ga-init" 和 strategy:"afterInteractive"。
以下是從 GA4 Data API 取得的修正前後數字(兩者都以網站主機名做篩選)。
修正前修正後webasy:價格頁 PV 與 pricing_viewPV 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 位,還不能判定。因此我們事先定好了判定標準並記錄下來。
我不認為只修掉 30ms 的視窗,就能完整解釋 15 對 0。不過如果修完之後比例還是沒有上升,就要懷疑是不是有其他原因,例如使用者是從沒有被計量到的連結進入 LP。這類修正的目的,不是「修完數字就一定會變好」,而是「根據修完後的數字,決定下一步該懷疑哪裡」。
dataLayer)」、「推送函式(gtag())」、「送出的本體(gtag.js)」。gtag() 只要初始化那幾行一動就能用。lazyOnload,就會出現「明明可以按,但沒有計量函式」的時間窗,而那段時間的事件會沒有任何錯誤地消失。dataLayer 的話,會排在 config 前面;如果用陣列去塞,gtag.js 甚至不會讀。兩種都會靜悄悄壞掉。afterInteractive,或是在送出端等到 gtag 可用再送(加上上限)。dataLayer 的內容,而是要看實際傳給 gtag() 的內容。用 fake timers 驗證「等待、只送一次、順序正確、超時就丟掉」。原文出處:https://qiita.com/yamadagenki/items/d35d769e9771997de3be