はじめに
😳「GA4 のアクティブユーザーが、前の期間の 1 割になってる…」
🤔「広告は 196 回押されてるのに、ページを見た記録が 6 回?」
😵「登録が少ないのは、ページのせい? ログインのせい? もう分からない…」
個人開発で広告を出すと、まず GA4 を開きますよね👀
ところが、Consent Mode で同意の初期値を denied にすると、GA4 のレポートに出るのは基本的に同意した人だけ。
同意を正しく扱うほど、見える人は減っていきます。
個人開発している口コミサービス あじぴた でも、まさにこれが起きました📉
それでもどこで人が落ちているかは突き止められました。GA4 とは別に、流入元をアプリの DB に残す仕組みを作っていたからです。
この記事では、
- Cookie 同意の不具合を直したら、GA4 がほとんど何も見えなくなった話
- 流入元を自分の DB に、登録直後の 1 回だけ記録する設計(Supabase)
- 「登録の途中で止まった人」を SQL 1 本で数える方法
を、実際の広告費と人数つきで書きます🔎
この記事は、個人開発サービス「あじぴた」の設計を書くシリーズの 1 本です。
単体で読めるように書いていますが、サービスの全体像はハブ記事にまとめています。
前提:広告は押される。でも登録が来ない😇
あじぴたは、味覚の近い人の評価を頼りに、外れない一皿を探す食の口コミサービスです。
口コミを集めるため、「料理の写真を 3 件投稿すると Amazon ギフト券」というキャンペーンで Instagram 広告を出しました。
広告 → サービスサイト(LP)のキャンペーンページ → アプリで登録、という流れです。
1 回目(10/19〜10/25)は、¥4,976 で 243 クリック(CTR 4.82%)。広告はよく押されました👍
ところが登録は約 5 人。GA4 で追うと、こうなっていました。
キャンペーンページ 304 人 平均 3 秒・直帰率 80%
↓ 94% が離脱
アプリのトップ(ログイン) 18 人
↓
メールのリンクを送った 13 人
↓ リンクを開いても完了できない人がいた
登録完了 4〜5 人
穴は ページ と ログイン の 2 つ。
ログインは Instagram のアプリ内ブラウザとの相性が原因で、別の記事に書いたとおり直しました🔧
ページも最初の画面を作り直し、さあ 2 回目でどれだけ良くなったか…というところで、困ったことが起きます。
GA4 のユーザーが 9 割消えた?😳
2 回目(11/1〜)を出して 1 週間後、GA4 を開いて目を疑いました。
11/1〜11/8 のアクティブユーザーは 11 人。直前の 8 日間から 90.2% 減です。
(LP とアプリを合わせた数字。直前の 8 日間には 1 回目の広告の期間が入っています)
広告から来た人(instagram / paid_social)だけで比べると、もっとはっきりします。
| 1 回目(10 月) | 2 回目(11/1〜11/8) | |
|---|---|---|
| 広告のクリック | 243 回 | 196 回 |
| GA4 の記録 | セッション 295 | page_view 6 回・4 人 |
10 月はクリックとほぼ同じ数が記録されていたのに、11 月はクリックの 3% ほどしか残っていません📉
原因(の見込み):同意の初期化が、それまで壊れていた
心当たりがありました。2 回目の直前の 10/31、Cookie 同意の不具合を直して本番に出していたのです。
あじぴたは GTM(Google タグマネージャー)+ GA4 を Consent Mode で動かしています。
ページの先頭で gtag('consent', 'default', { analytics_storage: 'denied' }) を宣言し、
同意した人だけ granted に切り替える作りです。
ところが本番では、この宣言が実行されていませんでした😇
初期化のスクリプトを文字列で組み立てていて、その一部が本番ビルドの最適化で落ちていたのです。
// Before: 埋め込み(${...})の後ろ「=([^;]*)/);」が、本番ビルドで落ちていた
`var m=document.cookie.match(/(?:^|; )${CONSENT_STORAGE_KEY}=([^;]*)/);` +
// After: 埋め込みを使わず、文字列の連結で組む(関数の出力は同じ)
'var m=document.cookie.match(/(?:^|; )' +
CONSENT_STORAGE_KEY +
'=([^;]*)/);' +
正規表現が閉じずに構文エラーになり、スクリプトは丸ごと動きません。
単体テストは通るのに、ビルドした後にだけ壊れるタイプの不具合でした💥
| 直す前(10 月) | 直した後(11 月〜) | |
|---|---|---|
| 同意の初期値 | 設定されない(Consent Mode が無効の扱い) | denied |
| GA4 に記録される人 | 同意していない人も含めて、ほぼ全員 | 同意した人だけ |
直した後は、ビルド後の HTML を node --check に通し、headless の Chromium と Tag Assistant で denied → granted の切り替えを確かめました✅
11 月の記録が 3% まで減った原因がこの修正だ、という点は見込みです。
時期が重なっていることからの推測で、検証はしていません。
正しく直したら、穴の場所が見えなくなった
不具合を直したのは正しいことです。ただ、困りました🫠
ページを見た人が 4 人しか記録されないので、ページで落ちたのか、ログインで落ちたのかを GA4 では分けられません。
人単位で追うなら、自前で記録する💾
幸い、2 回目の前に登録した人がどの広告から来たかを、アプリの DB に残す仕組みを入れていました。
もともとの狙いは、広告ごとに「続けて使ってくれる人 1 人あたり、いくらかかったか」を比べることです。
GA4 はページの閲覧を数える道具なので、登録した人がその後も使い続けたかまでは追えません。それを知っているのは、アプリの DB だけです。
流入元は別のテーブルに、登録直後の 1 回だけ
広告の URL に付いた UTM(utm_source=instagram&utm_medium=paid_social&...)を、LP からアプリへのリンクにも引き継ぎます。
アプリは最初に開いたページの UTM を sessionStorage に持っておき、登録したときに DB へ書きます。
保存先は、users に列を足すのではなく別のテーブルにしました。
users には他の人に見せる列(ニックネーム等)があり、運営しか見ない記録を同居させたくなかったからです🔒
create table public.signup_attributions (
-- 1 ユーザー 1 行(最初の流入元だけを持つ)。退会でユーザーごと消える
user_id uuid primary key references public.users(id) on delete cascade,
utm_source text check (utm_source ~ '^[A-Za-z0-9._~+-]{1,100}$'),
utm_medium text check (utm_medium ~ '^[A-Za-z0-9._~+-]{1,100}$'),
-- utm_campaign / utm_content も同じ
landed_at timestamptz,
created_at timestamptz not null default now()
);
alter table public.signup_attributions enable row level security;
-- ポリシーは作らない(default deny)。アプリからは本人も含めて読めない
revoke all on public.signup_attributions from anon, authenticated;
メールのリンクを別のブラウザで開いても、流入元が落ちない
難所はメールのリンクでの登録でした。
アプリ内ブラウザで始めると、メールのリンクは Safari など別のブラウザで開かれ、sessionStorage は届きません😇
そこで、Supabase の signInWithOtp の options.data に流入元を載せました。
const attribution = readSignupAttribution() // sessionStorage に持っておいた UTM
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
// 新規ユーザーの raw_user_meta_data に保存される。
// メールのリンクをどのブラウザで開いても、DB 側に残っている
...(attribution && { data: { signup_attribution: attribution } }),
shouldCreateUser: true,
emailRedirectTo: `${window.location.origin}/api/auth/confirm`,
},
})
options.data は新しいユーザーを作るときにだけ保存されるので、既存ユーザーのログインで上書きされる心配もありません👍
| 案 | 判断 | 理由 |
|---|---|---|
options.data に載せる |
採用 | ユーザー作成の時点で DB に残る。どのブラウザで開いても失われない |
| メールのリンクの URL に載せる | 見送り | メールのテンプレートと受け口の両方を変える必要がある |
| 着地したときに Cookie へ書く | 見送り | 同意の前にトラッキング用の Cookie を発行することになる |
Google ログインは同じブラウザで完了するので、プロフィールを作るときに sessionStorage の値を送ります。
どの値を記録するかは、DB が決める
書き込みは SECURITY DEFINER の関数 record_signup_attribution() だけに許し、ルールは関数の中に置きました🛡️
- 登録直後(10 分以内)の 1 回だけ記録する。後から呼んでも上書きしない
- メールでの登録時の値を優先する(別のブラウザで開いても残っている値だから)
-
値は英数字と
. _ ~ + -だけ。外れた値はその項目だけ捨て、登録は止めない
ブラウザから来る値は書き換えられる前提で扱い、最後の砦を DB に置いています。
同意の外で動かす、という判断
この記録は Cookie の同意に関係なく動きます。ここは慎重に決めました。
- 使い道は運営の集計だけ。第三者には送らない
- トラッキング用の Cookie は使わない。サイトをまたいで追わない
- 記録するのは UTM と最初に来た日時だけ。プライバシーポリシーに明記し、退会で消える
- メールで登録した人は、ユーザーのメタデータにも写しが残る。本人に見える場所だが、本人の流入元なので問題ないと判断した
GA4 のように閲覧を広く集める道具ではなく、登録してくれた人がどこから来たかだけを残す仕組みです📝
「続けて使う人」を定義して、画面にする📊
記録と一緒に、それを使って何を数えるかも決めました。
あじぴたで大事なのは、登録した人数より口コミを書き続けてくれる人です。
| 用語 | 定義 |
|---|---|
| 投稿した人 | 登録から 28 日以内に 1 件以上投稿した |
| 判定の対象になる人 | 登録から 28 日が過ぎた人(続けて使うかを、もう判断できる人) |
| 続けて使う人 | 判定の対象になる人のうち、最初の投稿から 14 日以上あとにも投稿した |
この定義のまま、運営コンソールに流入元ごとの分析画面を作りました。
定義を変えるときは、画面と集計の SQL を同時に直すルールです。
登録の途中で止まった人を数える🔍
2 回目の 1 週目の数字です(広告マネージャと分析画面から)。
| 指標 | 数字 |
|---|---|
| 広告費(11/1〜11/8) | ¥5,651 |
| リンクのクリック | 196 回(CTR 3.11%) |
| 広告から登録した人 | 3 人(1 人あたり ¥1,884) |
| そのうち投稿した人 | 3 人全員(平均 2.3 件) |
登録した 3 人は全員が口コミを書いてくれています🎉
落ちているのは登録するまで。でも、ページとログインのどちらかは GA4 では分かりません。
登録には 3 つの段階がある
Supabase のメールでの登録を、DB の動きで分けるとこうなります。
| 段階 | DB で起きること |
|---|---|
| ① メールアドレスを入れて、リンクを送る |
auth.users に行ができる(shouldCreateUser: true) |
| ② メールのリンクを押して、ログインする |
auth.users.last_sign_in_at が入る |
| ③ プロフィールを作る |
public.users に行ができる |
ポイントは、auth.users はリンクを送った時点でできること👀
しかも options.data の流入元も入っているので、メールで始めた人なら登録を終えていない人の流入元まで分かります。
select
coalesce(au.raw_user_meta_data->'signup_attribution'->>'utm_medium', '(記録なし)') as utm_medium,
count(*) as started, -- ① 始めた
count(*) filter (where au.last_sign_in_at is null
and u.id is null) as link_not_opened, -- ② に進んでいない
count(*) filter (where au.last_sign_in_at is not null
and u.id is null) as no_profile, -- ③ に進んでいない
count(u.id) as completed -- 登録完了
from auth.users au
left join public.users u on u.id = au.id
where au.created_at >= '2026-11-01 00:00:00+09'
and au.created_at < '2026-11-09 00:00:00+09'
group by 1
order by started desc;
広告の行の結果です(「記録なし」の行は運営のアカウントなどが混ざるので外しています)。
| 流入元 | 始めた | リンクを押していない | プロフィール未作成 | 登録完了 |
|---|---|---|---|---|
paid_social(広告) |
4 | 1 | 0 | 3 |
Google で始めた人は、プロフィールを作るときに初めて流入元が記録されます。
途中でやめた人は数えられないので、「始めた 4 人」は少なく見積もった数です。
穴は、ページだった💡
広告のクリック 196 回
↓ 始めたのは、クリックの 2% ほど
登録を始めた(アドレス入力) 4 人
↓
登録完了 3 人
始めた 4 人のうち 3 人は登録を終えています。ログインの穴は塞がっていました。
落ちているのは、ページを見てから「はじめる」を押すまでです。
念のため自分のスマホでも、Instagram のアプリ内ブラウザからメールと Google の両方を試し、詰まる所が無いことを確かめました📱
GA4 では分けられなかった「ページか、ログインか」が、DB の行の有無だけで分かったわけです。
つまずいたポイント
① 運営のアカウントが数字に混ざる
「記録なし」の行には、動作確認に使う運営のアカウントが混ざります😂
登録が週に数人の段階では、1 人混ざるだけで数字が大きく動きます。
分析画面には、運営のアカウントを含める / 除く切り替えを付けました。
② UTM はページの先まで、自分で引き継ぐ
広告の UTM は、広告のリンク先(キャンペーンページ)までしか届きません。
LP とアプリはドメインが違う(アプリはサブドメイン)ので、LP 側で「今の URL の UTM をアプリへのリンクに付ける」処理を入れ、テストで固定しました🔗
アプリ側でも、未ログインでログイン必須のページに来るとトップへリダイレクトされます。
このときUTM だけはリダイレクト先に引き継ぐようにして、記録が落ちないようにしました。
③ GA4 の 10 月と 11 月は比べられない
同意の不具合を直す前と後で、GA4 の計測の条件がまったく違います。
比べるときは、同意の影響を受けない数字だけを使います⚖️
| 数字 | 出どころ | 同意の影響 |
|---|---|---|
| 広告のクリック | Meta の広告マネージャ | 受けない |
| 登録・投稿・続けて使う人 | アプリの DB | 受けない |
| ページの閲覧・ボタンのクリック | GA4 | 受ける(同意した人だけ) |
まとめ
-
Consent Mode で初期値を
deniedにすると、GA4 に出るのは基本的に同意した人だけ。同意を正しく扱うほど、見える人は減る - 同意の初期化はビルドした後に壊れることがある。ビルド後の HTML まで確かめる
- 広告の効果を人単位で見たいなら、自分の DB に流入元を残す
- 保存先は別のテーブルにして、アプリからは読めないようにする
- Supabase のメールでの登録は、
signInWithOtpのoptions.dataに載せると、別のブラウザで開いても落ちない
-
auth.usersはリンクを送った時点でできる。last_sign_in_atとpublic.usersの有無で、登録の途中で止まった人を数えられる
196 クリックで登録 3 人。数字はまだまだですが、どこを直せばいいかは分かりました。次はページを作り直します💪
シリーズの他の記事も、よろしければ📚
このシリーズでは、個人開発サービス「あじぴた」の設計をテーマごとに書いています。
サービスの全体像や、ほかの記事の一覧はハブ記事にまとめています👇
🔗 「低評価を公開しない」口コミサービスを個人開発した話 — 5,800コミット・7リポジトリの全体像
この記事と関係の深い記事です。
🔗 広告から人は来るのに、登録がない。Instagramのアプリ内ブラウザとSupabaseのPKCEの相性を解いた話(1 回目の広告で見つけた、ログインの穴)
🔗 漏洩してもエラーは出ない。非公開データをアプリではなくDBで守る、SupabaseのRLSを選んだ理由(運営しか見ないデータを DB で守る考え方)
設計の中身を通して読みたい方には、解剖ドキュメントも公開しています🔬
🔗 あじぴたの内側(ajipita-inside)
集客の数字に悩んでいる個人開発者の方の、参考になれば幸いです🙏