Adrienne Vermorel

GA4 + dbt : un template de projet prêt pour la production

Vous maîtrisez le schéma GA4 BigQuery. Vous savez unnester les paramètres d’événements sans même y réfléchir. Vous avez construit des tables de sessions et implémenté le user stitching. Reste le vrai défi : combiner le tout dans un projet dbt qui fonctionne réellement en production.

Cet article propose un template de projet complet centré sur une idée clé : une table intermédiaire large au grain événement avec des colonnes sessionisées. Chaque ligne d’événement porte son contexte de session (landing page, exit page, si la session a converti, nombre total de pages vues). Cette conception permet des analyses flexibles en aval sans avoir à joindre systématiquement les tables de sessions.

Nous construirons tout cela de zéro, en empruntant des patterns aux packages établis quand ils font sens. Vous obtiendrez un traitement incrémental qui gère les données tardives, des tests qui détectent les problèmes spécifiques à GA4, et une documentation qui maintient le projet maintenable.


Ce que les packages existants nous apprennent

Avant de construire un projet custom, examinons ce qui existe déjà. L’écosystème open source a convergé vers quelques approches qui méritent d’être comprises.

Velir/dbt-ga4 : le standard de la communauté

Le package Velir/dbt-ga4 domine le marché avec plus de 380 étoiles GitHub et un développement actif. Son créateur l’a positionné comme un lieu de partage des connaissances collectives autour de GA4 et BigQuery.

L’architecture suit un pattern propre de base vers marts :

models/
├── base/
│ ├── base/base_ga4__events
│ ├── stg_ga4__events
│ ├── stg_ga4__event_page_view
│ └── stg_ga4__sessions_traffic_sources
└── marts/
└── core/
├── dim_ga4__sessions
└── fct_ga4__sessions

Trois patterns de Velir méritent d’être repris :

Extraction de paramètres pilotée par variables. Plutôt que de coder en dur les paramètres d’événements à extraire, Velir utilise des variables dbt_project.yml :

vars:
ga4:
page_view_custom_parameters:
- name: "clean_event"
value_type: "string_value"

Lookback incrémental statique. Au lieu d’interroger dynamiquement la table de destination pour récupérer la dernière date (ce qui casse l’optimisation BigQuery), Velir retraite les N derniers jours à chaque exécution. Nous adopterons ce pattern.

Clés de session composites. Les sessions sont identifiées par user_pseudo_id + ga_session_id, pas ga_session_id seul. Cela évite les collisions entre utilisateurs.

Mais les modèles de sessions non partitionnés deviennent coûteux à grande échelle. La documentation recommande explicitement de désactiver certains modèles pour les sites à fort trafic.

Autres packages notables

Fivetran/dbt_ga4_export cible les équipes utilisant les connecteurs Fivetran. Il supporte plusieurs bases de données (Snowflake, Redshift, Databricks) mais ne fournit que 7 modèles au total, partant du principe que les paramètres d’événements sont aplatis lors de l’ingestion.

Le package de MO Data Consulting prend une approche différente : aplatir dynamiquement tous les event_params en colonnes individuelles à la compilation. Pratique pour l’exploration, mais peut créer des tables peu maniables.

admindio/simple_ga4_dbt propose une approche simplifiée et économique avec des modèles d’attribution intégrés et un minimum de dépendances.

Pourquoi construire en custom ?

Ces packages optimisent pour des marts au grain session. Notre objectif est différent : une table large au niveau événement où chaque ligne porte son contexte de session. Cela permet :

  • L’analyse de funnels sans jointures sur les sessions
  • L’attribution au niveau événement avec connaissance de la session
  • L’agrégation flexible à n’importe quel grain en aval

Nous construirons notre propre structure, en empruntant les meilleurs patterns au travail existant.


Architecture du projet

Structure des dossiers

models/
├── base/
│ └── ga4/
│ ├── _ga4__sources.yml
│ ├── _ga4__models.yml
│ └── base__ga4__events.sql ← Modèle de base unique
├── intermediate/
│ └── ga4/
│ ├── _int_ga4__models.yml
│ ├── int__ga4__event_items.sql ← Grain item
│ └── int__ga4__events_sessionized.sql ← La table principale
└── ga4/
├── _mrt_ga4__models.yml
├── mrt__analytics__sessions.sql
└── mrt__analytics__users.sql
macros/
└── ga4/
├── extract_event_param.sql
├── generate_surrogate_key.sql
└── default_channel_grouping.sql

Lien avec la série d’articles

Chaque article précédent correspond à un composant :

ArticleCorrespond à
1. Référence du schémabase__ga4__events.sql (shardé vers partitionné + nettoyage)
2. Patterns d’unnestingMacros d’extraction de paramètres dans base__ga4__events.sql
3. Tables de sessionsColonnes de session dans int__ga4__events_sessionized.sql
4. User stitchingRésolution d’identité alimentant mrt__analytics__users.sql

Conventions de nommage

