@v4/analytics
The site's measurement: OneTrust's cookie banner, ELB's GTM container, consent gating, page views and the tracking plan's events.
Overview
@v4/analytics is how the site measures. It is the only package that loads OneTrust (the cookie banner) and Google Tag Manager (ELB's container), and the only one that pushes the site's events to window.dataLayer. There is no GA4 or gtag.js code in the site: GA4, Nielsen and every other tag live in ELB's container, configured by Joe Dawber's team, and read what the site pushes.
A server-safe root holds the rules (settings, consent, the head scripts, the tracking plan's events, what a page and its markup declare); @v4/analytics/client holds the provider, the declarations and the hooks that send events. Built like @v4/wsc and @v4/ecal, and off until its variables are set — the shape every club integration shares is described in Club integrations.
In the head: OneTrust, then GTM
The root layout writes, in this order and before any other script:
- OneTrust's banner script, loaded
async(7 Nov 2026: synchronous, as OneTrust ships it, it held the first paint for about 0.77s, and it never made consent known before GTM — the stub loads the banner asynchronously itself; Consent Mode's default, set inline before GTM, is what holds the tags), with the environment's domain script id, and itsOptanonWrappercallback. - The dataLayer, with Google Consent Mode v2's default: every storage type denied except security, waiting up to 500 ms for OneTrust's answer.
- GTM's snippet for the environment's container.
They are plain server-rendered tags, so the browser runs them in document order. Each id is checked before it is written into the page (GTM-XXXXXXX; a UUID, -test for OneTrust's test script): one that fails is left out, with its scripts. The config refuses it at build first.
| Variable | Value |
|---|---|
NEXT_PUBLIC_GTM_CONTAINER_ID |
ELB's container for the environment. Unset, no container loads. |
NEXT_PUBLIC_ONETRUST_DOMAIN_SCRIPT_ID |
OneTrust's domain script; its -test script off production. Unset, no banner. |
NEXT_PUBLIC_WSC_TRACKING_PREVIEW |
Dev only: a tester may grant consent by hand (below). Named for the WSC tracking it began with |
Consent gates every event
The tracking plan gives each event one OneTrust category — Strictly Necessary, Analytics, Marketing or Functional — and the site sends it only once the reader has accepted that category. OneTrust names its categories by group id; which id is which is ELB's set-up, so they are settings (features.consent.groups in club.config.ts), defaulting to OneTrust's own (C0001–C0004).
| The reader has… | An event in a category they… |
|---|---|
| accepted it | is sent at once |
| not answered the banner yet | waits, and is sent the moment they accept |
| answered without it | is dropped |
Waiting is what lets a reader who accepts on their first page still have that page counted. At most 50 events wait; a reload starts empty. Without OneTrust on the page nothing is ever sent — except to a tester where a preview is allowed (every local dev server, and Dev with NEXT_PUBLIC_WSC_TRACKING_PREVIEW=true): localStorage.setItem("wsc:tracking-consent", "granted") in the console grants everything. Where OneTrust is on the page the switch is ignored, from the first event: OneTrust decides, however long its script takes to report.
Each change of answer is also sent to Consent Mode as gtag('consent', 'update', …) (pushed as an arguments object, which is the only shape GTM reads): Analytics opens analytics_storage; Marketing the three ad types; Functional functionality and personalisation.
The WSC events follow the same answer for the Analytics category: apps/web's WscTrackingConsent hands it to @v4/wsc.
Page views
page_viewed is pushed once per page: on the first load and every change of address — a change of the query or hash alone is not a page view. GA4's own history page views are off in ELB's container, which turns this push into GA4's page_view.
The page says what it is. Every route renders <PageAnalytics page={…} /> with what only the route knows: the page type, its competition, the id of what it shows. The address alone cannot tell an article from a category under /news/…. The view is sent once the page has declared itself, which may be after its address changes (a page that streams in). A page that never declares is still counted, as page_type: "unknown", after 5 seconds — it shows up in GTM's preview instead of going missing.
A page that moves itself to a new address (a team's tabs, a games round, the next video of a playlist) is viewed again at the new address. A page Next kept mounted and shows again on Back is viewed again. The 404 is viewed at the address the reader asked for.
After the first page, a page view waits up to one second (TITLE_WAIT_MS) for the new page's own <title>: Next can stream a page's metadata in after the page itself, and without the wait page_title would carry the previous page's. A view still waiting when the reader moves on again goes at once, with the title it has, so the next title to land is read for the next page only.
An event's page_path is the address as last reported to the provider, not the browser's at the moment of sending: Next moves the address before the page it leaves cleans up, so an event sent on the way out (a pause on hide, the Game Centre's exit) still belongs to the page it leaves.
page_type |
Declared by |
|---|---|
home, article, news_listing, content_page |
@v4/content-app's routes |
video_landing, video_detail, video_playlist, video_collection, video_category |
@v4/video-app's routes |
team_list, team, player_list, player, coach_list, coach, game_list, game, standings, stats |
@v4/basketball-app's routes |
not_found, error |
apps/web's not-found.tsx and error.tsx |
The values are provisional until ELB agrees the vocabulary.
What every event carries
The tracking plan's common properties that the browser knows, on every push: event_id, timestamp (when it happened, even if it waited), event_category, platform: "web", language, competition (the page's section slug, null for content linked to none), page_path, user_id (null before sign-in), and the consent snapshot as flat booleans (consent_state_analytics and the rest). GTM sets anonymous_id, session_id and device_category.
page_viewed adds page_type, page_title, page_location, page_referrer (the page before on the site, or the referring site on the first load), content_type, content_id, team_id, player_id and game_id. Every key is always present, null where the page has none: GTM's data model merges pushes, so a key left out would keep the previous page's value.
user_id
The reader's Cortex id (the session's userId, the ID token's sub). The header asks for the session once per load, and only when the signed-in hint cookie is there; a possibly signed-in reader's first page view waits for that answer, up to 1.5 seconds (USER_WAIT_MS), so it carries the id. The header hands the answer over with <AnalyticsUser />.
The site's events
Every event is one row of the catalogue, src/tracking-plan.ts: its name, its taxonomy category (event_category), the OneTrust category that gates it, and where its name was read. Only the plan's Legend tab could be read, so a name is marked by its source — v6-legend, jira, approach (the pre-V6 Approach page), wsc-list or assumed (the plan's convention applied; check it in GTM's preview). A rename is one line there, and track() takes only catalogue names and each event's own properties, so a typo is a type error.
Events are sent three ways, always on success, never on intent, and at most one per click (V6's legend):
| How | What the code does | Used for |
|---|---|---|
| A declared link | analyticsClick(name, props) on a link or around it (most components' nativeProps). One capture-phase listener on the document reports a followed link; the innermost declaration wins |
navigation, sponsors, the nav promo |
| A card in a list | analyticsListItem(name, props) on the card (newsCardProps, videoCardProps), analyticsList({ source_surface, display_pattern }) on its list. The position (from 1) is counted at the click among the list's shown cards |
content_selected (game_selected once games link anywhere) |
| Seen | analyticsView(name, props): half of the element on screen for a second, while the tab is shown, once per page view |
sponsor logos, the partner band, promo blocks |
| Sent by code | useTrack(), and the hooks over it: useVideoTracking, useShareTracking, useFilterTracking, useGameCentreTracking |
video, shares, filters, a button that navigates, WSC, Game Centre |
The precedence for a followed link: a promo block always reports in_product_placement_clicked; otherwise a link to a partner reports the partner's event (eltv_cta_clicked, fantasy_deeplink_clicked, hosts in apps/web/src/lib/analytics.ts); otherwise its declaration. A Login control is a button, so it never reports navigation. A click that names no source_surface takes the page's type.
| Area | Events | Where |
|---|---|---|
| General | navigation_item_clicked (header, flyout, menu, crest strip, footer), competition_switched (Functional consent), content_selected, content_shared, filter_applied, the page events below, video, WSC, eltv_cta_clicked, fantasy_deeplink_clicked |
apps/web site-chrome.tsx; the card builders and lists; share-buttons.tsx; every listing, schedule, standings, stats and list filter |
| Page | article_viewed, team_viewed, player_viewed, standings_viewed, schedule_viewed, stats_viewed, vod_library_viewed, vod_playlist_viewed: sent right behind the page's page_viewed, from PageAnalytics' event |
each route's declaration |
| Video | video_started (once per video), video_paused, video_completed, video_errored — Mux and YouTube (enablejsapi=1); Brightcove reports nothing yet |
the video page, the article header's video, Video Item embeds |
| WSC | WSC: story_opened, moment_opened, and ELB's WSC list as agreed (cta_click, like, unlike, share_click, widget_visible, widget_click), now through the same consent and common properties; the SDK's own category is wsc_event_category |
@v4/wsc (setWscEventSink) and apps/web WscTrackingConsent |
| Sponsors | sponsored_content_viewed, sponsored_content_clicked, sponsorship_banner_viewed, in_product_placement_viewed, in_product_placement_clicked, club_link_clicked — Marketing consent only |
sponsor badges (articles, listings, content pages, countdowns, video), the footer's partner band, every promo block and the nav promo, a club's website on its page |
| Game Centre | game_centre_viewed, _tab_viewed, _stats_overlay_toggled, _shot_selected, _play_highlight_viewed, _boxscore_player_selected, _exited, and game_centre_heartbeat every 60 s during a live game while the tab is shown |
the Game Centre (@v4/basketball-app game-centre-shell.tsx): a tab replacing another, a box-score player, the player modal (the stats overlay); game_id is the season's code and the game's (E2025-123) |
Not sent yet, and why: game_selected (not wired: game cards only began linking to the Game Centre when it landed), game_centre_shot_selected and game_centre_play_highlight_viewed (the Game Centre has no shot chart or highlights), game_prediction_submitted (the site has no predictor), Brightcove's video events (decision D4), a sponsor badge's click (the badges are not links). A video sponsor has no name in the platform's data, so its logo's file name stands in.
Still to come
| What | Waits on |
|---|---|
| The Game Centre's shot and highlight events | a shot chart and highlights on the page |
game_selected on game cards |
wiring: the cards now link to the Game Centre |
| The country-specific sponsor logos | the sponsor logic sign-off |
Waiting on ELB: the container id per environment, legal's guidance on the consent categories (whether page_viewed may go as Strictly Necessary — it is gated by Analytics until then), the user_id field, and the tracking plan's event rows — every assumed name and property in the catalogue is checked against them.
Where it lives
| File | What |
|---|---|
packages/analytics/src/settings.ts |
AnalyticsSettings: the GTM container, the OneTrust script id and groups, and the consent preview — plain values the app resolves from club.config.ts |
packages/analytics/src/head.ts |
The head scripts, in order |
packages/analytics/src/consent.ts |
OneTrust's answer → the four categories → send, wait or drop; Consent Mode |
packages/analytics/src/pipeline.ts |
The queue between an event and the dataLayer |
packages/analytics/src/page-views.ts |
When a page view happens |
packages/analytics/src/page.ts, common.ts |
The page view's and every event's properties |
packages/analytics/src/tracking-plan.ts |
The plan's events: name, category, consent, where the name was read, properties |
packages/analytics/src/declarations.ts |
What markup declares: a followed link, a card in a list, a placement seen; the partners' hosts |
packages/analytics/src/heartbeat.ts |
The Game Centre's heartbeat |
packages/analytics/src/client/ |
AnalyticsProvider, PageAnalytics, AnalyticsUser, useAnalyticsConsent, useTrack and its hooks, the click listener and the view observer |
apps/web/src/lib/analytics.ts |
The settings, from club.config.ts |
apps/web/src/app/[locale]/layout.tsx |
The head scripts and the provider |