Analytics
Predictor emits a typed event for every meaningful step of the user journey — viewed, started a
prediction, submitted, joined a private league, and so on. Use them to build funnels, conversion
goals and remarketing audiences in whatever analytics stack you already run.
Events are fire-and-forget observers: they never block rendering, never receive a return value,
and never change what the component does. A handler that throws is caught and ignored.
Currently
Predictoronly. The bus is component-agnostic, so other components can join without
a breaking change. For the participation-model callbacks on the play components
(onFinish/onShare), see Callbacks.
Two ways to consume
Both receive the identical stream — pick whichever fits your integration. You can use both at once.
1. The callbacks prop
callbacks propimport { Predictor } from "fansunited-frontend-components";
<Predictor
entityId="predictor-template-123"
sdk={sdk}
language="en"
callbacks={{
onEvent: (event) => {
analytics.track(event.name, { ...event.params, ...event.context });
},
}}
/>2. The global bus
For when you can't reach the JSX — a tag manager, a separately-bundled script, or anything that
loads after the component mounts.
import { componentEvents } from "fansunited-frontend-components";
const unsubscribe = componentEvents.subscribe((event) => {
console.log(event.component, event.name, event.params);
});subscribe returns an unsubscribe function. It also replays recent events to a new
subscriber — the last 50, within the last 5 minutes — so a listener that attaches after the
component has mounted still sees widget_view and anything else it missed. Without that, funnel
denominators would silently under-count.
The bus is subscribe-only by design. There is no public way to emit, so nothing on the page can
forge events into your analytics.
The event shape
type ComponentEventParams = Record<string, string | number | boolean | null>;
interface PredictorComponentEvent {
component: "predictor";
name: PredictorAnalyticsEventName;
params: ComponentEventParams;
context: PredictorAnalyticsContext;
}
/** Discriminated on `component`. One member today. */
type ComponentEvent = PredictorComponentEvent;
interface PredictorAnalyticsContext {
entityId: string;
template: "standard" | "embed";
language: LanguageType;
userIsLoggedIn: boolean;
templateTitle: string | null;
}context is the same on every event from a given instance; params are per-event and listed
below.
ComponentEvent is a discriminated union keyed on component, and every component publishes
to the one shared bus — so component is how a subscriber tells whose event it is. Narrowing it
narrows name and context together:
componentEvents.subscribe((event) => {
if (event.component !== "predictor") return;
event.context.entityId; // typed as PredictorAnalyticsContext here
});Narrowing now is worth the two lines: when a second component starts emitting, the union widens
and code that already narrows keeps compiling unchanged.
Three properties worth relying on:
- Flat primitives only. No nested objects or arrays. Multi-value fields (several markets) are
comma-joined strings, because tag managers and GA4 handle array parameters poorly. - Every key an event defines is always present,
nullrather than absent. This matters for
tag managers specifically — see the GTM section. - No personal data. No profile id, email, display name or token, and no private-league
invitation code. The only user-level fact iscontext.userIsLoggedIn. Identity stitching is
yours to do, in your own stack, under your own consent rules.
Event reference
Every event also carries context. PredictorAnalyticsEventName is the union of all name values.
Lifecycle
| Event | Fires when | Params |
|---|---|---|
widget_view | The template request settles, once per mount. Held until then, because before it the component is a skeleton. | — |
tab_view | A tab becomes visible — the initial one, a tab click, or a #hash change. | tab |
matchweek_change | Prev/next matchweek navigation on the Play tab. | direction ("prev"/"next"), group_id |
Prediction funnel
All five carry the match descriptors match_id, home_team, home_team_id, away_team,
away_team_id, kickoff_at.
Use the ids as join keys, not the names. match_id and the team ids are Fans United's canonical,
provider-agnostic identifiers (fb:m:*, fb:t:*) — stable across languages and across whichever
football data provider sits behind the account. match_id is also the exact value posted to
makeFootballPrediction, so an event joins 1:1 with its prediction record. The home_team /
away_team names are localised by the language prop ("Manchester City" vs "Манчестър Сити")
and exist for readable reports only.
| Event | Fires when | Extra params |
|---|---|---|
prediction_started | The user's first answer on a card — a stepper, a 1X2 pick, any control. Once per match, however much they then change it. | market |
prediction_submitted | At least one market saved. | markets (comma-joined), market_count, is_edit |
prediction_failed | At least one market failed to save. | markets, market_count, is_edit |
prediction_edited | A saved market is re-opened for editing. | market |
prediction_removed | A saved prediction is deleted. | market |
A partially-failed multi-market submit emits both
prediction_submittedand
prediction_failed, each listing its own markets — so a funnel counts what saved without
losing what didn't.
Gates
| Event | Fires when | Params |
|---|---|---|
sign_in_prompt_shown | A logged-out user submits a prediction. | trigger, plus the match descriptors above |
sign_in_click | The sign-in CTA is clicked. Your signInCTA.onClick still runs. | location ("play_tab" / "private_leagues") |
consent_gate_shown | Required consents are missing and the gate opens. | consent_ids (comma-joined) |
consent_accepted | The gate is accepted. | consent_ids |
consent_declined | The gate is dismissed. | consent_ids |
A
signInCTA.componentyou supply owns its own clicks, sosign_in_clickdoes not fire for it —
instrument your own component instead.
Engagement
| Event | Fires when | Params |
|---|---|---|
odds_click | A bookmaker odd is clicked. Reports the odd clicked, not the one the prediction implies. | match_id, outcome, odd_value, operator |
leaderboard_page_change | Leaderboard pagination. | page, total_pages |
embed_success_cta_click | The post-submission CTA on the embed template. | match_id, cta_label, url |
Private leagues
All carry league_id and league_name; either can be null where the calling surface doesn't
have it.
| Event | Fires when |
|---|---|
private_league_created | A league is created. |
private_league_joined | A league is joined by code. |
private_league_view | A league's detail view is opened. |
private_league_invite_shared | An invitation code is copied. The code itself is never reported. |
private_league_invitation_accepted | A pending invitation is accepted. |
private_league_invitation_declined | A pending invitation is declined. |
private_league_left | The user leaves a league. |
private_league_deleted | An owner deletes a league. |
Wiring this to Google Tag Manager
Why this is needed at all
Every component renders inside its own shadow root. GTM's built-in no-code triggers — All
Clicks, Form Submission, Element Visibility — attach listeners at the document level, and a
click from inside a shadow root arrives retargeted to the shadow host. GTM sees one anonymous
<div> for every click anywhere in the component, with empty Click Text, Click Classes and
Click ID. Native submit events don't escape a shadow root at all.
That's inherent to the CSS isolation, not something a GTM setting can undo. Pushing named events
into the data layer is the standard answer, and it's better anyway: the events are stable and
documented, rather than tied to markup that changes between releases.
The adapter
One push per event, from either sink:
import { componentEvents } from "fansunited-frontend-components";
window.dataLayer = window.dataLayer || [];
componentEvents.subscribe((event) => {
window.dataLayer.push({
event: `fu_${event.name}`,
...event.params,
...event.context,
});
});Then in GTM: a Custom Event trigger on fu_prediction_submitted, a GA4 Event tag, and
Data Layer Variables for the parameters you want. A single trigger with the regex ^fu_ and
{{Event}} as the tag's event name forwards the whole catalogue in one tag.
Two things to get right
Prefix the event names. dataLayer is shared with everything else on the page. fu_ keeps
the component's vocabulary from colliding with the host site's own events. The library
deliberately does not prefix for you — that would bake our naming into your container.
GTM merges pushes, it does not replace them. If one push sets match_id: "match-1" and a
later event omits that key, GTM still reports "match-1" for the later one. That's the most
common integration bug in this area. Two mitigations, both fine:
// Explicitly clear the keys this event doesn't define.
const ALL_KEYS = ["match_id", "market", "markets", "tab", "league_id", "page" /* … */];
componentEvents.subscribe((event) => {
const cleared = Object.fromEntries(ALL_KEYS.map((k) => [k, undefined]));
window.dataLayer.push({ event: `fu_${event.name}`, ...cleared, ...event.params, ...event.context });
});…or nest everything under one key and read it with dot notation (fu.match_id) in GTM:
window.dataLayer.push({ event: `fu_${event.name}`, fu: { ...event.params, ...event.context } });Campaign patterns this supports
- Conversion tracking — mark
prediction_submittedas a GA4 key event; combined with UTMs you
get cost per prediction, per campaign. - Abandonment audience — users who fired
prediction_startedbut never
prediction_submitted. A direct remarketing segment. - Virality —
private_league_createdandprivate_league_joinedagainst
private_league_invite_sharedgives an invite-to-join rate. - Friction —
sign_in_prompt_shownandconsent_declinedshow where the funnel leaks before
a prediction is ever saved.
Consent mode
The component pushes nothing on its own — your adapter does. Gate it however your consent policy
requires; remember that once events are in dataLayer, every tag in the container can read them.
That's exactly why the payloads carry no personal data.
Testing without a container
window.dataLayer.filter((e) => String(e.event).startsWith("fu_"));The Predictor's Analytics Storybook story logs every event from both sinks side by side, which
is the quickest way to see the catalogue without wiring anything up.
Updated about 3 hours ago
