DEV Community

Cover image for Every Check Said Our GA4 Analytics Worked. It Had Never Recorded a Visit
Talha Anwar
Talha Anwar

Posted on

Every Check Said Our GA4 Analytics Worked. It Had Never Recorded a Visit

Summer Bug Smash: Smash Stories 🐛🛹

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.

Adding analytics to a website is the most boring task in web development. Google hands you eight lines, you paste them above </head>, and traffic shows up in the dashboard about a minute later.

On a real app you tidy it up a little. The measurement ID moves into an environment variable, the snippet becomes a module, and you add a cookie banner with Consent Mode so nothing is tracked before a visitor agrees. Still a small job, and it still looks finished when the banner renders.

Here's the part nobody warns you about: every layer you just added can fail without saying a word. A build tool can drop the env var. A queue can accept commands it will never run. A CI secret can point at a property nobody is watching. None of them throws. None of them logs. The site loads fine, the banner works, and the dashboard stays at zero.

So the only check that means anything is the last one — did a hit actually arrive? This is the story of the three silent failures I had to clear before the answer was yes, and the second one nearly had me file a bug report against Google.

The setup

I work on AcruxCore, an LLM ops platform. Two sites: the marketing site and dashboard (apps/web, React + Vite, deployed in Docker), and the docs site (apps/docs, Docusaurus, deployed to Cloudflare Pages).

I shipped Google Analytics 4 to both in one PR. Consent Mode v2 with analytics_storage denied by default, a cookie banner, consent stored as a first-party cookie shared across subdomains.

I tested it properly before merging:

  • Playwright against a real dev server — accept, decline, reload, reopen from the footer
  • built the bundle with the env var set and grepped the output for the measurement ID
  • built it again with the var unset and confirmed the tag never loads

All green. I merged it, rebuilt the production image, and opened Google's tag inspector on the live site.

Tag not detected.

Bug 1 — the tag was never in the bundle

The cookie banner rendered on production, so the new frontend had definitely deployed. That's what made this confusing: the same PR shipped both, and only half of it arrived.

I SSH'd into the VPS and worked backward from the artifact rather than forward from the config.

# search the actually-served bundle for Google's script host
docker exec acruxcore-web grep -rc googletagmanager /usr/share/nginx/html/assets/
# → 0
Enter fullscreen mode Exit fullscreen mode

Zero. Not a wrong ID, not a consent problem — the tag was never compiled in. A rebuild with docker compose build web still gave zero, and a full docker build --no-cache reproduced the same empty result.

At that point I'd checked the Docker ARG/ENV lines, the args: block in docker-compose.yml, and the define in vite.config.ts. All three were correct. So I stopped reading config and ran the build one layer down, inside the same built image:

# bypass the orchestrator, call the bundler directly
GA4_MEASUREMENT_ID=G-XXXXXXXXXX npx vite build
# → googletagmanager IS in the output
Enter fullscreen mode Exit fullscreen mode

vite build produced the tag. turbo run build did not. Same image, same env, same everything else. The bug wasn't in Docker, Compose or Vite — it was in the layer between them.

What Turborepo was doing

Our Dockerfile builds through the monorepo task runner:

RUN npx turbo run build --filter=@acruxcore/web
Enter fullscreen mode Exit fullscreen mode

Turborepo's default envMode is strict. Strict means a task's process starts with a filtered environment. It sees only the variables you declared in that task's env config, plus any auto-inferred from a recognized framework's naming convention. For Vite, that's anything prefixed VITE_.

Our variable is called GA4_MEASUREMENT_ID. It is deliberately not VITE_-prefixed, because vite.config.ts remaps it at build time:

define: {
  'import.meta.env.VITE_GA4_MEASUREMENT_ID':
    JSON.stringify(process.env.GA4_MEASUREMENT_ID ?? ''),
},
Enter fullscreen mode Exit fullscreen mode

That ?? '' is the whole tragedy in two characters. Turbo stripped GA4_MEASUREMENT_ID before Vite's config file ever ran, so process.env.GA4_MEASUREMENT_ID was undefined, the fallback kicked in, and the bundle compiled a perfectly valid empty string. Analytics reads the ID, finds '', and correctly decides not to load. No error, because nothing went wrong — every layer did exactly what it was told.

Docker and Compose passing the variable in correctly made no difference. Turbo removed it after they handed it over.