Nous suivons les préfixes standard de dbt :

  • base_ (données source, nettoyées et typées)
  • int_ (transformations intermédiaires, non exposées aux utilisateurs finaux)
  • mrt_ (tables mart pour le reporting métier)

La couche base

GA4 exporte les données sous forme de tables shardées par date (events_YYYYMMDD) plutôt qu’une table partitionnée unique. Cela casse les stratégies incrémentales standard car les clauses WHERE dynamiques provoquent des scans complets de table (BigQuery doit scanner toutes les shards pour évaluer le filtre).

Notre modèle de base unique gère tout : conversion shardé-vers-partitionné, nettoyage, typage et extraction de paramètres.

-- models/base/ga4/base__ga4__events.sql
{{
config(
materialized='incremental',
incremental_strategy='insert_overwrite',
partition_by={
"field": "event__date",
"data_type": "date",
"granularity": "day"
},
cluster_by=['user__pseudo_id', 'session__key', 'event__name']
)
}}
{% set lookback_days = var('ga4_static_incremental_days', 3) %}
WITH source AS (
SELECT
event_date,
event_timestamp,
event_name,
event_params,
event_bundle_sequence_id,
user_id,
user_pseudo_id,
user_properties,
device,
geo,
ecommerce,
items,
collected_traffic_source,
is_active_user
FROM {{ source('ga4', 'events') }}
WHERE _TABLE_SUFFIX >= '{{ var("ga4_start_date", "20230101") }}'
{% if is_incremental() %}
AND _TABLE_SUFFIX >= FORMAT_DATE(
'%Y%m%d',
DATE_SUB(CURRENT_DATE(), INTERVAL {{ lookback_days }} DAY)
)
{% endif %}
-- Exclure les tables intraday
AND _TABLE_SUFFIX NOT LIKE '%intraday%'
),
cleaned AS (
SELECT
-- Timestamps
PARSE_DATE('%Y%m%d', event_date) AS event__date,
TIMESTAMP_MICROS(event_timestamp) AS event__timestamp_utc,
event_timestamp AS event__timestamp_raw,
-- Identifiants utilisateur
user_pseudo_id AS user__pseudo_id,
user_id AS user__id,
-- Identifiants de session
{{ extract_event_param('event_params', 'ga_session_id', 'int_value') }}
AS session__ga_id,
{{ extract_event_param('event_params', 'ga_session_number', 'int_value') }}
AS session__number,
-- Détails de l'événement
event_name AS event__name,
event_bundle_sequence_id AS event__bundle_sequence_id,
-- Informations de page
{{ extract_event_param('event_params', 'page_location', 'string_value') }}
AS page__location,
{{ extract_event_param('event_params', 'page_title', 'string_value') }}
AS page__title,
{{ extract_event_param('event_params', 'page_referrer', 'string_value') }}
AS page__referrer,
-- Source de trafic (niveau événement)
{{ extract_event_param('event_params', 'source', 'string_value') }}
AS event__source,
{{ extract_event_param('event_params', 'medium', 'string_value') }}
AS event__medium,
{{ extract_event_param('event_params', 'campaign', 'string_value') }}
AS event__campaign,
-- Engagement
{{ extract_event_param('event_params', 'session_engaged', 'string_value') }}
AS session__is_engaged,
{{ extract_event_param('event_params', 'engagement_time_msec', 'int_value') }}
AS event__engagement_time_msec,
{{ extract_event_param('event_params', 'entrances', 'int_value') }}
AS event__is_entrance,
-- Device et géolocalisation
device.category AS device__category,
device.mobile_brand_name AS device__brand,
device.operating_system AS device__os,
device.web_info.browser AS device__browser,
geo.country AS geo__country,
geo.city AS geo__city,
-- Source de trafic (niveau session, depuis collected_traffic_source)
collected_traffic_source.manual_source AS session__source,
collected_traffic_source.manual_medium AS session__medium,
collected_traffic_source.manual_campaign_name AS session__campaign,
collected_traffic_source.gclid AS session__gclid,
-- E-commerce
ecommerce.transaction_id AS transaction__id,
ecommerce.purchase_revenue AS transaction__revenue,
-- Conserver les structures imbriquées pour les modèles en aval
event_params AS event__params,
user_properties AS user__properties,
items AS items__array
FROM source
)
SELECT
-- Clé primaire
{{ generate_surrogate_key(['user__pseudo_id', 'event__timestamp_raw', 'event__name']) }}
AS event__key,
-- Clé de session (composite)
{{ generate_surrogate_key(['user__pseudo_id', 'session__ga_id']) }}
AS session__key,
-- Timestamps
event__date,
event__timestamp_utc,
event__timestamp_raw,
-- Identifiants utilisateur
user__pseudo_id,
user__id,
-- Identifiants de session
session__ga_id,
session__number,
-- Détails de l'événement
event__name,
event__bundle_sequence_id,
-- Informations de page
page__location,
page__title,
page__referrer,
-- Source de trafic (niveau événement)
event__source,
event__medium,
event__campaign,
-- Engagement
session__is_engaged,
event__engagement_time_msec,
event__is_entrance,
-- Device et géolocalisation
device__category,
device__brand,
device__os,
device__browser,
geo__country,
geo__city,
-- Source de trafic (niveau session)
session__source,
session__medium,
session__campaign,
session__gclid,
-- E-commerce
transaction__id,
transaction__revenue,
-- Structures imbriquées
event__params,
user__properties,
items__array
FROM cleaned

