← All projects
Data

Attribution

We were spending real money on Google Ads and couldn't tell which keywords turned into signups. Paid signups were showing up in HubSpot as direct traffic with no keyword, and Google Ads was reporting a fraction of the conversions it should have. So I went through every system in the chain to find out where the data was getting lost.

Spring–Summer 2026·Google Tag Manager, Segment, HubSpot, Google Ads, PostHog
01Where I started

Map the whole chain before touching anything

Tracking at a startup piles up over the years, so before changing anything I went through every piece: Tag Manager on the website, Segment and all its destinations, HubSpot, Google Ads, GA4 and PostHog. Most of what looked messy was old stuff that was already turned off. The handful of things that were actually broken were all in the same spot, where someone goes from our website to signing up in the app.

Tracking plan
Conversion tracking architecture
Part of the tracking plan I wrote for the team: what already worked, what was new, what could wait.
02Tag Manager

Two tags fighting over one cookie

Google Ads was showing about one signup in two weeks when there should have been more than a dozen. Two tags in Tag Manager were both writing the click ID cookie in different formats, and the app only understood one of them, so whichever tag fired last decided whether the conversion counted. The fix was a few lines telling our custom tag to leave the cookie alone if Google's own tag had already set it.

Tag Manager · GCLID Capture - All Pages
var gclid = new URLSearchParams(location.search).get('gclid');
if (!gclid) return;

// Don't overwrite _gcl_aw if the Conversion Linker already wrote it.
// It writes GCL.timestamp.gclid, which the app's cookie reader expects.
// Our raw format was breaking it.
if (document.cookie.indexOf('_gcl_aw=') > -1) {
  localStorage.setItem('gclid', gclid);  // keep a backup copy
  return;
}
The real fix, trimmed.
03Cookies

The keyword was dying between www and the app

With the click ID fixed, the keyword and campaign were still blank. I read the app's signup code to see exactly which cookies it reads, then loaded our site in a test browser and looked at the cookies directly. They were being saved to www only, so the app on its own subdomain couldn't see them. One line in the site's footer fixed it, and I checked it the same way afterward.

Follow a click from the ad to the CRM
Ad click?utm_term=invoice+ocr
www.sensible.sowrites tracking cookies
utm_sourceutm_campaignutm_termgclid
saved to www only
app.sensible.sosignup form reads them
CRM record
Click ID
…
Source
…
Keyword
…
- new UtmCookie({ additionalParams: ['gclid'] })+ new UtmCookie({ domain: '.sensible.so', additionalParams: ['gclid'] })
A simplified version of what the browser test showed.
04Segment

Two destinations writing the same fields

Then some records looked right and quietly changed later. I went through every Segment destination's field mappings one by one, not just the list of names, and found two different HubSpot destinations writing the same first-touch fields from different places. Whichever fired last won. I fixed the mappings and picked one as the source of truth.

Segment → HubSpot, both writing first_touch_*
// 1. HubSpot Cloud Mode (server), on "Signed Up - Server"
"first_touch_keyword":  "$.properties.utm_term",
"first_touch_campaign": "$.properties.utm_campaign",

// 2. HubSpot Web (browser), on identify
"first_touch_keyword":  "$.traits.utm_cookies.IUTERM",
"first_touch_campaign": "$.traits.utm_cookies.UCAMPAIGN",

// same contact, same fields, last writer wins
05Google Ads

Send Google a better signal

Once records were clean, I wanted Google's bidding to learn from more than a raw form fill. A daily script pulls new HubSpot contacts that came from an ad click and sends each one back to Google Ads as a conversion, worth more when it's a business email than a Gmail address, so the bidding learns which clicks turn into actual companies.

weighted_lead_upload.py
"""Upload domain-weighted lead conversions to Google Ads.

Reads new HubSpot contacts that carry a Google click ID,
classifies the email domain as free-provider vs business,
and uploads a click conversion to "HubSpot Lead - Domain Weighted".
"""
VALUE_FREE = 10.0       # gmail.com, outlook.com, ...
VALUE_BUSINESS = 300.0  # a company domain, close to an MQL
06Checking it

Check it against what people do in the app

PostHog is where the product events live, so that's how I checked all of this against reality: real signups, guest sessions from the widget on our homepage, and a lot of test signups from our own deploys that had to be filtered out before any of the numbers meant anything.

PostHog · HogQL
SELECT toStartOfWeek(timestamp) AS week, count() AS signups
FROM events
WHERE event = 'Account Created'
  AND NOT person.properties.email ILIKE '%mailosaur%'  -- deploy smoke tests
GROUP BY week
ORDER BY week DESC
Where it is now

Signups land in HubSpot with their source and keyword, and Google Ads gets a conversion signal that can tell a business signup from a Gmail one.