Repository navigation
sendBeacon transport sends a null visit_token as the string "null" when no ahoy_visit token is present #90
Description
Activity
We hit this too, on a multi-tenant Rails app, and have production numbers that may be useful — including one part of your report that turns out not to be the main driver, and one part you were unsure about that I can confirm.
Scale
One
ahoy_visitsrow,visit_token = 'null', started 2024-03-04. As of today it holds 12,524 events, all pageviews, and it is still collecting. A sample of the most recent 12,000 of them spans 184 distinct tenants.Current arrival rate is about 20–24 a day, which is 1.1–1.3 basis points of all pageviews (19 / 29 / 25 against 167k / 223k / 199k on three consecutive days). That rate is only meaningful for those three days — our own pageview pipeline was broken for the month before them, so anything older understates it.
It is mostly not iframes
Your report points at cross-origin iframes. Ours says that is real but minor. Of the last 73 events on that row, 68 were first-party loads on the tenant's own registered domain — ordinary pages, on sites that have plenty of healthy traffic alongside. Only 5 were inside a third-party embed.
So the trigger looks like cookie refusal generally — private mode, ITP, strict settings — rather than embedding specifically. Worth knowing if anyone tries to reproduce it by embedding alone.
Your "if I'm not mistaken" is correct
You wrote that stripping the null out would make a Ruby backend complain about missing params. It does, and it is worth spelling out because it rules out the obvious fix.
Ahoy::BaseControllerrunscheck_paramsbefore anything else, andAhoy::Tracker#missing_params?isif Ahoy.cookies? && api? && Ahoy.protect_from_forgery !(existing_visit_token && existing_visitor_token)
all three of which are on by default. Omit the key and every one of these requests answers 400 — so the events are lost rather than misfiled, which is worse.
That path is not exercised today, and the reason is this issue: the beacon transport always sends something, even when that something is
"null". We have zero[ahoy] Missing required parameterslines in production across those three days.Two more details for whoever fixes this:
Ahoy::Tracker#ensure_tokensanitises withgsub(/[^a-z0-9\-]/i, "")."null"is all lowercase letters, so it passes through untouched — the existing sanitiser cannot catch this.- The
visit_tokenunique index does not help either.'null'is a perfectly good string, so the constraint is satisfied by the one shared row.
What we did, and what might suit upstream better
Server-side, we treat
"null"/"undefined"/"NaN"as unusable: they never match an existing visit, and they are replaced with a generated token before the visit is stored. Each such request then gets its own visit. That fixes the cross-tenant correlation and keeps the events, and it works for clients we do not control — but it does throw away session grouping, because one request is all we know about.The better fix is probably the one you hinted at with "falling back to an existing, but less durable token": when
createVisit()finds the cookie unwritable, generate an id and keep it in memory for the life of the page instead of sending null. Events from one page load then share a visit, the backend sees a well-formed token, and nothing 400s. It does not survive navigation, but a per-page-load visit is closer to the truth than either a shared row or one visit per event.Happy to open a PR for the client-side change if that direction is wanted.
When the page can't write an
Ahoy-Visitcookie (e.g. when it is embedded within an iframe),ahoy.getVisitId()returnsnulland the FormData that gets sent via Beacon carries it as a"null"string.We hit this with the app embedded in cross-origin iframes, where cookie writes silently no-op. One ahoy_visit row (we're on a ruby backend) with
visit_token == 'null'accumulated hundreds of thousands of events from many unrelated accounts over two years, and anything keyed onvisit_idas a session boundary correlates one visitor's activity with another's.Honestly, not sure what to suggest here. Stripping the null values out may also cause unwanted side-effects (in a ruby backend it's going to complain of missing params if I'm not mistaken), but keeping the "null" is also misleading, because if there's no proper validation, "null" becomes a valid token. Another idea would be falling back to an existing, but less durable token.