Apr 30, 2025

GA4 Bilang X, DB Bilang Y. Ini Cara Berhenti Nge-guess.

Analytics

GA4

Metrics

Targetnya bukan “angka sama”. Targetnya “makna sama”, lalu pilih sumber yang tepat buat keputusan.

Kalau GA4 bilang signups 10.000 tapi DB cuma 7.200, itu bukan “analytics problem”. Itu problem definisi + measurement. Solusinya bukan “pilih angka yang lebih enak”. Solusinya: bikin mismatch jadi terlihat setiap hari, bisa di-slice, dan punya keputusan source-of-truth per KPI.

Implementasi praktis: bikin mismatch jadi “observable”, bukan “debatable”
Cara paling cepat berhenti dari ritual “GA4 vs DB” adalah bikin satu view/table rekonsiliasi yang bisa dilihat semua orang. Bukan dashboard megah. Table membosankan yang jawab: gap-nya berapa, muncul di mana, dan apakah normal.

Contoh: rekonsiliasi harian (DB vs events analytics yang sudah kamu import)

-- Sesuaikan nama tabel/kolom:
-- users(created_at, is_internal, is_test)
-- analytics_events(event_time, event_name, source, user_id)

create or replace view recon_signups_daily as
with db_signups as (
  select
    date_trunc('day', created_at)::date as day,
    count(*) as db_signups
  from users
  where created_at >= current_date - 30
    and coalesce(is_internal,false) = false
    and coalesce(is_test,false) = false
  group by 1
),
ga4_like as (
  select
    date_trunc('day', event_time)::date as day,
    count(*) as ga4_signups
  from analytics_events
  where event_name = 'signup_completed'
    and event_time >= now() - interval '30 days'
  group by 1
)
select
  coalesce(d.day, g.day) as day,
  coalesce(g.ga4_signups, 0) as ga4_signups,
  coalesce(d.db_signups, 0) as db_signups,
  (coalesce(g.ga4_signups, 0) - coalesce(d.db_signups, 0)) as diff,
  case
    when coalesce(d.db_signups, 0) = 0 then null
    else (coalesce(g.ga4_signups, 0) - coalesce(d.db_signups, 0))::numeric / d.db_signups
  end as pct_diff
from db_signups d
full outer join ga4_like g using (day)
order by day desc;

Kalau view ini sudah ada, meeting mingguan berhenti jadi “angka siapa yang bener?”, dan berubah jadi:

  • gap-nya stabil (expected) atau spike (bug)?

  • spike itu muncul di platform tertentu?

  • mulai sejak release/campaign kapan?

Stop bandingin total doang. Slice mismatch-nya.
Total menutup pola. Slice membuka pola.

Slice by platform/source (biasanya langsung ketahuan iOS/Safari yang “aneh”):

select
  date_trunc('day', event_time)::date as day,
  source,
  count(*) as cnt
from analytics_events
where event_name = 'signup_completed'
  and event_time >= now() - interval '14 days'
group by 1,2
order by 1 desc, 2;

Slice DB signups by method (email/google/apple) kalau kamu punya kolomnya:

select
  date_trunc('day', created_at)::date as day,
  signup_method,
  count(*) as db_signups
from users
where created_at >= now() - interval '14 days'
  and coalesce(is_internal,false) = false
  and coalesce(is_test,false) = false
group by 1,2
order by 1 desc, 2;

Kalau gap cuma muncul di satu source/platform/metode, kamu baru aja ngubah “misteri” jadi bug yang jelas pemiliknya.

Playbook debug versi waras (dan pakai kode)
Urutannya: boundary → raw count → identity → sampling.

Step A: Samain batas waktu (timezone + cutoffs)
Banyak mismatch itu cuma masalah “hari” yang beda karena timezone/late ingestion.

Postgres contoh (jadikan boundary WIB):

select
  (created_at at time zone 'UTC' at time zone 'Asia/Jakarta')::date as day_wib,
  count(*) as db_signups
from users
where created_at >= now() - interval '7 days'
group by 1
order by 1 desc;

