IT技術ブログ

GA4のMeasurement Protocolで、LINEや電話で受けた予約と、その後の来館・成約をGA4へ送る実装。client_idの受け渡し、イベントの設計、api_secretの置き場所、72時間の制約、二重送信の防ぎ方、個人情報を送らない点検

LINEや電話で受けた予約と来館・成約を、GA4のMeasurement Protocolで送る実装です。client_idの保存、イベント名、api_secret、72時間の制約、二重送信と個人情報の防ぎ方を、検証サーバーで確かめました。

この記事の結論:LINEや電話で受けた予約、その後の来館・成約は、サーバーからGA4に送る形で、3つの段階に分けて組みます。①Webの予約フォームを送る時点でGA4のclient_idを予約の記録に保存しておき、②来館や成約をスタッフが記録したら「送る予定の表」に1行積み、③サーバーがその行を取り出してMeasurement Protocolで送る、という順です。api_secretはサーバーの環境変数だけに置き、72時間より古い出来事と、個人情報らしい値を含むイベントは、送る側で止めます。同じ出来事を2回送らないように、送る予定の表には「予約×段階」の一意制約をかけます。

この記事のコードは、手元のMacで実際に動かしたものです。送り先は、Googleの検証サーバー(/debug/mp/collect)だけにしました。本番の送り先には1件も送っておらず、measurement_idとapi_secretは仮の値です。そのため、GA4のレポートにどう出るかは確かめていません。どこまで確かめたかは、後半の「動作確認した環境」にまとめています。

予約から成約までを、GA4と顧客管理のどちらで数えるかという設計の全体像は、ブライダルフェアの予約から成約までをGA4とCRMで追う記事に書きました。この記事は、そのうち「サーバーからGA4へ送る」部分を、業種を問わず使える実装として掘り下げます。

Measurement Protocolは、ブラウザの外で起きた出来事をサーバーからGA4へ届ける口

Measurement Protocolは、サーバーなどからHTTPのPOSTでGA4へイベントを送る仕組みです。サイトに入れたタグ(gtag.jsやタグマネージャー)が拾えない出来事、たとえば店頭での来館や、契約書を交わした成約を、あとからGA4に足すために使います。Googleのドキュメントでは、Measurement Protocolは完成した段階にあり廃止の予定はないとしたうえで、これから作るサーバー間の連携にはData Manager APIを勧めています。この記事では、GA4のプロパティへ直接送れて手元で試しやすいMeasurement Protocolを使います。

送り先と本文の要点は次のとおりです。

項目 内容
送り先 https://www.google-analytics.com/mp/collect?measurement_id=G-…&api_secret=…
必須の本文 client_id(Webのデータストリームの場合)とeventsの配列
よく使う任意の項目 user_id、timestamp_micros、validation_behavior
1回に送れる数 イベントは25件まで、1件のパラメータは25個まで
名前の決まり イベント名・パラメータ名は40文字以内で、英字で始まり、英数字と_だけ
値の長さ パラメータの値は100文字まで(GA4 360は500文字まで)

注意したいのは、本番の送り先は「形が間違っていてもエラーを返さない」ことです。Googleのドキュメントにそう明記されていて、確かめる手段として検証サーバーが用意されています。送る処理を書いたら、本番に向ける前に検証サーバーで形を確かめる、という順番を崩さないようにします。

つなぐ鍵はclient_idで、予約を受けた時点で予約の記録に保存する

GA4の中で「Webで来た人」と「来館・成約した人」を同じ人として扱わせるには、Webで計測されたclient_idを、来館や成約のイベントにも付けて送ります。そのため、予約を受けた時点でclient_idを予約の記録に保存しておくことが、この仕組みの出発点になります。

予約の入口ごとに、手に入る鍵は次のように違います。

予約の入口 client_id この記事の扱い
自社サイトの予約フォーム フォームを送るときにgtagから取れる 予約の記録に保存し、来館・成約で使う
LINEのトークから開いた予約ページ ページにタグがあれば取れる 保存する。ただし、ふだん使うブラウザとは別の値になることがある
LINEのトークだけで完結した予約 無い GA4へは送らず、自社の記録で数える
電話 無い 同上

LINEのトークから開いたページは、LINEアプリの中のブラウザで表示されることが多いので、同じ人でも、以前にSafariやChromeで見たときとは別のclient_idになることがあります。ここは「つながればよい」くらいに考え、成約までの経路をきちんと数えるのは、自社の予約の記録で行います。

client_idが無い予約を、サーバーで作った値でGA4へ送ることもできます。ただ、client_idは必須なので、作った値で送ると、GA4の中にWebへ一度も来ていない利用者が1人増えるだけで、どの流入から来たかは分かりません。この記事のコードでは、client_idが無ければ送らずに記録だけ残します。

ブラウザ側では、フォームを送る直前にgtagのgetでclient_idを取り出し、隠し項目に入れます。広告ブロッカーなどでタグが動いていないと、getのコールバックが呼ばれないまま待ち続けることがあるので、1.5秒で諦めて空のまま送るようにしました。予約そのものを止めないことを優先します。

