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 → reports

Hops 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/collect dialect 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.