A One-Line JavaScript Bug Broke My GA4 Tracking for Five Months
For five months, Google Analytics told me nobody on my site ever signed up, converted a file, or bou 2026-11-4 09:15:3 Author: hackernoon.com(查看原文) 阅读量:3 收藏

For five months, Google Analytics told me nobody on my site ever signed up, converted a file, or bought anything. Page views looked fine. Sessions looked fine. The key events report said zero, every day, and I read that as "the funnel is bad" instead of "the funnel is not being recorded".

The bug was one line. This is what it was, why nothing warned me, and how to check your own site in about a minute.

The setup

I run BatchSet, a set of browser-based image tools. GA4 is loaded the standard way, with the snippet Google gives you:

<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'G-XXXX');
</script>

Custom events came from a small typed helper in the app code, so components could call trackEvent({ action: "sign_up" }) instead of touching window directly:

function gtag(...args: unknown[]) {
  if (typeof window === "undefined") return
  const w = window as unknown as { dataLayer?: unknown[] }
  w.dataLayer = w.dataLayer || []
  w.dataLayer.push(args)
}

export function trackEvent({ action, params }: GTagEvent) {
  gtag("event", action, params)
}

It looks like a faithful TypeScript port of the snippet. It is not.

The difference

Google's gtag() pushes arguments. Mine pushed args.

arguments is an array-like object. Its type is [object Arguments]. ...args is a rest parameter, and that gives you a real Array. They hold the same values in the same order, and in DevTools they look almost identical:

Object.prototype.toString.call(arguments) // "[object Arguments]"
Object.prototype.toString.call(args)      // "[object Array]"

gtag.js does not treat those two the same. I tested it on the live site: pushing an array sent no hit at all, and pushing an Arguments object with the same three values sent the event. No console error, no warning, no failed request. The array just sat in dataLayer and was never acted on.

I can't point you to a line in Google's docs that says "arrays are ignored". I can tell you what happened on a real production site, and that the fix changed the result.

Why nothing warned me

Three things hid it.

Page views kept working. The config call comes from Google's own snippet, which uses arguments. So page_view, session_start, first_visit, scroll and user_engagement all arrived. The dashboard looked alive. Only the events from my own helper were missing.

dataLayer looked full. If you open the console and type dataLayer, you see your events sitting there with the right names and parameters. That feels like proof. It only proves the push happened, not that anything read it.

Modern style pushes you toward the bug. Rest parameters are the idiomatic way to write variadic functions now. ESLint even has a core rule, prefer-rest-params, that flags use of arguments. Writing the "clean" version is exactly what breaks it.

On top of that, zero is a believable number for a small site. I had a funnel I had designed and instrumented, and when it read zero I started theorising about why people were leaving, not about whether the instrument worked. That cost more time than the bug itself.

The fix

function gtag(..._args: unknown[]) {
  if (typeof window === "undefined") return
  const w = window as unknown as { dataLayer?: unknown[] }
  w.dataLayer = w.dataLayer || []
  // Must be the Arguments object, like Google's snippet.
  // gtag.js does not act on plain arrays.
  w.dataLayer.push(arguments)
}

The ..._args parameter is only there so TypeScript accepts calls with arguments. The value that gets pushed is arguments. If you use prefer-rest-params, turn it off for that line and leave a comment, or the next tidy-up will put the bug back.

The simpler option is to not write your own wrapper at all and call window.gtag(...), which is Google's function. I kept the wrapper because it also does a server-side usage record and a consent check, but if yours does nothing extra, delete it.

I also added a test, because this is the kind of thing a refactor reintroduces without anyone noticing:

it("pushes an Arguments object, not an array", () => {
  trackEvent({ action: "batch_started" })
  const entry = window.dataLayer.at(-1)
  expect(Array.isArray(entry)).toBe(false)
  expect(Object.prototype.toString.call(entry)).toBe("[object Arguments]")
})

How to check your own site in a minute

1. Open your site, open DevTools, and trigger something that should send a custom event.

2. In the console, run:

dataLayer.map(e => Object.prototype.toString.call(e))

Google's own entries will say [object Arguments]. If your custom events say [object Array], you have this bug.

3. To be sure, open the Network tab, filter on collect, and trigger the event again. A GA4 hit is a request to .../g/collect with your event name in the en= parameter. No request, no event.

4. In GA4, check Realtime or DebugView, not the standard Events report. The standard reports lag by up to a day, and on the day I deployed the fix they showed zero for everything, including page views. That is processing delay, not a second bug.

The second trap: events that arrive but don't count

After the fix, events started arriving within minutes. Then I hit a quieter problem.

My most visited tool page showed a 2.5% key-event rate. The bulk converter, on similar traffic, showed 28.5%. That looks like a broken page. It wasn't. The resizer's completion event, resize_completed, was firing fine. It just had never been marked as a key event in GA4 Admin, so no report that counts key events included it. Three other tools had the same gap.

Two things worth knowing about that:

  • Marking an event as a key event is not retroactive. Your totals step up on the day you change it. Write the date down, or you will read that step as growth later.
  • The list of key events is a decision, not a default. Every time you add a new tool or flow, the event it fires has to be added to that list by hand, or it is invisible to every conversion number you look at.

What the real numbers showed

Once both problems were fixed, the data finally answered the question I had been guessing at. Over 28 days, on the two biggest tool pages:

  • Bulk image converter: 101 visitors, 26 added a file, 25 finished.
  • Image resizer: 137 visitors, 29 added a file, 27 finished.

Of the people who added a file, 93 to 96% finished the job. The tools work. The loss is one step earlier: roughly four out of five visitors leave without adding anything. I had spent months assuming the conversion flow was the problem, and the flow was the one part that was fine.

These are small numbers from a small site, and I'm not claiming more than they show. But the point stands at any size: check that the instrument records before you trust what it says. A funnel that reads zero is far more often broken tracking than broken product.

Checklist

  • Your gtag() wrapper pushes arguments, or you call window.gtag directly.
  • A test asserts the pushed entry is [object Arguments].
  • You have seen your custom event in a g/collect request, not just in dataLayer.
  • You have seen it in GA4 Realtime or DebugView.
  • Every completion event you care about is marked as a key event, and you noted the date.

That last one is still on my own to-do list.


文章来源: https://hackernoon.com/a-one-line-javascript-bug-broke-my-ga4-tracking-for-five-months?source=rss
如有侵权请联系:admin#unsafe.sh