ga-ids.js(予約フォームのページに置く)js
// ga-ids.js — 予約フォームの送信前に、GA4 の client_id を隠し項目へ入れる
// タグが止められている・まだ読み込まれていないなどで値が返らなくても、予約の送信は止めない
function readGtagField(measurementId, field, timeoutMs = 1500) {
  return new Promise((resolve) => {
    if (typeof window.gtag !== 'function') return resolve('');
    const timer = setTimeout(() => resolve(''), timeoutMs);
    window.gtag('get', measurementId, field, (value) => {
      clearTimeout(timer);
      resolve(value == null ? '' : String(value));
    });
  });
}

document.querySelector('#booking-form').addEventListener('submit', async (e) => {
  const form = e.currentTarget;
  if (form.dataset.ready === '1') return; // 2回目の submit はそのまま送る
  e.preventDefault();
  form.elements.ga_client_id.value = await readGtagField('G-XXXXXXXXXX', 'client_id');
  form.dataset.ready = '1';
  form.requestSubmit();
});

フォームのHTMLには<input type="hidden" name="ga_client_id">を足しておき、サーバーは受け取った値を予約の記録のga_client_id列に保存します。gtagの読み込みを差し替えた関数で、タグが無いとき・値が返るとき・応答が無いときの3通りを動かすと、次のようになりました。

gtagなし: ""
gtagあり: "1838291045.1759550000"
応答なし: "" 1502ms

user_idも送れますが、使うのは「会員を登録したときに無作為に作った仮名のID」だけにします。GoogleのUser-IDの説明では、第三者が本人を特定できる情報を含めないこと、256文字以内であること、1人に1つの変わらないIDにすることが求められています。ヘルプには、ログインのときにメールアドレスから一意のIDを作る例も載っていますが、メールアドレスや電話番号をハッシュにした値は、同じ電話番号を知っている人なら計算し直して照らし合わせられるので、この記事では使いません。

イベント名はGA4の推奨イベントのリード用を使い、パラメータは2つに絞る

予約から成約までの段階は、GA4の推奨イベントにあるリード用の名前に当てはめます。自分で名前を作るより、GA4の画面やドキュメントと意味がそろい、あとから見た人にも伝わりやすいからです。

予約システムの段階 送るイベント 推奨イベントの説明(要約)
LINE・電話などで予約を受けた generate_lead フォームなどでリードが生まれた
来館・来店して担当者と会った working_lead 利用者が担当者と連絡を取った、または取られた
成約した close_convert_lead 見込み客が顧客になった(契約など)
成約に至らなかった close_unconvert_lead 顧客にならなかったと判断した

Webの予約フォームからの予約は、ブラウザのタグでgenerate_leadを送っている前提です。サーバーから同じ予約のgenerate_leadをもう一度送ると二重に数えるので、サーバーから送るのは、ブラウザの外で受けた予約と、来館・成約などの後ろの段階だけにします。

パラメータはlead_source(予約の入口。web・line・phone)とbooking_ref(予約番号)の2つだけです。lead_sourceは推奨イベントの説明にもある名前です。どちらもGA4のレポートで項目として見るには、管理画面でカスタムディメンションとして登録しておきます。予約番号は、あとで予約の記録と突き合わせるために送りますが、数字だけの番号にはしません。理由は、後の「個人情報」の章で書きます。

金額(valueとcurrency)も送れますが、この記事のコードでは送っていません。送るなら、成約の金額をGA4で見せてよい人の範囲を先に決めてからにします。

api_secretはサーバーの環境変数だけに置き、ログにも残さない

api_secretは、GA4の管理画面の[管理]→[データの収集と修正]→[データ ストリーム]で対象のWebストリームを開き、[Measurement Protocol API Secrets]から作ります。Googleのドキュメントでは、この値をサイトやアプリのクライアント側のコードに出してはいけないと書かれていて、漏れると第三者がGA4へでたらめなデータを送れるようになると説明されています。

この記事のコードでは、GA4_MEASUREMENT_IDとGA4_API_SECRETをサーバーの環境変数から読み、無ければ送らずにエラーにしています。もう1つ気をつけたいのは、api_secretがURLのクエリに入ることです。送信先のURLをそのままログに出す作りだと、ログを見られる人全員に鍵が渡ります。送信のログには、URLではなく予約番号と段階と結果だけを書くようにします。

鍵が漏れた、または漏れたかもしれないときは、管理画面で新しいシークレットを作ってサーバーを切り替え、古いものを消します。複数のシステムから送るなら、システムごとに別のシークレットを作っておくと、1つだけ止めることができます。

72時間より古い出来事は、送る側で先に止める

Measurement Protocolでは、timestamp_microsで出来事の時刻を指定できますが、さかのぼれるのは72時間までです。それより古い時刻を送ったときの動きは、validation_behaviorで変わります。

