A Practical Guide to Full Attribution Tracking - From Click to Your CRM
Discover how to build a complete attribution and user journey tracking system using GA4, Google Tag Manager, and HubSpot without expensive SaaS tools.
Discover how to build a complete attribution and user journey tracking system using GA4, Google Tag Manager, and HubSpot without expensive SaaS tools.
When it comes to B2B (and not only), one thing that comes up over and over again, is the challenge to have full visibility on your traffic and the conversions (leads in your CRM). GA4 tracks your ads, HubSpot captures your leads, but there’s a gap in between. I wanted a way to see exactly how a visitor arrived, which pages they visited, and which campaign converted them, all without an expensive data warehouse.
The solution combines GA4, GTM, HubSpot CRM properties, and a HubSpot form (which can be swapped for something else). All these, stitched together with a few lines of JavaScript, localStorage, and dataLayer events, every page view, UTM parameter, and form submission gets tracked and connected to the contact record.
localStoragepage_view and lead_submitCRM sync → hidden HubSpot fields auto-filled on page loadThis script initializes your dataLayer, captures UTMs, and saves first and last touch data across sessions.
We will have to make sure GA4 is setup and initialized on your website. This is the easy part, and if you don't know how to add the code to your site, please visit this documentation.
Then:
This is the custom script you will add to the tag:
<script>
// --- Consent defaults (keep) ---
window.dataLayer = window.dataLayer || [];
dataLayer.push({
event: 'default_consent',
ad_storage: 'denied',
analytics_storage: 'granted',
ad_user_data: 'denied',
ad_personalization: 'denied'
});
(function () {
// ---- helpers (ES5-safe)
function parseQuery(qs) {
var o = {};
if (!qs) return o;
if (qs.charAt(0) === '?') qs = qs.slice(1);
var a = qs.split('&');
for (var i = 0; i < a.length; i++) {
var kv = a[i].split('=');
if (!kv[0]) continue;
o[decodeURIComponent(kv[0])] = kv[1] ? decodeURIComponent(kv[1].replace(/\+/g, ' ')) : '';
}
return o;
}
function getHost(u) {
var a = document.createElement('a');
a.href = u;
return a.hostname || '';
}
function hasAnyUtm(q) {
return !!(q.utm_source || q.utm_medium || q.utm_campaign || q.gclid || q.li_fat_id || q.wbraid || q.gbraid);
}
function sameHost(h) {
try { return !!h && h.replace(/^www\./,'') === location.hostname.replace(/^www\./,''); } catch(e){ return false; }
}
// Boot from localStorage into sessionStorage so first touch persists across tabs
try {
var lsFirst = localStorage.getItem('first_src');
if (lsFirst && !sessionStorage.getItem('first_src')) {
sessionStorage.setItem('first_src', lsFirst);
}
var lsFirstUtm = localStorage.getItem('first_utm');
if (lsFirstUtm && !sessionStorage.getItem('first_utm')) {
sessionStorage.setItem('first_utm', lsFirstUtm);
}
} catch(e){}
// ---- query + ref
var qp = parseQuery(location.search);
var hasUTM = hasAnyUtm(qp);
var refHost = '';
try { refHost = document.referrer ? getHost(document.referrer) : ''; } catch (e) {}
// ---- UTM/source persistence per your rules
try {
if (hasUTM && qp.utm_source) {
// normalize
var src = (qp.utm_source + '').toLowerCase().trim();
// snapshot (handy elsewhere)
var utmSnap = {
utm_source: src,
utm_medium: qp.utm_medium || '',
utm_campaign:qp.utm_campaign|| '',
utm_term: qp.utm_term || '',
utm_content: qp.utm_content || '',
gclid: qp.gclid || '',
li_fat_id: qp.li_fat_id || '',
wbraid: qp.wbraid || '',
gbraid: qp.gbraid || ''
};
// ALWAYS update last_* when a new utm_source is present
sessionStorage.setItem('last_src', src);
sessionStorage.setItem('last_utm', JSON.stringify(utmSnap));
// Set first_* ONLY once
if (!sessionStorage.getItem('first_src')) {
sessionStorage.setItem('first_src', src);
try { localStorage.setItem('first_src', src); } catch(e){}
}
if (!sessionStorage.getItem('first_utm')) {
var snap = JSON.stringify(utmSnap);
sessionStorage.setItem('first_utm', snap);
try { localStorage.setItem('first_utm', snap); } catch(e){}
}
} else {
// No UTMs on this page: only set/refresh a sensible last_src fallback
// Use external referrer host if present; ignore internal referrers.
var fallback = (!sameHost(refHost) && refHost) ? refHost : 'direct';
if (!sessionStorage.getItem('last_src')) {
// sessionStorage.setItem('last_src', fallback);
} else {
// optional: update last_src on every page WITHOUT UTMs
// comment out next line if you want last_src to freeze until a new UTM appears
// sessionStorage.setItem('last_src', fallback);
}
// first_src stays untouched here (never overwritten)
}
} catch (e) {}
// ---- dataLayer page_view (keep)
dataLayer.push({
event: 'page_view',
page: {
url: location.href,
path: location.pathname,
title: document.title,
referrer: document.referrer
},
user: {
hubspot_utk: (document.cookie.match(/hubspotutk=([^;]+)/) || [])[1] || null
},
traffic: {
utm_source: qp.utm_source || '',
utm_medium: qp.utm_medium || '',
utm_campaign: qp.utm_campaign || '',
utm_term: qp.utm_term || '',
utm_content: qp.utm_content || '',
gclid: qp.gclid || '',
wbraid: qp.wbraid || '',
gbraid: qp.gbraid || '',
li_fat_id: qp.li_fat_id || ''
}
});
// ---- journey store (path+ts only, dedupe, skip /contact, 7-day TTL, max 20)
try {
var key = 'journey_paths', max = 20, ttlDays = 7;
var raw = null, arr = [];
try { raw = localStorage.getItem(key); } catch (e) {}
try { arr = raw ? JSON.parse(raw) : []; } catch (e) { arr = []; }
var now = Date.now(), ttlMs = ttlDays*24*60*60*1000, pruned = [];
for (var i=0; i<arr.length; i++){
if (now - (arr[i].ts || 0) <= ttlMs) pruned.push({ path: arr[i].path, ts: arr[i].ts });
}
arr = pruned;
var path = location.pathname;
if (path !== '/contact') {
var lastPath = arr.length ? arr[arr.length-1].path : null;
if (lastPath !== path) {
arr.push({ path: path, ts: now }); // no title
}
}
while (arr.length > max) arr.shift();
try { localStorage.setItem(key, JSON.stringify(arr)); } catch (e) {}
} catch (e) {}
})();
</script>Once you added the JS to your tag, submit it and publish.
To validate and ensure that's working properly, in Google Chrome you can click on View > Developer Tools > Console. You sould see something like this when you run the command:

This will confirm that:
✅ journey tracking works
✅ tag fires correctly on initialization
Next, create a GA4 Event tag calledpage_viewand link it to your initialization script. Add parameters forpage_location,page_referrer, andpage_titleso you can view clean data in GA4.
Go to Variables → New three times:
1. Variable Name: DLV – page.url
page.url2. Variable Name: DLV – page.referrer
page.referrer3. Variable Name: DLV – page.title
page.titleparameter > value
page_location > {{DLV – page.url}}page_referrer > {{DLV – page.referrer}}page_title > {{DLV – page.title}}page_viewSave ✅
Now we have to make a change to Google Analytics > Google Tag
How to adjust your existing Google Tag (so it behaves like our GA4 config)
send_page_viewfalseIn this step we’ll:
This will allow you to see full user journeys right inside HubSpot contact records.

Once you've added the fields as hidden fields to your form, publish your form.
Then, let's add a custom script for your form instead of the embed code you get from Hubspot:
<!-- HubSpot Forms v2 embed -->
<script src="https://js.hsforms.net/forms/embed/v2.js" charset="utf-8"></script>
<!-- where the form will render -->
<div id="hubspot-form"></div>
<script>
(function(){
var PORTAL = "...<Your portal ID here>...";
var FORM = "...<Your form ID here>...";
var REGION = "...<Your Region here>...";
var HS = {
journey: 'journey_json',
pages: 'pages_viewed_count',
firstSrc: 'first_touch_source',
lastSrc: 'last_touch_source',
gclid: 'gclid',
li: 'li_fat_id',
wbraid: 'wbraid',
gbraid: 'gbraid'
};
var DEBUG = false;
function log(){ if (DEBUG && window.console) try{ console.log.apply(console, arguments);}catch(e){} }
function qp(k){ try{ return new URLSearchParams(location.search).get(k)||'';}catch(e){return'';} }
function getSS(k){ try{ return sessionStorage.getItem(k);}catch(e){return null;} }
function getJSON(k){ try{ var v=getSS(k); return v?JSON.parse(v):null;}catch(e){return null;} }
function getJourney(){ try{ return localStorage.getItem('journey_paths')||'[]';}catch(e){return'[]';} }
function journeyCount(j){ try{ return (JSON.parse(j||'[]')||[]).length||0;}catch(e){return 0;} }
function refHost(){ try{ var a=document.createElement('a'); a.href=document.referrer||''; return a.hostname||'';}catch(e){return'';} }
function deriveSource(which){
var map = (which==='first')?(getJSON('first_utm')||{}):(getJSON('last_utm')||{});
if (map && map.utm_source) return map.utm_source;
if (map && map.gclid) return 'google_ads';
if (map && map.li_fat_id) return 'linkedin';
if (map && (map.wbraid||map.gbraid)) return 'google_crossnet';
return refHost()||'direct';
}
function setField(root, name, value){
var el=root.querySelector('input[name="'+name+'"]')||root.querySelector('textarea[name="'+name+'"]');
if(!el)return false;
el.value=value;
try{el.dispatchEvent(new Event('input',{bubbles:true}));}catch(e){}
try{el.dispatchEvent(new Event('change',{bubbles:true}));}catch(e){}
return true;
}
function fill(root){
var journeyRaw=getJourney();
var ok=true;
ok=setField(root,HS.journey,journeyRaw)&&ok;
ok=setField(root,HS.pages,journeyCount(journeyRaw))&&ok;
ok=setField(root,HS.firstSrc,(getSS('first_src')||deriveSource('first')))&&ok;
var lastMap=getJSON('last_utm')||{};
var lastSrc=qp('utm_source')||lastMap.utm_source||deriveSource('last');
ok=setField(root,HS.lastSrc,lastSrc)&&ok;
ok=setField(root,HS.gclid,(qp('gclid')||lastMap.gclid||''))&&ok;
ok=setField(root,HS.li,(qp('li_fat_id')||lastMap.li_fat_id||''))&&ok;
ok=setField(root,HS.wbraid,(qp('wbraid')||lastMap.wbraid||''))&&ok;
ok=setField(root,HS.gbraid,(qp('gbraid')||lastMap.gbraid||''))&&ok;
return ok;
}
hbspt.forms.create({
region:REGION,
portalId:PORTAL,
formId:FORM,
target:"#hubspot-form",
onFormReady:function($form){
var root=($form&&$form.length?$form[0]:$form);
if(!root)return;
var tries=0;
(function retry(){tries++;var ok=fill(root);if(!ok&&tries<6)setTimeout(retry,200);})();
try{var mo=new MutationObserver(function(){fill(root);});mo.observe(root,{childList:true,subtree:true});}catch(e){}
},
onFormSubmitted: function($form){
try{
var root = ($form && $form.length ? $form[0] : $form);
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
event: 'lead_submit',
form: {
portal_id: '244295857',
form_id: '4f97365b-fb42-4c2e-9bbf-61af24bced69',
page_url: location.href,
page_path: location.pathname,
first_source: sessionStorage.getItem('first_src') || '',
last_source: sessionStorage.getItem('last_src') || ''
}
});
}catch(e){}
}
});
})();
</script>Ensure that you use the Legacy Hubspot form, as that’s the one allowing you to customize the form and it will work with this.
That's most of the heavy stuff. The only piece we will have to setup is to ensure that your form submit works as well with GTM.
Triggers → New
CE – lead_submitlead_submitThen ... we will create the event: Tags → New
GA4 Event – lead_submitlead_submitpage_location → {{Page URL}}page_path → {{Page Path}}CE – lead_submitNow, an important part, is to mark this as a GA4 conversion: Within a few hours (up to 24 h), you’ll see the event populate under:GA4 → Configure → Events → lead_submit → Then check the Star mark - make it a key event.
One important part that's covered with this code is the specific logic between first_source and last_source:
we ensured that the first_source will not change / not being overwritten, and we always overwrite the last_source.
You’ve now got a two-path attribution system:
Script involved: GTM Custom HTML “Initialization / Journey Tracker”
What it does:
utm_*, gclid, li_fat_id, wbraid, gbraid)sessionStorage.first_src / last_srcsessionStorage.first_utm / last_utmlocalStorage for cross-tab persistence)localStorage.journey_paths{path, ts} objects (max 20, 7-day TTL)Used by:
GA4 Event → page_view)DLV – page.url, DLV – page.referrer, etc.)journey_paths grows with each visited path.page_view event is sent manually (GA4 handles it).Script involved: HubSpot Form Embed + Form-Fill Helper
What happens:
first_src, last_src, first_utm, last_utmjourney_pathsgclid, li_fat_id, wbraid, gbraidUsed by:
Script involved: HubSpot form onFormSubmitted callback
What it does:
Used by:
lead_submit or contact_us_submit GA4 Event Tagcontact_us_url = {{Page URL}}first_source = {{JS – First Source}}last_source = {{JS – Last Source}}If you’re trying to unify your analytics stack without heavy infrastructure, this setup gets you 80% there. If you want help deploying it or customizing the event flow, reach out to us.
Discover how to build a complete attribution and user journey tracking system using GA4, Google Tag Manager, and HubSpot without expensive SaaS tools.

If you need to create an intelligent auto-reply with specific conditions—like restricting replies to certain domains, or controlling when the auto-responder fires—this script will do the job perfectly.

Keeping customers with recurring subscriptions or product renewals is quite critical for sustainable revenue growth. One tactic is to offer targeted pricing discounts that can nudge customers into renewing their products—without sacrificing your overall revenue.

AI-driven search engines like ChatGPT and Google Gemini are changing how content ranks in 2025. To optimize for AI-powered search, content must be conversational, structured in a Q&A format, and utilize long-tail keywords. Implementing FAQ schema and structured data improves discoverability, while authority and freshness enhance ranking. As AI search evolves, businesses must adapt their content strategies to stay competitive.

Managing multiple Google Calendars can be a hassle, especially when juggling clients, businesses, or teams that need visibility into your availability. While paid tools exist, there’s a free and easy way to sync your calendars automatically using Google Apps Script. In this guide, I'll walk you through setting up a two-way calendar sync that updates events in real time—without the need for third-party software.