· ハルボウヤ · analytics · 13 min read
ポーリングで待つCVタグは、なぜそう書かれたのか
外部ベンダーのCVタグをGTMから動かすとき、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 = 40、250ms 間隔で最大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()、MAX、ITEM_GRACE、orderSeenAt がすべて不要になり、コードは半分以下になる。
サイト側の改修が要る場合もあるが、dataLayer.push を1回追加するだけで済むことが多い。10秒待つ処理を保守し続けるより安い。
改善2:待ち時間中の離脱を勘定に入れる
トリガーを変えられない事情があるなら、10秒という待ち時間の意味を確認しておく。
購入完了ページを10秒見ずに閉じる人は一定数いる。その分は CV が欠損する。欠損率を把握するには、give_up_no_order の発生件数を数えるだけでは足りない(ページを閉じた場合は診断イベントも送られない)。
GA4 の purchase 件数と、ベンダー側の CV 件数を日次で突き合わせて差分を見るのが確実。
改善3:データソースを1本に寄せる計画を持つ
2系統対応は移行期には正しい。問題は、いつ片方を消すか決まっていないこと。
diag()が記録しているsrc(orderObject/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() は送っているだけでは意味がない。
- GA4 で
vendor_cv_diagをカスタムイベントとして受ける reason(ok/timeout_empty_items/give_up_no_order/exception:*)をカスタムディメンションにする- 日次で、理由別の件数と
okの比率をダッシュボード化する ok比率が閾値を下回ったら気づける状態にする
計測の異常は、売上の異常より発見が遅れる。数字を見ている人がいないため。閾値と通知先まで決めて、はじめて監視になる。
優先順位のまとめ
| 優先度 | 改善 | 効果 |
|---|---|---|
| 高 | トリガーをカスタムイベントに変更 | ポーリング処理が丸ごと不要になる |
| 高 | 診断イベントを GA4 で監視 | 壊れたことに気づけるようになる |
| 中 | 二重送信の防止 | 件数の水増しを防ぐ |
| 中 | ベンダーJSの読み込み失敗を検知 | 「届いていない」を切り分けられる |
| 中 | データソースの1本化計画 | 保守対象を減らす |
| 低 | 空の catch をやめる | 例外の原因が追える |
| 低 | 金額・区切り文字の仕様確認 | 突き合わせの精度が上がる |
まとめ
- ポーリングは、ページビュートリガーで発火していることの帳尻合わせ。トリガーを変えれば消える
ITEM_GRACEのような二段構えの待機は、注文IDと明細の到着差を吸収するための工夫。理由を理解せずに消さない- 2系統のデータソース対応は移行期には正しい。いつ消すかを決めておく
- 診断イベントを入れた判断は正しい。あとは監視に載せる
- 計測タグは、壊れても売上には影響しない。だから誰も気づかない。気づく仕組みまでを実装に含める



