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__sessionsTrois 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.sqlLien avec la série d’articles
Chaque article précédent correspond à un composant :
| Article | Correspond à |
|---|---|
| 1. Référence du schéma | base__ga4__events.sql (shardé vers partitionné + nettoyage) |
| 2. Patterns d’unnesting | Macros d’extraction de paramètres dans base__ga4__events.sql |
| 3. Tables de sessions | Colonnes de session dans int__ga4__events_sessionized.sql |
| 4. User stitching | Ré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 cleanedPourquoi 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 itemCe 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 pagewith_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 functionswith_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 groupingwith_channel AS (
SELECT *, {{ default_channel_grouping('session__source_final', 'session__medium_final') }} AS session__channel_grouping FROM with_session_metrics
),
-- Joindre les agrégats itemsfinal 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 finalDé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 couranteLAST_VALUE(page__path) OVER (PARTITION BY session__key ORDER BY event__timestamp_utc)
-- CORRECT : renvoie la vraie dernière pageLAST_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_pageFlags 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_purchaseLa 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
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
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_nullTests 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_startFROM session_statsWHERE session__has_start = 0 AND session__events > 3 -- Sessions légitimes avec plusieurs événementsLIMIT 100Test 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_idFROM {{ 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 100Tests de schéma pour la table large
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
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
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-
Exécutez le projet :
Terminal window # Chargement initial completdbt build --full-refresh# Incrémental quotidiendbt build
Modèles clés
| Modèle | Grain | Description |
|---|---|---|
int__ga4__events_sessionized | Événement | Chaque événement avec contexte de session |
mrt__analytics__sessions | Session | Une ligne par session |
mrt__analytics__users | Utilisateur | Une 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 :
```bashdbt docs generatedbt docs servePour 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: trueExécuter le projet
Configuration initiale (full refresh) :
# Tester la connexiondbt debug
# Exécuter un full refresh pour backfill toutes les donnéesdbt build --full-refresh
# Générer la documentationdbt docs generateOpérations quotidiennes :
# Exécution incrémentale standarddbt 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, 5Mart 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 uLEFT JOIN user_identity i ON u.user__pseudo_id = i.user__pseudo_idPièges courants et solutions
Après avoir implémenté ce pattern sur plusieurs projets, ces problèmes reviennent régulièrement :
| Piège | Symptôme | Solution |
|---|---|---|
| Resources exceeded | La 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 lents | Les exécutions incrémentales prennent 30+ minutes | Vé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 interface | 1-2% de variance avec les rapports GA4 | Attendu. L’interface GA4 utilise l’échantillonnage et des agrégations différentes. Documenter et accepter. |
| Données tardives manquantes | Les conversions apparaissent des jours plus tard | Augmenter ga4_static_incremental_days à 4-5 |
| Collision d’IDs de session | session__keys en double | Toujours utiliser une clé composite : user__pseudo_id + session__ga_id |
| Landing page NULL | Beaucoup de sessions affichent une landing page NULL | Utiliser IGNORE NULLS dans la window function ; certains événements n’ont pas de page__location |
| Channel grouping différent | Votre “Paid Search” ne correspond pas à GA4 | Revoir 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.