validation_behavior 72時間より古い時刻
指定なし・RELAXED(既定) 受け付けるが、時刻を72時間前に書き換える
ENFORCE_RECOMMENDATIONS 受け付けない

既定のままだと、5日前の成約が「3日前の成約」として静かに記録されます。日付がずれたデータは、後で気づきにくいので、この記事のコードではENFORCE_RECOMMENDATIONSを付けたうえで、送る前に自分でも時刻を調べ、71時間を過ぎたものは送らずに記録だけ残します。1時間の余裕は、送信の処理が遅れたときの分です。なお、Googleのリファレンスでは、取りこぼしを減らすために、送るときはvalidation_behaviorを指定しないことを勧めており、厳しさを優先したい場合にだけENFORCE_RECOMMENDATIONSを付けるよう書かれています。ENFORCE_RECOMMENDATIONSでは、上限を超えたパラメータを含むイベントも受け付けられないので、この記事では、送る前の点検で同じ上限を先に調べています。

検証サーバーに80時間前の時刻を送ると、既定ではメッセージは空、ENFORCE_RECOMMENDATIONSではVALUE_INVALIDが返りました(全文は後の「検証サーバーで確かめたこと」の章に載せています)。

もう1つの数字が48時間です。ドキュメントには、gtag.jsで集めたイベントと結びつけて扱わせたいMeasurement Protocolのイベントは、元のイベントの時刻から48時間以内にGA4へ届く必要があり、それより遅いとコンバージョンの計測などで期待どおりに処理されないことがある、と書かれています。来館や成約は予約から何日も後になることが多いので、「GA4の中で、どの広告からの成約か」がきれいに出ることは期待しすぎないほうが安全です。流入元ごとの成約数は、予約のときに保存した流入の記録で数えます。

もう1つ、timestamp_microsは「マイクロ秒」です。JavaScriptのDate.now()はミリ秒なので、1000を掛け忘れると1970年の時刻として扱われます。検証サーバーに試すと、こちらも「過去すぎる」としてVALUE_INVALIDが返りました。

二重送信は、送る予定の表に「予約×段階」の一意制約をかけて防ぐ

同じ来館を2回送らないために、来館や成約を記録したら直接送るのではなく、送る予定の表(ga4_outbox)に1行積み、別の処理がそこから送ります。表には(booking_id, stage)の一意制約をかけ、管理画面でボタンが2回押されても、行は1つしか増えないようにします。

GA4の側で重複を消してもらう手は、このイベントには使えません。GA4のヘルプでは、同じ取引IDを持つpurchaseイベントを重複として除く仕組みが説明されていますが、対象はpurchaseで、リード用のイベントは含まれていません。Measurement Protocolのドキュメントにも、同じイベントを2回送ったときにまとめる仕組みは書かれていないので、送る側で1回に絞ります。

送る処理は、次の3つの状態を順に進めます。

  1. 積む:INSERT … ON CONFLICT (booking_id, stage) DO NOTHING。積み済みなら何も起きない
  2. 取る:UPDATE … SET status = 'sending' WHERE status = 'pending'。1行更新できたときだけ送る。送信の処理が同時に2つ動いても、片方は0行で終わる
  3. 終える:結果をsent・skipped・failedのどれかで書く

通信が途中で切れて、届いたか分からないときは、自動でpendingに戻さずfailedで止めています。戻して送り直すと、届いていた場合に2回数えるからです。GA4は傾向を見る場所で、件数の正本は自社の予約の記録だと割り切り、「1件欠けること」を「1件多く数えること」より許す側に倒しました。failedの行は、件数を見て人が判断します。

個人情報は、送る項目を絞ったうえで、送る直前にも調べる

GA4には、メールアドレス・電話番号・氏名のように、Googleが個人を特定できる情報を送ってはいけません。Googleのヘルプにも、URLやページのタイトル、User-ID、カスタムディメンション、利用者がフォームに入力した値を含めて、個人情報を送らないよう書かれています。

守り方の中心は、送る項目を最初から決めておくことです。この記事のコードで送るのは、client_id、仮名のuser_id、イベント名、lead_source、booking_refだけで、予約の記録にある氏名や連絡先の列は、送る処理からそもそも読みません。

そのうえで、送る直前に、値の中にメールアドレスや電話番号らしい文字列が無いかを正規表現で調べ、見つかったら送らずにskippedにします。わざわざ調べるのは、検証サーバーが個人情報を見てくれないからです。パラメータにメールアドレスと電話番号を、user_idにメールアドレスを入れて検証サーバーへ送ってみると、validationMessagesは空で返ってきました。形が正しければ通ってしまいます。

正規表現は誤って引っかかることがあります。手元で試すと、000123のような0で始まる6桁以上の数字だけの値も電話番号らしいと判定されました。誤判定は「送らない」側に倒れるので害は小さいのですが、予約番号をbk_7f3a9cのように英字を混ぜた形にしておくと、誤判定も、予約番号から何かを推測される余地も減らせます。

