發生了什麼

我們自家營運的兩個 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() 還不能用的時機就送出事件」。不過,壞掉的方式不只一種,而且一開始的修法我也試錯了一次。這篇文章會依照以下順序說明:

  1. 前提:dataLayer、gtag()、gtag.js 的關係
  2. 兩種壞掉方式(默默丟掉/順序錯亂)以及修法
  3. 實際測量「會被丟掉多久」的方法
  4. 兩個網站各自的修法與測試
  5. 部署後的確認方式,以及修正前後的數字

前提: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,等本體到來後再依序送出。

反過來說,如果在 ② 動作之前就想送事件,就沒有地方可以接收。這時候「會怎麼表現」取決於實作方式,而這次剛好遇到兩種壞掉方式。

壞掉方式 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 會在掛載後立刻執行,因此它比 ② 的初始化更早被呼叫,結果完全沒有記錄到。

第一次下的修正是「如果沒有 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,反而會壞)。

修法 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 之後。 不會發生等待佇列順序錯亂的問題。
  • 等待期間進來的事件也會照順序只送一次。 透過 fn && pending.length === 0 的條件,避免後來的事件超車。
  • 超過上限(10 秒)就丟掉。 開發環境、stage 環境、廣告封鎖時,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.gtagRealtime 的 cta_click_download從載入約 2.8 秒後(本體載入完成後)點擊有定義+1(有送達)hydration 直後(249ms)點擊undefined(在 283ms 才定義)+0(沒送到)點擊當下的 dataLayer 裡連 js、config 都還沒被放進去,事件完全沒有留下痕跡。

在這個測量環境(高速網路、快取已存在)中,被丟掉的時間窗約為 30ms。看起來很短,但 lazyOnload 的意思是「瀏覽器有空之後才執行」,所以在網路較慢的手機或首次造訪時,這個視窗會變長。越是文章一打開就立刻按 CTA 的人,越容易漏計,這正是麻煩的地方。

另外,因為正式 GA4 會收到測試事件,所以我們會在 URL 上加上 ?ga_check=... 再操作,並在每週分析時排除該時刻的 session。

測試:用 fake timers 確認「等待、只送一次、順序正確」

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 位,還不能判定。因此我們事先定好了判定標準並記錄下來。

  • 成功:文章來源的 LP PV 中,CTA 點擊達到 8 成以上
  • 失敗:不到 5 成(表示不只是漏計,還要去查連結路徑)
  • 無法判定:文章來源的 LP PV 少於 10

我不認為只修掉 30ms 的視窗,就能完整解釋 15 對 0。不過如果修完之後比例還是沒有上升,就要懷疑是不是有其他原因,例如使用者是從沒有被計量到的連結進入 LP。這類修正的目的,不是「修完數字就一定會變好」,而是「根據修完後的數字,決定下一步該懷疑哪裡」。

總結

  • GA4 標籤可拆成 3 個部分:「等待佇列(dataLayer)」、「推送函式(gtag())」、「送出的本體(gtag.js)」。gtag() 只要初始化那幾行一動就能用。
  • 如果把初始化也延後到 lazyOnload,就會出現「明明可以按,但沒有計量函式」的時間窗,而那段時間的事件會沒有任何錯誤地消失。
  • 自己先塞進 dataLayer 的話,會排在 config 前面;如果用陣列去塞,gtag.js 甚至不會讀。兩種都會靜悄悄壞掉。
  • 修法有兩種:把初始化提早到 afterInteractive,或是在送出端等到 gtag 可用再送(加上上限)。
  • 測試不要看 dataLayer 的內容,而是要看實際傳給 gtag() 的內容。用 fake timers 驗證「等待、只送一次、順序正確、超時就丟掉」。
  • 「沒有錯誤」「自己測試送得到」都不能證明計量正確。把 PV 和事件數量對起來,問題才會浮現。

原文出處:https://qiita.com/yamadagenki/items/d35d769e9771997de3be


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝7   💬1   ❤️1
279
🥈
boluoji
📝6   💬2   ❤️3
117
🥉
mike
1
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登