· HARUBOUYA · analytics · 14 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系統のデータソース対応は移行期には正しいです。いつ消すかを決めておきます
- 診断イベントを入れた判断は正しいです。あとは監視に載せます
- 計測タグは、壊れても売上には影響しません。だから誰も気づきません。気づく仕組みまでを実装に含めます
参考になったら、いいねを押してください。





