Opas

GA4-datan mallinnus BigQueryssa dbt:n avulla

GA4:n BigQuery-vienti tuottaa tapahtumadataa, josta raportin tarvitsemat istunnot, kanavat ja liiketoiminnan mittarit on usein laskettava erikseen. dbt auttaa hallitsemaan nämä SQL-mallit, niiden riippuvuudet ja testit yhtenä projektina. Opas näyttää, miten raakadatasta rakennetaan ylläpidettäviä raportointitauluja.

Julkaistu 2025-05-20 · Päivitetty 2026-09-19 · Lukuaika 12 min

Kirjoittaja: · Data-analytiikan ja raportoinnin toteutuksia vuodesta 2018.

Aihe: Rajapinnat, BigQuery ja dataputket

Oppaan ydinkohdat

  • Mallintakaa tapahtumat, istunnot ja raportointimittarit eri kerroksiin, kun se helpottaa laskennan hallintaa.
  • Huomioikaa myöhästyvät tapahtumat, puuttuvat tunnisteet ja mittarit, joita ei voi summata yli päivien.
  • Versionhallinta, testit ja ajon valvonta kuuluvat tuotantokäyttöön myös pienessä dbt-projektissa.

Mitä dbt tekee GA4-datalle

GA4:n BigQuery-vienti tuo havaittua tapahtumadataa sisäkkäisessä rakenteessa. Se ei sisällä kaikkea käyttöliittymän rikastettua tai mallinnettua tietoa. Raakadataa voi kysellä suoraan, mutta yhteinen mallinnuskerros vähentää toistuvaa purkutyötä ja mittarien määrittelyeroja.

dbt ratkaisee tämän tekemällä purkamisesta yhden, versionhallitun määritelmän. Kirjoitat SQL:ää, dbt hoitaa riippuvuudet, materialisoinnin, testit ja dokumentaation. Lopputulos on joukko tauluja, joissa sarakkeet ovat ihmisen luettavia ja mittarit on laskettu kerran.

Kun istunnon tai liidin määritelmä muuttuu, versionhallinnasta voidaan tarkistaa muutos ja sen vaikutukset. dbt tarjoaa tähän valmiita rakenteita; vastaavat ylläpitoperiaatteet ovat hyödyllisiä myös muilla välineillä toteutetuissa SQL-projekteissa.

Lähtötilanne: miltä GA4:n raakadata näyttää

Päivittäinen vienti luo events_YYYYMMDD-taulut. events_intraday_YYYYMMDD syntyy vain, jos streaming-vienti on käytössä. Tapahtuman parametrit eivät ole sarakkeita vaan toistuva rakenne, jossa jokaisella parametrilla on avain ja tyypitetty arvo. Sama koskee käyttäjäominaisuuksia ja verkkokaupan tuoterivejä.

Yksittäisiä kenttiä voi valita suoraan, mutta toistuvan parametrirakenteen purkamiseen tarvitaan esimerkiksi UNNEST. Seuraava esimerkki lukee yhden parametrin:

Yhden parametrin lukeminen ilman mallinnusta
select
  event_date,
  event_name,
  user_pseudo_id,
  (select value.string_value from unnest(event_params)
     where key = 'page_location') as page_location,
  (select value.int_value from unnest(event_params)
     where key = 'ga_session_id') as ga_session_id
from `projekti.analytics_123456789.events_*`
where _table_suffix = format_date('%Y%m%d', current_date() - 1)

Kun sama purku toistuu useissa kyselyissä, yhteinen malli vähentää toistoa ja helpottaa muutosten tekemistä.

Kerrosmalli: staging, välimallit, raportointitaulut

Seuraava kerrosjako erottaa lähteiden käsittelyn liiketoiminnan laskennasta ja raportoinnin tarpeista. Mallien määrä ja rajat sovitetaan projektin laajuuteen.

  • Staging: lähteen kenttien purku, nimeäminen ja tietotyyppien yhdenmukaistaminen. Mahdolliset suodatukset dokumentoidaan.
  • Välimallit: esimerkiksi tapahtumien kokoaminen istunnoiksi, kanavaryhmittely ja lähteiden yhdistäminen.
  • Raportointitaulut eli martit: käyttötarpeeseen sopivalla tarkkuustasolla olevat tiedot, joita raportit voivat hyödyntää yhdessä.

GA4-tapahtumien purkaminen omiin tauluihin

