GTMカスタムHTMLタグ3つで作る広告リード追跡パイプライン──gclid取得からオフラインCV連携・ITP対策・Consent Modeまで完全解説
この記事で解決できること
- 広告をクリックしたユーザーが、フォームを送信するまでの流れをGTMカスタムHTMLタグ3つで追跡できるようになる
- gclid・fbclid・yclidやUTMパラメータを取りこぼさずフォームに渡す仕組みを、コピペで動くコード付きで実装できる
- Safari ITPの現実的な限界を理解し、サーバーサイドGTMやGoogle Tag Gatewayとの併用を判断できる
- Google Consent Mode v2への対応方法と、同意なしでCookieを書き込む法的リスクを把握できる
- 取得したgclidをGoogle Adsに戻すオフラインコンバージョンインポートの完全フローを構築できる
- 拡張コンバージョン for リードで、Cookieに依存しないマッチ率向上を実装できる
- Jotform以外のフォーム(Contact Form 7、HubSpot Forms、Typeform、ネイティブHTML)にも応用できる
なぜ「広告クリック→フォーム送信」の追跡がこんなに面倒なのか
病院の受付を想像してほしい。患者が来院したとき、「どの広告を見て来ましたか?」と聞いても、ほとんどの人は覚えていない。だから病院では紹介状や予約番号で来院経路を管理する。
Web広告のリード追跡も同じだ。Google広告をクリックした人がフォームを送信したとき、「この人はどの広告から来たのか」を紐づけるには、紹介状にあたるID(gclid)をクリックからフォーム送信まで持ち回す仕組みが必要になる。
ところが、この「持ち回す」がとにかく厄介だ。理由は3つある。
- URLパラメータはページ遷移で消える。広告のランディングページにはgclidが付くが、フォームページに遷移した時点でURLから消える
- Cookieはブラウザに削除される。SafariのITPは、JavaScriptで書いたCookieを最短24時間で消す
- フォームが外部サービスのiframe。JotformやTypeformをiframeで埋め込んでいる場合、親ページのデータを直接渡せない
この記事では、GTMのカスタムHTMLタグ3つでこれらの問題を解決するパイプラインを構築する。そのうえで、ITPの現実的な限界、Consent Modeへの対応、オフラインCVインポートまで、実務で必要な全工程をカバーする。
全体アーキテクチャ: 3つのタグが担う役割
まず全体像を掴もう。病院の比喩で言えば、こうなる。
タグ | 役割 | 病院で言えば |
|---|---|---|
タグ1: UUID生成 | 訪問者に一意のIDを振る | 初診の患者に診察券番号を発行する |
タグ2: パラメータ取得 | gclid/UTM等の広告情報を保存する | 紹介状の内容をカルテに転記する |
タグ3: フォーム注入 | 保存した情報をフォームに渡す | カルテの情報を問診票に添付する |
3つのタグはすべてGTMのカスタムHTMLタグとして実装する。トリガーは「All Pages」(全ページ)で、ページが読み込まれるたびに動く。
タグ1: UUID生成 ── 訪問者に診察券番号を発行する
なぜUUIDが必要か
gclidはGoogle広告のクリックごとに発行されるが、同じユーザーが複数回訪問しても同一人物だと判別できない。また、Meta広告やYahoo広告にはgclidに相当するIDがあるものの、フォーマットがバラバラだ。
そこで、自社で発行する一意のID(UUID)を各訪問者に振る。これが診察券番号にあたる。
実装コード
<script>
(function() {
'use strict';
var COOKIE_NAME = '_lead_uuid';
var LS_KEY = '_lead_uuid';
var COOKIE_DAYS = 390;
// UUID v4 生成
function generateUUID() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
var r = Math.random() * 16 | 0;
var v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// Cookie書き込み
function setCookie(name, value, days) {
var expires = '';
if (days) {
var date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
expires = '; expires=' + date.toUTCString();
}
document.cookie = name + '=' + encodeURIComponent(value)
+ expires + '; path=/; SameSite=Lax; Secure';
}
// Cookie読み取り
function getCookie(name) {
var nameEQ = name + '=';
var ca = document.cookie.split(';');
for (var i = 0; i < ca.length; i++) {
var c = ca[i].trim();
if (c.indexOf(nameEQ) === 0) {
return decodeURIComponent(c.substring(nameEQ.length));
}
}
return null;
}
// localStorage読み取り(エラー時はnull)
function getLS(key) {
try { return localStorage.getItem(key); } catch(e) { return null; }
}
// localStorage書き込み(エラー時は無視)
function setLS(key, value) {
try { localStorage.setItem(key, value); } catch(e) {}
}
// 既存UUIDの取得(Cookie優先、なければlocalStorage)
var uuid = getCookie(COOKIE_NAME) || getLS(LS_KEY);
if (!uuid) {
uuid = generateUUID();
}
// 二重保持: CookieとlocalStorageの両方に書く
setCookie(COOKIE_NAME, uuid, COOKIE_DAYS);
setLS(LS_KEY, uuid);
// dataLayerに格納(GA4やGTMの他タグから参照可能にする)
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'lead_uuid': uuid
});
})();
</script>CookieとlocalStorageの二重保持にする理由
「Cookieだけでいいのでは?」と思うかもしれないが、二重に保持する理由がある。
- Cookie: SafariのITPにより、JavaScriptで書いたCookieは最大7日で消える(後述)。ただし、サーバーサイドGTMを使えばHTTPレスポンスヘッダーでCookieを設定でき、この制限を回避できる
- localStorage: ITPの影響を受けない。ただし、Safariのプライベートブラウジングでは使えない。また、ユーザーが手動で消せる
片方が消えても、もう片方から復元できるようにしておく。完璧ではないが、取りこぼしを減らす実務的な判断だ。
タグ2: 広告パラメータ取得 ── 紹介状の内容をカルテに転記する
取得するパラメータ一覧
パラメータ | 媒体 | 用途 |
|---|---|---|
gclid | Google Ads | クリックID。オフラインCV連携に必須 |
gbraid / wbraid | Google Ads | iOS14.5以降のプライバシー対応クリックID |
fbclid | Meta Ads | Facebookのクリック追跡ID |
yclid | Yahoo広告 | Yahoo広告のクリック追跡ID |
msclkid | Microsoft Ads | Bingのクリック追跡ID |
utm_source | 共通 | 流入元(google, facebook等) |
utm_medium | 共通 | メディア種別(cpc, display等) |
utm_campaign | 共通 | キャンペーン名 |
utm_content | 共通 | 広告コンテンツ |
utm_term | 共通 | 検索キーワード |
実装コード
<script>
(function() {
'use strict';
var PARAMS = [
'gclid', 'gbraid', 'wbraid',
'fbclid', 'yclid', 'msclkid',
'utm_source', 'utm_medium', 'utm_campaign',
'utm_content', 'utm_term'
];
var STORAGE_PREFIX = '_lp_';
var COOKIE_DAYS = 390;
function setCookie(name, value, days) {
var date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
document.cookie = name + '=' + encodeURIComponent(value)
+ '; expires=' + date.toUTCString()
+ '; path=/; SameSite=Lax; Secure';
}
function getCookie(name) {
var nameEQ = name + '=';
var ca = document.cookie.split(';');
for (var i = 0; i < ca.length; i++) {
var c = ca[i].trim();
if (c.indexOf(nameEQ) === 0) {
return decodeURIComponent(c.substring(nameEQ.length));
}
}
return null;
}
function getLS(key) {
try { return localStorage.getItem(key); } catch(e) { return null; }
}
function setLS(key, value) {
try { localStorage.setItem(key, value); } catch(e) {}
}
// URLからパラメータを取得
var urlParams = new URLSearchParams(window.location.search);
var dlData = {};
PARAMS.forEach(function(param) {
var urlValue = urlParams.get(param);
if (urlValue) {
// URLにパラメータがある場合: 保存して上書き
var storageKey = STORAGE_PREFIX + param;
setCookie(storageKey, urlValue, COOKIE_DAYS);
setLS(storageKey, urlValue);
dlData['lp_' + param] = urlValue;
} else {
// URLにない場合: 既存の保存値を読み込み
var storageKey = STORAGE_PREFIX + param;
var saved = getCookie(storageKey) || getLS(storageKey);
if (saved) {
dlData['lp_' + param] = saved;
}
}
});
// ランディングページURLも保存(初回のみ)
var lpKey = STORAGE_PREFIX + 'landing_page';
if (!getCookie(lpKey) && !getLS(lpKey)) {
var lp = window.location.href.split('?')[0];
setCookie(lpKey, lp, COOKIE_DAYS);
setLS(lpKey, lp);
dlData['lp_landing_page'] = lp;
} else {
dlData['lp_landing_page'] = getCookie(lpKey) || getLS(lpKey);
}
// 初回訪問日時も保存
var ftKey = STORAGE_PREFIX + 'first_touch';
if (!getCookie(ftKey) && !getLS(ftKey)) {
var now = new Date().toISOString();
setCookie(ftKey, now, COOKIE_DAYS);
setLS(ftKey, now);
dlData['lp_first_touch'] = now;
} else {
dlData['lp_first_touch'] = getCookie(ftKey) || getLS(ftKey);
}
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(dlData);
})();
</script>ポイント: 上書きルール
URLにパラメータがあるときは常に上書きする。これはラストクリックアトリビューションの考え方で、最後にクリックした広告が成果の起点として記録される。
もしファーストクリック(最初に接触した広告)を残したい場合は、上書き条件を「保存値が空のときだけ書き込む」に変える。この判断はビジネス要件による。
タグ3: フォームへのパラメータ注入 ── 問診票にカルテ情報を添付する
iframeフォーム(Jotform)の場合
Jotformをiframeで埋め込んでいる場合、親ページのJavaScriptからiframe内のDOMに直接アクセスできない(クロスオリジン制約)。そのため、iframeのURLにクエリパラメータとして渡す方法を使う。
<script>
(function() {
'use strict';
var STORAGE_PREFIX = '_lp_';
var PARAMS = [
'gclid', 'gbraid', 'wbraid',
'fbclid', 'yclid', 'msclkid',
'utm_source', 'utm_medium', 'utm_campaign',
'utm_content', 'utm_term',
'landing_page', 'first_touch'
];
function getCookie(name) {
var nameEQ = name + '=';
var ca = document.cookie.split(';');
for (var i = 0; i < ca.length; i++) {
var c = ca[i].trim();
if (c.indexOf(nameEQ) === 0) {
return decodeURIComponent(c.substring(nameEQ.length));
}
}
return null;
}
function getLS(key) {
try { return localStorage.getItem(key); } catch(e) { return null; }
}
// UUIDも取得
var uuid = getCookie('_lead_uuid') || getLS('_lead_uuid');
// 保存済みパラメータを収集
var queryParts = [];
if (uuid) {
queryParts.push('lead_uuid=' + encodeURIComponent(uuid));
}
PARAMS.forEach(function(param) {
var value = getCookie(STORAGE_PREFIX + param) || getLS(STORAGE_PREFIX + param);
if (value) {
queryParts.push(param + '=' + encodeURIComponent(value));
}
});
if (queryParts.length === 0) return;
var queryString = queryParts.join('&');
// iframeのsrcにパラメータを追加
function injectToIframes() {
var iframes = document.querySelectorAll('iframe[src*="jotform.com"]');
iframes.forEach(function(iframe) {
var src = iframe.getAttribute('src');
if (src.indexOf('lead_uuid') !== -1) return; // 注入済みならスキップ
var separator = src.indexOf('?') !== -1 ? '&' : '?';
iframe.setAttribute('src', src + separator + queryString);
});
}
// ページ読み込み時に実行
injectToIframes();
// SPAやJotformの遅延読み込み対応: MutationObserverで監視
if (typeof MutationObserver !== 'undefined') {
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === 1) {
if (node.tagName === 'IFRAME' && node.src &&
node.src.indexOf('jotform.com') !== -1) {
injectToIframes();
}
// 子要素にiframeがある場合も検知
var childIframes = node.querySelectorAll
? node.querySelectorAll('iframe[src*="jotform.com"]')
: [];
if (childIframes.length > 0) {
injectToIframes();
}
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
}
})();
</script>Jotform側のhidden field設定
- Jotformのフォームビルダーで「Hidden Field」要素を追加する
- フィールド名を
lead_uuid、gclid、utm_sourceなど、渡したいパラメータ名と一致させる - Jotformは、iframeのURLクエリパラメータとhidden fieldの名前が一致すると、自動的に値をセットする
- フォームの送信テストで、送信データにパラメータが含まれていることを確認する
Jotform以外のフォームに応用する
タグ3はJotformのiframe向けに書いたが、同じ考え方で他のフォームサービスにも対応できる。以下、主要なパターンを整理する。
Contact Form 7(WordPress)
Contact Form 7は親ページのDOMに直接レンダリングされるので、iframeの制約がない。hidden fieldに値を直接セットする。
まず、Contact Form 7のフォームテンプレートにhidden fieldを追加する。
[hidden gclid id:gclid default:""]
[hidden utm_source id:utm_source default:""]
[hidden utm_medium id:utm_medium default:""]
[hidden utm_campaign id:utm_campaign default:""]
[hidden lead_uuid id:lead_uuid default:""]次に、GTMのカスタムHTMLタグ(タグ3の代替版)でhidden fieldに値を注入する。
<script>
(function() {
'use strict';
var STORAGE_PREFIX = '_lp_';
var FIELDS = {
'gclid': 'gclid',
'utm_source': 'utm_source',
'utm_medium': 'utm_medium',
'utm_campaign': 'utm_campaign'
};
function getCookie(name) {
var nameEQ = name + '=';
var ca = document.cookie.split(';');
for (var i = 0; i < ca.length; i++) {
var c = ca[i].trim();
if (c.indexOf(nameEQ) === 0) {
return decodeURIComponent(c.substring(nameEQ.length));
}
}
return null;
}
function getLS(key) {
try { return localStorage.getItem(key); } catch(e) { return null; }
}
function fillFields() {
// UUID
var uuid = getCookie('_lead_uuid') || getLS('_lead_uuid');
var uuidField = document.getElementById('lead_uuid');
if (uuid && uuidField) uuidField.value = uuid;
// 広告パラメータ
Object.keys(FIELDS).forEach(function(param) {
var value = getCookie(STORAGE_PREFIX + param)
|| getLS(STORAGE_PREFIX + param);
var field = document.getElementById(FIELDS[param]);
if (value && field) field.value = value;
});
}
// DOM読み込み完了後に実行
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fillFields);
} else {
fillFields();
}
})();
</script>HubSpot Forms
HubSpot Formsはhidden fieldをサポートしている。ただし、HubSpotには独自のトラッキングCookie(hutk)があるため、2つのアプローチがある。
- 方法A: HubSpotのhidden field機能を使う。フォーム作成画面でhidden fieldを追加し、GTMから値をセットする。HubSpotのembed APIの
onFormReadyコールバックを使う - 方法B: HubSpotのネイティブトラッキングに任せる。HubSpotはhutk Cookieでユーザーを追跡し、Google Adsとの連携機能もある。自前パイプラインが不要になるケースもある
<script>
// HubSpot Forms の onFormReady コールバックで注入
window.addEventListener('message', function(event) {
if (event.data.type === 'hsFormCallback'
&& event.data.eventName === 'onFormReady') {
var form = document.querySelector('iframe.hs-form-iframe');
// HubSpot Formsは同一ドメインのiframeなので
// アクセス可能な場合がある(embedモード依存)
// Embed API方式(推奨)
// hbspt.forms.create の onFormReady で直接操作する
}
});
// 推奨パターン: HubSpot Embed APIのコールバック
hbspt.forms.create({
portalId: 'YOUR_PORTAL_ID',
formId: 'YOUR_FORM_ID',
onFormReady: function($form) {
var gclid = getCookie('_lp_gclid') || getLS('_lp_gclid');
if (gclid) {
$form.find('input[name="gclid"]').val(gclid).change();
}
}
});
</script>Typeform
Typeformにはiframe埋め込み版とJavaScript embed版がある。
- iframe版: Jotformと同じ方法でURLパラメータを付与する。ただしTypeformはhidden fieldの名前とURLパラメータの紐づけを、Typeform管理画面の「Hidden Fields」設定で行う必要がある
- JavaScript embed版: Typeformの
createWidgetAPI でhiddenオプションにオブジェクトを渡せる
<script>
// Typeform JavaScript embed版
window.tf.createWidget('YOUR_FORM_ID', {
container: document.getElementById('typeform-container'),
hidden: {
gclid: getCookie('_lp_gclid') || '',
utm_source: getCookie('_lp_utm_source') || '',
lead_uuid: getCookie('_lead_uuid') || ''
}
});
</script>ネイティブHTMLフォーム
自作のHTMLフォームなら一番シンプルだ。hidden inputを追加して、JavaScriptで値をセットするだけでいい。
<!-- フォーム内にhidden inputを追加 -->
<form action="/submit" method="POST">
<input type="hidden" name="gclid" id="form-gclid">
<input type="hidden" name="utm_source" id="form-utm-source">
<input type="hidden" name="lead_uuid" id="form-lead-uuid">
<!-- 通常のフォームフィールド -->
<input type="text" name="name" placeholder="お名前">
<input type="email" name="email" placeholder="メールアドレス">
<button type="submit">送信</button>
</form>React / SPA(シングルページアプリケーション)のフォーム
SPAでは画面遷移がページ読み込みではなくJavaScriptのhistory操作で行われるため、GTMのトリガー設定に注意が必要だ。
- GTMの「履歴の変更」トリガーを使い、画面遷移ごとにタグを発火させる
- フォームのsubmitイベントをインターセプトし、送信データにパラメータを追加する
- ReactのuseEffectでlocalStorageから値を読み取り、フォームのstateにセットするのが自然
Safari ITP: 2026年の現実と、正直な限界
ここからは、多くの記事が避けている「不都合な真実」を書く。
ITPの歴史と現状
AppleのIntelligent Tracking Prevention(ITP)は、2017年にSafari 11で導入されて以来、段階的に強化されてきた。2026年3月現在の状況を正確にまとめる。
制限 | 対象 | 有効期間 |
|---|---|---|
JavaScriptで書いたCookie(document.cookie) | Safari全般 | 最大7日 |
リンクデコレーション経由のCookie | gclid等のパラメータ付きURLから書いたCookie | 最大24時間 |
HTTPレスポンスヘッダーで書いたCookie(1st party) | サーバーサイドGTMなど | 通常の有効期限(最大400日) |
HTTPレスポンスヘッダーのCookie(CNAME + 異なるIP) | CDN経由のサーバーサイドGTM | 最大7日 |
この記事のパイプラインへの影響
タグ1・タグ2で書いているCookieは、すべて document.cookie(JavaScript由来のクライアントサイドCookie)だ。つまり、Safariユーザーの場合、広告クリックから7日以内にフォーム送信しなければ、gclidが消えている。
さらに厳しいのが「リンクデコレーション」の判定だ。Google広告のURLには ?gclid=xxx というパラメータが付く。SafariのITPはこれを「トラッキング目的のリンクデコレーション」と判定し、そのページで書かれたCookieの有効期限を24時間に短縮する。
つまり現実的には、Safariユーザーがgclidでマッチングできるのは、広告クリックから24時間以内にフォーム送信した場合だけだ。
localStorageは救世主か?
localStorageはITPの直接的な制限を受けない。だからこそ、タグ1でCookieとlocalStorageの二重保持にしている。localStorageにはgclidが残っているので、Cookieが消えた後もフォームに渡せる。
ただし、Safari 26(2025年9月リリース)以降、Advanced Fingerprinting Protection(AFP)がデフォルトで有効になった。AFPはlocalStorageを直接消すわけではないが、フィンガープリンティング検出の対象範囲が広がっている。将来的にlocalStorageへの制限が強化される可能性はある。
対策: サーバーサイドGTM + Google Tag Gateway
根本的な解決策は、サーバーサイドGTM(sGTM)を使い、CookieをHTTPレスポンスヘッダーで設定することだ。サーバー側で設定された1st party Cookieは、ITPの「JavaScript由来Cookie」制限を受けない。
ただし、sGTMを自社ドメインのサブドメイン(例: sgtm.example.com)で運用していても、そのサブドメインのIPアドレスがメインサイトと異なる場合は、やはり7日間に制限される。これは、CNAME経由の「なんちゃって1st party」を検出するITPの仕組みだ。
この問題を解消するのがGoogle Tag Gatewayだ。Google Tag Gatewayを使えば、Google Cloud上にsGTMをデプロイし、自社のインフラと同一IPレンジで運用できる。詳しくはGoogle Tag Gatewayで実現するファーストパーティ計測を参照してほしい。
正直な結論
クライアントサイドだけのパイプライン(この記事のタグ1〜3)では、Safariユーザーの長期追跡は不完全だ。これは認めなければいけない。
ただし、以下の理由から「だから無意味」とはならない。
- 日本のBtoBサイトではChromeのシェアが50〜60%を占める。Chromeユーザーに対しては問題なく動く
- Safariユーザーでも、広告クリックから24時間以内のCV(即日CVR)は追跡できる
- 後述する拡張コンバージョン for リードを併用すれば、Cookieに依存しないマッチングが可能になる
- localStorageのフォールバックにより、Cookieが消えた後もある程度のカバーは維持できる
Consent Mode: 同意なしでCookieを書くリスク
法的背景を整理する
ここまでのコードは、ページが読み込まれた瞬間にCookieとlocalStorageに書き込む。しかし、ユーザーの同意なくトラッキング用のCookieを書くことは、法的リスクを伴う。
- 日本の改正個人情報保護法(2022年施行): Cookie情報は「個人関連情報」に該当する。Cookie情報を第三者(Google Adsなど)に提供し、提供先で個人データと紐づける場合、本人の同意が必要
- 電気通信事業法(2023年施行の外部送信規律): 電気通信事業者に該当するサイトは、Cookieによる外部送信について通知・公表または同意取得が必要
- GDPR(EU向けサイト): トラッキングCookieの設置には事前のオプトイン同意が必須。同意バナーで「すべて拒否」を選んだユーザーにはCookieを書けない
Google Consent Mode v2
Google Consent Mode v2は、ユーザーの同意状態に応じてGoogleタグの動作を制御する仕組みだ。2024年3月以降、EEA(欧州経済領域)からのトラフィックに対してはConsent Modeの実装が必須になっている。
Consent Modeには2つのシグナルがある。
シグナル | 用途 | denied(拒否)時の動作 |
|---|---|---|
ad_storage | 広告関連のCookie | 広告Cookieを書き込まない。Cookieレスping(集約データ)のみ送信 |
analytics_storage | アナリティクスのCookie | GA4のCookieを書き込まない。Cookieレスpingのみ送信 |
この記事のパイプラインとConsent Modeの統合
問題は、タグ1〜3が書くCookieは「GTMのGoogleタグ」ではなく「カスタムHTMLタグ」だという点だ。Consent Modeは、Googleの公式タグ(GA4タグ、Google Adsタグなど)の動作を制御するが、カスタムHTMLタグのCookie書き込みは自動制御しない。
つまり、自分でConsent Modeの状態を確認し、同意がない場合はCookie書き込みをスキップするロジックを追加する必要がある。
<script>
(function() {
'use strict';
// Consent Modeの同意状態を確認
function hasAdStorageConsent() {
// Google Consent Modeのデフォルトコマンドを確認
var consentState = window.google_tag_data
&& window.google_tag_data.ics
&& window.google_tag_data.ics.entries;
// より確実な方法: GTMのdataLayerからconsentイベントを確認
var consentGranted = false;
(window.dataLayer || []).forEach(function(entry) {
if (entry[0] === 'consent' && entry[1] === 'update') {
if (entry[2] && entry[2].ad_storage === 'granted') {
consentGranted = true;
}
}
});
return consentGranted;
}
// 同意バナーの結果を待ってからCookieを書く
// 方法1: GTMトリガーで制御する(推奨)
// → カスタムHTMLタグのトリガーに
// 「Cookie同意granted」のカスタムイベントを追加
// 方法2: コード内でチェックする
if (!hasAdStorageConsent()) {
// 同意がない場合はlocalStorageのみ使用し、Cookieは書かない
// localStorageへの書き込みもGDPR下では同意が必要な場合がある
console.log('Ad storage consent not granted. Skipping cookie write.');
return;
}
// 以降、通常のCookie書き込み処理...
})();
</script>実務的な推奨パターン
最もシンプルで確実なのは、GTMのトリガー側でConsent Modeを制御する方法だ。
- GTMの「タグ」設定画面で、タグ1〜3のそれぞれに「同意の設定」を追加する
- 「追加の同意チェック」で
ad_storageを「必須」に設定する - これにより、
ad_storageがgrantedになるまでタグが発火しなくなる
この方法なら、コード自体は変更不要だ。GTMの管理画面だけで同意制御ができる。
日本国内のBtoBサイトで、EUからのアクセスがほぼないケースでは、改正個人情報保護法の「外部送信規律」への対応が優先事項になる。Cookie利用目的の公表(プライバシーポリシーへの記載)は最低限必要だ。同意バナーの要否は、電気通信事業者に該当するかどうかで変わる。判断に迷うときは法務に相談してほしい。
拡張コンバージョン for リード(Enhanced Conversions for Leads)
仕組み
ここまで構築したパイプラインは、gclid(広告クリックID)をキーにして広告とフォーム送信を紐づける。しかし、gclidはCookieやlocalStorageに保存するため、ITPやユーザーのCookieクリアで失われるリスクがある。
拡張コンバージョン for リード(ECL)は、Googleが提供するCookieに依存しない補完的なマッチング手段だ。仕組みはこうだ。
- ユーザーがフォームにメールアドレスを入力して送信する
- そのメールアドレスをSHA-256でハッシュ化してGoogleに送信する
- Googleは、広告クリック時にログインしていたGoogleアカウントのメールアドレスと照合する
- 一致すれば、Cookieがなくても広告クリックとコンバージョンが紐づく
つまり、gclidベースのマッチングと、メールアドレスベースのマッチングの二段構えにできる。
GTMでの設定手順
- コンバージョンリンカータグを追加する。GTMで「コンバージョンリンカー」タグを作成し、「All Pages」トリガーで発火させる。これがECLの前提条件になる
- Google Adsコンバージョンタグの設定。既存のGoogle Adsコンバージョンタグで「ユーザー提供データを含める」にチェックを入れる
- ユーザー提供データの変数を作成する。GTMの「変数」で「ユーザー提供データ」変数を新規作成し、メールアドレスのDOM要素またはdataLayerキーを指定する
- フォーム送信トリガーで発火させる。フォーム送信イベント(GTMのフォーム送信トリガーまたはカスタムイベント)で、コンバージョンタグが発火するように設定する
dataLayerへのユーザーデータ送信例
<script>
// フォーム送信時にdataLayerにプッシュ
// ※ハッシュ化はGTMが自動で行うため、平文で渡してよい
// (GTM側でSHA-256ハッシュ化される)
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
'event': 'form_submit',
'user_data': {
'email': document.getElementById('email-field').value,
'phone_number': document.getElementById('phone-field').value
}
});
</script>パイプラインとの補完関係
整理すると、この記事のパイプラインとECLは以下のように補完し合う。
マッチング方法 | キー | ITPの影響 | 用途 |
|---|---|---|---|
パイプライン(タグ1〜3) | gclid + UUID | 受ける | 全媒体対応。CRMへのデータ連携 |
拡張コンバージョン for リード | メールアドレス(ハッシュ化) | 受けない | Google Adsのコンバージョン計測精度向上 |
両方を実装することで、Cookieが消えても、メールアドレスでマッチングできる。逆にメールアドレスが取得できない場合(電話のみのフォーム等)でも、gclidでマッチングできる。
オフラインCVインポート: gclidをGoogle Adsに戻す完全ループ
ここが、多くの記事で「BigQueryで分析できます」と軽く触れて終わる部分だ。しかし、gclidをGoogle Adsに返すことで初めて、広告の自動入札が「質の高いリード」を学習できるようになる。これが本丸だ。
全体フロー
- ユーザーが広告をクリックしてサイトに来る(gclidが発行される)
- フォームを送信する(gclidがフォームデータに含まれる)
- フォームデータがCRM(Salesforce、HubSpot等)やスプレッドシートに格納される
- 営業がリードを評価し、「商談化」「成約」などのステータスを更新する
- 成約したリードのgclidとコンバージョン日時をGoogle Adsにアップロードする
- Google Adsの自動入札が「成約につながるクリック」の特徴を学習する
方法1: CSV手動インポート
最もシンプルな方法。月間リード数が少ない(50件以下程度)場合に向いている。
- スプレッドシートやCRMから、成約したリードの
gclid、コンバージョン名、コンバージョン日時、コンバージョン値をCSVで書き出す - Google Ads管理画面の「ツールと設定」→「コンバージョン」→「アップロード」からCSVをアップロードする
CSVのフォーマットは以下の通り。
Parameters:TimeZone=Asia/Tokyo
Google Click ID,Conversion Name,Conversion Time,Conversion Value
EAIaIQobChMI...,成約,2026-03-15 14:30:00,50000
EAIaIQobChMI...,商談化,2026-03-10 10:00:00,0方法2: Google Ads APIによる自動アップロード
リード数が多い場合や、リアルタイム性が求められる場合はAPIを使う。Google Ads APIの ConversionUploadService でオフラインコンバージョンをアップロードできる。
# Python例(google-ads-python クライアントライブラリ)
from google.ads.googleads.client import GoogleAdsClient
client = GoogleAdsClient.load_from_storage('google-ads.yaml')
conversion_upload_service = client.get_service('ConversionUploadService')
conversion_action_service = client.get_service('ConversionActionService')
# アップロードするコンバージョンデータ
click_conversion = client.get_type('ClickConversion')
click_conversion.gclid = 'EAIaIQobChMI...'
click_conversion.conversion_action = (
f'customers/{CUSTOMER_ID}/conversionActions/{CONVERSION_ACTION_ID}'
)
click_conversion.conversion_date_time = '2026-03-15 14:30:00+09:00'
click_conversion.conversion_value = 50000.0
click_conversion.currency_code = 'JPY'
# アップロード実行
request = client.get_type('UploadClickConversionsRequest')
request.customer_id = CUSTOMER_ID
request.conversions.append(click_conversion)
request.partial_failure = True
response = conversion_upload_service.upload_click_conversions(
request=request
)
# 結果確認
for result in response.results:
print(f'Uploaded: gclid={result.gclid}')方法3: Zapier / n8nによるノーコード自動連携
CRMとGoogle Adsの間をノーコードツールでつなぐ方法。エンジニアリソースが限られる場合に有効だ。
- Zapier: 「HubSpotの取引ステージが変更されたら → Google Adsにオフラインコンバージョンをアップロード」というZapを作成する。Google Adsの公式Zapier連携でオフラインCVアップロードがサポートされている
- n8n: 同様のフローをn8nで構築する。セルフホスト可能なので、データが外部サービスを経由しない利点がある。自動化ツールの選定基準については自動化の判断基準フレームワークも参考にしてほしい
方法4: CRM直接連携
SalesforceやHubSpotには、Google Adsとの直接連携機能がある。
- Salesforce: Google Adsの「Salesforce連携」を設定すると、Salesforceのリードステージ変更が自動的にGoogle Adsにコンバージョンとしてインポートされる
- HubSpot: HubSpotのGoogle Ads連携で、取引ステージの変更をオフラインコンバージョンとして送信できる
CRM直接連携は設定がシンプルだが、カスタマイズ性は低い。「成約」だけでなく「高単価成約」「リピート成約」などの細かいコンバージョンアクションを分けたい場合は、API連携のほうが柔軟だ。
インポートの注意点
- gclidの有効期限は90日。広告クリックから90日以内にインポートしないと、Google Adsがgclidを認識できない
- コンバージョン日時は、実際の成約日を使う。アップロード日ではなく、成約が確定した日時を指定する
- 重複アップロードに注意。同じgclidと同じコンバージョンアクションの組み合わせは、Google Adsが重複として弾くことがある(設定による)
- アップロードから反映まで最大24時間。すぐにはGoogle Adsのレポートに反映されない
デバッグとトラブルシューティング
「設定したけどデータが来ない」──この問題に遭遇する確率は高い。原因の切り分けフローを整理する。
ステップ1: Cookie / localStorageに値が保存されているか確認
- Chrome DevToolsを開く(F12)
- 「Application」タブ → 左メニューの「Cookies」→ サイトのドメインを選択
_lead_uuid、_lp_gclidなどのCookieが存在するか確認する- 同じく「Local Storage」→ サイトのドメインで
_lead_uuid、_lp_gclidを確認する
値がない場合、タグ1・タグ2が発火していない。GTMプレビューモードで確認する。
ステップ2: GTMプレビューモードでタグの発火を確認
- GTM管理画面で「プレビュー」をクリック
- サイトのURLを入力して接続
- Tag Assistantパネルで、タグ1〜3が「Tags Fired」に表示されているか確認する
- 「Tags Not Fired」に表示されている場合、トリガー条件またはConsent Mode設定を確認する
ステップ3: iframeのsrcにパラメータが付与されているか確認
- Chrome DevToolsの「Elements」タブで、iframeタグを検索する(Ctrl+F で「iframe」)
- src属性に
lead_uuid=やgclid=が含まれているか確認する - 含まれていない場合、タグ3のMutationObserverがiframeの追加より前に実行されている可能性がある
よくある原因と対策
症状 | 原因 | 対策 |
|---|---|---|
Cookieに値がない | Consent Modeでタグがブロックされている | GTMの「同意の設定」を確認。テスト時は一時的に同意を許可する |
localStorageに値がない | プライベートブラウジングモード | 通常モードでテストする |
iframeのsrcにパラメータがない | iframeがJavaScriptで遅延生成されている | MutationObserverが正しく動作しているか確認。observeのターゲット要素を確認する |
Jotformの送信データにパラメータがない | Jotform側のhidden field名がURLパラメータ名と不一致 | フィールド名を完全一致させる(大文字小文字も区別される) |
別ドメインのiframeにアクセスできない | クロスオリジン制約 | iframeのDOMには直接アクセスせず、URLパラメータ方式で渡す(タグ3のアプローチ) |
SPAでページ遷移後にタグが発火しない | GTMのトリガーが通常のページビューのみ | 「履歴の変更」トリガーを追加する |
本番環境でのデータ検証
テスト環境で動作確認ができたら、本番環境でも実際にデータが流れているかを検証する。
- GA4 DebugView: GA4の管理画面 →「DebugView」で、リアルタイムのイベントとパラメータを確認できる。Chrome拡張機能「Google Analytics Debugger」を有効にすると、自分のアクセスがDebugViewに表示される
- Jotform / CRMの送信データ確認: フォーム送信後、Jotformの管理画面またはCRMでhidden fieldの値が正しく記録されているか確認する
- Google Adsのオフラインコンバージョン診断: Google Ads管理画面の「ツールと設定」→「コンバージョン」→「診断」で、アップロードしたコンバージョンの処理状況を確認できる
クロスドメイン環境での注意点
広告のランディングページとフォームページが別ドメインにある場合(例: 広告から lp.example-clinic.com に着地し、フォームが www.example-clinic.com にある場合)、ドメインをまたぐときにCookieとlocalStorageが共有されない。
この場合の対策は2つある。
- URLパラメータで引き継ぐ。ドメイン間のリンクに、保存済みのgclidやUUIDをクエリパラメータとして付与する。タグ2がURLパラメータから値を読み取る仕組みなので、遷移先でも自動的にCookie/localStorageに保存される
- GA4のクロスドメイントラッキングと併用する。GA4のクロスドメイン設定を行えば、GA4のクライアントIDがドメイン間で引き継がれる。この記事のパイプラインはGA4とは独立した仕組みだが、GA4のクロスドメイン設定も併せて行っておくとよい。詳しくはGA4クロスドメイントラッキングの設定方法を参照
まとめ: 実装チェックリスト
この記事で構築したパイプラインの全体像を振り返る。
ステップ | 内容 | 所要時間(目安) |
|---|---|---|
1 | GTMでカスタムHTMLタグ3つを作成・公開 | 30分 |
2 | フォーム(Jotform/CF7/HubSpot等)にhidden fieldを追加 | 15分 |
3 | テスト: Cookie/localStorage、iframeパラメータ、フォーム送信データの確認 | 30分 |
4 | Consent Modeの設定(GTMの同意設定 + 同意バナー連携) | 1〜2時間 |
5 | 拡張コンバージョン for リードの設定 | 1時間 |
6 | オフラインCVインポートのフロー構築(CSV手動 or API自動) | 2〜4時間 |
7 | sGTM / Google Tag Gatewayの導入検討(ITP対策の根本解決) | 半日〜1日 |
ステップ1〜3だけでも基本的なリード追跡は動く。ステップ4以降は、計測精度を上げていくための段階的な改善だ。
最初から完璧を目指す必要はない。まずはタグ3つを入れて、フォームにgclidが渡っていることを確認する。それだけで、「どの広告がリードを生んでいるか」という最も基本的な問いに答えられるようになる。
あわせて読みたい
- Google Tag Gatewayで実現するファーストパーティ計測 ── sGTMとGoogle Tag Gatewayの導入手順と、ITP対策の根本的な解決方法を解説
- GA4クロスドメイントラッキングの設定方法 ── LPとメインサイトが別ドメインのとき、GA4でユーザーを一貫して追跡する設定手順
- 自動化の判断基準フレームワーク ── オフラインCVインポートの自動化をZapier/n8n/APIのどれで実装するか、判断の軸を整理



