· ハルボウヤ · analytics · 13 min read

ポーリングで待つCVタグは、なぜそう書かれたのか

外部ベンダーのCVタグをGTMから動かすとき、250msごとに値を待つ実装になりがち。そう書かざるを得なかった理由と、そこから何を削れるかを整理する。

ポーリングの待機を表す図。250msごとの再確認を示す点が並び、最後に完了のチェックが付いている

外部ベンダーの計測タグを GTM のカスタム HTML から動かすと、しばしば次のような形になる。値が揃うまで 250ms ごとに待ち、諦めたら診断イベントを出す。

一見すると過剰に見える。ただし、この形にはそれぞれ理由がある。理由を押さえたうえでないと、削ってはいけないところを削ることになる。

前回の記事では「静かに失敗するタグ」を扱った。今回はその反省を踏まえて書き直された側のコードを見る。以下は実際の実装をもとに、ベンダー名・サイトID・URL・変数名を伏せた形に書き換えている。

対象のコード

<div id="vendor_cv_tag"
  data-cid="(ベンダー発行のサイトID)"
  data-purchaseid=""
  data-itemnum=""
  data-itemid=""
  data-price=""
  data-size=""
  data-cuid="{{DLV - user_id}}">
</div>
<script>
(function () {
  var tries = 0;
  var MAX = 40;
  var ITEM_GRACE = 12;
  var orderSeenAt = -1;
  var DIAG = true;

  function getDLPurchase() {
    var dl = window.dataLayer || [];
    for (var i = dl.length - 1; i >= 0; i--) {
      var x = dl[i];
      if (x && x.ecommerce && x.ecommerce.purchase &&
          x.ecommerce.purchase.actionField && x.ecommerce.purchase.actionField.id) {
        return x.ecommerce.purchase;
      }
    }
    return null;
  }

  function getData() {
    if (typeof orderObject !== 'undefined' && orderObject && orderObject.orderId) {
      return {
        src: 'orderObject',
        orderId: orderObject.orderId,
        userId: orderObject.userId || '',
        items: (orderObject.items || []).map(function (it) {
          return { id: it.productId, price: it.unitPrice, quantity: it.quantity, size: it.size };
        })
      };
    }
    var p = getDLPurchase();
    if (p) {
      return {
        src: 'dataLayer',
        orderId: p.actionField.id,
        userId: '',
        items: (p.products || []).map(function (pr) {
          return { id: pr.id, price: pr.price, quantity: pr.quantity, size: pr.size || pr.variant };
        })
      };
    }
    return null;
  }

  function wait() {
    var d = getData();
    if (d && d.orderId) {
      if (orderSeenAt < 0) orderSeenAt = tries;
      if (d.items && d.items.length > 0) { send(d, 'ok'); return; }
      if ((tries - orderSeenAt) < ITEM_GRACE && tries < MAX) { tries++; setTimeout(wait, 250); return; }
      send(d, 'timeout_empty_items'); return;
    }
    if (tries++ < MAX) { setTimeout(wait, 250); return; }
    diag('give_up_no_order', null);
  }

  wait();
})();
</script>

send() は各値を data-* 属性に書き戻し、そのあとベンダー配布の JS を <script> として body に差し込む。diag() は状況を dataLayer に push する。

なぜこう書かれたのか

1. ベンダーのインターフェースが DOM 経由だから

このベンダーのタグは、data-* 属性に値を入れた要素を置き、その後で配布 JS を読み込むと、JS が DOM を読んで送信する方式。

JavaScript の関数を呼ぶ形なら引数で渡せば済む。DOM 経由だと「先に値を入れる」「後で JS を読む」という順序が必須になり、その順序制御をカスタム HTML 側で書くことになる。

読み込み URL に ?v= + タイムスタンプが付いているのも同じ理由。キャッシュされた JS が再実行されないケースを避けるため。

2. データの到着タイミングが読めないから

トリガーがページビューになっている。購入完了ページを開いた瞬間に発火するが、注文データはその時点でまだ確定していないことがある。

  • 注文データを後から API で取得して描画している
  • SPA でルート遷移後に dataLayer.push される
  • タグマネージャの読み込みのほうが先に終わる

このため「値が来るまで待つ」しかない。MAX = 40250ms 間隔で最大10秒。

3. 注文IDと商品明細の到着がずれるから

ITEM_GRACE = 12 がこれ。注文ID(orderId)は先に入るが、商品明細(items)が数百ミリ秒遅れて入るケースがある。

注文IDを見つけた時点で全体の待機を打ち切ると、商品情報が空のまま送信される。かといって無限に待つわけにもいかない。そこで「注文IDを見つけてから、さらに12回(3秒)だけ待つ」という二段構えになっている。

4. データソースが2系統あるから

getData()orderObject(ページ側のグローバル変数)を先に見て、なければ dataLayer の UA 形式(ecommerce.purchase.actionField)を見る。

これは移行期の産物。ページによって実装が異なる、あるいは移行が完了していないため、両方に対応せざるを得ない状態。

5. 過去に静かに失敗した経験があるから

diag() の存在がそれ。診断用のイベントを dataLayer に流し、理由(ok / timeout_empty_items / give_up_no_order)、データの取得元、待機時間を記録している。

