Configuration
The module uses a single YAML configuration file: includes/custom/modules/ga4_attribution/config.yaml.
It ships disabled. At minimum, set enabled: true, define your conversions, and set journey.closing_conversions to start producing journeys, models and reports.
Minimal example
version: 1
enabled: true
dependencies:
- ga4
touchpoint:
mode: session
conversions:
- name: purchase
eventFilter: purchase
revenue_field: ecommerce.purchase_revenue
journey:
closing_conversions:
- purchase
user_identifier: user_pseudo_id
channel_grouping: default_channel_group
attribution_models:
- { name: first_touch, type: first_touch }
- { name: last_touch, type: last_touch }
- { name: linear, type: linear }
- { name: position_based, type: position_based, first_weight: 0.4, last_weight: 0.4 }
- { name: time_decay, type: time_decay, half_life_days: 7 }
Top-level options
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Toggle the whole module. |
version | number | 1 | Config version (set by Superform Labs). |
dependencies | list | [ga4] | Required modules; the ga4 module must be enabled. |
rebuild_schedule | string | "* * *" | 3-field cron ("dayOfMonth month dayOfWeek") gating the rebuild layer (journeys, models and all reports) to cut cost. Touchpoint tables always run incrementally; on non-matching days the previous rebuild stays in place. "* * *" rebuilds on every run; "* * 1" = Mondays only; "1 * *" = 1st of each month. It can only skip runs - rebuilding more often than the pipeline runs requires changing the Dataform workflow schedule instead. The gate is evaluated when the Dataform release compiles (in the compiling host's timezone), not when the workflow executes - so the release must recompile at least as often as the schedule needs to take effect ("* * 1" needs a compilation on the Monday itself); on a non-matching compilation day the rebuild simply does not happen. |
revenue_field | string | ecommerce.purchase_revenue | Default revenue path for conversions without their own revenue_field. |
custom_lineage.touchpoints_custom | false | "view" | false | Interception point between the touchpoints table and ga4_attribution_journeys (offline touches, touch filters). See Custom lineage. |
custom_lineage.journeys_custom | false | "view" | false | Interception point between ga4_attribution_journeys and the models / reports (journey filters, column overrides). See Custom lineage. |
Touchpoints
touchpoint:
mode: session # or: source_change
source_change_unique_fields:
- fixed_traffic_source.source
- fixed_traffic_source.medium
- fixed_traffic_source.campaign
| Option | Type | Default | Description |
|---|---|---|---|
mode | session | source_change | session | session = one traffic touch per ga4_sessions row; source_change = one per intra-session traffic-source change. See Touchpoint modes. |
source_change_unique_fields | list | source / medium / campaign | source_change only. Dotted field paths whose change starts a new traffic touch. |
Conversions
Each entry is either structured (eventFilter + optional paramFilter) or open SQL (sql:) - not both. First match wins, so order matters; entries sharing a name are OR'd together.
conversions:
# First-match-wins: a more specific subset must come before the general one.
- name: high_value_purchase
eventFilter: purchase
paramFilter:
conditions:
- { param: 'safe_cast(ecommerce.purchase_revenue as numeric)', value: 500, operator: '>=' }
- { param: 'geo.country', value: ['Belgium', 'Netherlands'], operator: 'in' }
logic: and
revenue_field: ecommerce.purchase_revenue_in_usd # per-entry override
- name: purchase
eventFilter: purchase
- name: newsletter_signup # shared name is OR'd together
eventFilter: [signup, sign_up]
- name: newsletter_signup
sql: 'event_name = "page_view" AND page.path LIKE "/thank-you%"'
| Field | Description |
|---|---|
name | Conversion name (appears as conversion_name / closing_conversion_name). Reuse a name to OR several definitions. |
eventFilter | Event name, or list of event names, to match. |
paramFilter | Optional structured predicate: conditions (each param / value / operator) combined by logic (and / or). Operators include =, >=, in, like, and so on. |
sql | Open SQL boolean predicate - an escape hatch when eventFilter / paramFilter cannot express the match. Mutually exclusive with eventFilter / paramFilter. |
revenue_field | Optional per-entry revenue path overriding the top-level revenue_field. |
Dimensions & metrics
Standard channel dimensions (source, medium, campaign, default channel group, device) are always emitted. Use dimensions / metrics to carry extra fields into journeys and the models/reports.
dimensions:
- { name: geo.country, renameTo: country }
- { name: session_source.term, renameTo: utm_term, nullLabel: 'Direct' }
- { name: geo.region, renameTo: regions, pick: chain }
metrics:
- { name: ecommerce.tax_value, renameTo: tax_value }
| Field | Applies to | Description |
|---|---|---|
name (dimensions) | traffic rows | Source field path. On traffic rows the value is IFNULL(raw, nullLabel). |
nullLabel (dimensions) | traffic rows | Per-dimension null replacement. Default (not set); use Direct for source/channel dimensions. |
renameTo | both | Output column name (sanitized). |
pick (dimensions) | reports | Which value the six reports carry per journey: first (default, the journey's first traffic touch), last, min, max or chain (every distinct value in first-seen order, joined with ' > ' - high cardinality, like channel_chain). ga4_attribution_models always carries each touch's own value. |
name (metrics) | conversion rows | Metric field, populated on conversion rows only; traffic rows get a typed NULL. |
description | both | Optional BigQuery column description for the output column (for metrics it also describes the reports' total_<name> aggregate). Without it, the description is taken from the upstream GA4 column docs where available. |
Some fields only exist in one touchpoint mode - e.g. session_source.* and last_non_direct_traffic_source.* are session-mode fields. Choose dimensions that exist in your configured touchpoint.mode.
Where dimensions land
| Table | Value per row |
|---|---|
touchpoints, ga4_attribution_journeys (touches[]) | the touch's own value |
ga4_attribution_models | the credited touch's own value |
| the six reports | one value per journey, chosen by pick ((no touchpoint) for zero-touch journeys) |
One value per journey keeps every report additive: the dimension columns join the report's grain, and summing a report over them gives back the figures without dimensions. Every ga4 CUSTOM_CHANNEL_GROUPING is added the same way (always first). A dimension alias that collides with a report column (channel, path_length, lag_bucket, ...) or with a SQL alias the reports use fails compilation with a message naming the clash - pick another renameTo.
Journeys
journey:
closing_conversions:
- purchase
user_identifier: user_pseudo_id
channel_grouping: default_channel_group
inactivity_gap_days: null
pre_conversion_lookback_days: null
| Option | Type | Default | Description |
|---|---|---|---|
closing_conversions | list | [] | Conversion names that close a journey. Must exist in conversions[]. Empty = nothing closes and the models table is empty. Non-listed conversions become micro-conversions. |
user_identifier | string | user_pseudo_id | Identity key; non-user_pseudo_id values fall back to user_pseudo_id via COALESCE. |
channel_grouping | string | default_channel_group | Channel grouping the path reports build on: default_channel_group or a configured GA4 CUSTOM_CHANNEL_GROUPING name. |
inactivity_gap_days | number | null | null | Split a journey when the next touch is more than N days later. |
pre_conversion_lookback_days | number | null | null | A closing conversion only attributes touches within M days before it; older touches become a separate journey. |
See Journey boundaries for the full boundary semantics.
Attribution models
attribution_models:
- { name: first_touch, type: first_touch }
- { name: last_touch, type: last_touch }
- { name: linear, type: linear }
- { name: position_based, type: position_based, first_weight: 0.4, last_weight: 0.4 }
- { name: time_decay, type: time_decay, half_life_days: 7 }
# - { name: participation, type: participation } # full credit to every touch - overcounts (influence, not credit)
Defaults to the five standard fractional models. position_based takes first_weight / last_weight (default 0.4 / 0.4); time_decay takes half_life_days (default 7). The opt-in participation model gives full credit to every touch and overcounts by design - see Fractional credit vs influence. The models table only builds once journey.closing_conversions is set.