"1838291045.1759550000" []
"402918377.1759552222" []
"bk_7f3a9c" []
"090-1234-5678" [ 'phone' ]
"06 6123 4567" [ 'phone' ]
"+81 90 1234 5678" [ 'phone' ]
"[email protected]" [ 'email' ]
"000123" [ 'phone' ]

検証サーバーで確かめたこと:形は見るが、鍵と個人情報は見ない

検証サーバー(https://www.google-analytics.com/debug/mp/collect)は、本文の形の誤りをvalidationMessagesで返してくれます。ここに送ったイベントはレポートには載りません。一方で、api_secretが正しいかは調べないとドキュメントに書かれていて、実際に仮のmeasurement_idと仮のapi_secretで送っても、正しい形なら空の結果が返りました。

手元の点検を通さずに、7通りの本文を検証サーバーへ直接送った結果です。HTTPの状態コードは、どれも200でした。

1. 正しい形 → HTTP 200
   (validationMessages は空)
2. 80時間前・RELAXED(既定) → HTTP 200
   (validationMessages は空)
3. 80時間前・ENFORCE_RECOMMENDATIONS → HTTP 200
   VALUE_INVALID timestamp_micros: Measurement timestamp_micros has timestamp too far in the past. Measurement protocol only supports timestamps [72h] into the past. The current time in UNIX microseconds is [1791109866168959].
4. ミリ秒のまま入れた → HTTP 200
   VALUE_INVALID timestamp_micros: Measurement timestamp_micros has timestamp too far in the past. Measurement protocol only supports timestamps [72h] into the past. The current time in UNIX microseconds is [1791109866328252].
5. 予約済みの名前 → HTTP 200
   NAME_RESERVED events: Event at index: [0] has name [session_start] which is reserved.
6. 値が101文字・ENFORCE → HTTP 200
   VALUE_TOO_LONG events.params: Param [note] has value [あああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああ] longer than max characters [100].
7. メールと電話をパラメータに入れた → HTTP 200
   (validationMessages は空)

読み取れることは次の4つです。

  • 既定の動きでは、古い時刻も通る。 2の80時間前は、メッセージ無しで通った。止めたいならENFORCE_RECOMMENDATIONSを付けるか、送る側で止める
  • ミリ秒の入れ間違いは、ENFORCE_RECOMMENDATIONSなら見つかる。 4は1970年の時刻として「過去すぎる」と返った
  • 値の長さの誤りはVALUE_TOO_LONGという種類で返った。 検証のドキュメントにある種類の一覧(VALUE_INVALIDなど)には、この名前は載っていなかった(意味の近いVALUE_OUT_OF_BOUNDSは載っている)。種類の名前で分けて処理する作りにするなら、一覧に無い名前も来る前提にしておく
  • 個人情報は止まらない。 7はメールアドレスと電話番号を含んでいても空で返った

validationMessagesが空でも、GA4に正しく載ることまでは保証されません。鍵の取り違えが無いかは、本番に切り替えた最初の数件を、GA4の管理画面のリアルタイムやDebugViewで確かめる必要があります。この記事ではそこまで行っていません。

積む・点検する・送るを通しで動かした結果

予約5件と、来館・成約などの出来事を用意し、送る予定の表に積んでから、検証サーバーへ送る処理を2回続けて動かしました。用意したのは次のケースです。

予約番号 入口 出来事 ねらい
bk_7f3a9c Web 2時間前に来館(2回積む) 二重に積んでも1回しか送らないか
bk_c41e02 LINE 5時間前に予約 ブラウザの外で受けた予約を送れるか
bk_9d0b7e 電話 1時間前に来館 client_idが無いものを止めるか
bk_e5a113 Web 80時間前に成約 72時間を過ぎたものを止めるか
[email protected] Web 1時間前に来館 予約番号にメールを入れてしまった悪い例を止めるか

結果は次のとおりです(2026年10月4日19時34分ごろ、日本時間に実行)。

積む: true
同じ来館をもう一度積む: false
[
  {
    "id": "bk_e5a113",
    "stage": "contracted",
    "status": "skipped",
    "problems": [
      "発生から80時間たっている(72時間の上限に近いか超えている)"
    ]
  },
  {
    "id": "bk_c41e02",
    "stage": "booked",
    "status": "sent",
    "http": 200,
    "validationMessages": []
  },
  {
    "id": "bk_7f3a9c",
    "stage": "visited",
    "status": "sent",
    "http": 200,
    "validationMessages": []
  },
  {
    "id": "bk_9d0b7e",
    "stage": "visited",
    "status": "skipped",
    "problems": [
      "client_id がない(Webの訪問とつなぐ鍵がないので送らない)"
    ]
  },
  {
    "id": "[email protected]",
    "stage": "visited",
    "status": "skipped",
    "problems": [
      "個人情報らしい値: booking_ref(email)"
    ]
  }
]
2回目の flush: []
┌─────────┬────────────────────┬──────────────┬───────────┐
│ (index) │ booking_id         │ stage        │ status    │
├─────────┼────────────────────┼──────────────┼───────────┤
│ 0       │ 'bk_7f3a9c'        │ 'visited'    │ 'sent'    │
│ 1       │ 'bk_c41e02'        │ 'booked'     │ 'sent'    │
│ 2       │ 'bk_9d0b7e'        │ 'visited'    │ 'skipped' │
│ 3       │ 'bk_e5a113'        │ 'contracted' │ 'skipped' │
│ 4       │ '[email protected]' │ 'visited'    │ 'skipped' │
└─────────┴────────────────────┴──────────────┴───────────┘

送ったのは2件で、どちらも検証サーバーのvalidationMessagesは空でした。残りの3件は、送る前の点検で理由つきのskippedになり、検証サーバーへは届いていません。同じ来館を2回積んだbk_7f3a9cは、2回目のenqueueがfalseを返して行が増えず、2回目の送信の処理では、送る対象が0件でした。

コード全体

点検と送信の部分です。サーバー側だけで動かします。追加のパッケージは使わず、Node.js標準のfetchで送ります。

ga4-mp.mjsjs
// ga4-mp.mjs — 予約の後に起きた出来事(来館・成約など)を、Measurement Protocol で GA4 へ送る
// サーバー側だけで動かす。api_secret は環境変数から読み、ブラウザへは絶対に渡さない。
const ENDPOINT = {
  prod: 'https://www.google-analytics.com/mp/collect',
  debug: 'https://www.google-analytics.com/debug/mp/collect', // 検証用。レポートには載らない
};

// 予約システムの出来事 → GA4 の推奨イベント(リード用)
export const EVENT_MAP = {
  booked: 'generate_lead',        // 予約を受けた(LINE・電話など、ブラウザの外で受けたとき)
  visited: 'working_lead',        // 来館・来店して担当者と会った
  contracted: 'close_convert_lead', // 成約
  lost: 'close_unconvert_lead',   // 成約に至らなかった
};

const MAX_BACKDATE_MS = 72 * 60 * 60 * 1000;     // ドキュメント上の上限
const SAFETY_MARGIN_MS = 60 * 60 * 1000;          // 送信の遅れを見込んで1時間手前で打ち切る
const NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,39}$/;   // 英字で始まり、英数字と_、40文字以内
const RESERVED_PREFIX = /^(_|firebase_|ga_|google_|gtag\.)/;