Pourquoi 3 jours de lookback ? GA4 peut mettre à jour les événements pendant plus de 72 heures après l’export initial. Les conversions trackées via Google Ads, les mises à jour de réconciliation cross-device et les ajustements du mode consentement génèrent tous des données tardives. Le lookback statique retraite les partitions récentes à chaque exécution, garantissant qu’aucune donnée n’est manquée.

Pourquoi insert_overwrite ? Pour les données d’événements (immuables, partitionnées par date), cette stratégie remplace des partitions entières proprement. C’est plus rapide et moins coûteux que merge, qui scanne à la fois la source et la destination.


Les macros

La macro d’extraction de paramètres

La macro extract_event_param encapsule le pattern de sous-requête inline :

-- macros/ga4/extract_event_param.sql
{% macro extract_event_param(params_column, param_key, value_type='string_value') %}
(
SELECT value.{{ value_type }}
FROM UNNEST({{ params_column }})
WHERE key = '{{ param_key }}'
LIMIT 1
)
{% endmacro %}

Cette approche évite la multiplication de lignes (pas de produit cartésien par UNNEST) tout en gardant le SQL lisible. Pour les paramètres numériques stockés dans différents champs de valeur :

{% macro extract_event_param_numeric(params_column, param_key) %}
(
SELECT COALESCE(
value.int_value,
CAST(value.float_value AS INT64),
CAST(value.double_value AS INT64)
)
FROM UNNEST({{ params_column }})
WHERE key = '{{ param_key }}'
LIMIT 1
)
{% endmacro %}

La couche intermédiaire

Gérer l’array items

Les items e-commerce nécessitent un modèle séparé au grain item :

-- models/intermediate/ga4/int__ga4__event_items.sql
{{
config(
materialized='view'
)
}}
WITH events_with_items AS (
SELECT
event__key,
event__date,
event__timestamp_utc,
user__pseudo_id,
session__key,
event__name,
transaction__id,
items__array
FROM {{ ref('base__ga4__events') }}
WHERE event__name IN ('purchase', 'add_to_cart', 'remove_from_cart',
'view_item', 'begin_checkout', 'add_payment_info')
AND ARRAY_LENGTH(items__array) > 0
)
SELECT
e.event__key,
e.event__date,
e.event__timestamp_utc,
e.user__pseudo_id,
e.session__key,
e.event__name,
e.transaction__id,
-- Détails item
item.item_id AS item__id,
item.item_name AS item__name,
item.item_brand AS item__brand,
item.item_category AS item__category,
item.item_category2 AS item__category2,
item.item_category3 AS item__category3,
item.item_variant AS item__variant,
item.price AS item__price,
item.quantity AS item__quantity,
item.coupon AS item__coupon,
item.item_list_name AS item__list_name,
item.item_list_index AS item__list_index,
-- Calculer le revenu item
COALESCE(item.price, 0) * COALESCE(item.quantity, 1) AS item__revenue
FROM events_with_items e,
UNNEST(e.items__array) AS item

Ce modèle utilise intentionnellement un UNNEST cartésien car nous avons besoin du grain item.

La table large sessionisée

Chaque ligne d’événement reçoit son contexte de session via des window functions.

