PostHog cookieless mode said 200 Ok and dropped every pageview
Posted in Analytics, Privacy, Debugging
By Dušan Dželebdžić

I'd put PostHog on the marketing site for WebKeeper, in cookieless mode. No cookies and no person profiles, which also meant no consent banner. Every outgoing event went through a before_send allowlist that kept a dozen reviewed properties and discarded the rest.
I was quite pleased with that allowlist.
Then I opened Web analytics and PostHog told me the installation wasn't healthy:
$pageview
Complete the PostHog installation to start seeing events in your dashboard.The Complete installation button led to the generic JavaScript SDK page, which is PostHog's way of saying "I've never seen a pageview from you, have you tried installing me?"
I had. The network tab said so.
Everything looked fine from the browser
The page made its POST to https://eu.i.posthog.com/e/ and got this back:
{"status":"Ok"}Status 200. The payload was exactly what I'd designed:
{
"event": "$pageview",
"properties": {
"distinct_id": "$posthog_cookieless",
"$cookieless_mode": true,
"$host": "example.com",
"$pathname": "/",
"$browser": "Chrome",
"$os": "Mac OS X",
"$device_type": "Desktop",
"$lib": "web",
"$lib_version": "1.427.2"
}
}The right token, the right region, the cookieless sentinel as distinct_id. I even had a browser test suite that loaded the real SDK, intercepted the request and asserted on that payload. All green.
So I asked the database instead of the browser:
SELECT event, count()
FROM events
WHERE timestamp > now() - INTERVAL 7 DAY
GROUP BY eventThe product's own events were there. The marketing site had contributed zero rows. Not a few missing pageviews. None, ever, since launch.
It wasn't the project setting
Cookieless mode has a server-side half. In project settings there's a cookieless server hash mode, and if it's off, ingestion drops every cookieless event with the reason cookieless_team_disabled. That's the well-known trap and the first thing I checked.
It was on.
At that point the docs had nothing more to give, so I read the ingestion code. The relevant file is nodejs/src/ingestion/common/cookieless/cookieless-manager.ts in the PostHog repository, and the answer is about twenty lines long:
const userAgent = event.properties?.['$raw_user_agent']
const ip = event.properties?.['$ip']
const host = event.properties?.['$host']
if (!userAgent || !ip || !host) {
if (!userAgent) {
reason = 'cookieless_missing_ua'
type = 'cookieless_missing_user_agent'
missingProperty = '$raw_user_agent'
}
// ...
results[i] = drop(reason, /* ... */)
}Without cookies, PostHog can't recognise a visitor, so it computes a daily hash from a rotating salt, the IP address, the host and the user agent. That hash becomes the distinct_id and the session. No user agent, no hash. No hash, no event.
The user agent it reads isn't the HTTP header. It's the $raw_user_agent event property that posthog-js adds to every event.
Which is one of the properties my allowlist threw away, on purpose, with a comment explaining how privacy-conscious that was.
Why nothing complained
Capture and ingestion are separate systems. The capture endpoint validates the token, puts the event on a queue and answers 200. The decision to drop happens later, in a consumer, long after the browser has stopped listening. There's no channel back to the SDK, so posthog-js has nothing to log either.
My tests couldn't catch it for the same reason. They proved the browser sent what I intended. What I intended was wrong.
The drop isn't completely silent. That same code attaches an ingestion warning of type cookieless_missing_user_agent, so the Ingestion warnings page under Data management is worth opening before the source code is. I went the long way round.
The fix is one property
Let it through:
const userAgent = input.$raw_user_agent;
if (
typeof userAgent === 'string' &&
userAgent.length > 0 &&
userAgent.length <= 1000
)
properties.$raw_user_agent = userAgent;This felt like a privacy regression for about a minute. It isn't. The browser already sends the identical string as the User-Agent header on that very request, so PostHog learns nothing new. And right after hashing, ingestion runs this:
export function stripPIIProperties(event: PipelineEvent) {
if (event.properties) {
// we use these properties in the hash, but they should not be
// written to disk if explicit consent was not given
delete event.properties['$ip']
delete event.properties['$raw_user_agent']
}
// ...
}After the release I loaded the site once and queried again. One $pageview, with a cookieless_... distinct ID, a server-assigned $session_id, and null for both $raw_user_agent and $ip on the stored row. Exactly what the design promised, now with the small improvement of existing.
I also added an assertion to the browser tests that the payload carries $raw_user_agent. It guards the one property whose absence costs everything.
If your cookieless events aren't showing up
Check in this order:
- Project settings. Cookieless server hash mode must be enabled, otherwise the drop reason is
cookieless_team_disabled. - Ingestion warnings. Look for
cookieless_missing_user_agent,cookieless_missing_iporcookieless_missing_host. - Anything that rewrites events. A
before_sendhook, a reverse proxy that rebuilds the body, a server-side forwarder. Capturing from a backend is the classic case: there's no browser to supply$raw_user_agent, and$ipis your server's address unless you set it yourself. - Unsupported events.
$create_aliasand$merge_dangerouslyare dropped in cookieless mode by design.
Takeaway
I stripped a property to protect visitors and ended up protecting them from being counted at all. An allowlist is only as good as your knowledge of what the other end requires, and the other end won't always tell you.
A 200 means somebody took the letter. It doesn't mean anybody read it.
Is your analytics dashboard suspiciously quiet? Send me the details and I'll take a look.