Skip to content

PostHog cookieless mode said 200 Ok and dropped every pageview

Posted in Analytics, Privacy, Debugging

By Dušan Dželebdžić

Photo by daniel mironov on Unsplash
Photo by daniel mironov on Unsplash

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 event

The 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_ip or cookieless_missing_host.
  • Anything that rewrites events. A before_send hook, 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 $ip is your server's address unless you set it yourself.
  • Unsupported events. $create_alias and $merge_dangerously are 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.