-- models/intermediate/ga4/int__ga4__events_sessionized.sql
{{
config(
materialized='incremental',
incremental_strategy='insert_overwrite',
partition_by={
"field": "event__date",
"data_type": "date",
"granularity": "day"
},
cluster_by=['user__pseudo_id', 'session__key', 'event__name']
)
}}
{% set lookback_days = var('ga4_static_incremental_days', 3) %}
WITH events AS (
SELECT
event__key,
session__key,
event__date,
event__timestamp_utc,
user__pseudo_id,
user__id,
session__ga_id,
session__number,
event__name,
page__location,
page__title,
event__source,
event__medium,
event__campaign,
session__source,
session__medium,
session__campaign,
event__engagement_time_msec,
device__category,
geo__country,
transaction__revenue
FROM {{ ref('base__ga4__events') }}
{% if is_incremental() %}
WHERE event__date >= DATE_SUB(CURRENT_DATE(), INTERVAL {{ lookback_days }} DAY)
{% endif %}
),
-- Agréger les items par événement (éviter les jointures répétées)
event_items AS (
SELECT
event__key,
COUNT(*) AS event__items,
SUM(item__revenue) AS event__items_revenue
FROM {{ ref('int__ga4__event_items') }}
{% if is_incremental() %}
WHERE event__date >= DATE_SUB(CURRENT_DATE(), INTERVAL {{ lookback_days }} DAY)
{% endif %}
GROUP BY event__key
),
-- Nettoyer les URLs de page pour l'analyse landing/exit page
with_clean_urls AS (
SELECT
*,
-- Supprimer les paramètres de requête pour des landing/exit pages plus lisibles
REGEXP_REPLACE(
SPLIT(page__location, '?')[SAFE_OFFSET(0)],
r'#.*$',
''
) AS page__path
FROM events
),
-- Ajouter les métriques de session via window functions
with_session_metrics AS (
SELECT
e.*,
-- Landing et exit pages
FIRST_VALUE(page__path IGNORE NULLS) OVER (session_window) AS session__landing_page,
FIRST_VALUE(page__location IGNORE NULLS) OVER (session_window) AS session__landing_page_full,
LAST_VALUE(page__path IGNORE NULLS) OVER (session_window_full) AS session__exit_page,
-- Source de trafic de session (première source non nulle dans la session)
FIRST_VALUE(
COALESCE(event__source, session__source) IGNORE NULLS
) OVER (session_window) AS session__source_final,
FIRST_VALUE(
COALESCE(event__medium, session__medium) IGNORE NULLS
) OVER (session_window) AS session__medium_final,
FIRST_VALUE(
COALESCE(event__campaign, session__campaign) IGNORE NULLS
) OVER (session_window) AS session__campaign_final,
-- Métriques d'engagement de session
COUNT(*) OVER (PARTITION BY session__key) AS session__events,
COUNT(CASE WHEN event__name = 'page_view' THEN 1 END)
OVER (PARTITION BY session__key) AS session__pageviews,
SUM(event__engagement_time_msec) OVER (PARTITION BY session__key) AS session__engagement_time_msec,
-- Durée de session
TIMESTAMP_DIFF(
MAX(event__timestamp_utc) OVER (PARTITION BY session__key),
MIN(event__timestamp_utc) OVER (PARTITION BY session__key),
SECOND
) AS session__duration_seconds,
-- Flags de conversion de session
MAX(CASE WHEN event__name = 'purchase' THEN 1 ELSE 0 END)
OVER (PARTITION BY session__key) AS session__has_purchase,
MAX(CASE WHEN event__name = 'add_to_cart' THEN 1 ELSE 0 END)
OVER (PARTITION BY session__key) AS session__has_add_to_cart,
MAX(CASE WHEN event__name = 'begin_checkout' THEN 1 ELSE 0 END)
OVER (PARTITION BY session__key) AS session__has_checkout,
MAX(CASE WHEN event__name = 'sign_up' THEN 1 ELSE 0 END)
OVER (PARTITION BY session__key) AS session__has_signup,
MAX(CASE WHEN event__name = 'generate_lead' THEN 1 ELSE 0 END)
OVER (PARTITION BY session__key) AS session__has_lead,
-- Revenu de session
SUM(transaction__revenue) OVER (PARTITION BY session__key) AS session__revenue,
-- Séquençage d'événements dans la session
ROW_NUMBER() OVER (
PARTITION BY session__key
ORDER BY event__timestamp_utc
) AS event__sequence_number,
-- Est-ce le premier/dernier événement de la session ?
CASE
WHEN ROW_NUMBER() OVER (
PARTITION BY session__key
ORDER BY event__timestamp_utc
) = 1 THEN TRUE
ELSE FALSE
END AS event__is_session_start,
CASE
WHEN ROW_NUMBER() OVER (
PARTITION BY session__key
ORDER BY event__timestamp_utc DESC
) = 1 THEN TRUE
ELSE FALSE
END AS event__is_session_end,
-- Temps depuis l'événement précédent (pour l'analyse d'engagement)
TIMESTAMP_DIFF(
event__timestamp_utc,
LAG(event__timestamp_utc) OVER (
PARTITION BY session__key
ORDER BY event__timestamp_utc
),
SECOND
) AS event__seconds_since_previous
FROM with_clean_urls e
WINDOW
session_window AS (
PARTITION BY session__key
ORDER BY event__timestamp_utc
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
),
session_window_full AS (
PARTITION BY session__key
ORDER BY event__timestamp_utc
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)
),
-- Ajouter le channel grouping
with_channel AS (
SELECT
*,
{{ default_channel_grouping('session__source_final', 'session__medium_final') }}
AS session__channel_grouping
FROM with_session_metrics
),
-- Joindre les agrégats items
final AS (
SELECT
e.*,
COALESCE(i.event__items, 0) AS event__items,
COALESCE(i.event__items_revenue, 0) AS event__items_revenue
FROM with_channel e
LEFT JOIN event_items i ON e.event__key = i.event__key
)
SELECT * FROM final

Détails critiques sur les window functions

Le piège LAST_VALUE. Sans framing explicite, LAST_VALUE renvoie la ligne courante :