Ensimmäinen askel on makro, joka poistaa parametrien lukemisen toiston. Se kirjoitetaan kerran ja käytetään kaikkialla:

macros/ga4_param.sql
{% macro ga4_param(key, value_type='string') %}
  (select value.{{ value_type }}_value
     from unnest(event_params)
    where key = '{{ key }}')
{% endmacro %}
models/staging/stg_ga4__events.sql
{% set partitions_to_replace = [
    "date_sub(current_date('Europe/Helsinki'), interval 1 day)",
    "date_sub(current_date('Europe/Helsinki'), interval 2 day)",
    "date_sub(current_date('Europe/Helsinki'), interval 3 day)"
] %}

{{ config(
    materialized='incremental',
    incremental_strategy='insert_overwrite',
    partitions=partitions_to_replace,
    partition_by={'field': 'event_date', 'data_type': 'date'},
    cluster_by=['event_name', 'user_pseudo_id']
) }}

select
  parse_date('%Y%m%d', event_date)              as event_date,
  timestamp_micros(event_timestamp)             as event_at,
  event_name,
  user_pseudo_id,
  user_id,
  {{ ga4_param('ga_session_id', 'int') }}       as ga_session_id,
  {{ ga4_param('page_location') }}              as page_location,
  {{ ga4_param('page_title') }}                 as page_title,
  collected_traffic_source.manual_source       as source,
  collected_traffic_source.manual_medium       as medium,
  collected_traffic_source.manual_campaign_name as campaign,
  {{ ga4_param('engagement_time_msec', 'int') }} as engagement_time_msec,
  ecommerce.transaction_id,
  ecommerce.purchase_revenue,
  device.category                               as device_category,
  geo.country
from {{ source('ga4', 'events') }}
-- source-taulun identifier on events_*; sulje intraday pois myös full-refreshissä.
where regexp_contains(_table_suffix, r'^[0-9]{8}$')

{% if is_incremental() %}
  -- Kolmen vuorokauden ikkuna: GA4 täydentää ja korjaa vientiä
  -- jälkikäteen, ja insert_overwrite korvaa nämä partitiot kokonaan.
  and _table_suffix between
        format_date('%Y%m%d', current_date('Europe/Helsinki') - 3)
    and format_date('%Y%m%d', current_date('Europe/Helsinki') - 1)
{% endif %}

Lähteen _TABLE_SUFFIX-rajaus vähentää luettavia päivätauluja. Kohteen partitiointi auttaa myöhempiä raporttikyselyitä, mutta ei yksin rajaa lähdevientiä. insert_overwrite korvaa kolme erikseen listattua päivää myös silloin, kun jonkin päivän tulos on tyhjä. Varmista ensin kaikkien korvattavien lähdepäivien saatavuus ja vaihda Europe/Helsinki omaisuuden aikavyöhykkeeksi.

Google täydentää päivätauluja normaalisti enintään kolmen päivän ajan tapahtumapäivän jälkeen. Poikkeuksellinen uudelleenkäsittely voi muuttaa vanhempaakin dataa, joten mahdollista myös erilliset historian korjausajot.

Tapahtumista istunnoiksi

BigQueryn tapahtumadatasta istunnot rakennetaan valitun laskentasäännön mukaan. Kirjatkaa myös, miten rajatapaukset ja puuttuvat tunnisteet käsitellään, jotta raportin käyttäjät ymmärtävät mahdolliset erot GA4:n käyttöliittymään.

Istunnon avain muodostetaan käyttäjätunnisteen ja istuntotunnuksen yhdistelmästä. Kumpikaan ei yksin riitä, koska ga_session_id on uniikki vain käyttäjän sisällä.

models/marts/fct_sessions.sql
with events as (
    select * from {{ ref('stg_ga4__events') }}
),

sessions as (
    select
      concat(user_pseudo_id, '-', cast(ga_session_id as string)) as session_key,
      user_pseudo_id,
      max(user_id)                                    as user_id,
      min(event_date)                                 as session_date,
      min(event_at)                                   as session_start,
      max(event_at)                                   as session_end,
      countif(event_name = 'page_view')               as page_views,
      countif(event_name = 'purchase')                as purchases,
      sum(coalesce(purchase_revenue, 0))              as revenue,
      sum(coalesce(engagement_time_msec, 0)) / 1000   as engaged_seconds,
      -- Yksinkertaistettu lähde: ensimmäinen kerätty arvo, ei valmis GA4-attribuutio
      array_agg(if(source is not null or medium is not null,
                   struct(source as source, medium as medium), null)
                ignore nulls order by event_at limit 1)[safe_offset(0)] as acquisition,
      array_agg(if(event_name = 'page_view', page_location, null)
                ignore nulls order by event_at limit 1)[safe_offset(0)]
        as landing_page,
      max(device_category)                            as device_category,
      max(country)                                    as country
    from events
    where ga_session_id is not null and user_pseudo_id is not null
    group by session_key, user_pseudo_id
)

