Summary
The capture flow resolves the visitor's client id server-side and bakes it into the injected inline <script> as a literal payload. When the storefront HTML is served from a full-page cache (Varnish, Symfony HttpCache, a CDN, FOSHttpCache, Sylius page caching, …), the cached HTML carries one visitor's client id, which is then served to everyone — silently corrupting attribution data.
Related: PR #6 documented this as a README caveat but did not fix it, because a proper fix is a design change (how the id enters the payload) rather than a bug fix.
Mechanism
EventSubscriber\AddJavascriptSubscriber runs on the storefront request and calls Resolver\ClientInformationResolver::resolve(), which reads the client id from the setono_client_id cookie (via setono/client-bundle's ClientContext).
- That id is embedded into the inline script as a literal JSON body:
fetch('/track', { method:'POST', headers:{'Content-Type':'application/json'},
body: '{"clientId":"abc-123","page":"…","source":"google",…}' });
- The browser POSTs it to
TrackAction (/track), creating a Model\Source row keyed by that client id.
- At checkout,
EventSubscriber\AddClientIdSubscriber stamps the order with the current client id, and Provider\OrderAttributionProvider joins the order to Source rows with the same id.
The whole chain assumes the embedded id belongs to the visitor viewing the page.
The problem
A full-page cache stores the rendered HTML and serves the same bytes to many visitors. That HTML contains whichever visitor's client id populated the cache. So every visitor served the cached page:
- runs the same script and POSTs
clientId = A (the cached one), regardless of who they are;
- has all their page views recorded under client A's
Source rows.
Result: one visitor's identity leaks into everyone else's tracking; source/medium data collapses onto the cached id. It fails silently — no error, just wrong numbers. The order side is usually spared (checkout is typically uncached, so a fresh id is stamped), which makes it harder to notice: orders get correct-but-unmatchable ids while the Source table fills up with one poisoned id.
Root cause
The client id is resolved server-side and embedded into the response body, instead of being read client-side from the cookie at fetch time.
Proposed fix
The setono_client_id cookie is deliberately HttpOnly=false (JS-readable). Rework the injected snippet so the browser reads the client id (and derives page/referrer) client-side at POST time instead of embedding server-resolved values. Then the cached HTML carries no visitor-specific data and is safe to share.
Sketch:
// read the JS-readable client cookie in the browser, not server-side
const clientId = document.cookie.match(/(?:^|;\s*)setono_client_id=([^;]+)/)?.[1];
fetch('/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId,
page: location.href,
referrer: document.referrer,
// source/medium/campaign still resolved server-side from the request, which is fine to cache
...serverResolvedCampaign,
}),
});
Open questions to resolve in the implementation:
- Only
clientId, page, and referrer are truly per-visitor. source/medium/campaign come from the request's query/referrer and are safe to keep server-side — but note the landing URL that determines them is itself part of the cached page, so campaign attribution on a cached landing page needs thought.
- The cookie format is a dotted string (
version.firstSeenAt.lastSeenAt.clientId); the JS must parse out the clientId segment (or the server can expose just the id). Consider a small helper rather than a cookie regex.
- Decide how to behave when the cookie isn't present yet (first-ever visit, before
client-bundle's response listener sets it).
Interim mitigation (already documented in the README)
Exclude pages carrying the tracking snippet from full-page caching, or only enable the JS feature on uncached responses.
Summary
The capture flow resolves the visitor's client id server-side and bakes it into the injected inline
<script>as a literal payload. When the storefront HTML is served from a full-page cache (Varnish, Symfony HttpCache, a CDN, FOSHttpCache, Sylius page caching, …), the cached HTML carries one visitor's client id, which is then served to everyone — silently corrupting attribution data.Related: PR #6 documented this as a README caveat but did not fix it, because a proper fix is a design change (how the id enters the payload) rather than a bug fix.
Mechanism
EventSubscriber\AddJavascriptSubscriberruns on the storefront request and callsResolver\ClientInformationResolver::resolve(), which reads the client id from thesetono_client_idcookie (viasetono/client-bundle'sClientContext).TrackAction(/track), creating aModel\Sourcerow keyed by that client id.EventSubscriber\AddClientIdSubscriberstamps the order with the current client id, andProvider\OrderAttributionProviderjoins the order toSourcerows with the same id.The whole chain assumes the embedded id belongs to the visitor viewing the page.
The problem
A full-page cache stores the rendered HTML and serves the same bytes to many visitors. That HTML contains whichever visitor's client id populated the cache. So every visitor served the cached page:
clientId = A(the cached one), regardless of who they are;Sourcerows.Result: one visitor's identity leaks into everyone else's tracking; source/medium data collapses onto the cached id. It fails silently — no error, just wrong numbers. The order side is usually spared (checkout is typically uncached, so a fresh id is stamped), which makes it harder to notice: orders get correct-but-unmatchable ids while the
Sourcetable fills up with one poisoned id.Root cause
The client id is resolved server-side and embedded into the response body, instead of being read client-side from the cookie at fetch time.
Proposed fix
The
setono_client_idcookie is deliberatelyHttpOnly=false(JS-readable). Rework the injected snippet so the browser reads the client id (and derivespage/referrer) client-side at POST time instead of embedding server-resolved values. Then the cached HTML carries no visitor-specific data and is safe to share.Sketch:
Open questions to resolve in the implementation:
clientId,page, andreferrerare truly per-visitor.source/medium/campaigncome from the request's query/referrer and are safe to keep server-side — but note the landing URL that determines them is itself part of the cached page, so campaign attribution on a cached landing page needs thought.version.firstSeenAt.lastSeenAt.clientId); the JS must parse out theclientIdsegment (or the server can expose just the id). Consider a small helper rather than a cookie regex.client-bundle's response listener sets it).Interim mitigation (already documented in the README)
Exclude pages carrying the tracking snippet from full-page caching, or only enable the JS feature on uncached responses.