-- FAUX : renvoie la page de la ligne courante
LAST_VALUE(page__path) OVER (PARTITION BY session__key ORDER BY event__timestamp_utc)
-- CORRECT : renvoie la vraie dernière page
LAST_VALUE(page__path) OVER (
PARTITION BY session__key
ORDER BY event__timestamp_utc
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)

IGNORE NULLS est essentiel. GA4 ne tracke pas page_location pour tous les événements. Utiliser IGNORE NULLS garantit d’obtenir les vraies valeurs de page :

FIRST_VALUE(page__path IGNORE NULLS) OVER (session_window) AS session__landing_page

Flags booléens via MAX. Pour propager un flag à tous les événements d’une session :

MAX(CASE WHEN event__name = 'purchase' THEN 1 ELSE 0 END)
OVER (PARTITION BY session__key) AS session__has_purchase

La macro de channel grouping

La logique de channel grouping par défaut de Google, encapsulée dans une macro réutilisable :

-- macros/ga4/default_channel_grouping.sql
{% macro default_channel_grouping(source_column, medium_column) %}
CASE
-- Direct
WHEN {{ source_column }} IS NULL
OR {{ source_column }} = '(direct)'
OR {{ source_column }} = '(none)'
THEN 'Direct'
-- Paid Search
WHEN REGEXP_CONTAINS({{ medium_column }}, r'^(cpc|ppc|paidsearch)$')
OR ({{ source_column }} = 'google' AND {{ medium_column }} = 'sem')
THEN 'Paid Search'
-- Paid Social
WHEN REGEXP_CONTAINS({{ medium_column }}, r'^(paidsocial|paid-social|paid_social)$')
OR (REGEXP_CONTAINS({{ source_column }}, r'facebook|instagram|linkedin|twitter|tiktok')
AND REGEXP_CONTAINS({{ medium_column }}, r'^(cpc|ppc|paid)'))
THEN 'Paid Social'
-- Display
WHEN REGEXP_CONTAINS({{ medium_column }}, r'^(display|cpm|banner)$')
THEN 'Display'
-- Organic Search
WHEN {{ medium_column }} = 'organic'
OR REGEXP_CONTAINS({{ source_column }}, r'google|bing|yahoo|duckduckgo|baidu')
AND {{ medium_column }} = 'organic'
THEN 'Organic Search'
-- Organic Social
WHEN REGEXP_CONTAINS({{ source_column }}, r'facebook|instagram|linkedin|twitter|tiktok|pinterest|youtube')
AND ({{ medium_column }} IS NULL OR {{ medium_column }} IN ('social', 'referral', '(none)'))
THEN 'Organic Social'
-- Email
WHEN REGEXP_CONTAINS({{ medium_column }}, r'^(email|e-mail|e_mail)$')
THEN 'Email'
-- Affiliate
WHEN REGEXP_CONTAINS({{ medium_column }}, r'^(affiliate|affiliates)$')
THEN 'Affiliates'
-- Referral
WHEN {{ medium_column }} = 'referral'
THEN 'Referral'
-- Other
ELSE 'Other'
END
{% endmacro %}

Tests et qualité des données

Les tests standard de dbt passent à côté des problèmes spécifiques à GA4. Cette section couvre une stratégie de tests complète.

Configuration des sources avec fraîcheur

models/base/ga4/_ga4__sources.yml
version: 2
sources:
- name: ga4
database: "{{ var('ga4_project_id') }}"
schema: "{{ var('ga4_dataset') }}"
description: "Export GA4 vers BigQuery"
tables:
- name: events
identifier: "events_*"
description: "Table d'événements GA4 shardée par date"
freshness:
warn_after: {count: 24, period: hour}
error_after: {count: 48, period: hour}
loaded_at_field: "TIMESTAMP_MICROS(event_timestamp)"

Tests du modèle de base

models/base/ga4/_ga4__models.yml
version: 2
models:
- name: base__ga4__events
description: "Événements GA4 : shardé vers partitionné, nettoyé, typé, avec clés de session"
columns:
- name: event__key
description: "Clé de substitution pour l'événement"
tests:
- unique
- not_null
- name: user__pseudo_id
description: "Client ID (identifiant de device)"
tests:
- not_null
- name: event__timestamp_utc
description: "Timestamp de l'événement en UTC"
tests:
- not_null
- name: session__key
description: "Identifiant de session composite"
tests:
- not_null

Tests singuliers spécifiques à GA4

Test pour les événements session_start manquants :

-- tests/singular/test_sessions_missing_session_start.sql
-- Les sessions devraient avoir un événement session_start
-- Certaines sessions peuvent légitimement ne pas en avoir (bloqueurs de pub, consentement),
-- mais un pourcentage élevé indique un problème de tracking
WITH session_stats AS (
SELECT
session__key,
COUNT(*) AS session__events,
MAX(CASE WHEN event__name = 'session_start' THEN 1 ELSE 0 END) AS session__has_start
FROM {{ ref('int__ga4__events_sessionized') }}
WHERE event__date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)
GROUP BY session__key
)
SELECT
session__key,
session__events,
session__has_start
FROM session_stats
WHERE session__has_start = 0
AND session__events > 3 -- Sessions légitimes avec plusieurs événements
LIMIT 100