// 個人情報らしい値を送らないための最後の網(これだけに頼らず、送る項目を最初から絞る)
const PII_PATTERNS = [
  { kind: 'email', re: /[^\s@]+@[^\s@]+\.[^\s@]+/ },
  { kind: 'phone', re: /(?<!\d)(?:\+81[-\s]?|0)\d{1,4}[-\s]?\d{1,4}[-\s]?\d{3,4}(?!\d)/ },
];

export function findPii(value) {
  const s = String(value);
  return PII_PATTERNS.filter((p) => p.re.test(s)).map((p) => p.kind);
}

/**
 * 送る前の点検。問題があれば理由の配列を返す(空なら送ってよい)
 * @param {{clientId:string, userId?:string, name:string, params:object, occurredAt:Date}} ev
 */
export function checkEvent(ev, now = new Date()) {
  const errors = [];
  if (!ev.clientId) errors.push('client_id がない(Webの訪問とつなぐ鍵がないので送らない)');
  if (!NAME_RE.test(ev.name)) errors.push(`イベント名が規則に合わない: ${ev.name}`);
  const age = now - ev.occurredAt;
  if (age > MAX_BACKDATE_MS - SAFETY_MARGIN_MS) errors.push(`発生から${Math.floor(age / 3600000)}時間たっている(72時間の上限に近いか超えている)`);
  if (age < -5 * 60 * 1000) errors.push('発生時刻が未来になっている(時計か入力の誤り)');
  const entries = Object.entries(ev.params ?? {});
  if (entries.length > 25) errors.push('パラメータが25個を超えている');
  for (const [k, v] of entries) {
    if (!NAME_RE.test(k) || RESERVED_PREFIX.test(k)) errors.push(`パラメータ名が使えない: ${k}`);
    if (typeof v === 'string' && v.length > 100) errors.push(`値が100文字を超えている: ${k}`);
    const pii = findPii(v);
    if (pii.length) errors.push(`個人情報らしい値: ${k}(${pii.join(',')})`);
  }
  for (const [k, v] of [['client_id', ev.clientId], ['user_id', ev.userId]]) {
    if (v && findPii(v).length) errors.push(`${k} に個人情報らしい値`);
  }
  return errors;
}

