GA4のデータとフォーム送信したデータをBigQueryに書き込み突合してみた
概要
フォーム送信データをGA4のMeasurement Protocolを使ってGA4にUser-IDを送信しつつ、同時にデータをBigQueryにも書き込むことで、BigQuery上でGA4のイベントデータとフォーム送信データを突合できるかを検証してみました。
「フォームを送信したユーザーがその前後にどんなページを見ていたか」「GA4で計測しているセッション情報とフォーム送信を紐づけたい」といったユースケースを想定しています。
Measurement Protocolを用いたGA4へのUser-ID登録方法は以下の記事で検証しています。
構築するアーキテクチャのイメージは以下の図となります。

一意なユーザーIDとしてPythonのuuid4()で生成したUUIDをGA4のUser-IDに設定し、GA4とBigQueryの両方に同じ値を書き込むことで突合のキーとします。
アーキテクチャ
全体の流れを整理します。
- フォーム → Cloud Run(受信用): フォームからのPOSTリクエストを受け取り、
uuid4()で一意なUser_idを生成する。リクエストデータと生成したUser_idをPub/Subにパブリッシュする - Pub/Sub: メッセージキューとして受信用Cloud RunとGA4送信/BigQuery書き込みCloud Run間を非同期に連携する。1つのトピックに対して2つのPushサブスクリプションを作成し、それぞれ別のCloud Runに配信する
- Pub/Sub → Cloud Run(GA4送信用): GA4のMeasurement ProtocolでUser_idを含むイベントを送信する
- Pub/Sub → Cloud Run(BigQuery書き込み用): BigQueryにフォーム送信データをUser_id付きで書き込む
Pub/Subを間に挟むことで、フォーム送信のレスポンスを高速に返しつつ、後続の処理を非同期に行えます。処理側で基盤障害など何らかのエラーが発生した場合もPub/Subのリトライ機能で再送されるため、データの欠損リスクを低減できます。
また、GA4送信とBigQuery書き込みを別の関数に分離することで、一方の障害がもう一方に影響しません。
たとえばGA4のAPIが一時的に不調でも、BigQueryへの書き込みは正常に処理されます。Pub/Subの1つのトピックに複数のサブスクリプションを紐づけると、パブリッシュされたメッセージは各サブスクリプションにそれぞれ配信されるため、同じデータを両方の関数で受け取ることができます。
事前準備
GA4のMeasurement IDとAPIシークレットの取得
Measurement Protocolを使用するにはMeasurement ID(測定ID)とAPIシークレットが必要です。取得方法やMeasurement Protocolの使用方法は以下の記事で解説しています。
Pub/Subトピックの作成
メッセージキューとなるPub/Subトピックを作成します。
gcloud pubsub topics create form-submission-topic
BigQueryテーブルの作成
フォーム送信データを格納するテーブルを作成します。
bq mk --dataset --location=asia-northeast1 PROJECT_ID:ga4_analysis
bq mk --table PROJECT_ID:ga4_analysis.submissions \ user_id:STRING,client_id:STRING,form_type:STRING,name:STRING,email:STRING,message:STRING,submitted_at:TIMESTAMP
| カラム名 | データ型 | 概要 |
|---|---|---|
| user_id | STRING | uuid4()で生成した一意なユーザーID。GA4との突合キー |
| client_id | STRING | GA4 Measurement Protocol用のクライアントID |
| form_type | STRING | フォーム種別。どのフォームから受信したかをわかるように |
| name | STRING | フォーム送信者名 |
| STRING | フォーム送信者メールアドレス | |
| message | STRING | お問い合わせ内容 |
| submitted_at | TIMESTAMP | フォーム送信日時(UTC) |
GA4とBigQueryでデータを突合するためには、両方に同じUser-IDを書き込む必要があります。今回はuuid4()で生成したUUIDをUser-IDとして使用します。
やってみる
ディレクトリ構成
以下のようなディレクトリ構成で作業します。
form-to-ga4-bq/
├── receiver/
│ ├── main.py
│ └── requirements.txt
├── ga4-sender/
│ ├── main.py
│ └── requirements.txt
└── bq-writer/
├── main.py
└── requirements.txt
receiverがフォームからのリクエストを受け取る関数、ga4-senderがGA4にMeasurement Protocolでイベントを送信する関数、bq-writerがBigQueryにデータを書き込む関数です。
receiver(フォーム受信用)
requirements.txt
functions-framework==3.*
google-cloud-pubsub==2.*
main.py
import json
import uuid
import functions_framework
from google.cloud import pubsub_v1
PROJECT_ID = "PROJECT_ID"
TOPIC_ID = "form-submission-topic"
publisher = pubsub_v1.PublisherClient()
topic_path = publisher.topic_path(PROJECT_ID, TOPIC_ID)
@functions_framework.http
def receive_form(request):
"""フォーム送信を受け取り、Pub/Subにパブリッシュする"""
req = request.get_json(silent=True)
if not req:
return "リクエストボディが空です", 400
# フォームの必須フィールド
name = req.get("name")
email = req.get("email")
if not name or not email:
return "nameとemailは必須です", 400
# uuid4()で一意なuser_idを生成
user_id = str(uuid.uuid4())
# client_idもuuid4()で生成(ブラウザのclient_idと紐づけない場合)
client_id = str(uuid.uuid4())
# Pub/Subに送信するメッセージを構築
message = {
"user_id": user_id,
"client_id": client_id,
"form_type": req.get("form_type", "contact"),
"name": name,
"email": email,
"message": req.get("message", ""),
"debug_mode": req.get("debug_mode", 0),
}
# Pub/Subにパブリッシュ
future = publisher.publish(topic_path, json.dumps(message).encode("utf-8"))
message_id = future.result()
print(f"Pub/Subにパブリッシュ完了: message_id={message_id}, user_id={user_id}")
return {
"status": "success",
"user_id": user_id,
"pubsub_message_id": message_id,
}, 200
少し解説します。
user_id = str(uuid.uuid4())
uuid4()でランダムなUUIDを生成しています。uuid4()はランダムなビットから生成されるため、MACアドレスやタイムスタンプに依存せずプライバシーの面でも安全です。生成される値は550e8400-e29b-41d4-a716-446655440000のような形式です。
client_id = str(uuid.uuid4())
GA4のMeasurement Protocolではclient_idが必須フィールドです。今回はサーバーサイドのイベント送信のみで完結するため、ブラウザ側のClient IDとは紐づけずランダムな値を指定しています。フロントエンドのGA4セッションと紐づけたい場合は、フォーム送信時にブラウザのGA4 Cookie(_ga)の値をリクエストに含めてサーバーに連携する必要があります。
future = publisher.publish(topic_path, json.dumps(message).encode("utf-8"))
message_id = future.result()
Pub/Subクライアントでメッセージをパブリッシュしています。future.result()でメッセージIDを取得し、パブリッシュの完了を確認しています。
ga4-sender(GA4送信用)
requirements.txt
functions-framework==3.*
requests==2.*
main.py
import base64
import json
import functions_framework
import requests
# GA4の設定
GA4_MEASUREMENT_ID = "測定ID"
GA4_API_SECRET = "APIシークレット"
MP_ENDPOINT = "https://www.google-analytics.com/mp/collect"
@functions_framework.cloud_event
def send_to_ga4(cloud_event):
"""Pub/Subメッセージを受け取り、GA4にMeasurement Protocolで送信する"""
# Pub/Subメッセージのデコード
message_data = base64.b64decode(cloud_event.data["message"]["data"]).decode("utf-8")
submission = json.loads(message_data)
user_id = submission["user_id"]
client_id = submission["client_id"]
# Measurement Protocolペイロードの構築
event_params = {
"engagement_time_msec": "10000",
"form_type": submission.get("form_type", "contact"),
}
# debug_modeが指定されている場合はGA4のDebugViewに表示する
if submission.get("debug_mode"):
event_params["debug_mode"] = 1
payload = {
"client_id": client_id,
"user_id": user_id,
"events": [
{
"name": "form_submission",
"params": event_params,
}
],
}
url = f"{MP_ENDPOINT}?measurement_id={GA4_MEASUREMENT_ID}&api_secret={GA4_API_SECRET}"
response = requests.post(url, json=payload)
print(f"GA4送信完了: status={response.status_code}, user_id={user_id}")
少し解説します。
@functions_framework.cloud_event
def send_to_ga4(cloud_event):
Pub/SubのPushサブスクリプションからトリガーされるCloud Eventハンドラです。HTTPトリガーの@functions_framework.httpとは異なり、@functions_framework.cloud_eventデコレータを使用します。
message_data = base64.b64decode(cloud_event.data["message"]["data"]).decode("utf-8")
submission = json.loads(message_data)
Pub/Subメッセージのデータ部分はBase64エンコードされているため、デコードしてからJSONパースしています。
event_params = {
"engagement_time_msec": "10000",
"form_type": submission.get("form_type", "contact"),
}
GA4に送信するイベントパラメータには、送信者の氏名やメールアドレスなどのPII(個人を特定できる情報)を含めないよう注意が必要です。GA4のMeasurement Protocolの規約でPIIの送信は禁止されています。ここではform_typeとしてフォームの種別(contact、document_requestなど)をフォームからのリクエストパラメータで受け取り、GA4側でどのフォームからの送信かを識別できるようにしています。送信者の個人情報はBigQueryにのみ保存しています。
if submission.get("debug_mode"):
event_params["debug_mode"] = 1
フォームからdebug_modeが渡された場合、イベントパラメータにdebug_mode: 1を追加します。これによりGA4のDebugViewでイベントを確認できるようになります。再デプロイなしでデバッグのオン・オフを切り替えられるため、開発時の動作確認に便利です。
bq-writer(BigQuery書き込み用)
requirements.txt
functions-framework==3.*
google-cloud-bigquery==3.*
main.py
import base64
import json
import datetime
import functions_framework
from google.cloud import bigquery
# BigQueryの設定
BQ_PROJECT_ID = "PROJECT_ID"
BQ_DATASET = "ga4_analysis"
BQ_TABLE = "submissions"
bq_client = bigquery.Client()
@functions_framework.cloud_event
def write_to_bigquery(cloud_event):
"""Pub/Subメッセージを受け取り、BigQueryに書き込む"""
# Pub/Subメッセージのデコード
message_data = base64.b64decode(cloud_event.data["message"]["data"]).decode("utf-8")
submission = json.loads(message_data)
table_ref = f"{BQ_PROJECT_ID}.{BQ_DATASET}.{BQ_TABLE}"
rows = [
{
"user_id": submission["user_id"],
"client_id": submission["client_id"],
"form_type": submission.get("form_type", "contact"),
"name": submission["name"],
"email": submission["email"],
"message": submission.get("message", ""),
"submitted_at": datetime.datetime.utcnow().isoformat(),
}
]
errors = bq_client.insert_rows_json(table_ref, rows)
if errors:
print(f"BigQuery書き込みエラー: {errors}")
raise RuntimeError(f"BigQuery insert failed: {errors}")
print(f"BigQuery書き込み完了: user_id={submission['user_id']}")
BigQueryへの書き込みにはストリーミングインサート(insert_rows_json)を使用しています。エラーが発生した場合は例外をraiseすることで、Pub/Sub側でメッセージがACKされず再送される仕組みです。
なお、Pub/SubのPushサブスクリプションはat-least-once(少なくとも1回)配信のため、ACKタイムアウトや関数側の障害時にメッセージが再送され、同じデータが重複して処理される可能性があります。本番運用で厳密な重複排除が必要な場合は、Pub/SubのメッセージIDを使った冪等性チェックなどの対策を検討してください。
デプロイ
receiverのデプロイ
cd receiver
gcloud run deploy form-receiver \
--source . \
--function=receive_form \
--base-image python312 \
--region asia-northeast1 \
--allow-unauthenticated
--allow-unauthenticatedを指定しているのは、外部のフォームからリクエストを受け取るためです。本番運用では不正なリクエストに対しての対策(CORSの設定やreCAPTCHAなど)を別途検討してください。
ga4-senderのデプロイ
cd ga4-sender
gcloud run deploy ga4-sender \
--source . \
--function=send_to_ga4 \
--base-image python312 \
--region asia-northeast1 \
--no-allow-unauthenticated
bq-writerのデプロイ
cd bq-writer
gcloud run deploy bq-writer \
--source . \
--function=write_to_bigquery \
--base-image python312 \
--region asia-northeast1 \
--no-allow-unauthenticated
サービスアカウントの作成
Pub/SubからCloud Runを呼び出すためのサービスアカウントを作成し、必要なロール(run.invoker)を付与します。
# サービスアカウントの作成
gcloud iam service-accounts create pubsub-invoker \
--display-name="Pub/Sub Cloud Run Invoker"
# Cloud Runの呼び出し権限を付与
gcloud projects add-iam-policy-binding PROJECT_ID \
--member="serviceAccount:pubsub-invoker@PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/run.invoker"
Pub/Subサブスクリプションの作成
1つのトピックに対して2つのPushサブスクリプションを作成し、それぞれの関数に配信します。
GA4_SENDER_URL=$(gcloud run services describe ga4-sender --region asia-northeast1 --format='value(status.url)')
BQ_WRITER_URL=$(gcloud run services describe bq-writer --region asia-northeast1 --format='value(status.url)')
# GA4送信用サブスクリプション
gcloud pubsub subscriptions create form-submission-ga4-sub \
--topic=form-submission-topic \
--push-endpoint=${GA4_SENDER_URL} \
--push-auth-service-account=pubsub-invoker@PROJECT_ID.iam.gserviceaccount.com
# BigQuery書き込み用サブスクリプション
gcloud pubsub subscriptions create form-submission-bq-sub \
--topic=form-submission-topic \
--push-endpoint=${BQ_WRITER_URL} \
--push-auth-service-account=pubsub-invoker@PROJECT_ID.iam.gserviceaccount.com
--push-auth-service-accountには先ほど作成したpubsub-invokerサービスアカウントを指定しています。
Pub/Subでは1つのトピックに複数のサブスクリプションを紐づけると、パブリッシュされたメッセージが各サブスクリプションにそれぞれ配信されます。これにより、receiverが1回パブリッシュするだけでGA4送信とBigQuery書き込みの両方がトリガーされます。
動作確認
デプロイした関数を呼び出してフォーム送信をシミュレートしてみます。
RECEIVER_URL=$(gcloud run services describe form-receiver --region asia-northeast1 --format='value(status.url)')
curl -X POST ${RECEIVER_URL} \
-H "Content-Type: application/json" \
-d '{
"form_type": "contact",
"name": "テスト太郎",
"email": "test@example.com",
"message": "お問い合わせテストです",
"debug_mode": 1
}'
以下のようなレスポンスが返ってきたら成功です。
{
"status": "success",
"user_id": "550e8400-e29b-41d4-a716-446655440000",
"pubsub_message_id": "1234567890"
}
しばらく待ってから、GA4のDebugViewとBigQueryの両方でデータが書き込まれているか確認します。
GA4 DebugViewでの確認
リクエストに"debug_mode": 1を含めて送信しているので、GA4の「管理」→「データの表示」→「DebugView」でイベントを確認できます。DebugViewにform_submissionイベントが表示され、ユーザープロパティにUser-IDが表示されていれば成功です。debug_modeを省略するか0にすればDebugViewには表示されず、通常のイベントとして処理されます。
BigQueryでの確認
bq query --use_legacy_sql=false \
"SELECT user_id, name, email, submitted_at FROM ga4_analysis.submissions ORDER BY submitted_at DESC LIMIT 5"
BigQuery上でGA4データとフォーム送信データを突合する
GA4のデータはBigQueryエクスポートを設定することでBigQueryに蓄積されます。GA4のBigQueryエクスポートの設定方法については公式ドキュメントを参照してください。
エクスポートされたGA4のイベントデータはanalytics_PROPERTY_ID.events_*テーブルに格納されます。PROPERTY_IDはGA4環境ごとに異なります。
このテーブルのUser-IDフィールドと、今回作成したga4_analysis.submissionsテーブルのUser-IDフィールドをキーにJOINすることで突合が可能です。
SELECT
s.user_id,
s.name,
s.email,
s.submitted_at,
e.event_name,
e.event_timestamp,
e.device.category AS device_category,
e.geo.country AS country,
e.traffic_source.source AS traffic_source,
e.traffic_source.medium AS traffic_medium
FROM
`PROJECT_ID.ga4_analysis.submissions` AS s
LEFT JOIN
`PROJECT_ID.analytics_PROPERTY_ID.events_*` AS e
ON
s.user_id = e.user_id
WHERE
_TABLE_SUFFIX BETWEEN
FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY))
AND FORMAT_DATE('%Y%m%d', CURRENT_DATE())
ORDER BY
s.submitted_at DESC,
e.event_timestamp ASC
このクエリにより、フォームを送信したユーザーがGA4で計測されたどのイベント(ページビュー、スクロール、クリックなど)を行っていたかを確認できます。
3回フォーム登録を行なった後、実際に上記のクエリを実行してみた結果は以下となります。
| user_id | name | submitted_at | event_name | device_category | country | traffic_source | traffic_medium | |
|---|---|---|---|---|---|---|---|---|
| 25fd2e6a-... | bbbbbbaaaaaaa | testaaaa@example.com | 2026-04-14 12:08:09 UTC | form_submission | desktop | (not set) | (direct) | (none) |
| 7fb8ef17-... | テストABCDEFG | test@example.com | 2026-04-14 12:07:50 UTC | form_submission | desktop | (not set) | (direct) | (none) |
| 45699e6d-... | テスト太郎 | test@example.com | 2026-04-14 12:07:37 UTC | form_submission | desktop | (not set) | (direct) | (none) |
BigQueryのsubmissionsテーブルに保存したフォーム送信データ(user_id、name、email、submitted_at)と、GA4のBigQueryエクスポートに含まれるイベントデータ(event_name、device_category、traffic_sourceなど)がuser_idをキーにJOINされていることが確認できます。
今回はMeasurement Protocolでサーバーサイドから送信したため、event_nameはform_submissionのみ、traffic_sourceは(direct)、countryは(not set)になっています。これはブラウザからのアクセスではなくCloud Runからの送信であるため、GA4がユーザーのアクセス元やロケーション情報を取得できないことが理由です。
フロントエンド側のGA4セッションと紐づけた場合は、同じuser_idに対してpage_viewやscrollなどブラウザ側で計測されたイベントもJOINされ、フォーム送信前後のユーザー行動を一覧で確認できるようになります。
- フォーム送信前にどのページを閲覧していたか
- フォーム送信ユーザーの流入元(オーガニック検索、広告、SNSなど)
- フォーム送信ユーザーのデバイスカテゴリ(デスクトップ、モバイル)
ただし、今回はclient_idをランダム生成しているため、GA4のブラウザセッションとの紐づけはUser-IDベースでのみ行われます。Measurement Protocolで送信したform_submissionイベントはGA4側で確認できますが、ブラウザ側で計測されたpage_viewなどのイベントと同一セッションとして扱われるわけではない点に注意してください。
まとめ
フォーム送信データをCloud Run + Pub/Sub経由でGA4とBigQueryに書き込み、BigQuery上で突合する方法を検証してみました。
ポイントをまとめると以下の通りです。
- uuid4()で生成したUUIDをUser-IDとしてGA4とBigQueryの両方に書き込むことで、突合のキーとして使用できる。uuid4()は122ビットのランダム値から生成されるため実用上は衝突の心配はない
- GA4送信とBigQuery書き込みを別のCloud Run に分離し、Pub/Subの1トピック・2サブスクリプション構成にすることで、一方の障害がもう一方に影響しない
- Pub/Subを間に挟むことでフォームのレスポンスを高速に返しつつ、非同期でGA4送信とBigQuery書き込みを行える
- GA4のMeasurement ProtocolではPII(氏名やメールアドレスなど)をイベントパラメータに含めてはならない。個人情報はBigQueryにのみ保存し、GA4にはフォーム種別などの非個人情報のみを送信する
- BigQuery上でGA4エクスポートデータとUser-IDでJOINすることで、フォーム送信ユーザーの行動分析が可能になる