Test pour les transactions orphelines :

-- tests/singular/test_purchase_without_session.sql
-- Les achats devraient avoir un contexte de session valide
SELECT
event__key,
event__date,
session__ga_id,
user__pseudo_id
FROM {{ ref('base__ga4__events') }}
WHERE event__name = 'purchase'
AND transaction__id IS NOT NULL
AND (session__ga_id IS NULL OR user__pseudo_id IS NULL)
AND event__date >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY)
LIMIT 100

Tests de schéma pour la table large

models/intermediate/ga4/_int_ga4__models.yml
version: 2
models:
- name: int__ga4__events_sessionized
description: "Table d'événements large avec contexte de session sur chaque ligne"
columns:
- name: event__key
tests:
- unique
- not_null
- name: session__key
tests:
- not_null
- name: session__landing_page
description: "Premier chemin de page dans la session"
tests:
- not_null:
where: "event__name = 'page_view'"
- name: session__pageviews
tests:
- dbt_utils.accepted_range:
min_value: 0
max_value: 1000 # Signaler les sessions suspectes
- name: session__duration_seconds
tests:
- dbt_utils.accepted_range:
min_value: 0
max_value: 86400 # 24 heures max
- name: session__channel_grouping
tests:
- accepted_values:
values: ['Direct', 'Organic Search', 'Paid Search', 'Paid Social',
'Organic Social', 'Email', 'Referral', 'Display',
'Affiliates', 'Other']

Documentation et lignage

Une bonne documentation rend le projet maintenable.

Documentation des sources