/** Measurement Protocol の本文を組み立てる */
export function buildBody(ev) {
  const body = {
    client_id: ev.clientId,
    timestamp_micros: ev.occurredAt.getTime() * 1000, // マイクロ秒(ミリ秒ではない)
    validation_behavior: 'ENFORCE_RECOMMENDATIONS',   // 72時間より前などは黙って直さず拒否させる
    events: [{ name: ev.name, params: { ...ev.params } }],
  };
  if (ev.userId) body.user_id = ev.userId;
  return body;
}

/** 送る。debug=true なら検証サーバーへ送り、validationMessages を返す */
export async function send(ev, { debug = false, env = process.env, fetchImpl = fetch } = {}) {
  const { GA4_MEASUREMENT_ID: mid, GA4_API_SECRET: secret } = env;
  if (!mid || !secret) throw new Error('GA4_MEASUREMENT_ID と GA4_API_SECRET をサーバーの環境変数に置く');
  const url = `${debug ? ENDPOINT.debug : ENDPOINT.prod}?measurement_id=${encodeURIComponent(mid)}&api_secret=${encodeURIComponent(secret)}`;
  const res = await fetchImpl(url, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(buildBody(ev)),
  });
  // 本番の /mp/collect は、中身が壊れていてもエラーのコードを返さない。確かめられるのは「届いたか」だけ
  if (!debug) return { status: res.status };
  return { status: res.status, ...(await res.json()) };
}

送る予定の表と、送信の処理です。手元ではNode.js 22に入っているnode:sqliteで動かしました(実行すると、試験的な機能だという警告が出ます)。SQLは、PostgreSQLでもほぼそのまま使える書き方にしています。

outbox.mjsjs
// outbox.mjs — 送る予定のイベントを DB に積み、1回だけ送るための仕組み(SQLite で確認。PostgreSQL でも同じ考え方)
import { DatabaseSync } from 'node:sqlite';
import { EVENT_MAP, checkEvent, send } from './ga4-mp.mjs';

export function openDb(path = ':memory:') {
  const db = new DatabaseSync(path);
  db.exec(`
    CREATE TABLE IF NOT EXISTS bookings (
      id           TEXT PRIMARY KEY,           -- 予約番号(数字だけにしない。例 bk_7f3a9c)
      channel      TEXT NOT NULL,              -- web / line / phone
      ga_client_id TEXT,                       -- 予約の時点で保存した GA4 の client_id(無ければ NULL)
      member_key   TEXT,                       -- user_id に使う会員の仮名ID(氏名・電話・メールから作らない)
      created_at   TEXT NOT NULL               -- UTC の ISO 形式
    );
    CREATE TABLE IF NOT EXISTS ga4_outbox (
      booking_id  TEXT NOT NULL REFERENCES bookings(id),
      stage       TEXT NOT NULL,               -- booked / visited / contracted / lost
      occurred_at TEXT NOT NULL,               -- 出来事が起きた時刻(UTC)
      status      TEXT NOT NULL DEFAULT 'pending', -- pending / sending / sent / skipped / failed
      note        TEXT,
      updated_at  TEXT,
      UNIQUE (booking_id, stage)               -- 同じ予約の同じ段階は1行だけ=二重送信の元を作らない
    );
  `);
  return db;
}

/** 管理画面で「来館」などを記録したときに呼ぶ。2回押されても行は増えない */
export function enqueue(db, bookingId, stage, occurredAt) {
  const r = db.prepare(
    `INSERT INTO ga4_outbox (booking_id, stage, occurred_at) VALUES (?, ?, ?)
     ON CONFLICT (booking_id, stage) DO NOTHING`,
  ).run(bookingId, stage, occurredAt.toISOString());
  return r.changes === 1; // false なら積み済み
}

/** 送る前に「送信中」へ変える。別の処理が先に取っていたら false */
function claim(db, bookingId, stage, now) {
  const r = db.prepare(
    `UPDATE ga4_outbox SET status = 'sending', updated_at = ?
      WHERE booking_id = ? AND stage = ? AND status = 'pending'`,
  ).run(now.toISOString(), bookingId, stage);
  return r.changes === 1;
}

function finish(db, bookingId, stage, status, note, now) {
  db.prepare(`UPDATE ga4_outbox SET status = ?, note = ?, updated_at = ? WHERE booking_id = ? AND stage = ?`)
    .run(status, note, now.toISOString(), bookingId, stage);
}