npx turbo run build --dry=json --filter=@acruxcore/web \
  | jq '.tasks[0].envMode, .tasks[0].environmentVariables.specified.env'
# → "strict"
# → []      ← nothing declared, so nothing gets through
Enter fullscreen mode Exit fullscreen mode

The fix is one line in turbo.json:

 "build": {
   "dependsOn": ["^build"],
   "outputs": ["dist/**", "build/**", ".docusaurus/**"],
+  "env": ["SENTRY_WEB_DSN", "GA4_MEASUREMENT_ID"]
 }
Enter fullscreen mode Exit fullscreen mode

The part that actually scared me

GA4_MEASUREMENT_ID wasn't alone in that env array. SENTRY_WEB_DSN was there too, and I only added it because it has the same shape — a non-VITE_ name remapped in the same define block.

Then I reverted the fix and re-ran the build with SENTRY_WEB_DSN set, the same way I'd reproduced the GA4 failure. Identical result: stripped, empty string, no SDK.

Sentry had been wired into the web app six days earlier. In that window, the browser SDK on the deployed site had almost certainly never sent a single error report.

And nothing would ever have told me. A quiet analytics dashboard is at least ambiguous — maybe nobody visited. A quiet error monitor looks like good news. That is the failure mode I keep thinking about: the tool whose whole job is telling you when something is wrong can be the one thing that's broken, and its silence reads as success.

What wasn't affected. The docs site builds in CI with npm run build -w @acruxcore/docs — never through Turbo — so it picked up the measurement ID on the first deploy. Half the platform worked from day one, which is exactly why I initially suspected something specific to the marketing site.

Bug 2 — the tag loaded, the queue filled, nothing was sent

New image deployed. googletagmanager now in the bundle, script loading in the network tab, cookie banner working, consent granted.

GA4 Realtime: zero users.

I went through the browser console expecting an obvious break, and instead found every single signal healthy:

  • the https://www.googletagmanager.com/gtag/js?id=… script loads with a 200
  • window.google_tag_manager is defined
  • window.dataLayer.push !== Array.prototype.push — gtag.js has replaced the queue's push, so it is watching
  • the queue fills up with my consent, js and config commands

And no request to /g/collect. No _ga cookie. Nothing dispatched, ever.

I also ran GA4's own "Test your website" check from the admin panel. It went green — which I now know only means the tag script is present on the page. It never checks whether a hit arrives.

The wrong turn

Everything on my side was correct, so I started building the case that the problem was Google's.

I set up a second, completely unrelated GA4 property and pointed the site at it. Identical zero-dispatch behaviour. That felt conclusive: not my account, not my property, not my tag config.

I got as far as writing a standalone ga4-zero-hits-repro.html to hand over, drafting the issue report, and opening a PR to track the escalation. The report was finished and queued to post.

The moment it broke

Before posting I re-read my own repro page, and the thing I'd been ignoring finally registered. I had hand-written that page from Google's documentation rather than copying the code my product actually runs.

Which means it contained Google's canonical stub:

function gtag(){ window.dataLayer.push(arguments); }
Enter fullscreen mode Exit fullscreen mode

And my product contained mine:

function gtag(...args: unknown[]): void {
  window.dataLayer = window.dataLayer ?? [];
  window.dataLayer.push(args);
}
Enter fullscreen mode Exit fullscreen mode

Those look equivalent. TypeScript is happy, ESLint prefers the second one, and every reviewer including me read straight past it. My "repro of the bug" had quietly swapped out the one line that was the bug.

So I stopped reasoning and isolated the single variable. Two pages served from the same local origin, same measurement ID, same Consent Mode sequence, differing only in the push form:

stub hits to /g/collect _ga cookie
push(args) — what shipped 0 not set
push(arguments) — canonical 2 (page_view, scroll) set

One line. Everything else identical.

Why arguments is load-bearing

gtag.js replaces dataLayer.push with its own processor. That queue carries two different kinds of thing, and the processor has to tell them apart:

  • commandsgtag('config', 'G-…'), gtag('consent', 'update', {…})
  • data-layer objects — ordinary values other tags push in for their own use

It tells them apart by checking whether the pushed value is a real arguments object. A rest parameter gives you a plain Array instead, which fails that check — so the value gets filed away as data and no command ever runs.

