The Measurement Protocol is the escape hatch every serious GA4 setup ends up needing. Ad-blockers, iOS ITP, Consent Mode denies — client-side gtag loses 20–50% of paid sessions before they ever reach analytics. Sending events server-to-server closes that gap. Every conversion arrives.
And then you open the Traffic Acquisition report and every one of those server-sent events shows Direct / (none). Even the ones you explicitly stamped with utm_source=google and utm_medium=cpc in the payload. It looks like a bug. It isn't. It's how GA4 sessions actually work.
Why This Happens: The Session-Scope Rule
GA4 stores traffic source on the session, not on individual events. And GA4 only writes to a session's source field under one condition: a session_start event fires from the client, on a page that had UTM parameters or a gclid, from a browser that GA4 had already accepted into the session.
Measurement Protocol events fired from your server never trigger asession_start. They join an existing session by ga_session_id. If no session with that ID was ever created client-side — because gtag was blocked, or the user opened your MP-only flow, or you passed a synthetic client_id — the event still gets accepted, but there is no session for it to attach source data to. Session source stays empty. GA4 fills it as (not set), and the reporting layer maps that to Direct.
event_params, not in sessionSource.Confirming the Trap Is What You're Seeing
Before you rebuild anything, confirm the diagnosis with a query, not a guess. In GA4 → Explore → Free-form, run:
| Dimension | What to look for |
|---|---|
| Session source / medium | (not set) rows appearing next to real sources |
| Event source (custom parameter) | Same events show google / cpc when this is defined |
| Session default channel group | Direct dominant among your MP-recovered events |
| User pseudo ID | Values from your MP payloads vs client-generated ones |
If sessionSource is (not set) but a custom event-level source parameter contains google, you have the trap in its textbook form. The data is there — it's just being read from the wrong scope.
What Doesn't Work (And Why Teams Waste Weeks On It)
Sending utm_source in the MP payload
GA4 accepts the parameter and stores it at event scope. It does not back-fill session scope. Every dashboard using session traffic source will still show Direct. Wasted work.
Setting the campaign parameter directly
The campaign parameter behaves identically. It shows up in the event payload. It does not attach to the session. Same result.
Reusing an existing session_id from a client event
This almost works. If a real client-side session_start fired and you send MP events with the same session_id afterwards, the MP events inherit that session's source. But this defeats the entire purpose — if a client-side session started, gtag wasn't blocked, and you didn't need MP.
Building a fake session_start via MP
GA4 accepts a session_start sent through MP. It creates the session. It still does not write source, because MP events don't hit the client-side attribution engine that reads the referrer and URL. You get a session in Direct, permanently.
What Actually Works: The Custom Channel Group Route
GA4's Default Channel Group only reads session-scope source. But since GA4 introduced Custom Channel Groups (2024), you can build a channel definition that reads event-scope parameters instead. This is the one path that survives and produces accurate reporting.
In your server code, add an event parameter — call it traffic_source or mp_source — on every MP event. Populate it from the same UTM logic you already have (google / cpc, meta / paid, tiktok / paid, etc.). Register the parameter as an event-scoped custom dimension in GA4 Admin.
Admin → Data display → Channel groups → Create custom channel group. Add rules that read your custom parameter first, then fall back to session source. Example: Paid Search whentraffic_source matches google|pm OR session medium is cpc.
In every report and Explore where source matters, change the primary dimension from “Session default channel group” to your custom group. Only the custom group reads your event-scope override — default reports will still show Direct.
Enable the BigQuery export. Once you have raw events, both scopes become explicit: event_params.traffic_source andtraffic_source.source. You can reconcile MP events against client events per user pseudo ID and build any attribution view without GA4's reporting layer in the way.
(not set) for session source over three weeks. The custom channel group reassigned them correctly — 62 to Paid Search, 19 to Paid Social, 7 to Direct (genuinely direct users we recovered via first-party email hash). Google Ads / GA4 conversion gap went from 44% to 11%. The default reports still show Direct for those events — we retrained the client to only trust the custom group.The Session-ID Hybrid Approach (When You Control the Client)
If your MP pipeline runs alongside working client-side gtag — for example, you use MP as a redundancy layer for Consent Mode denies rather than as a full replacement — there's a second path. Extract the real _ga and _ga_XXX cookies on your server. Parse the session_id from _ga_XXX. Pass that exact session_id in your MP payload as ga_session_id.
MP events with a matching real session_id inherit that session's source correctly. Default reports work. But this only helps for sessions where a client-side session_start did fire. For the pure MP path — Consent Mode denied, gtag never ran — the session_id you pass has no client-side counterpart, and you're back to (not set).
The One Sentence Nobody Tells You
Google's own MP documentation does not state this: sessionSource is a client-only attribute. Every “send source in the payload” tutorial online implies it maps to session scope. It doesn't. And the only official escape hatch — Custom Channel Groups — is buried three menus deep in an admin panel most implementers never open.
This is one of the most expensive gotchas in server-side GA4. Teams spend weeks rebuilding MP payloads that already work, when the actual fix is to add one event parameter and one channel group definition. If you're in this loop right now, the checklist above is where to start. More MP and GA4 patterns in the Sterling Lab blog, or see our server-side tracking service for how we design pipelines that avoid this from day one.