Skip to content

Tracking snippet embeds the client id server-side, which breaks under full-page caching #7

Description

@loevgaard

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

  1. 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).
  2. 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",…}' });
  3. The browser POSTs it to TrackAction (/track), creating a Model\Source row keyed by that client id.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions