A Practical Guide to Full Attribution Tracking - From Click to Your CRM
Growth

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.

The B2B tracking and attribution problem

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.

How are we going to solve this? 

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.

Key concepts explained

  • UTM tracking → first & last touch source
  • Journey storage → path history via localStorage
  • DataLayer eventspage_view and lead_submit
  • CRM sync → hidden HubSpot fields auto-filled on page load

Summary on tracking implementation

Step 1: Add Base Tracking Script

This 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: 

  • create a new tag with custom HTML
  • the trigger needs to be all pages - initialization type

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: 

Screenshot 2025-11-05 at 5.18.18 PM.png

This will confirm that: 

journey tracking works

tag fires correctly on initialization

Step 2: Configure GA4

Next, create a GA4 Event tag called page_view and link it to your initialization script. Add parameters for page_location, page_referrer, and page_title so you can view clean data in GA4.

Create the Data Layer Variables

Go to Variables → New three times:

1. Variable Name: DLV – page.url

  • Variable Type: Data Layer Variable
  • Data Layer Variable Name: page.url
  • Leave default settings → Save

2. Variable Name: DLV – page.referrer

  • Data Layer Variable Name: page.referrer

3. Variable Name: DLV – page.title

  • Data Layer Variable Name: page.title

Add Event Parameters

parameter > value
  • page_location > {{DLV – page.url}}
  • page_referrer >  {{DLV – page.referrer}}
  • page_title > {{DLV – page.title}}

Add the Trigger

  • Trigger Type: Custom Event
  • Event Name: page_view
  • Fire on: All Custom Events named page_view

Save ✅

GA4 Event Tag

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)

  • Open your Google Tag
  • Scroll to Advanced Settings → Additional Tag Metadata.
    • Add a row:
      • Key: send_page_view
      • Value: false
  • Make sure its trigger is All Pages.
  • Save → Submit → Publish.

Step 3 — HubSpot Form + Journey Mapping

In this step we’ll:

  1. Update your Hubspot Form
  2. Automatically pass the following into HubSpot:
  • journey JSON (the pages they visited)
  • UTM values and gclid/li_fat_id/etc.
  • first touch & last touch sources
  • page count

This will allow you to see full user journeys right inside HubSpot contact records.

3.1. First - create the hidden fields in Hubspot

  • we need to first create custom properties: Properties → Create Property, make these custom Contact properties
Screenshot 2025-11-05 at 9.25.23 PM.png

3.2. Now add these properties to the form as hidden fields.

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.

Step 4: Ensure form submit works as well with GTM

Triggers → New

  • Name: CE – lead_submit
  • Type: Custom Event
  • Event name: lead_submit
  • This trigger fires on: All Custom Events
  • Save

Then ... we will create the event: Tags → New

  • Name: GA4 Event – lead_submit
  • Tag Type: Google Analytics: GA4 Event
  • Configuration: uses your existing Google tag (you’ll see the green check)
  • Event Name: lead_submit
  • Event Parameters:
    • page_location{{Page URL}}
    • page_path{{Page Path}}
  • Trigger: CE – lead_submit
  • Save

Now, 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.

Recap on the data flows

You’ve now got a two-path attribution system:

  1. Client-side (GTM + GA4): captures traffic, page activity, and form-submit events for analytics and advertising.
  2. Form-side (HubSpot front-end): injects the same attribution and journey data into hidden fields, so CRM records match GA4.

1️⃣ Visitor lands on the website

Script involved: GTM Custom HTML “Initialization / Journey Tracker”

What it does:

  • Parses the URL (utm_*, gclid, li_fat_id, wbraid, gbraid)
  • Derives first and last touch sources
  • Saves them to:
    • sessionStorage.first_src / last_src
    • sessionStorage.first_utm / last_utm
    • (and mirrored in localStorage for cross-tab persistence)
  • Builds/updates localStorage.journey_paths
    → array of {path, ts} objects (max 20, 7-day TTL)
  • Pushes a structured event to the dataLayer

Used by:

  • GA4 Page-view Tag (GA4 Event → page_view)
  • GTM Data-Layer Variables (DLV – page.url, DLV – page.referrer, etc.)

2️⃣ Visitor navigates other pages

  • Each page fires the same initialization script.
  • journey_paths grows with each visited path.
  • No new page_view event is sent manually (GA4 handles it).

3️⃣ Visitor opens the Contact page

Script involved: HubSpot Form Embed + Form-Fill Helper

What happens:

  • The helper reads sessionStorage/localStorage values:
    • first_src, last_src, first_utm, last_utm
    • journey_paths
    • gclid, li_fat_id, wbraid, gbraid
  • Fills the hidden inputs in your form

Used by:

  • HubSpot CRM (stored on contact record)

4️⃣ Visitor submits the form

Script involved: HubSpot form onFormSubmitted callback

What it does:

  • Pushes a new dataLayer event

Used by:

  • GTM trigger for the lead_submit or contact_us_submit GA4 Event Tag
  • GA4 receives event parameters such as:
    • contact_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.

Latest Articles

A Practical Guide to Full Attribution Tracking - From Click to Your CRM

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.

November 12, 2025
How to Build a Smarter Gmail Auto-Reply with Google Apps Script

How to Build a Smarter Gmail Auto-Reply with Google Apps Script

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.

October 2, 2025
Increase Your Revenue with Renewal Discounts

Increase Your Revenue with Renewal Discounts

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.

October 2, 2025
The Future of SEO: Optimizing Content for AI-Powered Search Engines

The Future of SEO: Optimizing Content for AI-Powered Search Engines

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.

March 20, 2025
How to Sync Multiple Google Calendars for Free Using Google Apps Script

How to Sync Multiple Google Calendars for Free Using Google Apps Script

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.

December 4, 2025