Why did GA4 fill up with (not set) users the day I switched on server side tracking?

Back in August I blocked googletagmanager.com at the network layer and loaded a product page on a store running server side tracking. No _ga cookie got set, because Google’s tag never ran. The browse events left anyway, each one carrying an id that had been invented locally a second earlier.

From the reporting end that shows up as a block of new users whose Country, Landing page and Session source all read (not set), or Unassigned for the source. No page_view anywhere, just browse events. Every one counts as an active user, so the totals move and the reports underneath stop making sense. Merchants can usually name the day it started: the day a server side tracking tool went live.

It reads like a bot wave. Mostly it is not one.

The question came from a merchant before I went looking at it myself. The post stops before a fix list deliberately: the remedies differ per tool, and one of those tools is ours.

Geography has a boring answer. Events reaching GA4 through the Measurement Protocol arrive from whichever server sent them, and GA4 works out country and city from the IP on the request. The protocol carries an ip_override field so a server can pass the real one through. Leave it unset and the only address Google sees is the sender’s, so Country comes back blank.

Identity accounts for the other two columns. Google’s tag is what hands a visitor an id GA4 already recognises, and a hit arriving without one has no earlier session to inherit a landing page or a campaign from.

So who are those visitors?

Not bots, in large part. People. Anyone on uBlock or AdGuard, on Brave, on Firefox with strict protection, or behind a Safari content blocker never loads Google’s tag, and on plenty of storefronts that is a real slice of desktop traffic. Shopify’s own first party pixel does load for them, since it is not on anyone’s blocklist. So the day the server side tool goes on is the first day those customers appear in your reports, stripped of what the tag would have supplied. The traffic was always there. Your ability to see it is what changed.

Real crawlers are in there too. GA4 screens known bots on the IP and user agent of the request it receives, and that request now comes from a server, so the ones that run JavaScript get a pass.

Before filtering any of it, two checks. Exclude Country = (not set) and see whether what remains looks like your normal store, which tells you whether you have isolated the bucket or lost your data. And leave a partial day alone for a day or two, since attribution keeps settling and Unassigned shrinks on its own.

The split is the part I cannot give you. On a given store, what share of that block is a blocked browser and what share is a crawler? If anyone has pulled theirs apart I would like to hear how, because I have not found a clean way from inside GA4.

The split is gettable, but not from inside GA4 on its own. You have to give the hits something to split on before they land.

Two things I would do first. Send the customer’s user agent as the User-Agent header on the Measurement Protocol request instead of whatever your server library defaults to, alongside ip_override. GA4 runs its known-bot check against the request it actually receives, so right now it is checking your server, which is why the crawlers that run JavaScript walk straight through. Fix the header and a chunk of that block disqualifies itself. The size of the drop before and after the change is your first estimate of the crawler share, measured instead of guessed.

Second, stop minting a new id per hit. In the Shopify pixel sandbox you can read and write your own first party id through browser.localStorage, so a blocked-browser human keeps the same id across pageviews and across the session. That alone changes the shape of the bucket. Humans without the Google tag still scroll, still see two or three pages, still fire an add_to_cart sometimes. A crawler that runs JS usually fires one browse event and is never seen again under the same id, because it is not carrying storage forward.

Once the id is stable the split is just a segment. Sessions with one event and engagement_time_msec at zero, against sessions with more than one event. I would put the line there rather than at any bot list, since the bot list is the thing that already failed you.

The sharpener on top of that is a custom dimension for whether the _ga cookie existed at hit time. Blocked-browser humans are a hard no on that and a yes on everything else, which is exactly the separation you are missing. Have you got room to add a param on the events you are already sending?

That pattern is usually orphaned Measurement Protocol hits, not a sudden bot spike. I’d fix it in this order:

  • Stop creating a client_id for every event. Persist one first-party ID in localStorage or a cookie and reuse it.
  • Keep one session_id for the visit, rolling it after 30 minutes of inactivity. Send page_view first with page_location and page_referrer, then the browse events.
  • Pass the visitor user agent and, where consent permits, ip_override. Otherwise GA4 sees your server and geography or bot filtering will be wrong.
  • Add a parameter such as google_tag_loaded=true/false. Register it as a custom dimension so blocked-browser traffic can be compared separately.

Existing GA4 data will not be repaired retroactively, so annotate the launch date and exclude that period from baseline comparisons.

@lumine Yes, there is room for another parameter. This is where I would spend it, though not on the tag flag.

Take that one first. Whether the _ga cookie existed at hit time is the thing that puts a visitor in this bucket to begin with, since without it GA4 has no id it recognises and nothing earlier to attach the visit to. Inside the bucket it therefore reads false for the crawler and false for the person running uBlock. It re-draws the line I already have from Country = (not set) instead of cutting inside it.

The stable id is a different matter and I think you have that right. Once one id survives across pages, a session holding a single browse event and nothing after it stops resembling a session with four pageviews and a cart event, and that shape is expensive for a crawler to fake. Behaviour looks like the only place a split is available.