select
  *,
  {{ channel_group('acquisition.source', 'acquisition.medium') }} as channel_group,
  purchases > 0                           as converted
from sessions

Esimerkin lähde ja medium ovat ensimmäisiä kerättyjä arvoja, eivät GA4:n valmis istuntoattribuutio. Tarkkaan vertailuun käytä session_traffic_source_last_click-rakennetta sen saatavuuden rajoissa. channel_group on oma, erikseen toteutettava makro. Tunnisteettomat tapahtumat jäävät tämän istuntomallin ulkopuolelle; seuraa niiden määrää erikseen.

Datan yhdistäminen muista lähteistä

Tietovarastossa verkkosivuston tapahtumia voidaan verrata tilausten lopulliseen tilaan, katteeseen ja asiakkuuden kehitykseen. Nämä tiedot tulevat tyypillisesti verkkokaupasta, taloushallinnosta tai CRM:stä.

Liitoskohtia on käytännössä kolme, ja ne kannattaa tunnistaa ennen kuin mallinnusta aloittaa.

  • transaction_id: tilaustunnus toimii liitosavaimena, jos sen arvo ja muoto vastaavat toisiaan molemmissa lähteissä. Tarkista myös tunnuksen yksilöllisyys ja peruutusten käsittely.
  • user_id: suunniteltu sisäinen tunniste voi yhdistää kirjautuneen käyttäjän tapahtumia muihin tietoihin. GA4:ään ei lähetetä sähköpostia, nimeä tai muuta suoraan tunnistavaa henkilötietoa.
  • Päivä ja kampanja: mainoskulujen liittämiseen. Kustannusdata tulee omana lähteenään ja liitetään päivä- ja kampanjatasolla, ei tapahtumatasolla.
models/marts/fct_orders_attributed.sql
with order_sessions as (
  -- Yksi istuntoliitos per tilaus, myös usean ostoksen istunnoissa.
  select
    transaction_id,
    concat(user_pseudo_id, '-', cast(ga_session_id as string)) as session_key
  from {{ ref('stg_ga4__events') }}
  where event_name = 'purchase'
    and transaction_id is not null and transaction_id != ''
    and user_pseudo_id is not null and ga_session_id is not null
  qualify row_number() over (
    partition by transaction_id order by event_at, session_key
  ) = 1
)
select
  o.order_id,
  o.ordered_at,
  o.net_revenue,
  o.margin,
  o.is_cancelled,
  s.session_key,
  s.channel_group,
  s.landing_page,
  s.device_category
from {{ ref('stg_crm__orders') }} o
left join order_sessions os on o.order_id = os.transaction_id
left join {{ ref('fct_sessions') }} s on os.session_key = s.session_key
models/marts/agg_channel_performance.sql: kulut mukaan
with sessions as (
    select
      session_date  as date,
      channel_group,
      count(*)                          as sessions,
      countif(converted)                as conversions
    from {{ ref('fct_sessions') }}
    group by 1, 2
),

cost as (
    select
      date,
      {{ channel_group("'google'", "'cpc'") }} as channel_group,
      sum(cost)                                as ad_cost
    from {{ ref('stg_google_ads__campaign_stats') }}
    group by 1, 2
)

select
  coalesce(s.date, c.date)                   as date,
  coalesce(s.channel_group, c.channel_group) as channel_group,
  coalesce(s.sessions, 0)                    as sessions,
  coalesce(s.conversions, 0)                 as conversions,
  coalesce(c.ad_cost, 0)                     as ad_cost,
  safe_divide(c.ad_cost, s.conversions)      as cost_per_conversion
from sessions s
full outer join cost c
  on s.date = c.date and s.channel_group = c.channel_group

