1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Cookie同意を直したら、GA4のユーザーが9割消えた。広告の効果を自前のDBで追った話

1
Posted at

はじめに

😳「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)

集客の数字に悩んでいる個人開発者の方の、参考になれば幸いです🙏

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?