---
url: /internals/tracking-pipeline.md
---
# Tracking pipeline

Two halves: the browser side, where the theme renders one GTM snippet and a Consent Mode default and pushes events to the `dataLayer`, and the server side, where Evolve records conversions and delivers them to the GA4 Measurement Protocol and the Meta Conversions API. The webmaster view is on [Tracking and consent](/site/tracking-and-consent).

## The two halves

```mermaid
flowchart LR
    EM["Evolve::emitsTracking()<br/>TRACKING_EMIT, else the environment type"]
    subgraph browser["Browser, rendered by the theme"]
        HEAD["head.blade.php<br/>consent default, GTM snippet"] --> DL["dataLayer"]
        CC["cookie-consent.ts<br/>evolve_consent cookie, gtag consent update"] --> DL
        ET["event-tracking.ts<br/>generate_lead, purchase, funnel events"] --> DL
        DL --> GTM["GTM container<br/>GA4, Meta, LinkedIn, Pinterest, Ads tags"]
    end
    subgraph server["Server, core/src/Tracking"]
        CALL["FormController<br/>Shopify SendPurchaseTrackingEvent"] --> TM["TrackingManager::record()"]
        TM --> UC["UserContext snapshot<br/>IP, user agent, _ga, _fbp, _fbc, consent"]
        UC --> JOB["SendServerSideTrackingEvent<br/>one job per enabled driver"]
        JOB --> GA["GA4 Measurement Protocol"]
        JOB --> META["Meta Conversions API"]
    end
    EM -. gates .-> HEAD
    EM -. gates .-> TM
    CALL -. same EventId .-> ET
```

## Emission

`Evolve::emitsTracking()` (`core/src/Evolve.php`) decides whether anything leaves the site: `evolve.tracking.emit` (`TRACKING_EMIT`) when set, otherwise `EnvironmentType::emitsTracking()`, which is true for live only. The theme partials and `TrackingManager::record()` both consult it.

## Browser side

`theme/views/head.blade.php` renders, in order, the verification metas, preconnects (when emitting), the include of `theme::consentMode`, the reCAPTCHA script when a site key exists, and the GTM snippet when emitting and `services.google.gtm-id` is set. `theme/views/consentMode.blade.php` defines `dataLayer` and `gtag()`, reads the `evolve_consent` cookie, exposes `window.EVOLVE_CONSENT` and `window.evolveConsentAllows(category)`, and calls `gtag('consent', 'default', ...)` with `analytics_storage`, `ad_storage`, `ad_user_data`, `ad_personalization` granted or denied per the policy, plus `wait_for_update: 500` for `opt-in`. The same view defines `window.evolveConsentUpdate({analytics, marketing})`, the bridge for consent decided outside the banner (#300): it writes `evolve_consent` in the banner's format, sets `EVOLVE_CONSENT.state`, sends `gtag('consent', 'update')` only when `managedBy` is `evolve`, pushes `consent_updated` and dispatches `evolve:consent-updated`; unchanged choices are skipped. With `evolve.tracking.consent.cmp = cookiebot` it also listens to `CookiebotOnConsentReady`, `CookiebotOnAccept` and `CookiebotOnDecline`. `theme/views/consentPlatform.blade.php`, included last in `head.blade.php`, renders Cookiebot's `uc.js` (synchronous, `data-blockingmode="auto"`) when the driver and the ID are set. `theme/views/trackingConfig.blade.php` renders the deprecated `window.*_TRACKING` globals behind `TRACKING_LEGACY_VARIABLES` and the Hotjar snippet, both only when emitting.

In the starter theme, `theme/theme/src/lib/cookie-consent.ts` holds the Alpine store `consent` (analytics, marketing, `acceptAll()`, `rejectAll()`, `save()`), writes the cookie for `cookieLifetimeDays`, calls `gtag('consent', 'update', ...)` and pushes `consent_analytics` and `consent_marketing` to the `dataLayer`. `theme/theme/src/lib/event-tracking.ts` is the event layer: `genericTrack(event, payload)` pushes to the `dataLayer`, `window.gtmTrack` aliases it, helpers build `generate_lead`, `form_submit`, `purchase` and the funnel events, and `[x-track]` attributes are wired on DOM ready. `theme/gtm_container.json` is the matching container export.

## Server side

`core/src/Tracking/`:

| Class | Role |
|---|---|
| `TrackingManager` | `record(TrackingEvent)`: snapshots the request (IP, user agent, `_ga`, `_fbp`, `_fbc` cookies, consent) into a `UserContext` and dispatches one `Jobs\SendServerSideTrackingEvent` per enabled driver on `evolve.tracking.queue` |
| `TrackingEvent` | name, event ID, params, user context, timestamp; factory `generateLead(Form, Request, eventId)` |
| `UserContext`, `ConsentState`, `Consent`, `ConsentPolicy`, `ConsentCategory` | the consent snapshot taken at record time; `ConsentPolicy::OptOut` grants by default, `OptIn` denies |
| `Drivers\GoogleAnalyticsMeasurementProtocolDriver` | enabled when measurement ID and `ANALYTICS_API_SECRET` exist; posts to the collect endpoint, or to the debug endpoint with `TRACKING_DEBUG` |
| `Drivers\MetaConversionsApiDriver` | enabled when pixel ID and `FACEBOOK_CONVERSIONS_API_KEY` exist; adds `FACEBOOK_TEST_EVENT_CODE` when set |
| `Support\HashesPersonalData` | SHA-256 of normalised email and digits-only phone before they leave |
| `Support\EventId` | the shared ID that lets the browser and server copy of one event deduplicate |

Callers: `Http\Controllers\Frontend\FormController` records `generate_lead` after a stored lead and hands the event ID to the redirect so the theme pushes the browser copy with the same ID; `shopify/src/.../SendPurchaseTrackingEvent` listens to `OrderUpdatedOrCreated`. The consent snapshot gates every delivery, so a visitor who declined marketing produces no Meta event. Drivers without credentials disable themselves, which is why `TRACKING_SERVER_SIDE=true` is a safe default.

`EncryptCookies::except()` in the core provider keeps `evolve_consent`, `_ga`, `_fbp` and `_fbc` unencrypted; otherwise PHP could not read them.

## Configuration

`core/config/evolve.php` → `tracking` (`server-side`, `emit`, `queue`, `debug`, `legacy-variables`, `consent.policy`, `consent.via-cmp`, `consent.cmp`, `consent.cookiebot-id`, `consent.cookie-name`, `consent.cookie-lifetime-days`), `evolve.cookie-consent` for the banner; platform IDs in `core/config/services.php`; the attribution cookie in `core/config/tracking.php` (`footprints`, duration, blacklisted paths). `Http\Middleware\AttributionTracking` writes `footprints` only when `Consent::allows(Marketing)`; before that the token sits in the session under `footprints_token`, and `AttributionTracking::currentToken()` (cookie, else session token) is what `Visit::previousVisits()` and `TracksAttribution` match leads by (#302). `Settings\TrackingSettings` overlays the public IDs, the policy, `server_side` and `cookie_banner`.

## Observability

`Health\Checks\TrackingCheck` fails a live site with missing or malformed GTM or GA4 IDs or a silencing `TRACKING_EMIT`, warns on a staging site left emitting, on an opt-in site whose only consent source is `via-cmp` (the Evolve cookie is never written) and on the Cookiebot driver without an ID. `HealthCheckController` returns the `tracking` block (type, emits, override, IDs, secrets-set booleans, consent, `sources`) that Fleet compares per environment. Tests: `core/tests/Feature/Tracking/*`.