Full outer join säilyttää sekä kulupäivät ilman istuntoja että liikenteen ilman kuluja. Tässä esimerkissä mainoskululähde on rajattu Google-haun mainoksiin; älä luokittele kaikkia Google Ads -kampanjoita google/cpc-arvoilla Paid Search -kanavaan. Shopping-, Display-, Video- ja Performance Max -kampanjat tarvitsevat oman luokittelunsa.

Aggregointi Data Studiota varten

Valmiiksi lasketut raportointitaulut vähentävät raskaiden lähdekyselyiden tarvetta. Jokainen avaus ei kuitenkaan lue raakadataa uudelleen: Data Studio ja BigQuery voivat käyttää välimuistia, kun kysely ja sen käyttöehdot sen sallivat.

Ratkaisu on laskea aggregaatit valmiiksi dbt:ssä. Raportille jää vain esitystehtävä, ja kysely osuu tauluun jossa on tuhansia rivejä miljoonien sijaan.

models/marts/agg_daily_traffic.sql
{{ config(
    materialized='table',
    partition_by={'field': 'date', 'data_type': 'date'},
    cluster_by=['channel_group']
) }}

select
  session_date                        as date,
  channel_group,
  device_category,
  country,
  count(*)                            as sessions,
  count(distinct user_pseudo_id)      as users,
  sum(page_views)                     as page_views,
  countif(converted)                  as conversions,
  sum(revenue)                        as revenue,
  sum(engaged_seconds)                as engaged_seconds
from {{ ref('fct_sessions') }}
group by 1, 2, 3, 4

Tarkkuustaso määrittää, mihin taulu vastaa. Sitoutumisajan keskiarvo lasketaan raportilla SUM(engaged_seconds) / SUM(sessions): ryhmäkeskiarvojen tavallinen keskiarvo painottaisi pientä ja suurta ryhmää yhtä paljon.

Yksilöllisten käyttäjien määriä ei voi summata kanavien tai päivien yli, koska sama käyttäjä voi esiintyä useassa ryhmässä. Jos raportointitaulussa on vain ryhmäkohtaiset count(distinct)-luvut, niistä ei voi palauttaa tarkkaa kokonaiskäyttäjämäärää. Laske kokonaisluku tarvitulle tarkkuustasolle erikseen ja estä virheellinen summaus raportilla.

Missä dbt ajetaan

dbt Core on avoimen lähdekoodin komentorivityökalu. Ajot voi järjestää omassa ympäristössä tai hyödyntää hallittuja palveluita. Valintaan vaikuttavat ajastuksen lisäksi kehittäjien yhteistyö, käyttöoikeudet, dokumentaatio ja ylläpito.

Google Cloud -ympäristössä seuraavat vaihtoehdot sopivat erilaisiin tarpeisiin:

  • Cloud Run Jobs ja Cloud Scheduler: konttiin pakattu dbt-projekti suoritetaan ajastettuna. Kustannus riippuu ajon kestosta, resursseista, tiheydestä ja BigQuery-kyselyistä.
  • Orkestraattori, kuten Dagster tai Cloud Composer: hyödyllinen, kun dbt-ajo liittyy useisiin lähteisiin ja muuhun ajoketjuun. Arvioi myös palvelun ja ylläpidon kustannus.
  • GitHub Actions: luonteva paikka muutosten tarkistuksille ja testeille. Tuotannon ajastuksessa on arvioitava ajankohtien tarkkuus, suoritusrajat ja häiriöiden valvonta.
Cloud Run -job, ajastettuna Cloud Schedulerilla
gcloud run jobs create dbt-ga4 \
  --image europe-north1-docker.pkg.dev/PROJEKTI/dbt/dbt-bigquery:1.8 \
  --region europe-north1 \
  --service-account dbt-runner@PROJEKTI.iam.gserviceaccount.com \
  --max-retries 2 \
  --task-timeout 30m \
  --command dbt \
  --args build,--target,prod

gcloud scheduler jobs create http dbt-ga4-daily \
  --location europe-north1 \
  --schedule "0 6 * * *" \
  --time-zone "Europe/Helsinki" \
  --uri "https://europe-north1-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/PROJEKTI/jobs/dbt-ga4:run" \
  --oauth-service-account-email dbt-runner@PROJEKTI.iam.gserviceaccount.com

Cloud Runissa ajolle liitetään rajattu palvelutili. Muista ympäristöistä voidaan käyttää esimerkiksi tuettua identiteettifederaatiota. Näin tunnistautuminen voidaan toteuttaa ilman versionhallintaan tai konttiin tallennettua yksityistä avainta.