Step B: Bandingin “bahan mentah”, bukan metric di dashboard
GA4-side: hitung occurrences atau distinct users yang fire event.
DB-side: hitung rows created (user/account) atau server-confirmed event.

Distinct user yang fire signup_completed:

select
  date_trunc('day', event_time)::date as day,
  count(distinct user_id) as users_fired
from analytics_events
where event_name = 'signup_completed'
  and event_time >= now() - interval '7 days'
group by 1
order by 1 desc;

Interpretasi cepat:

  • GA4 > DB: kemungkinan double firing, retries, bots, atau client event tanpa server confirmation.

  • DB > GA4: tracking missing, blocked (adblock/consent), atau nggak firing di flow tertentu.

Step C: Trace journey nyata (sample kecil, nilai tinggi)
Ambil 50 signup dari DB, lalu cek apakah event analyticsnya ada untuk identity yang sama.

with sample as (
  select user_id, created_at
  from users
  where created_at >= now() - interval '2 days'
    and coalesce(is_internal,false) = false
    and coalesce(is_test,false) = false
  order by created_at desc
  limit 50
)
select
  s.user_id,
  s.created_at as db_created_at,
  min(a.event_time) as first_analytics_event_time,
  count(*) as analytics_event_count
from sample s
left join analytics_events a
  on a.user_id = s.user_id
 and a.event_name = 'signup_completed'
 and a.event_time between s.created_at - interval '1 day' and s.created_at + interval '1 day'
group by 1,2
order by s.created_at desc;

Kalau banyak user DB yang nggak punya event analytics: missing tracking/consent/adblock.
Kalau event analytics per user banyak banget: double fire/retry.

Guardrails: QA checks biar nggak rusak lagi minggu depan
Tracking itu bukan “sekali beres”. Itu perlu pagar.

Duplicate spike (contoh dedup key: event_name + user_id + event_time + object_id):

select
  date_trunc('day', event_time)::date as day,
  count(*) as duplicate_groups
from (
  select
    event_name,
    user_id,
    event_time,
    coalesce(object_id,'-') as object_id,
    count(*) as c
  from analytics_events
  where event_time >= now() - interval '7 days'
    and user_id is not null
  group by 1,2,3,4
  having count(*) > 1
) d
group by 1
order by 1 desc;

Missing required fields (minimal user_id):

select
  date_trunc('day', event_time)::date as day,
  source,
  count(*) as total,
  sum(case when user_id is null then 1 else 0 end) as missing_user_id
from analytics_events
where event_name = 'signup_completed'
  and event_time >= now() - interval '14 days'
group by 1,2
order by 1 desc, 2;

Keputusan penting: pilih source of truth per KPI (ditulis, bukan diingat)
Ini momen yang orang sering hindari karena harus tegas. Tapi ini yang bikin guessing mati.

kpi_sources:
  - kpi: signup_completed
    official: product_db
    supporting: [ga4]
    note: "GA4 directional; DB official buat akun beneran"
  - kpi: activation_rate
    official: warehouse_events
    supporting: [ga4]
    note: "Business-critical -> server/warehouse validated"
  - kpi: landing_page_dropoff
    official: ga4
    supporting: []
    note: "Funnel web behavior -> GA4 paling pas"

Definisi “done”
Kamu bisa bilang mismatch-nya kenapa dalam 1 kalimat (duplicate, missing flow, consent, timezone, late arrival).
1. Ada recon view harian yang semua orang bisa cek tanpa meeting.
2. Ada 2–3 QA query yang nge-alert sebelum leadership nanya.
3. Setiap KPI punya official source yang dipilih dengan sadar.

Angka yang sama itu bonus. Makna yang sama itu wajib.


Kalau kamu suka tulisan yang fokus ke debugging measurement dan bikin keputusan mingguan lebih waras, follow aja. Kalau tim kamu capek debat GA4 vs DB dan pengen audit cepat (recon + QA + KPI source rules), booking call singkat juga bisa. Tanpa drama, kita cari gap yang paling mahal dulu.

Create a free website with Framer, the website builder loved by startups, designers and agencies.