/** 数分おきに動かす送信の処理 */
export async function flush(db, { now = new Date(), debug = false, env, fetchImpl } = {}) {
  const rows = db.prepare(
    `SELECT o.booking_id, o.stage, o.occurred_at, b.channel, b.ga_client_id, b.member_key
       FROM ga4_outbox o JOIN bookings b ON b.id = o.booking_id
      WHERE o.status = 'pending' ORDER BY o.occurred_at`,
  ).all();
  const results = [];
  for (const r of rows) {
    if (!claim(db, r.booking_id, r.stage, now)) continue;
    const ev = {
      clientId: r.ga_client_id,
      userId: r.member_key ?? undefined,
      name: EVENT_MAP[r.stage],
      occurredAt: new Date(r.occurred_at),
      params: { lead_source: r.channel, booking_ref: r.booking_id },
    };
    const problems = checkEvent(ev, now);
    if (problems.length) {
      finish(db, r.booking_id, r.stage, 'skipped', problems.join(' / '), now);
      results.push({ id: r.booking_id, stage: r.stage, status: 'skipped', problems });
      continue;
    }
    try {
      const res = await send(ev, { debug, env, fetchImpl });
      const msgs = res.validationMessages ?? [];
      const status = res.status >= 200 && res.status < 300 && msgs.length === 0 ? 'sent' : 'failed';
      finish(db, r.booking_id, r.stage, status, msgs.map((m) => m.description).join(' / ') || null, now);
      results.push({ id: r.booking_id, stage: r.stage, status, http: res.status, validationMessages: msgs });
    } catch (e) {
      // 届いたか分からないときは pending に戻さず failed で止め、人が確かめてから戻す
      finish(db, r.booking_id, r.stage, 'failed', String(e.message), now);
      results.push({ id: r.booking_id, stage: r.stage, status: 'failed', error: e.message });
    }
  }
  return results;
}

通しで動かしたスクリプトです。measurement_idとapi_secretは仮の値で、debug: trueなので検証サーバーにしか送りません。

run-debug.mjsjs
// run-debug.mjs — 検証サーバー(/debug/mp/collect)だけを相手に、積む→点検→送るを通しで動かす
// 本番の /mp/collect には送らない。measurement_id と api_secret は仮の値
import { openDb, enqueue, flush } from './outbox.mjs';

const env = { GA4_MEASUREMENT_ID: 'G-XXXXXXXXXX', GA4_API_SECRET: 'placeholder-secret' };
const now = new Date();
const hoursAgo = (h) => new Date(now.getTime() - h * 3600_000);

const db = openDb();
const addBooking = db.prepare('INSERT INTO bookings VALUES (?, ?, ?, ?, ?)');
addBooking.run('bk_7f3a9c', 'web',   '1838291045.1759550000', 'm_2b8e41d0', hoursAgo(30).toISOString());
addBooking.run('bk_c41e02', 'line',  '402918377.1759552222',  null,         hoursAgo(5).toISOString());
addBooking.run('bk_9d0b7e', 'phone', null,                     null,         hoursAgo(3).toISOString());
addBooking.run('bk_e5a113', 'web',   '771023456.1759100000',  null,         hoursAgo(120).toISOString());
addBooking.run('[email protected]', 'web', '55501234.1759553333', null,     hoursAgo(2).toISOString()); // 悪い例:予約番号にメール

console.log('積む:', enqueue(db, 'bk_7f3a9c', 'visited', hoursAgo(2)));
console.log('同じ来館をもう一度積む:', enqueue(db, 'bk_7f3a9c', 'visited', hoursAgo(2)));
enqueue(db, 'bk_c41e02', 'booked', hoursAgo(5));
enqueue(db, 'bk_9d0b7e', 'visited', hoursAgo(1));
enqueue(db, 'bk_e5a113', 'contracted', hoursAgo(80)); // システムが止まっていて80時間前の成約が残っていた
enqueue(db, '[email protected]', 'visited', hoursAgo(1));

const results = await flush(db, { now, debug: true, env });
console.log(JSON.stringify(results, null, 2));

console.log('2回目の flush:', JSON.stringify(await flush(db, { now, debug: true, env })));
console.table(db.prepare('SELECT booking_id, stage, status FROM ga4_outbox').all());

検証サーバーの動きを7通り試したスクリプトです。