Milloin ajo kannattaa käynnistää

GA4:n päivittäiselle viennille ei ole taattua kellonaikaa. Googlen mukaan vienti valmistuu tyypillisesti iltapäivällä omaisuuden aikavyöhykkeellä ja voi viivästyä seuraavaan päivään. Kiinteä aamuyön ajo tarvitsee lähteen saatavuustarkistuksen ja uudelleenyritykset.

Puuttuva lähdepäivä voi tuottaa teknisesti onnistuneen mutta puutteellisen mallin. Saatavuustarkistuksen on siksi pysäytettävä julkaisu ennen kuin keskeneräinen aineisto korvaa raportoinnin käytössä olevan version.

Sopikaa lähteen odottamisesta, uudelleenyrityksistä ja hälytyksistä esimerkiksi näin:

  • Tarkista ennen ajoa, että edellisen päivän events_-taulu on olemassa. Jos ei ole, älä aja vaan yritä myöhemmin uudelleen.
  • Aseta uudelleenyritys riittävän monta kertaa muutaman tunnin välein sen sijaan, että ajaisit kerran ja toivoisit parasta.
  • Suorita tarvittaessa dbt source freshness erikseen. Tuoreustarkistus täydentää taulun olemassaolon ja sisällön tarkistuksia, eikä yksin todista aineiston täydellisyyttä.
  • Mitoita ajotiheys raportoinnin tarpeeseen. Käytä myöhästyvien tapahtumien uudelleenlaskentaikkunaa ja mahdollista myös sitä vanhemman historian korjaus.
  • Jos raportointi tarvitsee kuluvan päivän luvut, käsittele events_intraday erillisenä mallina. Älä sekoita sitä valmiiseen päivädataan, koska se korvautuu myöhemmin lopullisella versiolla.

Testit ja tarkistettu raportoinnin julkaisu

dbt:n testit tarkistavat mallinnuksen oletuksia. dbt run ei aja testejä: käytä dbt build -komentoa mallien ja testien ajamiseen riippuvuusjärjestyksessä tai aja dbt test erikseen. source freshness on oma komentonsa. Testit eivät palauta jo kirjoitettua taulua vanhaan tilaan; julkaise raportointikerros vasta hyväksytyn tarkistuksen jälkeen.

models/marts/schema.yml
models:
  - name: fct_sessions
    columns:
      - name: session_key
        tests: [unique, not_null]
      - name: session_date
        tests: [not_null]
      - name: channel_group
        tests:
          - accepted_values:
              values: ['Direct', 'Organic Search', 'Paid Search',
                       'Social', 'Referral', 'Email', 'Other']
      - name: revenue
        tests:
          - dbt_utils.accepted_range:
              min_value: 0

Lisätkää tarkistus, joka vertaa mallinnuksen ja raakadatan tapahtumamääriä sovituilla rajauksilla. Se voi paljastaa purussa tai liitoksissa kadonneita ja monistuneita rivejä. Testatkaa lisäksi liiketoiminnan laskentasäännöt tunnetuilla esimerkeillä.

Yleisimmät virheet

  • Lähteen purku ja useiden raporttien laskentalogiikka kietoutuvat yhteen ilman selkeitä mallirajoja.
  • Koko historia lasketaan toistuvasti, vaikka rajattu päivitys riittäisi.
  • Myöhästyviä tapahtumia tai lähteen puuttuvia päiviä ei huomioida.
  • Raportti suorittaa käyttötarpeeseen nähden liian raskaita lähdekyselyitä.
  • Istuntoavain käyttää vain ga_session_id:tä ilman käyttäjätunnistetta.
  • Ryhmäkohtaiset yksilöllisten käyttäjien määrät summataan kokonaisluvuksi.
  • Testit eivät kata liitosten monistumista tai liiketoiminnan laskentasääntöjä.

Tarvitsetteko GA4-datan mallinnettuna?

Rakennamme dbt-projektit BigQueryyn testeineen ja dokumentaatioineen, myös silloin kun pohjalla on toisen tekemä ympäristö. Kartoituskeskustelu on maksuton.

Katso data engineering -palvelumme

Keskustellaan seuraavasta kehitysaskeleesta

Kertokaa tavoitteistanne ja nykyisestä ympäristöstänne. Arvioimme yhdessä sopivan aloituskohdan ja yhteistyömallin. Ensimmäinen keskustelu on maksuton.