12. GA4 End to End
One purchase event traced through every hop — dataLayer push, web tag, first-party wire, GA4 client, server tag, Google's endpoint — with the debugging workflow for each hop.
Everything so far — data layer, web container, transport, server container — assembled into one journey. We'll follow a single purchase from the checkout code to the GA4 report, watching the payload transform at every hop, then turn the same path into a debugging map. If you can narrate this chapter from memory, you understand server-side GA4.
The road:
HOP 1 app code dataLayer.push({event:"purchase", ecommerce:{…}})
HOP 2 web container CE trigger → GA4 event tag (transport_url set)
HOP 3 the wire POST https://pulse.shop.example/g/collect + cookies
HOP 4 server: client GA4 client claims → event data model
HOP 5 server: tags GA4 tag → POST to Google's collection endpoint
HOP 6 Google property processing → DebugView → reportsHops 1–2: nothing new — and that's the point
The push is Chapter 5's purchase, verbatim (ecommerce: null reset, then the
event with transaction_id, value, currency, items). The web container
is Chapter 6's wiring: a Custom Event trigger on purchase, a GA4 event tag
reading the ecommerce object, the config-level Google tag carrying
transport_url.
This is the migration's best property: the entire client-side investment survives unchanged. A site with a clean Part-2 setup moves to server-side by changing one setting and deploying zero new site code. (A site without a clean Part-2 setup has a prerequisite, not a server problem.)
Hop 3: the wire, now first-party
What leaves the browser (trimmed to what matters):
POST https://pulse.shop.example/g/collect?v=2
&tid=G-ABC123XYZ
&cid=1712345678.1699… ← still sent (JS _ga world)
&en=purchase
&ep.transaction_id=T-1024
&epn.value=138.0
&cu=EUR
&dl=https://shop.example/checkout/thanks
Cookie: FPID=AaBb12…; _ga=GA1.1.1712345678.1699…; _gcl_au=1.1.…
└── attached automatically by the browser: same-site request.
FPID (HttpOnly) was never visible to any JS — but it
rides to YOUR server on every hit.Three observations before the request disappears into the server:
- It's the same
/g/collectdialect from Chapter 2 — Safari, ad blockers, and DevTools all see a site talking to itself. - The cookies arrive as an HTTP header — this is how the server-managed identity (Chapter 11) physically travels.
- It's still typically a
sendBeacon, still survives the thank-you-page-to-anywhere navigation.
Hop 4: claimed and modeled
The container offers the request to its clients (Chapter 10); the GA4 client recognizes the dialect and claims it, parsing it into the event data model:
{
event_name: "purchase",
client_id: "AaBb12…", // derived from FPID — the durable one
ip_override: "203.0.113.7", // the visitor's IP, not your server's
user_agent: "Mozilla/5.0 …",
page_location: "https://shop.example/checkout/thanks",
currency: "EUR",
value: 138.0,
transaction_id: "T-1024",
items: [ { item_id: "SKU-1", price: 89.0, quantity: 1 }, … ],
"x-ga-…": … // GA4 transport internals, incl. consent
}Note ip_override and user_agent: the visitor's network facts, captured
from the claimed request and carried in the model so downstream vendors can
geo-locate and match correctly. When Chapter 11's proxy trap eats the real
IP, this is the field that silently fills with garbage.
One mechanical detail: GA4's web tag sometimes batches several events into one HTTP request — the client expands them, and the container runs once per event, not per request. Preview shows this as one incoming request fanning into multiple event rows.
Hop 5: the server GA4 tag fires
A server-side GA4 tag (trigger: event_name equals purchase — or simply
"all events" in the standard setup) takes the event model and re-emits it to
Google:
POST https://google-analytics.com/g/collect?v=2&tid=G-ABC123XYZ
&cid=AaBb12-derived…&en=purchase&epn.value=138.0&…
(sent from your server's process — the browser is out of the loop)It speaks the same native collect dialect inbound traffic used — no API key
involved. (Don't confuse this with the public "Measurement Protocol API"
and its api_secret — that's a separate, limited API for sending events
from your own code. The server GA4 tag is a first-class GA4 sender; your
backend webhooks usually enter via a client instead, Chapter 14.)
This hop is also where your checkpoint privileges (Chapter 10) apply: add parameters the browser never had (Firestore-looked-up margin), strip ones that shouldn't leave (PII in a URL), or let a Transformation redact centrally. The default tag with zero changes forwards faithfully — which is exactly the right starting configuration: first make it identical, then make it better.
Hop 6: Google's side
The property can't tell this was server-side — same processing, same reports, same BigQuery export. Verification order (Chapter 8's platform layer, unchanged): DebugView while previewing (debug flags travel through the server hop), Realtime within minutes, standard reports after the usual day-plus lag, key-event flag in Admin if conversions are the question.
What stakeholders actually see after a migration: not a new GA4 — the same GA4 with more events (recovered blocking losses) and better returning -user and attribution numbers (durable identity). Set that expectation explicitly before go-live, or the uplift gets misread as a bug.
Debugging: the four-layer trace
Chapter 8's triangle (model → wire → platform) gains a fourth layer — server — and the workflow becomes: run web preview and server preview side by side, then walk the hops in order until the event disappears. Symptom-first:
| Symptom | Broken hop | Check |
|---|---|---|
| Event absent in web preview | 1–2 | Part 2 debugging (Ch. 8) — not a server problem |
| In web preview, absent in server preview | 3 | transport_url actually set on the firing tag's config? DNS resolves? request visible in Network tab going to your domain? |
| Arrives but "claimed by" wrong/no client | 4 | client priorities; exotic paths need a client that claims them |
| Event claimed, fields missing/wrong | 4 | compare raw request vs parsed event in server preview; usually a web-tag mapping gap |
| Server tag shows error / no outbound call | 5 | server preview → the tag's outgoing HTTP request and Google's response are both inspectable — read them |
| In DebugView, never in reports | 6 | key-event flag, 24h lag, property filters — platform-side patience |
| All geo = your datacenter city | 3/5 | ip_override lost — almost always the Chapter 11 proxy trap |
| Purchases doubled after migration | 3 | browser sending both direct to Google and via server — some tag still lacks transport_url. One stream or the other, never both |
That last row is the migration bug. GA4 has no event-ID dedup across
streams (it dedupes transaction_id for purchases, which masks the problem
on purchases while every other event doubles). The fix is hygiene: the
transport_url lives in shared Google-tag settings (Chapter 7), set once,
inherited everywhere.
A note on consent: the browser-side consent state (Chapter 17) travels
inside the request (gcs/gcd parameters) and the server GA4 tag honors
and forwards it. Server-side does not launder a denied signal — by design.
The pattern to internalize
the same event, four shapes:
dataLayer message → collect request → event data model → vendor API call
(Ch. 5) (Ch. 2/11) (Ch. 10) (per vendor)GA4 was the gentle introduction: one vendor, whose web tag, server client, and server tag are all built for each other, with no deduplication puzzle. Meta is where server-side earns its keep — and demands its tax: the browser pixel and the Conversions API send the same events from two places, and making that a feature instead of a double-count is its own discipline: Chapter 13 — Meta CAPI.