Skip to main content

Custom lineage

💎Premium Feature

This feature is exclusive for Premium users.

The attribution module has two interception points, the same idea as the GA4 module's custom lineage: a view of yours sits between a core table and its consumers, and everything downstream reads your version.

active touchpoints table (session or source_change)
|
+---> int_ga4_attribution_touchpoints_custom ---> ga4_attribution_journeys
|
+---> ga4_attribution_journeys_custom ---> ga4_attribution_models
+---> the six report tables
Interception pointSits betweenDatasetUse for
touchpoints_customthe active touchpoints table and ga4_attribution_journeystransformationsUNION ALL offline / CRM touches; drop touches (e.g. Direct) before journeys are built
journeys_customga4_attribution_journeys and the models + six reportsoutputsfilter whole journeys (internal users, no paid touch); override journey columns (margin as closing_revenue)

Touch-level edits belong in the first interception point: journey boundaries, lengths and chains are derived from the touches before the second interception point runs. A journey filtered at the second interception point also loses its attribution credit, because ga4_attribution_models reads the same view.

Configuration​

In includes/custom/modules/ga4_attribution/config.yaml:

custom_lineage:
touchpoints_custom: false # false | "view"
journeys_custom: false # false | "view"

Only "view" exists. The rebuild layer (journeys, models, reports) reads its whole source on every rebuild, so a materialized copy would add storage and checkpoint drift without saving a scan. Window functions (ROW_NUMBER, LAG, ...) are fine inside these views. Changes take effect on the next rebuild; no reprocess is needed.

Template files​

The templates live in definitions/custom/modules/ga4_attribution/ and ship with new installs:

  • int_ga4_attribution_touchpoints_custom.sqlx
  • ga4_attribution_journeys_custom.sqlx
Existing installs: create the files yourself

The installer's update flow never touches definitions/custom/, so an install created before v2.2.13 does not have these two files. Enabling an interception point without its file fails compilation with a missing-table error. Create the files with the exact names above, in that folder, with the contents below, then commit and recompile the release.

definitions/custom/modules/ga4_attribution/int_ga4_attribution_touchpoints_custom.sqlx
config {
type: "view",
schema: dataform.projectConfig.vars.TRANSFORMATIONS_DATASET,
tags: ["module_ga4_attribution", "module_ga4_attribution_touchpoints"],
description: "Custom lineage: intercept the active touchpoints table before it flows into ga4_attribution_journeys. Edit to add offline touches (UNION ALL) or to filter touches.",
disabled: !require("includes/core/modules/ga4_attribution/helpers").helpers.getConfig().enabled
|| !(require("includes/core/modules/ga4_attribution/helpers").helpers.getConfig().custom_lineage || {}).touchpoints_custom // off when custom_lineage.touchpoints_custom is false
}

js {
const { helpers } = require("includes/core/modules/ga4_attribution/helpers");
const config = helpers.getConfig();
/* The active touchpoints table (session or source_change mode). Never ref this view from itself. */
const activeTouchpointsName = helpers.activeTouchpointsName(config);
}

/*
Pass-through by default. Enable with custom_lineage.touchpoints_custom: "view".
View only - journeys reads the whole table on every rebuild; window functions are fine here.
Keep the touchpoints column order and types: touch_id INT64, touch_date, touch_timestamp, user_id,
user_pseudo_id (required), session_id INT64, source, medium, campaign, default_channel_group,
device_category, conversion_name, revenue NUMERIC, conversion_count, is_conversion_touch,
<dimensions>, <metrics>, is_final, <CUSTOM_CHANNEL_GROUPING columns>.

A) offline touches:
SELECT * FROM source_touchpoints
UNION ALL
SELECT FARM_FINGERPRINT('offline_' || CAST(o.offline_id AS STRING)) AS touch_id, o.touch_date, o.touch_timestamp,
o.user_id, o.user_pseudo_id, FARM_FINGERPRINT('offline_' || CAST(o.offline_id AS STRING)) AS session_id,
'crm' AS source, 'offline' AS medium, o.campaign, 'Offline' AS default_channel_group, '(not set)' AS device_category,
CAST(NULL AS STRING) AS conversion_name, CAST(0 AS NUMERIC) AS revenue, 0 AS conversion_count, FALSE AS is_conversion_touch,
-- one column per dimension / metric / grouping
TRUE AS is_final
FROM `my-project.my_dataset.offline_touches` AS o WHERE o.user_pseudo_id IS NOT NULL
B) drop Direct traffic touches:
SELECT * FROM source_touchpoints WHERE NOT (default_channel_group = 'Direct' AND NOT is_conversion_touch)
*/
WITH source_touchpoints AS (
SELECT *
FROM ${ref({"database": dataform.projectConfig.defaultProject, "schema": dataform.projectConfig.vars.TRANSFORMATIONS_DATASET, "name": activeTouchpointsName})}
)