models/base/ga4/_ga4__sources.yml
version: 2
sources:
- name: ga4
description: |
Tables d'export GA4 vers BigQuery.
Voir [la documentation du schéma Google](https://support.google.com/analytics/answer/7029846)
pour la référence complète des champs.
**Notes importantes :**
- Les tables sont shardées par date sous forme `events_YYYYMMDD`
- Les tables intraday (`events_intraday_YYYYMMDD`) sont exclues du traitement
- Les données peuvent arriver jusqu'à 72 heures en retard
tables:
- name: events
description: |
Données d'événements GA4 brutes exportées vers BigQuery.
Chaque ligne représente un événement unique (page_view, purchase, événements custom, etc.).
Les paramètres d'événements sont stockés dans des champs RECORD imbriqués qui nécessitent un unnesting.

Documentation des modèles

models/intermediate/ga4/_int_ga4__models.yml
version: 2
models:
- name: int__ga4__events_sessionized
description: |
Table d'événements large avec contexte de session attaché à chaque ligne.
**Grain :** Une ligne par événement
**Décisions de conception clés :**
- Les métriques de session (session__landing_page, session__exit_page, session__has_purchase) sont
calculées via window functions et attachées à chaque événement de la session
- La source de trafic utilise la première source non nulle de la session (correspond au comportement GA4)
- Le channel grouping suit les règles par défaut de Google
**Usage :**
- Utiliser pour l'analyse au niveau événement avec contexte de session
- Agréger au grain session avec `WHERE event__is_session_start = TRUE`
- Joindre à `mrt__analytics__users` pour l'analyse au niveau utilisateur
columns:
- name: session__key
description: |
Clé composite de user__pseudo_id + session__ga_id.
**Pourquoi pas juste session__ga_id ?**
Les IDs de session ne sont uniques qu'au sein d'un user_pseudo_id. Des utilisateurs différents peuvent avoir
la même valeur de ga_session_id.
- name: session__landing_page
description: |
Premier chemin de page (sans paramètres de requête) vu dans la session.
Calculé avec `FIRST_VALUE(page__path IGNORE NULLS)` pour gérer les événements qui
n'ont pas d'information de page.
- name: session__has_purchase
description: |
1 si un événement de la session était un achat, 0 sinon.
Ce flag est sur chaque ligne de la session, permettant l'analyse de funnel sans jointures.

Le README du projet

Créez un README à la racine du projet :

# Projet dbt GA4
Ce projet transforme les exports bruts GA4 BigQuery en tables prêtes pour l'analyse.
## Démarrage rapide
1. Configurez vos variables dans `dbt_project.yml` :
```yaml
vars:
ga4_project_id: "votre-projet-gcp"
ga4_dataset: "analytics_123456789"
ga4_start_date: "20230101"
ga4_static_incremental_days: 3
  1. Exécutez le projet :

    Terminal window
    # Chargement initial complet
    dbt build --full-refresh
    # Incrémental quotidien
    dbt build

Modèles clés

ModèleGrainDescription
int__ga4__events_sessionizedÉvénementChaque événement avec contexte de session
mrt__analytics__sessionsSessionUne ligne par session
mrt__analytics__usersUtilisateurUne ligne par utilisateur avec identité réconciliée

Architecture

base__ga4__events (shardé → partitionné, nettoyé, typé)
int__ga4__events_sessionized (sessionisé)
mrt__analytics__sessions / mrt__analytics__users (marts)

Limitations connues

  • Latence des données : Les données GA4 peuvent arriver jusqu’à 72 heures en retard. Nous retraitons les 3 derniers jours à chaque exécution pour capturer les événements tardifs.

  • Écarts avec l’interface : Attendez-vous à 1-2% de variance par rapport aux rapports GA4. L’interface utilise des données échantillonnées et des méthodes d’agrégation différentes.

  • Attribution de session : Nous utilisons la première source non nulle de la session pour l’attribution, ce qui correspond au comportement de source de trafic scopée session de GA4.

### Visualisation du lignage
Générez la documentation dbt pour le lignage visuel :
```bash
dbt docs generate
dbt docs serve

Pour un diagramme Mermaid simple dans votre README :

graph LR
A[events_*] --> B[base__ga4__events]
B --> C[int__ga4__event_items]
B --> D[int__ga4__events_sessionized]
C --> D
D --> E[mrt__analytics__sessions]
D --> F[mrt__analytics__users]

Assembler le tout

Le dbt_project.yml complet

name: 'ga4_analytics'
version: '1.0.0'
profile: 'ga4_analytics'
model-paths: ["models"]
analysis-paths: ["analyses"]
test-paths: ["tests"]
seed-paths: ["seeds"]
macro-paths: ["macros"]
target-path: "target"
clean-targets:
- "target"
- "dbt_packages"
vars:
# Configuration GCP
ga4_project_id: "{{ env_var('GA4_PROJECT_ID') }}"
ga4_dataset: "{{ env_var('GA4_DATASET', 'analytics_123456789') }}"
# Configuration de traitement
ga4_start_date: "20230101"
ga4_static_incremental_days: 3
# Configuration métier
ga4_conversion_events:
- 'purchase'
- 'sign_up'
- 'generate_lead'
- 'contact_form_submit'
# Nettoyage d'URL
ga4_query_params_to_remove:
- 'gclid'
- 'fbclid'
- 'utm_id'
- '_ga'
models:
ga4_analytics:
base:
+schema: base
+materialized: view
+tags: ['ga4', 'base']
ga4:
+materialized: incremental
intermediate:
+schema: intermediate
+materialized: incremental
+tags: ['ga4', 'intermediate']
marts:
+schema: marts
+materialized: table
+tags: ['ga4', 'marts']
tests:
+severity: warn
+store_failures: true

Exécuter le projet

Configuration initiale (full refresh) :

Terminal window
# Tester la connexion
dbt debug
# Exécuter un full refresh pour backfill toutes les données
dbt build --full-refresh
# Générer la documentation
dbt docs generate

Opérations quotidiennes :

Terminal window
# Exécution incrémentale standard
dbt build
# Exécuter avec une plage de dates spécifique (pour les backfills)
dbt build --vars '{"ga4_start_date": "20240101"}'

Planning recommandé : Exécution quotidienne, 4-6 heures après minuit dans votre fuseau horaire principal. Cela laisse le temps à l’export quotidien GA4 de se terminer et à la plupart des données tardives d’arriver.


La couche mart

Avec la table intermédiaire large complète, construire les marts est simple.

Mart sessions

-- models/marts/ga4/mrt__analytics__sessions.sql
{{
config(
materialized='table',
partition_by={
"field": "event__date",
"data_type": "date"
},
cluster_by=['session__channel_grouping', 'device__category']
)
}}
SELECT
session__key,
user__pseudo_id,
session__ga_id,
session__number,
-- Timestamps
event__date,
MIN(event__timestamp_utc) AS session__started_at,
MAX(event__timestamp_utc) AS session__ended_at,
-- Attributs de session (prendre de n'importe quelle ligne, ils sont tous identiques)
ANY_VALUE(session__landing_page) AS session__landing_page,
ANY_VALUE(session__exit_page) AS session__exit_page,
ANY_VALUE(session__source_final) AS session__source,
ANY_VALUE(session__medium_final) AS session__medium,
ANY_VALUE(session__campaign_final) AS session__campaign,
ANY_VALUE(session__channel_grouping) AS session__channel_grouping,
ANY_VALUE(device__category) AS device__category,
ANY_VALUE(geo__country) AS geo__country,
-- Métriques d'engagement
ANY_VALUE(session__pageviews) AS session__pageviews,
ANY_VALUE(session__events) AS session__events,
ANY_VALUE(session__duration_seconds) AS session__duration_seconds,
ANY_VALUE(session__engagement_time_msec) / 1000 AS session__engagement_time_seconds,
-- Flags de conversion
ANY_VALUE(session__has_purchase) AS session__has_purchase,
ANY_VALUE(session__has_add_to_cart) AS session__has_add_to_cart,
ANY_VALUE(session__has_checkout) AS session__has_checkout,
ANY_VALUE(session__has_signup) AS session__has_signup,
-- Revenu
ANY_VALUE(session__revenue) AS session__revenue
FROM {{ ref('int__ga4__events_sessionized') }}
GROUP BY 1, 2, 3, 4, 5

Mart utilisateurs

Ce modèle intègre le user stitching de l’article 4 :

-- models/marts/ga4/mrt__analytics__users.sql
{{
config(
materialized='table',
cluster_by=['user__first_seen_at']
)
}}
WITH user_sessions AS (
SELECT
user__pseudo_id,
user__id,
session__key,
event__date,
session__started_at,
session__channel_grouping,
device__category,
geo__country,
session__has_purchase,
session__revenue
FROM {{ ref('mrt__analytics__sessions') }}
),
user_first_last AS (
SELECT
user__pseudo_id,
-- Première visite
MIN(event__date) AS user__first_seen_at,
FIRST_VALUE(session__channel_grouping) OVER (
PARTITION BY user__pseudo_id
ORDER BY event__date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS user__first_channel,
FIRST_VALUE(device__category) OVER (
PARTITION BY user__pseudo_id
ORDER BY event__date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS user__first_device,
-- Dernière visite
MAX(event__date) AS user__last_seen_at,
LAST_VALUE(session__channel_grouping) OVER (
PARTITION BY user__pseudo_id
ORDER BY event__date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS user__last_channel,
-- Agrégats
COUNT(DISTINCT session__key) AS user__sessions,
MAX(session__has_purchase) AS user__has_purchased,
SUM(session__revenue) AS user__lifetime_revenue
FROM user_sessions
GROUP BY user__pseudo_id
),
-- Réconcilier user_id (depuis l'article 4)
user_identity AS (
SELECT DISTINCT
user__pseudo_id,
FIRST_VALUE(user__id IGNORE NULLS) OVER (
PARTITION BY user__pseudo_id
ORDER BY event__date DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS user__stitched_id
FROM user_sessions
WHERE user__id IS NOT NULL
)
SELECT
u.user__pseudo_id,
i.user__stitched_id AS user__id,
u.user__first_seen_at,
u.user__last_seen_at,
u.user__first_channel,
u.user__first_device,
u.user__last_channel,
u.user__sessions,
u.user__has_purchased,
u.user__lifetime_revenue,
-- Métriques dérivées
DATE_DIFF(u.user__last_seen_at, u.user__first_seen_at, DAY) AS user__lifespan_days,
DATE_DIFF(CURRENT_DATE(), u.user__last_seen_at, DAY) AS user__days_since_last_visit
FROM user_first_last u
LEFT JOIN user_identity i ON u.user__pseudo_id = i.user__pseudo_id

Pièges courants et solutions

Après avoir implémenté ce pattern sur plusieurs projets, ces problèmes reviennent régulièrement :

PiègeSymptômeSolution
Resources exceededLa requête échoue avec “Resources exceeded”Ajouter le partitionnement au modèle intermédiaire ; réduire la fenêtre de lookback ; utiliser le clustering
Builds lentsLes exécutions incrémentales prennent 30+ minutesVérifier les scans complets de table dans le SQL compilé ; s’assurer que la clause WHERE utilise la colonne de partition
Écart de données vs interface1-2% de variance avec les rapports GA4Attendu. L’interface GA4 utilise l’échantillonnage et des agrégations différentes. Documenter et accepter.
Données tardives manquantesLes conversions apparaissent des jours plus tardAugmenter ga4_static_incremental_days à 4-5
Collision d’IDs de sessionsession__keys en doubleToujours utiliser une clé composite : user__pseudo_id + session__ga_id
Landing page NULLBeaucoup de sessions affichent une landing page NULLUtiliser IGNORE NULLS dans la window function ; certains événements n’ont pas de page__location
Channel grouping différentVotre “Paid Search” ne correspond pas à GA4Revoir et aligner les patterns regex dans la macro avec vos conventions UTM

Conclusion

Vous disposez maintenant d’un projet dbt GA4 complet et prêt pour la production :

  • Un modèle de base unique qui convertit les tables shardées en partitionnées, nettoie les données et extrait les paramètres
  • Des macros réutilisables pour l’extraction de paramètres et le channel grouping
  • Une table intermédiaire large où chaque événement porte son contexte de session
  • Une couche mart avec des tables sessions et utilisateurs
  • Des tests complets qui détectent les problèmes spécifiques à GA4
  • Une documentation qui maintient le projet maintenable

Cette architecture lie ensemble tout ce que nous avons vu dans la série : la connaissance du schéma de l’article 1, les patterns d’unnesting de l’article 2, la logique de sessionisation de l’article 3, et le user stitching de l’article 4.

L’approche par table intermédiaire large diffère des packages GA4 classiques qui construisent directement des marts au grain session. En conservant le grain événement avec le contexte de session attaché, vous permettez des analyses flexibles en aval (taux de conversion de funnel, attribution au niveau événement, analyse d’engagement) sans avoir à rejoindre systématiquement les tables de sessions.