Your session ID parser probably returns nothing, and nothing is not an error
Somewhere in a lot of measurement setups there is a small function that reads the GA4 session ID out of the browser. It finds the _ga_ cookie for the data stream, splits the value on a full stop and takes the third element. For years that returned a ten-digit number: the session ID, ready to be stitched into a hidden form field, a Measurement Protocol payload or a CRM record.
The session cookie for a GA4 data stream now uses a newer format. That same code still runs. It does not throw, it does not log, and it does not break the page. It returns a fragment of a different structure: a truthy, non-empty string that is not a session ID. Downstream, that value gets written into a payload, or used as a join key, and everything continues to look like it is working.
If your parser predates mid 2025 and nobody has looked at it since, this is worth ten minutes today.
Old shape versus new shape
The old cookie value, with the stream ID replaced:
_ga_XXXXXXXXXX=GS1.1.1746825440.14.0.1746825460.60.0.295082955
Everything is positional. Index 0 is the version prefix, index 1 a sub-version, index 2 the session ID (a Unix timestamp marking when the session started), index 3 the running session count, and so on down the line. value.split('.')[2] was crude, but it worked, and it is the pattern copy-pasted through years of blog posts and Stack Overflow answers.
The current value on any site running GA4:
_ga_XXXXXXXXXX=GS2.1.s1747132561$o14$g0$t1747132655$j60$l0$h0
Three structural changes matter to a parser:
- The version prefix changed from
GS1toGS2. - The session ID is no longer a bare positional segment. It sits inside a labelled block:
smarks the session ID,othe session count,gthe engaged flag,tthe last hit timestamp. Those fields are delimited by dollar signs, not full stops. - There are now only two full stops in the whole value, both before the labelled block. Splitting on the full stop gives three elements, and index 2 is the entire labelled block, starting with
s.
Vendors change cookie formats. This one rolled out in May 2025 without an announcement, which is normal: the cookie is an implementation detail of the Google tag, not a public interface. The interesting question is not why it changed. It is what your code should have done when it did.
Why nothing broke loudly
The old parser applied to the new format returns s1747132561$o14$g0$t1747132655$j60$l0$h0. That is a wrong answer, not a missing one, and every layer after it behaves normally:
- A truthy string passes an
ifcheck. The guard clause was written to catch a missing cookie, not a wrong answer, so the wrong answer sails through. - A join on a wrong key returns zero rows. Zero rows reads as no matching data yet, which reads as processing delay, which reads as give it another day. An empty join result does not announce that the key itself is malformed.
- A payload sent with a malformed identifier still returns a success response. The receiving endpoint validates the request: the shape of the call, the required fields, the authentication. It does not validate your semantics. Measurement Protocol will happily accept a session ID that never existed.
So the failure surfaces weeks later, as a gap in joined data that looks exactly like latency, consent loss or attribution noise. Analysts conclude GA4 is delayed. Nobody suspects a string split.
The general rule: a parser that cannot fail is not a parser, it is a coincidence. Positional parsing of a string that still splits cleanly returns a wrong answer rather than no answer, and wrong answers travel further than errors do.
The fix
The broken version, still in production on a lot of sites:
function getSessionId(streamId) {
var m = document.cookie.match(new RegExp('_ga_' + streamId + '=([^;]+)'));
return m ? m[1].split('.')[2] : '';
}
The corrected version:
function getSessionId(streamId) {
var m = document.cookie.match(new RegExp('_ga_' + streamId + '=([^;]+)'));
if (!m) {
throw new Error('GA4 session cookie not found for stream ' + streamId);
}
var session = m[1].match(/\.s(\d+)\$/);
if (!session) {
throw new Error('Unexpected _ga_ cookie format: ' + m[1]);
}
return session[1];
}
The properties that matter, more than the exact code:
- It matches the session ID by its label, the
sprefix inside the value, rather than by counting delimiters. Fields elsewhere in the block can be reordered, added or removed without touching it. - It asserts the result is numeric before returning it. The
\d+does that work: a fragment of some future format will not match. - It returns an explicit failure, loudly, rather than an empty string or undefined. Catch it at the call site and log it somewhere a human looks.
- The assertions stay in production, not just in tests. Format drift happens in production, which is the only place it matters.
Pin the shape in a test as well: paste a real current cookie value in as a fixture and assert the parser returns the exact ID. The next format change then surfaces as a failing test, not as a quiet gap in the data three weeks later.
Same failure, different surface
The cookie is one instance of a class: parsing that fails silently and presents as a data problem. Two more from the same family, both cheap to check.
Datetime fields in platform CSV exports
Some exports write a narrow no-break space, code point U+202F, between the time and the AM or PM marker rather than an ordinary space. In a spreadsheet the two are indistinguishable. Splitting or regexing on a normal space matches nothing and returns empty, silently, and every row of an export parses to the same nothing. The fix is to normalise the unusual space characters (U+202F and U+00A0 cover most of it) before parsing anything, and when a parse does fail, print the code points of the offending string rather than trusting what the cell looks like.
Row limits in GA4 explorations
The default number of rows shown in an exploration is small. Transactions beyond that limit are not sampled and not delayed, they are simply not displayed, which looks identical to processing lag if you are scanning for one specific transaction ID. Raise the row limit before concluding data is missing. Plenty of escalations have been built on this non-problem.
Three checks
- If a parser can return nothing, make it say so. Silent empties are how wrong values get promoted to facts.
- If you are reading a vendor's string format, pin its shape in a test. The vendor owes you nothing about that string, and history says they will use the freedom.
- Before deciding data is missing, confirm you are looking at all of it. Row limits, date ranges and default filters answer a surprising share of "missing data" investigations.
This is the class of defect I look for first in tracking and measurement work: not the tag that never fired, but the value that has been quietly wrong since a format changed underneath it.