Two nights ago I built a session trail for BinderDex: every API call sends an event, and events that share a session id can be replayed as a single user journey. The first production read the next day was ugly. Of 121,345 api_request events over four days, 121,316 had no session attached, and each one had gone out under its own anonymous identity.
Then came day one of the full firehose: about 173,000 events against a normal 7,000 to 12,000 a day. We're on a free analytics plan with a hard 1M events a month, and at that rate ingestion just stops around November 1st. That's the real failure mode of telemetry without a budget: not "bad data," but "no data by Sunday."
Where 173k phantom events come from
Three sources, and none of them were people:
- ~93% were our own server calling itself while rendering card pages. An in-process fetch can never carry a browser session, so each call looked like a fresh stranger.
- Crawlers hammering the four public price routes. Roughly 39k calls each, against 237 human card views of the same pages. That ratio is the whole story.
- The mobile app's session code hadn't shipped to devices yet (it goes out in the next OTA), so even real app traffic arrived anonymous for now.
Worse, every unlinked event minted its own analytics "person," so the dashboard claimed 121k new people in a week. Any all-events count was noise.
The rule that fixed it
The fix is one decision, applied consistently: keep every request that carries a session id; sample everything else. Sessionless events drop through a 1% gate, and each survivor gets stamped with its sample rate so counts can be scaled back up in queries. A separate brake on the whole pipeline stays in place for emergencies.
The decision tree in code is small enough to paste:
function shouldSend(req: ApiRequest): boolean {
// never hide server-internal chatter: it can't join a trail
if (req.isServerFetch) return false;
// every session'd request is kept at full rate
if (req.sessionId) return true;
// everything else (crawlers, consentless visitors) is sampled
return Math.random() < SESSIONLESS_SAMPLE_RATE; // 0.01
}
The server-fetch skip mattered as much as the sampling. Server renders will never produce a joinable trail, so logging them is pure spend.
One more trap worth knowing: our CDN only forwards cache-key fields on cached API paths, so session headers get stripped at the edge and those cache fills stay unlinked no matter what the app does. Fixing that means an origin request policy on the CDN side, and that's queued as a follow-up. The app can now do everything right and still have the edge silently drop the evidence.
And one trick I'll reuse: instead of dropping sessionless events entirely, everything unattributable now records under one shared bucket id. Replay still joins real journeys on the session id, so nothing honest changes, but the ghosts all pile into a single visible pile instead of a hundred thousand fake ones. If that bucket suddenly grows, that's a signal too (it's how we caught the crawler wave).
What I'd tell someone starting from zero
Sample the unattributable traffic on day one, before you've built anything on top of the counts. Unsampled, your analytics budget goes to bots that will never convert and identities that will never recur, and the first real product decision you try to make from the data drowns in them. The cheap version is one random gate and one header check; the expensive version is explaining to yourself why ingestion stopped on November 1st.
The event budget is still tight (mobile volume lands as the OTA spreads), so there's a follow-up checklist with exact queries for tomorrow's check. But the pipeline now spends on journeys instead of ghosts.