LAB LIVE
SCANNING424 CAMPAIGNS
ANOMALIES18 FLAGGED
AVG LIFT+4.15× ROAS
Analytics

GA4 sessionSource = (not set): The Measurement Protocol Trap

You built a Measurement Protocol pipeline to recover the 30–50% of paid traffic ad-blockers eat. Events arrive. Users are matched. And every one of them lands in Direct with sessionSource = (not set). Here's the architectural reason and the one workaround that survives.

Alex Sterling··9 min read

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.

The rule in one lineGA4 session source is written by the client. Measurement Protocol events never write it — they only read it. If you passed source in your MP payload, GA4 accepts the params but ignores them at the session level. You'll see them in 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:

DimensionWhat 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 groupDirect dominant among your MP-recovered events
User pseudo IDValues 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.

01
Stamp your MP payload with a custom source param

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.

02
Build a Custom Channel Group in GA4

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.

03
Switch reports to the custom group

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.

04
Pipe to BigQuery for full audit

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.

Real numbers from an auditAn EU e-commerce client we run: 88 of 88 MP-only conversions landed in (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.

Alex Sterling

Alex Sterling

Founder at Sterling Lab · Google Ads strategist · 42 client accounts across 10 countries