That includes config. Without config, the tag is initialised with no measurement ID, so it has nothing to send and no reason to complain. Everything downstream still looks alive because everything downstream is alive — the script really did load, the queue really is hooked, the commands really are in it. They're just sitting there.

I didn't get this from the docs. It's just the only difference between the two rows of that table. It's also a decent rule of thumb: when a vendor snippet uses an unfashionable function(){} where a modern one would do, assume the old form is doing something.

Lint rules encode what's usually true. prefer-rest-params is right nearly every time, and this is the case where "nearly" costs you all your data.

The fix

Keep Google's original stub verbatim, and leave a note loud enough to survive the next cleanup:

/**
 * **`arguments` here is load-bearing — do not "modernize" it into a rest
 * parameter.** gtag.js tells a command apart from a data-layer object by
 * checking the pushed value is a real `arguments` object. A plain array is
 * silently ignored: the tag loads, the queue fills, and not one hit is sent.
 */
export const gtag: GtagFn = function () {
  window.dataLayer = window.dataLayer ?? [];
  // eslint-disable-next-line prefer-rest-params -- a rest array is not a command
  window.dataLayer.push(arguments);
};
Enter fullscreen mode Exit fullscreen mode

A comment alone wasn't enough for me here, because the failure is invisible and the "cleanup" that reintroduces it is a one-character edit. So the test asserts the exact property that matters:

it('queues each command as an `arguments` object, which is what gtag.js recognizes', () => {
  vi.stubGlobal('window', {} as Window);
  gtag('config', 'G-TEST123');
  const queue = window.dataLayer ?? [];
  expect(Object.prototype.toString.call(queue[0])).toBe('[object Arguments]');
  expect(Array.from(queue[0] as IArguments)).toEqual(['config', 'G-TEST123']);
});
Enter fullscreen mode Exit fullscreen mode

Rewrite the stub as a rest parameter and that test fails immediately with [object Array].

Verified end to end against a real vite build: accepting the consent banner produces a gcs=G101 hit and sets _ga.

Bug 3 — the site that worked was reporting to nobody

While isolating bug 2 I went back to the docs site as a control, because it had looked healthy from day one. Its snippet was hand-written directly into docusaurus.config.ts and never shared code with apps/web, so it had the correct arguments form all along.

It was sending hits. It was sending them to the wrong property.

Its measurement ID comes from a GitHub Actions secret that was never updated when the property changed, so every visit it recorded had been landing somewhere nobody was looking. No code change — update the secret, re-run the workflow — but it made the point for a third time in one week.

Working and reporting where you're watching are two different states, and only one of them shows up on a dashboard.

Before and after

before after
GA4 tag in the production bundle absent present
Sentry browser SDK in the production bundle absent present
gtag commands recognized by gtag.js 0 of 3 3 of 3
hits reaching /g/collect 0 page_view, scroll, consent hits
docs-site hits landing in the watched property 0 all of them
checks that had reported a problem 0 1 failing unit test if it regresses

That last row is the one I'd frame. Before the fix, the total number of automated signals warning me about any of this was zero.

What I'm carrying to the next codebase

A repro has to copy the product's code path, not your memory of it. I re-typed Google's snippet from the docs, which silently replaced the variable under test, and that one shortcut is the entire reason I wrote a bug report against Google. Paste the real function in and the two-page test finds it in ten minutes.

Testing a second account felt like ruling something out. It ruled out nothing. Both accounts ran the same broken client code, so the experiment could only ever return the same answer. A control group that shares the defect isn't a control group — and the confidence it gave me is what pushed me toward blaming Google instead of looking closer.

Assert the last link, not the layers. I had a check for the env var, a check for the bundle, a check for the banner, and Google's own check for the tag. Four green lights, zero data. Every one of them verified a precondition for a hit; none verified a hit.

When one config gap swallows a variable, check every variable of the same shape. SENTRY_WEB_DSN was found only because it looked like GA4_MEASUREMENT_ID, and it was the more dangerous of the two — nobody files a ticket saying "I'm getting suspiciously few errors."


If you've got analytics or error monitoring wired into a monorepo build, two things are worth ten minutes today: grep your deployed bundle for the tag host, and check the type of what your gtag stub actually pushes.

What did yours turn out to be — an Array, or Arguments?

Top comments (0)