この部分は素直に良い実装。前回扱った「エラーが console.log で終わるタグ」と比べると、失敗が観測可能になっている。

ここからどう改善するか

理由があって書かれたコードなので、闇雲には削れない。効果の大きい順に並べる。

改善1:トリガーを変えてポーリング自体をなくす(最優先)

ポーリングはページビュートリガーで発火していることの帳尻合わせ。トリガーを変えれば根本から消える。

  • トリガー:カスタムイベント purchase(サイト側が dataLayer.push するイベント)
  • 条件:{{DLV - transaction_id}} が空でない、かつ {{DLV - items}} の要素数が 0 でない

こうすると、値が揃った瞬間にだけタグが発火する。wait()MAXITEM_GRACEorderSeenAt がすべて不要になり、コードは半分以下になる。

サイト側の改修が要る場合もあるが、dataLayer.push を1回追加するだけで済むことが多い。10秒待つ処理を保守し続けるより安い。

改善2:待ち時間中の離脱を勘定に入れる

トリガーを変えられない事情があるなら、10秒という待ち時間の意味を確認しておく。

購入完了ページを10秒見ずに閉じる人は一定数いる。その分は CV が欠損する。欠損率を把握するには、give_up_no_order の発生件数を数えるだけでは足りない(ページを閉じた場合は診断イベントも送られない)。

GA4 の purchase 件数と、ベンダー側の CV 件数を日次で突き合わせて差分を見るのが確実。

改善3:データソースを1本に寄せる計画を持つ

2系統対応は移行期には正しい。問題は、いつ片方を消すか決まっていないこと。

  • diag() が記録している srcorderObject / dataLayer)を GA4 のカスタムディメンションとして集計する
  • 一方の経路が数週間ゼロになったら、そのコードを削除する

記録しているだけで判断に使っていないと、2系統対応が永久に残る。

改善4:空の catch をやめる

try { ... } catch (e) {}

例外が起きても何も残らない。diag() の仕組みがあるので、そこに流せばよい。

} catch (e) {
  diag('exception:' + (e && e.name ? e.name : 'unknown'), d);
}

改善5:ベンダーJSの読み込み失敗を検知する

現状は <script> を差し込んだ時点で成功扱い。ベンダー側の障害や広告ブロッカーで読み込めなくても気づけない。

jsEl.onerror = function () { diag('vendor_script_error', d); };
jsEl.onload  = function () { diag('vendor_script_loaded', d); };

「タグは動いた」と「ベンダーに届いた」を分けて観測する。

改善6:二重送信を防ぐ

SPA の遷移や、ページビュートリガーが複数回発火する構成では、同じ注文が2回送られうる。送信済みの注文IDを保持して弾く。

window.__cvSent = window.__cvSent || {};
if (window.__cvSent[d.orderId]) { diag('duplicate_skipped', d); return; }
window.__cvSent[d.orderId] = true;

CV の水増しは、欠損より発見が遅れる。件数が「多い」ことは問題として認識されにくいため。

改善7:値の変換仕様を確認する

2点、事故になりやすい箇所がある。

金額の丸めMath.floor(Number(price) || 0) は小数を切り捨てる。税込・税抜のどちらを送るのか、送料や割引を含むのかをベンダー仕様と照合する。GA4 の value と定義がずれていると、後で突き合わせができない。

区切り文字:複数商品を %23# のエンコード)で連結しつつ、各値を encodeURIComponent している。商品IDやサイズ表記に #% が含まれると壊れる可能性がある。実データに記号が混ざっていないか確認し、混ざるならテストケースを1つ作っておく。

改善8:診断イベントを監視に載せる

diag() は送っているだけでは意味がない。

  1. GA4 で vendor_cv_diag をカスタムイベントとして受ける
  2. reasonok / timeout_empty_items / give_up_no_order / exception:*)をカスタムディメンションにする
  3. 日次で、理由別の件数と ok の比率をダッシュボード化する
  4. ok 比率が閾値を下回ったら気づける状態にする

計測の異常は、売上の異常より発見が遅れる。数字を見ている人がいないため。閾値と通知先まで決めて、はじめて監視になる。

優先順位のまとめ

優先度改善効果
トリガーをカスタムイベントに変更ポーリング処理が丸ごと不要になる
診断イベントを GA4 で監視壊れたことに気づけるようになる
二重送信の防止件数の水増しを防ぐ
ベンダーJSの読み込み失敗を検知「届いていない」を切り分けられる
データソースの1本化計画保守対象を減らす
空の catch をやめる例外の原因が追える
金額・区切り文字の仕様確認突き合わせの精度が上がる

まとめ

  • ポーリングは、ページビュートリガーで発火していることの帳尻合わせ。トリガーを変えれば消える
  • ITEM_GRACE のような二段構えの待機は、注文IDと明細の到着差を吸収するための工夫。理由を理解せずに消さない
  • 2系統のデータソース対応は移行期には正しい。いつ消すかを決めておく
  • 診断イベントを入れた判断は正しい。あとは監視に載せる
  • 計測タグは、壊れても売上には影響しない。だから誰も気づかない。気づく仕組みまでを実装に含める
Share:
Back to Blog

Related Posts

View All Posts »