probe-debug.mjsjs
// probe-debug.mjs — 手元の点検を通さずに、検証サーバーが何を見て何を見ないかを確かめる
const URL_ = 'https://www.google-analytics.com/debug/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=placeholder-secret';
const nowMs = Date.now();
const base = (ev, extra = {}) => ({ client_id: '1838291045.1759550000', ...extra, events: [ev] });
const cases = {
  '1. 正しい形': base({ name: 'working_lead', params: { lead_source: 'web', engagement_time_msec: 1 } }),
  '2. 80時間前・RELAXED(既定)': base({ name: 'working_lead' }, { timestamp_micros: (nowMs - 80 * 3600e3) * 1000 }),
  '3. 80時間前・ENFORCE_RECOMMENDATIONS': base({ name: 'working_lead' }, { timestamp_micros: (nowMs - 80 * 3600e3) * 1000, validation_behavior: 'ENFORCE_RECOMMENDATIONS' }),
  '4. ミリ秒のまま入れた': base({ name: 'working_lead' }, { timestamp_micros: nowMs, validation_behavior: 'ENFORCE_RECOMMENDATIONS' }),
  '5. 予約済みの名前': base({ name: 'session_start' }),
  '6. 値が101文字・ENFORCE': base({ name: 'working_lead', params: { note: 'あ'.repeat(101) } }, { validation_behavior: 'ENFORCE_RECOMMENDATIONS' }),
  '7. メールと電話をパラメータに入れた': base({ name: 'working_lead', params: { email: '[email protected]', tel: '090-1234-5678' } }, { user_id: '[email protected]' }),
};
for (const [label, body] of Object.entries(cases)) {
  const res = await fetch(URL_, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
  const j = await res.json();
  console.log(`${label} → HTTP ${res.status}`);
  for (const m of j.validationMessages) console.log(`   ${m.validationCode} ${m.fieldPath}: ${m.description}`);
  if (!j.validationMessages.length) console.log('   (validationMessages は空)');
}

本番に向ける前に決めておくこと

コードを本番に向ける前に、次の5つを決めておきます。

  • 送る処理を動かす間隔:48時間・72時間の制約があるので、1日1回ではなく、数分〜数十分おきに動かす
  • skippedとfailedを誰が見るか:件数が増えたら気づける場所(管理画面や通知)に出す
  • カスタムディメンションの登録:lead_sourceとbooking_refを、GA4の管理画面で登録する
  • プライバシーポリシーの文面:アクセス解析の識別子を予約の記録に保存し、来館・成約の分析に使うことを書いているか
  • 同意の扱い:同意を得てから計測する作りのサイトなら、同意が無い人のclient_idは空で届くので、その予約は送らない

このような計測の仕組みは、数字を見て次の手を決める人がいて初めて役に立ちます。GA4の基本の見方や、問い合わせをキーイベントとして数える設定は、会社のコラムGA4の使い方。中小企業が最低限見る3つの画面で扱っています。株式会社bundlyzeでは、外部のWeb責任者として、計測の設計から数字の分析、次の施策の判断までを引き受けるWEB戦略代行を行っています。予約とGA4をつなぐところから相談したい場合は、WEB戦略代行のページをご覧ください。

動作確認した環境

確認日は2026年10月4日、使ったのは手元のMacです。

項目 バージョン・内容
OS macOS 26(Darwin 25.6.0)
Node.js 22.23.2(標準のfetchとnode:sqlite。追加のパッケージなし)
送り先 https://www.google-analytics.com/debug/mp/collect(検証サーバー)のみ
measurement_id / api_secret 仮の値(G-XXXXXXXXXX / placeholder-secret)
確かめたこと ブラウザ側のclient_idの取り出し(gtagを差し替えた関数で3通り)、一意制約による二重の積み込みの防止、送る前の点検(client_id・72時間・個人情報)、検証サーバーの応答7通り、通しでの送信

確かめていないことは次のとおりです。本番の送り先(/mp/collect)へは送っていないので、GA4のレポート・リアルタイム・DebugViewにどう出るか、本物のGA4プロパティのapi_secretで通るか、カスタムディメンションの表示は見ていません。ga-ids.jsは、本物のgtag.jsを読み込んだブラウザでは動かしておらず、gtagを差し替えた関数で動きだけを確かめました。PostgreSQLでの実行、送る処理を複数同時に動かしたときの取り合いも、今回は試していません。

参照した公式ドキュメント(確認日:2026年10月4日)

よくある質問

Measurement Protocolで送ったイベントが間違っていたら、エラーが返ってきますか?

本番の送り先(/mp/collect)は返しません。Googleのドキュメントには、イベントの形が壊れていても、必須の項目が抜けていても、HTTPのエラーコードは返さないと書かれています。形が正しいかは、検証サーバー(/debug/mp/collect)に同じ本文を送って、validationMessagesが空かどうかで確かめます。ただし検証サーバーはapi_secretが正しいかは調べないので、鍵の取り違えは別に確かめる必要があります。

電話で受けた予約も、GA4へ送ったほうがよいですか?

Webの訪問とつなぐ鍵(client_id)が無いなら、送らないほうが扱いやすいです。Measurement Protocolではclient_idが必須なので、送るにはサーバーで値を作るしかなく、GA4の中に「Webに来ていない利用者」が増えるだけになります。電話の予約は、自社の予約の記録で数えます。

3日以上前の来館や成約を、あとからまとめて送れますか?

そのままの日時では送れません。Measurement Protocolでさかのぼれるのは72時間までで、それより古い時刻は、既定の動きでは72時間前に書き換えられて受け付けられ、ENFORCE_RECOMMENDATIONSを指定すると受け付けられません。Webの訪問と結びつけて扱わせたいなら、元の訪問から48時間以内に届くことも求められています。記録したらすぐ送る作りにし、間に合わなかった分は自社のデータベースで数えます。

user_idに、メールアドレスをハッシュにした値を使ってもよいですか?

この記事の実装では使っていません。GoogleのUser-IDの説明では、第三者が本人を特定できる情報をuser_idに含めてはいけないとされています。メールアドレスや電話番号から作った値は、同じ値を持つ人が計算し直せば本人とつながってしまうので、会員を登録したときに無作為に作った仮名のIDを使います。