On the user agent, the ceiling is the list. GA4 drops what Google’s own research plus the IAB International Spiders and Bots List already recognise, so the crawlers it removes are the ones announcing themselves. Headless Chrome sending an ordinary Chrome string is on nobody’s list, and that kind carries enough JavaScript to reach a server side pipeline anyway. The size of that drop measures the declared share, which is more than I have now, and it leaves whatever is presenting as an ordinary browser sitting exactly where it already was.

Both of those land, and the second one is the more useful correction.

You are right that a tag present flag is constant inside the bucket. It reads false for the uBlock visitor and false for the headless crawler, so it restates the boundary you already drew with Country = (not set) rather than cutting inside it. I was treating it as a sharpener when it is really a second name for the same line.

If there is one parameter to spend, I would put it on storage persistence instead, because that one does vary inside the bucket. Three states, written on every event. The id was minted fresh on this hit, the id was read back from storage that this same browser wrote earlier, or the storage read itself failed. A person on uBlock or Brave reads fresh on their first pageview and carried on every one after it, because those tools block the request to googletagmanager, not the storage API. A crawler running JavaScript in a fresh context per fetch reads fresh every single time and never once reads carried. The ratio of carried to fresh ids inside the bucket is then the split you were asking for, and it is a count rather than a judgement about session shape.

Two caveats. Safari caps script written localStorage at seven days, so carried holds within a visit and across a few days but not across a month, which matters if you are looking at a monthly total rather than a daily one. And the genuinely one page human, the person who lands, reads, leaves, looks identical to the crawler under this test. That floor is real, but you can size it from your normal single page session rate on traffic outside the bucket, which is a number you already have.

On the user agent, agreed on the ceiling and I said that too loosely. What it buys is the declared crawler count, not the crawler count, plus geography stops being wrong. Anything presenting as ordinary Chrome sits exactly where it was. Still worth sending, just not the thing that does the separating.

Out of curiosity, what does your single page session rate look like on the traffic that is not in this bucket? That number is the floor on how much of it you can ever attribute.

@lumine Storage state is the right thing to spend it on. There is a trap in writing it correctly from inside the Shopify pixel, though, and it decides whether the ratio means anything.

The sandbox does not have storage. It has a proxy to storage. browser.localStorage.getItem hands back a promise and Shopify resolves it out in the top frame, so every read is a round trip out of the worker and back again. Anything you dispatch before that promise settles has no earlier id in hand and gets written down as new. The bias is not spread evenly either. It falls hardest on the first event of a page load, and the first event of a page load is most of what a crawler ever fires, so an unsettled read moves ordinary first pageviews into the column you were reserving for crawlers. The flag has to be written off the read finishing rather than off the event being ready to go, or the ratio comes back agreeing with whatever you already suspected.

On the single page rate outside the bucket, I do not have a number I would put in front of you. Where I have gone looking at this I was looking at what was inside the bucket, and the comparison against the rest of the traffic never got run. I would want it measured on your own property rather than borrowed from mine in any case, because that limit moves with the catalogue. A store where most visits are one product page and out sits far higher than a store people browse through, and the same ratio would mean two different things on the two of them.

You are right, and the ordering problem is worse than the round trip cost. All three are promises resolved out in the top frame, cookie, localStorage and sessionStorage alike, so there is no version of this that avoids it by picking a different store.

Reading once at register and holding it in a worker local takes care of the per event cost. What it does not take care of is the events that fire while that first read is still in flight, and that window is exactly where the measurement lives. Let them through marked fresh and you mint fresh states that were never real. Drop them and you delete the single event visitor first, because a crawler that fetches one page emits one page_viewed and leaves, so the pre settle window is all of its output and a rounding error of a human’s. Either policy bends the ratio in the direction of the thing being measured. Buffering those events and flushing after the read resolves is the only handling that does not.

Separate thing worth checking before trusting any ratio, the customer_privacy block in the extension toml. The pixel manager only loads a pixel when there is visitor permission for everything it declares as required. If analytics is declared true, unconsented visitors never emit at all, so the bucket you are looking at has already been filtered by who accepts banners, and the population that ignores banners entirely is the automated one. That alone would push carried against fresh toward crawler heavy before storage enters the picture.

What does yours declare?

The technical thread on client_id and IP override is the right rabbit hole if you want clean attribution, but it’s worth separating two questions: is data messy, or is revenue actually missing? “(not set)” from ad-blocker users is expected and mostly a labeling problem, not a lost-order problem, since the server hit still lands even without a clean client ID.

Since this migration is a direct result of Shopify killing Additional Scripts on 26 Aug for non-Plus stores, the check I’d run first: pull yesterday’s Shopify order count and compare it to ecommercePurchases in GA4 Explore for the same day. If they’re within roughly 15% once you account for consent, your server tracking is capturing revenue fine and the “(not set)” bucket is just an attribution quirk to clean up later, not a fire drill.

There’s a free page that shows which tags your storefront still has wired up: Is my Shopify conversion tracking alive? Free check | TrackAlive. The daily order-vs-GA4 comparison is what my app, TrackAlive, does after install, if you’d rather not run it by hand.