SELECT *
FROM source_touchpoints
definitions/custom/modules/ga4_attribution/ga4_attribution_journeys_custom.sqlx
config {
type: "view",
schema: dataform.projectConfig.vars.OUTPUTS_DATASET,
tags: ["module_ga4_attribution", "module_ga4_attribution_journeys"],
description: "Custom lineage: intercept ga4_attribution_journeys before it flows into ga4_attribution_models and the six reports. Edit to filter journeys or override journey-level columns.",
disabled: !require("includes/core/modules/ga4_attribution/helpers").helpers.getConfig().enabled
|| !(require("includes/core/modules/ga4_attribution/helpers").helpers.getConfig().custom_lineage || {}).journeys_custom // off when custom_lineage.journeys_custom is false
}

js {
const { helpers } = require("includes/core/modules/ga4_attribution/helpers");
}

/*
Pass-through by default. Enable with custom_lineage.journeys_custom: "view".
Feeds models AND the six reports (a filtered journey loses its credit). Keep every journeys
column, name and type - add, filter or REPLACE only. Touch-level edits belong in
int_ga4_attribution_touchpoints_custom (boundaries and chains are derived before this view).

A) SELECT * FROM source_journeys WHERE EXISTS (SELECT 1 FROM UNNEST(touches) AS t WHERE t.medium IN ('cpc', 'paid_social'))
B) SELECT * REPLACE(closing_revenue * 0.35 AS closing_revenue) FROM source_journeys
C) SELECT j.* FROM source_journeys AS j
LEFT JOIN `my-project.my_dataset.internal_users` AS u ON u.user_pseudo_id = j.user_identifier
WHERE u.user_pseudo_id IS NULL
*/
WITH source_journeys AS (
SELECT *
FROM ${ref({"database": dataform.projectConfig.defaultProject, "schema": dataform.projectConfig.vars.OUTPUTS_DATASET, "name": "ga4_attribution_journeys"})}
)

SELECT *
FROM source_journeys

Examples​

Both templates are pass-throughs by default. Edit the final SELECT only; keep the config and js blocks.

Offline touches (touchpoints_custom). The added rows must follow the touchpoints column contract in the template header - same names and types, including one column per configured dimension, metric and custom channel grouping. touch_id and session_id are INT64 hashes; user_pseudo_id is required, as journeys are keyed on it.

UNION ALL BY NAME

UNION ALL matches columns by position, so the order in the template header matters. BigQuery's UNION ALL BY NAME matches them by name instead - use it to make the offline SELECT order-independent. Every column must still be present with the right type; only the order becomes free.

SELECT * FROM source_touchpoints
UNION ALL BY NAME
SELECT
FARM_FINGERPRINT('offline_' || CAST(o.offline_id AS STRING)) AS touch_id,
o.touch_date, o.touch_timestamp, o.user_id, o.user_pseudo_id,
FARM_FINGERPRINT('offline_' || CAST(o.offline_id AS STRING)) AS session_id,
'crm' AS source, 'offline' AS medium, o.campaign, 'Offline' AS default_channel_group,
'(not set)' AS device_category,
CAST(NULL AS STRING) AS conversion_name, CAST(0 AS NUMERIC) AS revenue, 0 AS conversion_count,
FALSE AS is_conversion_touch,
-- one column per configured dimension, metric and channel grouping
TRUE AS is_final
FROM `my-project.my_dataset.offline_touches` AS o
WHERE o.user_pseudo_id IS NOT NULL

Drop Direct traffic touches (touchpoints_custom) - conversion touches must stay:

SELECT * FROM source_touchpoints
WHERE NOT (default_channel_group = 'Direct' AND NOT is_conversion_touch)

Only journeys with a paid touch (journeys_custom):

SELECT * FROM source_journeys
WHERE EXISTS (SELECT 1 FROM UNNEST(touches) AS t WHERE t.medium IN ('cpc', 'paid_social'))

Margin instead of revenue (journeys_custom) - models and reports follow:

SELECT * REPLACE(closing_revenue * 0.35 AS closing_revenue) FROM source_journeys

Exclude internal users (journeys_custom):

SELECT j.*
FROM source_journeys AS j
LEFT JOIN `my-project.my_dataset.internal_users` AS u ON u.user_pseudo_id = j.user_identifier
WHERE u.user_pseudo_id IS NULL

Limitations​

  • Keep every column of the source table with its name and type: add, filter or REPLACE only. The journeys view feeds seven tables, so a dropped column breaks all of them at the next rebuild.
  • The pass-through template still creates a view when its key is "view". Enable an interception point only once you have added logic to it.
  • Files in definitions/custom/ are preserved by installer updates but never created by them (see the warning above).