← All case studies

Investigation

The Consent Gap

GA4 was recording only a fraction of a Shopify Plus store's revenue. Both vendors' dashboards said everything was fine. I found the fault by instrumenting the handshake between them.

ClientEuropean outdoor retailer (anonymized)
StackShopify Plus, GA4, server-side GTM, Consent Mode
RegionEU
Symptom severityMost revenue unattributed
StatusResolved, verified live
01 The symptom

GA4 was recording a small fraction of the store's actual sales, on the order of one in ten, and nobody could say why. The rest of the revenue was real and happening, it just never showed up in analytics. What made it stranger was that Google Ads kept working fine the whole time, reporting conversions and optimizing campaigns normally. Only the analytics side was dark.

That split was the useful clue. One Google product was receiving the data and another was receiving almost none, so the problem wasn't that tags never fired or that everything fired. Something was firing selectively, and my job was to find the exact point where the two paths diverged.

02 What everyone assumed

Three suspects came up first, and each one was a reasonable place to start.

A broken GA4 tag. A gap that large looks like something simply isn't firing. But the tag was configured correctly and fired cleanly when I forced it in isolation, so it wasn't broken in the ordinary sense.

A misconfigured GA4 property. The next thought was that events were arriving and a filter was dropping them, or that they were landing somewhere I wasn't looking. That didn't hold either, because the collect endpoint never received the traffic in the first place. Nothing arrived to be misfiled.

The server-side pipeline dropping data. With server-side GTM in the mix, a parameter getting dropped downstream was plausible. But the loss was happening before the server ever saw a request. The GA4 tag never loaded in the browser at all.

What connected all three guesses is that each one assumed the fault lived inside a single system. It didn't live inside any of them.

03 The first wrong turn

The instrument was breaking, not the store.

Before I get to the real cause, there's a part I got wrong early, and it changed how I ran everything afterward.

My first round of testing ran through a VPN that had a built-in DNS tracker blocker I'd forgotten was switched on. It was quietly resolving googletagmanager.com to NXDOMAIN, which killed the entire analytics stack at the network layer before a single tag had a chance to load.

$ nslookup www.googletagmanager.com Server: 10.0.0.1 ** server can't find www.googletagmanager.com: NXDOMAIN

That meant every observation I'd made through that connection was worthless. The tag looked broken because my own test setup was breaking it. I discarded those conclusions and re-ran the whole investigation on a clean connection.

Lesson

Check that your test environment is clean before trusting anything it tells you. On this one I nearly chased a network problem on my own machine into the client's codebase, which would have cost a day and pointed the fix in the wrong direction.

04 The real investigation

The failure only existed in the space between them.

On a clean connection I started over. On the surface the consent banner (a CMP) worked. I clicked Accept, Consent Mode flipped to granted in the dataLayer, and the Ads pings fired. By every visible signal, consent was being honored.

But GA4's collect endpoint still received zero traffic and the GA4 tag still never loaded. Consent was reading as granted while GA4 behaved as though the visitor had never answered the banner at all. That gap between the two pointed at a handoff between systems rather than a problem with any one tag.

To see the handoff directly, I proxied Shopify's customerPrivacy.setTrackingConsent in the console before clicking Accept, so that anything the CMP told Shopify would be logged. Then I clicked.

// installed BEFORE clicking Accept const orig = Shopify.customerPrivacy.setTrackingConsent; Shopify.customerPrivacy.setTrackingConsent = (...a) => (console.log('consent ->', a), orig(...a)); // clicked Accept. console stayed empty.

Nothing logged. The CMP never called setTrackingConsent. It updated its own dataLayer state and fired the Ads pings, but it never passed the consent signal to Shopify. Shopify's Customer Privacy API therefore stayed at no_interaction, and Shopify's native Google channel, which waits on that signal before loading GA4, held GA4 back.

Both systems were behaving correctly by their own logic. The CMP treated its work as finished once it had updated the dataLayer, while Shopify was waiting on a function call that the CMP was never going to make.

Before and after: the consent handshake between the CMP and Shopify BEFORE: HANDSHAKE NEVER HAPPENS Visitor CMPAccept clicked Shopify Privacyno_interaction GA4never loads no setTrackingConsent AFTER: HANDSHAKE FIXED Visitor CMPAccept clicked Shopify Privacyconsent granted GA4 loadsrevenue visible setTrackingConsent fires

The whole failure lived in one link: the call from the CMP to Shopify that never happened.

05 Going deeper

Knowing the handshake was missing wasn't enough on its own. I wanted to understand why it was missing, so that the fix addressed the cause rather than papering over the symptom. I fetched the CMP's runtime chunks and read through them, and found two separate bugs that compounded each other.

The first was a name mismatch. The banner dispatched an event called cf_consent_updated, while the bridge script that was supposed to relay consent to Shopify was listening for cf_consent. The two names are similar enough that the mistake is easy to miss in review, but different enough that the listener never once fired.

The second was that even when the names lined up, the bridge crashed on its first line. It referenced window.Shopify at a point in the theme where that object had not yet been defined, so it threw an error before it could do anything useful.

banner dispatches: cf_consent_updated bridge listens for: cf_consent Uncaught TypeError: Cannot read properties of undefined (reading 'customerPrivacy')

There was a useful control sitting right next to the problem. The retailer's US store ran the same bridge script, placed correctly in its theme, and there it worked. The script itself was sound. What differed was where it had been placed in the EU theme, which told me the fix was about placement and ordering rather than logic.

06 The fix and the proof

The fix itself was deliberately small: a two-line placement change so that window.Shopify existed before the bridge ran, plus a hardened version of the bridge that listened for the correct event and failed safely instead of throwing.

Even a small change on a live store needs to be proven before it ships. Because this was an EU consent path, I verified it from where the affected visitors actually are, running a live proof of concept from a German VPN exit and testing both the fresh-consent and returning-visitor paths.

[de-fra] proof of concept Accept clicked setTrackingConsent t+0.8ms { analytics: granted } // fresh consent setTrackingConsent t+0.9ms { analytics: granted } // returning visitor

setTrackingConsent fired within a millisecond of Accept on both paths, confirmed from an EU exit before the change went out.

07 The result

Once the change was live, I compared matched weekdays 48 hours later rather than raw day-over-day numbers, so the comparison actually meant something.

-8%
Traffic, matched weekdays
+152%
GA4 tracked revenue
~9% → 85-95%
Capture rate (projected)
08 What this taught me

The break is in the connection nobody is watching.

Traffic was actually down 8% on the matched comparison, and GA4 tracked revenue was up 152%. The store did not suddenly sell more product over those two days. The increase was the gap closing: revenue that had been happening all along, now recorded where it should have been. As Consent Mode modeling calibrated, the capture rate moved from roughly 9% toward a projected 85 to 95%.

The failure lived between two systems rather than inside either one. The CMP was doing its job, Shopify was doing its job, and both of their dashboards read as healthy because each was reporting accurately on itself. The fault was only visible in the handoff between them, and the only way to find it was to instrument that handshake directly and watch it fail to happen.

← All case studies