@3halves-labs/score-tracker
v1.21.2
Published
Lightweight React-friendly website tracking SDK for Score Anything Loyalty. Batching, session/UTM, SPA route, scroll, video, and custom events.
Maintainers
Readme
@3halves-labs/score-tracker
React-friendly tracking SDK for the Score platform.
- ✅ Batching & retry
- ✅ Session/anonymous IDs
- ✅ Identity graph —
alias/group/ungroup; group memberships persist + stamp every event with at-event-time context - ✅ Deferred identify replay — opt-in re-emit of pre-identify events with
_backfill+_originalEventIdfor warehouse joining - ✅ Delivery guarantees —
criticalevents get flush priority, bypass visibility-pause, and emitdelivery_failedon age-eviction - ✅ Cross-domain identity stitching — opt-in linker param carries
anonymousId/sessionIdbetween cooperating domains - ✅ Middleware pipeline —
tracker.use((evt, next) => …)for enrichment / transformation / custom drops - ✅ Declarative React components —
<Track>,<TrackVisibility>,<Sponsor>for CMS / marketing-friendly wiring - ✅ UTM & referrer capture
- ✅ SPA route + pageviews
- ✅ Scroll thresholds
- ✅ Video events (play/pause/end)
- ✅ Field-level PII handling — denylist or per-field actions (drop / redact / tokenize / hash)
- ✅ React provider + hook
- ✅ Consent gating (
init({ getConsent })+shutdown()) — boolean or category-aware ({ analytics, personalization, advertising }) - ✅ Regional privacy policy engine —
privacy: 'auto' | 'gdpr' | 'cpra' | 'lgpd' | 'quebec' | 'off'; auto-adjusts consent defaults, retention, and fingerprint signals per jurisdiction - ✅ Session-replay correlation — opt-in
replayIdstamped on every event to join analytics to an external recording (rrweb/FullStory); respects the privacy policy - ✅ Multi-destination fan-out — mirror events to extra sinks (built-in
ga4Destination; bring your own) alongside Score; independent + per-destination consent-gated - ✅ Typed events (
defineEvent) — compile-time prop checking, no runtime cost, composes with consent categories - ✅ Web vitals capture — opt-in LCP / CLS / INP / TTFB / FCP via
web-vitals(optional peer dep) - ✅ Adaptive batching — opt-in backpressure based on flush latency, page visibility, and Save-Data
- ✅ Dev debug overlay — opt-in floating panel showing live
inspect()state (hotkey-toggleable) - ✅ Auth0-friendly JWT plumbing (
init({ getToken })— refreshed each flush) - ✅ Optional durable queue (IndexedDB) — survives reload, tab crash, stadium WiFi
- ✅ Match-state context enrichment — stamp live
gameMinute/scoreState/ etc. per event - ✅ Engagement scoring (
tracker.getEngagementScore()) — passive 0–100 score from dwell, scroll, interactions, video, return frequency - ✅ Sponsor surface tracking — viewability impression / dwell / click via
data-score-sponsortagging + IntersectionObserver - ✅
tracker.inspect()debug snapshot — queue/consent/identity/persistence/engagement/sponsorship
Install
npm i @3halves-labs/score-tracker
# or
pnpm add @3halves-labs/score-trackerQuick start
import { ScoreTracker } from '@3halves-labs/score-tracker';
import { ScoreProvider, useScore } from '@3halves-labs/score-tracker/react';
const tracker = new ScoreTracker({
publishableKey: 'pub_abc123',
endpoint: 'https://middleware.example.com/api/tracking/website',
siteId: 'mysite',
batch: { max: 20, intervalMs: 2500 },
respectDNT: true,
autoPageviews: true,
autoRouteChange: true,
autoScroll: [25,50,75,100],
autoVideo: true,
debug: false
});
function App() {
return (
<ScoreProvider tracker={tracker}>
{/* your app */}
</ScoreProvider>
);
}Identity linking (anonymous → signed-in)
Every event the tracker sends carries a context.anonymousId generated on first
visit. The backend resolves a fan from that id through a link table — so until
the link exists, events fired before sign-in cannot be attributed to anyone,
no tracking rule can award, and no goal registers progress.
identify() creates that link for you:
// After your auth flow completes:
tracker.identify(user.id);That POSTs once to /api/identity/link-anonymous with the anonymousId this
browser has been sending all along, authenticated with the fan token from
getToken (or setUserToken). Nothing else changes — the id is not regenerated
and no extra event is emitted.
Ordering is forgiving. If the token arrives after identify() — a popup
login, a silent refresh, a pasted JWT — setUserToken() retries the link. You
do not need to sequence them.
It is consent-gated. The link only happens with analytics consent, for the
same reason events are gated: associating a pre-auth browsing identifier with a
named person is exactly the processing consent governs. This is not lossy — with
consent denied, those pre-auth events were never sent either, so there is
nothing to back-fill. Note that granting consent later in the session does not
retroactively link.
Check it worked via inspect():
tracker.inspect().identity.link;
// { linked: 1, skipped: 0, lastSkipReason: null }lastSkipReason is the field to read when attribution is not happening:
'consent', 'no_token', 'already_linked' or 'error'.
| Option | Default | Notes |
| --- | --- | --- |
| identityEndpoint | derived | Defaults to /api/identity/link-anonymous resolved against endpoint's origin — the whole path is replaced, not just the last segment. If your ingest is proxied under a prefix (/proxy/api/tracking/website), set this explicitly. |
The call is idempotent server-side (201 created, 200 duplicate), and the SDK
additionally remembers the (anonymousId, userId) pair so a reload does not
re-post. The pair is deliberate: a shared device where a second fan signs in
produces a second link.
Consent gating
The tracker does not make consent decisions for you. It accepts a getConsent predicate that you wire to your CMP (OneTrust, Cookiebot, etc.) — events only fire while that predicate resolves truthy. If consent is revoked mid-session, call shutdown() to flush the in-flight buffer (via sendBeacon when available), unbind listeners, restore monkey-patched history methods, and lock the gate.
const tracker = new ScoreTracker({
publishableKey: 'pub_abc123',
endpoint: 'https://middleware.example.com/api/tracking/website',
siteId: 'mysite',
// Sync or async predicate. Throwing or rejecting is treated as DENIED.
getConsent: () => window.OnetrustActiveGroups?.includes('C0002') ?? false,
});
// Recommended entry point — awaits any async predicate before starting.
await tracker.init();init() is the recommended entry point. start() is preserved for backward compat and honors a synchronous getConsent; async predicates require init().
OneTrust recipe
OneTrust dispatches a OneTrustGroupsUpdated event whenever the user's choice changes. The simplest pattern is to (re-)init() on every event and let the SDK consult the predicate fresh:
const tracker = new ScoreTracker({
publishableKey: 'pub_abc123',
endpoint: 'https://middleware.example.com/api/tracking/website',
siteId: 'mysite',
// Adjust the group ID to whichever OneTrust group your legal team has
// designated for product analytics / loyalty tracking.
getConsent: () => (window.OnetrustActiveGroups ?? '').split(',').includes('C0002'),
});
await tracker.init(); // initial run on page load
window.addEventListener('OneTrustGroupsUpdated', async () => {
// Revocation: tear everything down and stop attribution.
tracker.shutdown();
// Re-grant: init() consults getConsent and only starts if it's now truthy.
await tracker.init();
});Notes:
- Events that arrive before consent is granted are dropped, not buffered. The SDK does not try to retroactively attribute pre-consent events — by design.
shutdown()is final until the nextinit(). It clears the timer, unbinds all listeners, restoreshistory.pushState/history.replaceState, best-effort flushes the queue withnavigator.sendBeacon, and locks the consent gate.- Server-side rendering: the constructor is SSR-safe.
init()/start()must be called in a browser context. - Failures fail closed: if your
getConsentthrows or rejects, the SDK treats it as denied and stays idle.
Category-aware consent
getConsent can return either a boolean (legacy) or a category object — the SDK normalizes both shapes internally:
getConsent: () => ({
analytics: true, // page views, scroll, video, custom track()
personalization: false, // recommendation / segmentation signals
advertising: false, // sponsor impression, ad_*, etc.
})Categories: analytics / personalization / advertising. The tracker is considered active if any category is true; a fully-false object behaves like getConsent: () => false.
Per-event declarations gate which events can fire under which categories:
tracker.track('ad_impression', { sponsor: 'Acme' }, {
requires: ['advertising'],
});
// Multiple categories — all must be true:
tracker.track('personalized_ad', props, {
requires: ['advertising', 'personalization'],
});Events whose requires lists a category currently false are dropped and counted — visible via inspect().consent.dropped. Built-in events declare their own categories:
| Event | Declares |
|---|---|
| pageview, route_change, scroll_depth, video_*, form_submit, cta_click | analytics |
| sponsor_impression, sponsor_dwell, sponsor_click | advertising |
| Custom track(name, props) without options.requires | (nothing — fires whenever the global gate is open) |
OneTrust category recipe
Map OneTrust groups to the consent categories — these IDs follow OneTrust's defaults; check your tenant config and adjust:
function readOneTrustCategories() {
const groups = (window.OnetrustActiveGroups ?? '').split(',');
return {
analytics: groups.includes('C0002'), // Performance
personalization: groups.includes('C0003'), // Functional
advertising: groups.includes('C0004'), // Targeting / Advertising
};
}
const tracker = new ScoreTracker({
publishableKey: 'pub_abc123',
endpoint: 'https://middleware.example.com/api/tracking/website',
siteId: 'mysite',
getConsent: readOneTrustCategories,
});
await tracker.init();
window.addEventListener('OneTrustGroupsUpdated', async () => {
// Re-evaluate categories on every CMP update. The SDK will adopt the new
// category map and start/stop accordingly.
tracker.shutdown();
await tracker.init();
});Returning a boolean from getConsent remains fully supported and is equivalent to all-three-equal — existing integrations need no changes.
Regional privacy policy engine
Set a single privacy option and the tracker adopts the right posture for a jurisdiction — consent defaults, retention cap, and fingerprint-signal suppression — without hand-wiring each knob. Opt-in: when privacy is unset, behaviour is identical to before (equivalent to 'off').
const tracker = new ScoreTracker({
publishableKey: 'pk_…',
endpoint: 'https://mid.example.com/api/tracking/website',
siteId: 'site_…',
privacy: 'auto', // or 'gdpr' | 'cpra' | 'lgpd' | 'quebec' | 'off'
});Object form for auto resolution and tuning:
privacy: {
mode: 'auto',
// A browser can't read CDN request headers, so surface the edge-resolved
// country to the client and return it here (ISO 3166-1 alpha-2, optional
// region subtag e.g. 'US-CA', 'CA-QC'). Async is fine.
getRegion: () => document.documentElement.dataset.country ?? null,
// Per-knob overrides for your legal team — merged onto the resolved policy.
overrides: { retentionMaxHours: 12 },
}Region resolution (mode: 'auto')
At init(), the region is resolved in order: explicit region → getRegion() → a built-in client hint (a __SCORE_REGION__ global or <meta name="score-region" content="US-CA">, both populatable by a CDN / edge function) → unknown falls back to the strictest policy (gdpr), fail-safe. The resolved region maps to a policy: EU/EEA/UK/CH → gdpr; CA-QC → quebec (rest of Canada → gdpr); any US → cpra; BR → lgpd; anything else → gdpr.
Policy table
These are conservative engineering defaults pending legal review (#47 DPIA / legal sign-off) — not legal advice. Override any value via
privacy.overrides.
| Policy | Consent default (no getConsent) | Retention cap | Fingerprinting | Session replay¹ | Geo enrichment¹ |
|---|---|---|---|---|---|
| gdpr | opt-in (all categories off) | 24 h | disabled | disabled | disabled |
| quebec | opt-in (all categories off) | 24 h | disabled | disabled | disabled |
| lgpd | opt-in (all categories off) | 48 h | disabled | disabled | allowed |
| cpra | analytics + personalization on, advertising off (do-not-sell) | 7 days | allowed | allowed | allowed |
| off | all on (legacy) | none | allowed | allowed | allowed |
¹ Declared-only. The SDK ships no session-replay integration (that's #146) and adds no geo/IP enrichment today, so these flags are surfaced via inspect() for those features to consult when they land — they are no-ops now. The other three columns are enforced.
What each knob does
- Consent default — applied only when no
getConsentpredicate is supplied. A predicate is always authoritative. An opt-in default (all-off) leaves the tracker idle until you wire consent — the correct GDPR/Law 25/LGPD behaviour. Composes with category-aware consent:cpradefaultsadvertising:false, so events declaringrequires: ['advertising']are dropped until the user opts in. - Retention cap — clamps
queueStorage.maxAgeHoursdown (never up). Withprivacy: 'gdpr'a configured 72 h durable queue is capped to 24 h. - Fingerprinting — strips the fingerprint-adjacent envelope signals
screenandlocale. (uais retained — it rides in the HTTP request regardless and is needed for baseline analytics.)
Inspecting the active policy
Region resolution + the full active policy are surfaced via inspect():
tracker.inspect().privacy
// {
// mode: 'auto',
// region: 'US-CA',
// resolved: true, // false only while an `auto` mode awaits init()
// policy: {
// name: 'cpra',
// consentDefault: { analytics: true, personalization: true, advertising: false },
// retentionMaxHours: 168,
// disableFingerprinting: false,
// disableSessionReplay: false,
// disableGeoEnrichment: false,
// },
// }The default policy table is also exported as PRIVACY_POLICIES for inspection in your own code.
Session-replay correlation
Join your analytics events to an external session recording (rrweb, FullStory, …)
without the SDK capturing or shipping any replay payload — it stamps a
replayId on every event's envelope context, so the warehouse can correlate
the two. Off by default.
// 1) Let the SDK mint a stable per-session id:
new ScoreTracker({ /* … */, replay: true });
// 2) Or sync to your recorder's session id (read on every flush):
new ScoreTracker({ /* … */, replay: { getReplayId: () => rrwebSession.id } });
// 3) Or set it imperatively when your recorder starts a session:
tracker.setReplayId(rrwebSession.id); // tracker.getReplayId() reads it backPrivacy-aware: suppressed automatically when the active privacy
policy sets disableSessionReplay (GDPR /
Quebec / LGPD by default) — inspect().replay.active goes false while
enabled stays true. Correlation-only by design; bundling full rrweb is out of
scope (the host owns the recorder).
Multi-destination fan-out
Mirror your events to additional analytics sinks alongside the Score middleware.
Score stays the primary, durable destination (queue / retry / critical-tier);
destinations are best-effort sinks that receive a PII-scrubbed copy of each
batch after it's acknowledged by Score.
import { ScoreTracker, ga4Destination } from '@3halves-labs/score-tracker';
new ScoreTracker({
// …
destinations: [
ga4Destination({ measurementId: 'G-XXXX', apiSecret: '…' }), // built-in
myWarehouseDestination, // bring your own
],
});Independent: one destination throwing (or hanging) never blocks another or the primary Score delivery — failures are isolated and counted (
inspect().destinations.dropped).Consent-gated per destination: set
requireson a destination and it's skipped unless that category is granted (ga4Destinationdefaults to'analytics'). Composes with category-aware consent.Custom adapters implement the
Destinationshape:const myWarehouseDestination: Destination = { name: 'warehouse', requires: 'analytics', async send(events, context) { await fetch('/sink', { method: 'POST', body: JSON.stringify({ events, context }) }); }, };
Only runs on a successful Score ack, so a retried batch isn't mirrored twice. Vendors beyond GA4 (Segment, Snowflake, …) ship as separate adapters with the same shape.
Typed events
tracker.track() accepts either a plain string (legacy) or an EventDefinition produced by defineEvent. The typed form gives you compile-time prop checking with zero runtime cost — there's no schema validator, no extra bundle weight, just TypeScript inference.
import { ScoreTracker, defineEvent } from '@3halves-labs/score-tracker';
export const VideoPlayed = defineEvent<{
videoId: string;
duration: number;
source: 'live' | 'replay';
}>('video_played');
const tracker = new ScoreTracker({ /* … */ });
await tracker.init();
tracker.track(VideoPlayed, { videoId: 'abc', duration: 120, source: 'live' });
// ✓ ok
tracker.track(VideoPlayed, { videoId: 'abc', duration: 120 });
// ✗ TS error: Property 'source' is missing
tracker.track(VideoPlayed, { videoId: 'abc', duration: 'two minutes' as any, source: 'live' });
// ✗ TS error: Type 'string' is not assignable to type 'number'.Encoding the consent contract with the event
A definition can carry its own requires so the consent contract lives next to the event, not at every call site. This composes with category-aware consent:
export const AdImpression = defineEvent<{ sponsor: string; placement: string }>(
'ad_impression',
{ requires: ['advertising'] },
);
tracker.track(AdImpression, { sponsor: 'Acme', placement: 'hero' });
// Automatically gated on advertising consent — no need to repeat at the call site.A per-call options.requires overrides the definition default if you need it:
tracker.track(AdImpression, props, { requires: ['advertising', 'personalization'] });Where to put definitions
Co-locate them with the feature that emits them, or centralise in a single events.ts — the SDK doesn't care:
// app/events.ts
import { defineEvent } from '@3halves-labs/score-tracker';
export const Checkout = defineEvent<{ orderId: string; amount: number; currency: string }>('checkout');
export const ShareClicked = defineEvent<{ network: 'x' | 'whatsapp' | 'native' }>('share_clicked');
export const AdImpression = defineEvent<{ sponsor: string }>('ad_impression', { requires: ['advertising'] });Generating definitions from JSON Schema
For teams that keep their event catalogue as schemas (analytics consistency, auto-docs, warehouse reliability), the SDK ships a generator that turns JSON Schema archetypes into the defineEvent definitions above — no hand-writing the TypeScript, and still zero runtime cost (the schema is a build-time input, not a bundled validator).
Describe each event once under schemas/events/*.schema.json:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "VideoPlayed",
"x-score-event": "video_played",
"x-score-requires": ["analytics"],
"type": "object",
"properties": {
"videoId": { "type": "string" },
"duration": { "type": "number" },
"source": { "type": "string", "enum": ["live", "vod", "clip"] }
},
"required": ["videoId", "duration"],
"additionalProperties": false
}title→ the exported const name (e.g.VideoPlayed).x-score-event→ the runtime event name passed totrack().x-score-requires→ consent categories baked into the definition.requireddecides which props are non-optional;enums become string-literal unions.
Run the generator:
npm run gen:events # writes src/generated/events.generated.tsThen import the generated definitions — re-exported under the Events namespace:
import { ScoreTracker, Events } from '@3halves-labs/score-tracker';
tracker.track(Events.VideoPlayed, { videoId: 'abc', duration: 120, source: 'live' });
// ✓ typed; IDE flags a missing videoId or a string duration
tracker.track(Events.AdImpression, { sponsor: 'Acme', placement: 'hero' });
// gated on advertising consent via the schema's x-score-requiresThe committed events.generated.ts is kept in sync with the schemas by a drift-guard test (src/generated/events.codegen.test.ts) — edit a schema without re-running gen:events and CI fails.
What about runtime validation?
Not in this release — by design. The typed form catches mismatches at the call site (where the developer can fix them) and the build (where CI catches them). Adding a runtime validator would balloon the bundle for a benefit most consumers don't need. If a future use case needs it (e.g. accepting events from an untrusted source), it'll land as a plugin via the middleware-pipeline ticket (#137).
Back-compat
The string form is unchanged. tracker.track('any_name', { props: 'as before' }) works exactly as it did pre-1.8.0 — no migration required.
Middleware pipeline
For enrichment, transformation, or custom filtering that doesn't fit any of the dedicated knobs (matchContext, pii, consent, etc.), register a synchronous middleware:
const removeDeviceEnricher = tracker.use((evt, next) => {
evt.context = { ...evt.context, deviceClass: detectDevice() };
next(evt); // pass through (mutated)
});
tracker.use((evt, next) => {
if (evt.name === 'internal_debug') return next(null); // drop
next(evt);
});
removeDeviceEnricher(); // detach laterWhere in the pipeline
Middlewares run inside enqueue(), after every built-in event-processing step but before the in-memory queue push. So your middleware sees:
- The consent gate already passed (otherwise the event would have been dropped earlier).
context.matchalready stamped (frommatchContext/setMatchContext).context.groupsalready stamped (from current memberships).deliveryalready resolved (from per-call options ordefaultDelivery).- The
requiresfield already stripped.
PII scrubbing (pii.rules) runs later, at envelope-build time, so middlewares still see the raw values. This is intentional: it lets you make routing/filtering decisions on the real data before scrubbing erases it.
Chain semantics
Multiple middlewares chain in registration order:
tracker.use((evt, next) => { evt.props = { ...evt.props, step1: true }; next(evt); });
tracker.use((evt, next) => { console.log('saw step1:', !!evt.props.step1); next(evt); });
// ^ logs `true`next(evt) advances to the next middleware (or pushes to the queue if it's the last). next(null) short-circuits the rest of the chain and drops the event.
Failure modes
The pipeline is defensive against badly-written middlewares:
- Throwing: the error is swallowed (debug-logged when
debug: true), the event passes through unchanged. A broken middleware can't break tracking. - Forgetting to call
next: the event is treated as dropped (and counted towardmiddleware.dropped). This matches the "if you don't say pass through, you said drop" mental model.
Live state
tracker.inspect().middleware
// → { count: 2, dropped: 7 }dropped is cumulative across every middleware's next(null) (or didn't-call-next). It's separate from the queue-cap drop counter and the consent-category drop counter — each tier has its own line.
Out of scope in v1
Async middlewares (async (evt, next) => { … }). Supporting await inside a middleware would require restructuring track() / enqueue() to be async, which is a breaking-change risk for every existing call site. The sync v1 covers enrichment, transformation, and filtering — the high-leverage use cases. Async paths (network lookups, awaiting a feature-flag client) can land in a follow-up after the sync API settles.
Declarative React components
For CMS-driven layouts, marketing pages, and any React tree where you'd rather attach tracking via JSX than via imperative tracker.track(...) calls in handlers, the react subpath exports three components:
import { ScoreProvider, Track, TrackVisibility, Sponsor } from '@3halves-labs/score-tracker/react';
<ScoreProvider tracker={tracker}>
<Track event="cta_clicked" props={{ location: 'hero' }}>
<Button>Buy tickets</Button>
</Track>
<TrackVisibility event="hero_seen" props={{ section: 'home' }} threshold={0.5}>
<Hero />
</TrackVisibility>
<Sponsor sponsor="Acme" slot="hero">
<img src="/acme.png" alt="Acme" />
</Sponsor>
</ScoreProvider>All three wrap their child in a <span style="display: contents"> so they add no layout box — the wrapped element renders exactly as it would on its own. (<Sponsor> accepts an as prop if you want the surface itself to be the sponsor element — e.g. an <a>.)
<Track> — fire on a DOM event
| Prop | Default | Notes |
|---|---|---|
| event | required | String event name OR a defineEvent(...) definition (composes with typed events — props are type-checked against the definition's Props). |
| props | {} | Forwarded to tracker.track() as the second argument. |
| on | 'click' | DOM event that triggers the call. Accepts 'click' \| 'mouseenter' \| 'mouseleave' \| 'focus' \| 'blur' \| 'submit'. |
| requires | — | Forwarded as options.requires (composes with category-aware consent). |
| delivery | — | Forwarded as options.delivery (composes with delivery guarantees). |
The listener attaches with { capture: true, passive: true } so it never interferes with the child element's own handlers.
const CtaClicked = defineEvent<{ location: 'hero' | 'footer' }>('cta_clicked');
<Track event={CtaClicked} props={{ location: 'hero' }} delivery="critical">
<Button>Buy</Button>
</Track>;<TrackVisibility> — fire when in viewport
| Prop | Default | Notes |
|---|---|---|
| event / props | required / {} | As above. |
| threshold | 0.5 | IntersectionObserver ratio (0–1). |
| once | true | Fire only the first time the element crosses the threshold. Set false to fire on every entry. |
| requires / delivery | — | Same as <Track>. |
<TrackVisibility event="sponsor_seen" props={{ sponsor: 'Acme' }} once>
<SponsorBanner />
</TrackVisibility>Falls back to a no-op on environments without IntersectionObserver (very old browsers / SSR). No polyfill is bundled.
<Sponsor> — declarative form of data-score-sponsor
| Prop | Default | Notes |
|---|---|---|
| sponsor | required | Becomes the data-score-sponsor attribute. |
| slot | — | Optional placement; becomes data-score-sponsor-slot. |
| as | 'span' (with display: contents) | Element type. Pass 'a' etc. when the surface itself is the sponsor element — no extra wrapper. |
| className | — | Passed through to the rendered element. |
The component emits no events itself — it just applies the data attributes that the existing sponsorship observer is already watching. So <Sponsor sponsor="Acme"> and <span data-score-sponsor="Acme"> are equivalent; pick whichever fits your style.
<Sponsor sponsor="Acme" slot="hero" as="a" className="cta">
<img src="/acme.png" alt="Acme" />
</Sponsor>React version compatibility
Declared as a peerDependency (already documented at the top of this README). Tested against React 19; works on 17 and 18 since the components use only stable hooks (useEffect, useRef, useContext).
Web vitals
Opt in via performance.webVitals: true to capture LCP / CLS / INP / TTFB / FCP for every page load and emit them as web_vital events:
const tracker = new ScoreTracker({
/* … */
performance: { webVitals: true },
});The SDK dynamically imports the web-vitals library — it's declared as an optional peer dependency so you only install it if you use the feature:
npm i web-vitalsIf the feature is enabled but web-vitals isn't installed (or fails to load), the SDK logs a debug warning, stays silent, and inspect().performance.webVitals reports false. Zero crashes, zero events.
Event shape
Each metric fires once per page load (the web-vitals library handles dedup). Every event is named web_vital; the specific metric is in props.metric:
{
"type": "track",
"name": "web_vital",
"occurredAt": "...",
"props": {
"metric": "LCP", // 'LCP' | 'CLS' | 'INP' | 'TTFB' | 'FCP'
"value": 2341.5, // metric value (ms for time-based; unitless ratio for CLS)
"rating": "good", // 'good' | 'needs-improvement' | 'poor' (per Google thresholds)
"id": "v3-...", // unique id from web-vitals (for de-dup downstream)
"navigationType": "navigate", // 'navigate' | 'reload' | 'back-forward' | 'prerender' | ...
"delta": 2341.5
}
}Filter by props.metric in the warehouse to chart any individual signal.
Composes with category-aware consent
Web vitals events declare requires: ['analytics']. If the user denies analytics, they're dropped and counted toward inspect().consent.dropped like any other analytics event — no special-casing for performance.
Zero overhead when disabled
If performance.webVitals is omitted or false:
- The dynamic
import('web-vitals')is never reached at runtime. - The bundler keeps it as a real dynamic import (the SDK marks it external), so it lands as a separate chunk that the consumer's app never fetches unless they enable the feature.
- No PerformanceObserver is registered, no events fire, no peer dep needs to be installed.
inspect().performance.webVitals reports the wiring state.
Adaptive batching
For sites where flush latency varies wildly — stadium WiFi, mobile carriers, off-peak vs match-night traffic — the static batch.max / batch.intervalMs aren't ideal. Opt in to adaptive batching to let the SDK react to network conditions, tab visibility, and the user's Save-Data preference:
const tracker = new ScoreTracker({
/* … */
batch: { max: 20, intervalMs: 2500, adaptive: true },
});Setting adaptive: true (or passing an object for tuned thresholds — see below) turns on three coupled behaviours.
1. Latency-aware batch sizing (EWMA backpressure)
The SDK measures end-to-end flush latency for every batch and feeds it into an exponentially-weighted moving average (α=0.3). The next batch is sized based on where the EWMA sits:
- Latency >
highLatencyMs(default 2000) → halve the current batch size (floorminBatchSize, default 1). The backend is struggling; ease up. - Latency <
lowLatencyMs(default 500) → double the current batch size (cap atbatch.max). Healthy connection; pack more per round-trip. - In between → leave the current size unchanged.
This converges naturally on whatever batch size the backend can handle without piling up.
2. Visibility pause
When document.visibilityState !== 'visible', the periodic flush timer is cleared and flush() calls become no-ops. The queue keeps accumulating in memory (and on disk if the durable queue is on). On visibilitychange back to visible, the timer is re-armed and the queue drains immediately.
pagehide still triggers the existing best-effort beacon flush, so events queued during a hidden tab don't all die when the user navigates away.
3. Save-Data awareness
When navigator.connection.saveData is true (Chrome / Android Chrome / some mobile browsers, set by the user via Data Saver), the SDK halves the effective max batch size and doubles the flush interval — fewer, smaller requests for users who've explicitly asked for it.
The change event on navigator.connection is watched, so toggling Data Saver mid-session updates the behaviour live.
Tuning
Pass an object instead of true to override defaults:
batch: {
max: 50,
intervalMs: 2500,
adaptive: {
highLatencyMs: 1500,
lowLatencyMs: 300,
minBatchSize: 5,
},
}Live state via inspect()
tracker.inspect().queue.adaptive
// → {
// enabled: true,
// currentBatchSize: 8, // halved from max because latency was high
// latencyEwmaMs: 2147.3, // rolling EWMA of post-flush latency
// paused: null | 'hidden' | 'saveData',
// }Useful for debugging "why aren't my events shipping?" — paused: 'hidden' is the most common answer.
Default off — back-compat
batch.adaptive is false by default. Sites that want every event flushed at the configured cadence regardless of conditions keep their current behaviour with zero changes.
Delivery guarantees
Per-event delivery tier. Default 'best-effort' for everything; mark commerce / ticketing / wagering / entitlement events 'critical' to get the asymmetric tier:
tracker.track('checkout_complete', { orderId, amount }, { delivery: 'critical' });
tracker.track('purchase_authorized', props, { delivery: 'critical' });Or set the default for a whole tracker instance dedicated to a critical flow:
const checkoutTracker = new ScoreTracker({ /* … */, defaultDelivery: 'critical' });
checkoutTracker.track('amount_changed', { amount }); // critical by default
checkoutTracker.track('promo_applied', { code }, { delivery: 'best-effort' }); // opt out per callWhat 'critical' actually does
| Behaviour | best-effort | critical |
|---|---|---|
| Position in batch | preserved enqueue order | sent first in the batch |
| When adaptive batching has paused the timer because the tab is hidden | flush is a no-op | flushes anyway |
| When age-evicted from the durable queue before ack | silently dropped | emits a delivery_failed event with originalEventId, originalEventName, originalOccurredAt, reason: 'age_evicted', ageHours |
delivery_failed event shape
When a critical event sits in IndexedDB longer than queueStorage.maxAgeHours without being acked (because the device was offline, the backend was down, etc.), the next init() evicts it and emits a follow-up event:
{
"name": "delivery_failed",
"props": {
"originalEventId": "uuid-of-the-dropped-event",
"originalEventName": "checkout_complete",
"originalOccurredAt": "2026-…",
"reason": "age_evicted",
"ageHours": 72
}
}The follow-up itself is best-effort — it's a signal, not the original event. Treat it server-side as "the user did this thing but we never saw the original event; reconcile through your authenticated channel" (e.g. check the order id against your order system).
Composition with prior tickets
- #129 (durable queue) — critical events benefit most from IndexedDB persistence. They survive reloads + crashes; their final failure mode is the age-evict /
delivery_failedpath. - #142 (adaptive batching) — critical events override the visibility-pause. The batch-size shrink under high latency still applies (you don't want to bombard a struggling backend); critical events just get the head of the queue.
- #133 (consent categories) —
delivery_faileditself declaresrequires: ['analytics'].
Live state
tracker.inspect().delivery
// → {
// defaultMode: 'best-effort',
// criticalQueued: 2,
// criticalDelivered: 17,
// criticalFailed: 0,
// }criticalFailed is cumulative across the tracker's lifetime. A non-zero value is your "investigate this" signal.
Out of scope in v1
Documented explicitly because the ticket called these out:
- Aggressive retry policies beyond the existing fail-and-requeue. Critical events use the same retry path as best-effort; they get a head-of-queue slot but no different backoff. If the backend is durably down, both tiers stay queued until the durable queue's
maxAgeHoursexpires. - Critical-only persistence when global
queueStorageis in-memory. Durability still requiresqueueStorage: { type: 'indexeddb' }. A future ticket can add a per-event persistence override; this one focuses on the priority + signal semantics.
Dev debug overlay
Opt in with debugOverlay: true to mount a small floating panel in the bottom-right of the page that reads tracker.inspect() on a timer and renders the live state. Useful during development to answer "why didn't that event ship?" without opening DevTools and writing tracker.inspect() by hand.
const tracker = new ScoreTracker({
/* … */
debugOverlay: true, // mounts collapsed, default hotkey Ctrl+Shift+E
// or:
debugOverlay: { startOpen: true, hotkey: 'Alt+D', refreshMs: 250 },
});What it shows
The panel groups the inspect snapshot by section. Each row updates on the configured refresh tick (default 500ms):
- SDK —
version,started - Queue —
size,dropped,maxSize,persistence(memory/indexeddb/unavailable), and if adaptive batching is on:adaptive.current,adaptive.latencyMs,adaptive.paused - Consent —
granted,hasPredicate, per-category flags (analytics/personalization/advertising),dropped(events blocked byrequires) - Identity —
anonymousId,sessionId,userId,hasJwt(never the JWT itself) - Engagement — composite
score,dwellSec,interactions,scrollMax,sessionCount - Match (only when configured) —
hasProvider,hasManualValue - Sponsorship (only when enabled) —
tracked,visible - Performance (only when enabled and wired) —
webVitals
Sections for unused features don't render at all, so the panel stays compact.
Options
| Option | Default | Notes |
|---|---|---|
| refreshMs | 500 | How often to re-read inspect() and re-render. |
| hotkey | 'Ctrl+Shift+E' | Modifier+key spec. Accepts ctrl, alt, shift, meta as modifiers. Case-insensitive. |
| startOpen | false | Whether to render the body expanded on first mount. The user's most recent toggle wins via sessionStorage. |
Persistence
When the user toggles the panel open/closed, the choice is persisted in sessionStorage under score.debugOverlay.open. The next page load in the same session honors that choice, regardless of startOpen — so you don't flash the panel on every reload during development.
Security / production
- The JWT is never in the panel. Only
hasJwt: true/falseis shown. - The panel uses inline styles + a
<style>block scoped to its own#score-debug-overlayid — no global CSS pollution. - Treat it as a development surface. Ship
debugOverlay: process.env.NODE_ENV !== 'production'or equivalent so it doesn't render in prod builds.
Zero overhead when disabled
If debugOverlay is omitted or false:
- No DOM element is created, no
setIntervalis registered, nokeydownlistener is attached. - The
DebugOverlayclass is never constructed — tree-shaking strips the module from bundles that don't reference it.
inspect().debugOverlay reports the current state:
tracker.inspect().debugOverlay
// → { mounted: true, open: false } panel mounted but collapsed
// → { mounted: true, open: true } panel mounted and expanded
// → { mounted: false, open: false } feature offDiagnosing ingest failures (auth-edge vs app)
The tracking host may sit behind an auth edge (e.g. score-auth's ForwardAuth). A failed send can be rejected at two layers, both surfacing as 401/403 but meaning different things:
- Edge — the request never reached the tracking service (an infra gate). Carries an
x-auth-request-redirectheader and/or{ error: 'Authentication required', redirectUrl: 'https://login.…' }. Usually meansendpointis pointed at a non-exempt path (the ingest path/api/tracking/websiteshould bypass the edge). - App — the ingest itself refused the batch: bad/inactive site key, origin mismatch, or rate-limit (
{ success: false, error }).
inspect().transport classifies the most recent failure so you don't have to guess:
tracker.inspect().transport
// → { edgeAuthFailures: 0, lastError: { status: 403, kind: 'app-4xx' } } // e.g. invalid site key
// → { edgeAuthFailures: 2, lastError: { status: 401, kind: 'edge-auth' } } // blocked at the ForwardAuth edge
// kind: 'edge-auth' | 'app-4xx' | 'http' | 'network'With debug: true, an edge rejection logs a distinct, actionable warning (pointing you at the edge-exempt ingest path) rather than a generic "send failed".
PII handling
The SDK supports two strategies for handling personally-identifiable fields. Both apply recursively to nested objects and arrays.
Denylist (legacy + shorthand)
The simplest form — list the property keys you don't want shipping. Matching keys are removed from props and context before the envelope is built.
new ScoreTracker({
/* … */
piiDenylist: ['email', 'phone', 'fullName'],
});This is equivalent to passing pii: { strategy: 'denylist', fields: [...] } — use whichever form fits your call site.
Classify (per-field actions)
For more granular control, use the classify strategy. Each rule pairs a field name with an action; the first matching rule wins for a given key.
new ScoreTracker({
/* … */
pii: {
strategy: 'classify',
rules: [
{ field: 'email', action: 'hash' }, // → 'h:<8 hex chars>'
{ field: 'phone', action: 'drop' }, // removed
{ field: 'fullName', action: 'redact' }, // → '<redacted>'
{ field: 'household', action: 'tokenize' }, // → 't:<n>' (stable)
],
},
});Actions
| Action | Output | Use when |
|---|---|---|
| drop | (key removed) | The value adds zero analytical signal — don't even ship it. |
| redact | '<redacted>' | You want to know the field was present but not its value. |
| tokenize | 't:1', 't:2', … | You need a stable opaque value for warehouse joins, scoped per-tracker-instance. Same input → same token for the lifetime of this tracker. |
| hash | 'h:<8 hex chars>' | You need a deterministic opaque value joinable across tracker instances. Caveat below. |
About hash
The hash is FNV-1a 32-bit, hex-encoded with an h: prefix. It's deterministic and fast (sync inside enqueue() so it composes with the durable queue and every other downstream feature). It is not cryptographic — short inputs over a small space (a list of email addresses, a list of phone numbers) are brute-forceable.
Use hash for analytics de-identification where the downstream join key needs to be stable across users / sessions / sites; do not use it as a security primitive. If you need cryptographic strength, redact the field instead and surface its real value only over your authenticated server-to-server channel.
Recursive behaviour
Rules apply at every nesting level — there's no path syntax, just a flat list of field names. A rule on email scrubs email everywhere it appears:
tracker.track('order', {
orderId: 'o-1',
customer: {
email: '[email protected]', // → 'h:…'
phone: '+5678', // → removed
addresses: [
{ city: 'Dublin', email: '[email protected]' }, // → 'h:…' (different hash than above)
{ city: 'Cork', phone: '+9999' }, // → phone removed
],
},
});Precedence + back-compat
- The legacy
piiDenylist: string[]option still works unchanged. - When both
piiandpiiDenylistare set,piiwins entirely — the denylist is ignored. This makes it safe to migrate one option at a time. - When neither is set, no scrubbing happens.
Live state
tracker.inspect().pii
// → { strategy: 'classify', ruleCount: 4, tokenizedValues: 1 }
// → { strategy: 'denylist', ruleCount: 3, tokenizedValues: 0 }
// → { strategy: 'none', ruleCount: 0, tokenizedValues: 0 }tokenizedValues is the number of distinct inputs the tokenizer has seen — useful for detecting when a tokenize-marked field is being shipped at higher cardinality than you expected.
Identity graph (alias / group / ungroup)
Beyond the basic identify(userId, traits) flow, the SDK exposes three Segment-style methods for modelling identity graphs — useful for household stitching, team / organisation memberships, sponsor cohorts, B2B account attribution, and so on.
tracker.alias(previousId, userId)
Bind a legacy or anonymous id to a real userId. Emits a single $alias event with both ids and rebinds the tracker's userId for subsequent events — no separate identify() call needed.
tracker.alias('anon-' + tracker.inspect().identity.anonymousId, 'user-42');
// envelope events from this point onward carry context.userId = 'user-42'tracker.group(groupId, traits?)
Add or update a group membership. Multiple group() calls accumulate — a user can belong to many groups at once (team + organisation + sponsor cohort + …). Calling group() again with an existing id replaces that group's traits.
tracker.group('team-munster', { name: 'Munster', tier: 'pro' });
tracker.group('sponsor-acme', { tier: 'gold' });Each group() call emits a $group event with the id + traits. From that point on, every enqueued event is stamped with context.groups containing the full membership list:
{
"type": "track",
"name": "cheer",
"context": {
"groups": [
{ "id": "team-munster", "traits": { "name": "Munster", "tier": "pro" } },
{ "id": "sponsor-acme", "traits": { "tier": "gold" } }
]
}
}tracker.ungroup(groupId)
Remove a membership. No-op if the user wasn't a member. Emits a $ungroup event when there was an actual removal, so downstream consumers can mirror the change.
tracker.ungroup('team-munster'); // emits $ungroup and stops stamping team-munster
tracker.ungroup('nobody'); // silent no-opAt-event-time guarantee + durable-queue composition
Group context is stamped at enqueue time, not at flush. An event fired while the user was in team-munster carries that membership even if the user has since called ungroup('team-munster') before the flush actually leaves. Because the membership is part of the TrackEvent itself, it also survives durable queue rehydration — a queued event reloaded from IndexedDB on the next init() keeps its original at-event-time group state, regardless of what the user's current memberships are.
Persistence
Memberships are persisted in localStorage under score.groups alongside anonymousId. They survive:
- Page reloads in the same browser
- Closing and reopening tabs
They do not survive clearing localStorage or moving to a different browser / device — both of which are the standard "logged out" signal for downstream systems.
PII + consent + inspect
- Group traits flow through the same PII handling you've configured. A rule on
emailscrubsemailinsidetraitsexactly like anywhere else. $alias,$group, and$ungroupevents declarerequires: ['analytics']— they respect category-aware consent like any other built-in event.inspect().groupsexposes{ count, ids }only. Thetraitsmap is deliberately not exposed viainspect()so debugging surfaces (the dev overlay, DevTools screenshots) don't leak group-level PII.
tracker.inspect().groups
// → { count: 2, ids: ['team-munster', 'sponsor-acme'] }Cross-domain identity stitching
Many sports properties span more than one domain — a main marketing site, a stats subdomain, a ticketing partner, a federated auth portal. Each domain runs its own ScoreTracker instance (the two domains, two instances section explains why), but for some flows you still want the anonymousId (and current sessionId) to follow the user across the hop. The cross-domain linker is the bridge.
const tracker = new ScoreTracker({
/* … */
crossDomain: {
enabled: true,
allowedDomains: ['rugby.com', '*.rugby.com'],
// linkerParam: '_score', // default
// freshnessMs: 30_000, // default 30s
},
});
await tracker.init();On the source side: append to outbound URLs
tracker.appendLinker(url) accepts a URL string and returns it with the linker query param appended if the URL's hostname matches allowedDomains. URLs to other hosts (and unparseable strings) pass through unchanged — safe to wrap unconditionally:
// React example — wrap the href of any cross-domain link.
<a href={tracker.appendLinker('https://stats.rugby.com/leaderboard')}>
Leaderboard
</a>For SPAs that build URLs in JS (Next.js router.push, Vue Router, …), prefer tracker.getLinkerValue() which returns the raw encoded value — append it under your own linker key:
const value = tracker.getLinkerValue();
if (value) router.push(`https://stats.rugby.com/leaderboard?_score=${value}`);On the destination side: adopt on init()
When the destination's ScoreTracker runs init(), it inspects window.location.search for the linker param. If all three of the following hold, the carried anonymousId and sessionId are adopted in place of the locally-stored / freshly-generated values:
- The linker payload decodes cleanly to
{ aid, sid, ts }. document.referrer's hostname matchesallowedDomains(the*.rugby.comwildcard form matches any subdomain but not the apex).- The encoded
tsis withinfreshnessMsof now (default 30s — roughly the time it takes for a click to land).
If any check fails, the tracker silently falls back to its normal id-or-generate flow — adoption is opt-in and bad input never breaks init().
Trust boundary (read this)
The linker payload is base64url(JSON({ aid, sid, ts })) with no cryptographic signature. Anyone who can construct a URL pointing at a cooperating domain can mint a payload claiming any anonymousId. That's by design: the use case is preserving identity continuity for analytics correlation across domains you control, not for asserting identity claims to a backend.
The document.referrer allowlist + freshness window raise the bar against drive-by adoption from arbitrary external sites; they are not a security boundary. If you need cryptographic guarantees (e.g. preventing a user from adopting another user's anonymousId), pair this with your own signed auth flow on each domain — the linker carries the analytics id, your auth flow carries the trust.
Wildcard semantics
allowedDomains accepts two entry shapes:
- Exact hostname:
'stats.rugby.com'— matches only that exact host. - Subdomain wildcard:
'*.rugby.com'— matchesa.rugby.com,b.c.rugby.com, etc., but not the apexrugby.comitself. To match both, list the apex separately:['rugby.com', '*.rugby.com'].
Live state
tracker.inspect().crossDomain
// → { enabled: true, adopted: true, allowedDomainCount: 2 } linker consumed
// → { enabled: true, adopted: false, allowedDomainCount: 2 } no linker / rejected
// → { enabled: false, adopted: false, allowedDomainCount: 0 } feature offadopted: true is set once at construction and stays true for the lifetime of the tracker. The allowedDomainCount is a sanity check ("did my config actually load?").
Zero overhead when disabled
If crossDomain is omitted or enabled: false:
- No URL parsing on
init(). appendLinker(url)returns its input unchanged.getLinkerValue()returnsnull.
Deferred identify replay
For pre-login attribution flows ("what content caused signup?", "what did this user do before they registered?"), identify() accepts an opt-in third argument that re-emits buffered anonymous events with the new userId attached:
tracker.identify('user-42', { plan: 'pro' }, { replayAnonymousEvents: true });How the buffer works
- Every event enqueued before
identify()is mirrored into a separate replay buffer (in addition to being normally enqueued and shipped through the regular queue). - The buffer is capped via
identifyReplayBufferSize(default 100). FIFO eviction; drops counted ininspect().identity.replayBuffer.dropped. - Identity events themselves (
$alias,$group,$ungroup,identify) are never buffered, so replay can't recursively re-fire them. - Calling
identify()without the replay option clears the buffer silently — pre-identify events past that line are no longer relevant.
What replay actually does
When replayAnonymousEvents: true:
- The buffer is drained.
- Each buffered event is re-emitted as a NEW envelope event (fresh id), preserving the original
occurredAt, props, andcontext(so at-event-time match state and group memberships are kept). - Two extra props are stamped:
_backfill: trueand_originalEventId: <orig.id>. - The envelope
context.userIdreflects the just-bound userId.
The original anonymous events are unchanged — they were already shipped via the normal queue at their original enqueue. Backfills are additive.
Warehouse de-dup recipe
Each backfill carries a join key (_originalEventId) pointing at its anonymous source. Downstream you have two natural choices:
Keep both rows. Common for funnel analysis where the anonymous attribution still matters and the identified backfill is just supplementary. Query for _backfill = false when you want raw anonymous counts; query for _backfill = true when you want identified attribution.
Replace anonymous with identified. Drop the original anonymous row when its event_id matches some _originalEventId in the backfill set. The backfill carries the same occurredAt + props + context as the original plus the userId.
-- "Replace" pattern:
DELETE FROM events
WHERE event_id IN (
SELECT props._originalEventId
FROM events
WHERE props._backfill = true
);The SDK ships the data + the join key; the policy is downstream's.
At-event-time guarantee through replay
Backfilled events preserve their original context — including match context and group memberships. A backfill of an event captured at minute 23 still reads context.match.gameMinute: 23 even if identify() was called at minute 67. This is why backfilled events are stamped at original enqueue time and not re-stamped on the replay path.
Live state
tracker.inspect().identity.replayBuffer
// → { size: 12, maxSize: 100, dropped: 0, replayed: 0 } pre-identify
// → { size: 0, maxSize: 100, dropped: 0, replayed: 12 } post-replay
// → { size: 0, maxSize: 100, dropped: 4, replayed: 0 } buffer overflowed, no replay yetdropped is FIFO eviction (pages with massive pre-login activity that overflowed the cap); replayed is cumulative across all identify() calls in this tracker's lifetime.
Back-compat
identify(userId, traits) (two args) is unchanged. The new third arg is optional and defaults to no replay — existing call sites need no migration.
JWT / Auth0
Pass a getToken provider at init time and the tracker refreshes the JWT before each batch flush — no need to call setUserToken() manually on every Auth0 refresh.
import { Auth0Client } from '@auth0/auth0-spa-js';
const auth0 = new Auth0Client({ domain: '...', clientId: '...' });
const tracker = new ScoreTracker({
publishableKey: 'pub_abc123',
endpoint: 'https://middleware.example.com/api/tracking/website',
siteId: 'mysite',
// Async provider — called once at init() and again before every flush.
// The Auth0 SDK handles silent refresh internally, so successful calls
// always return a fresh, valid access token.
getToken: async () => {
try {
return await auth0.getTokenSilently();
} catch (e: any) {
// login_required / consent_required / etc. mean the user is signed out
// or hasn't authenticated yet. Returning undefined tells the tracker to
// continue with anonymous attribution by anonymousId.
if (e?.error === 'login_required' || e?.error === 'consent_required') return undefined;
throw e; // surfaces in debug mode; tracker still continues anonymously
}
},
});
await tracker.init();Failure semantics
| getToken() returns | Behavior |
| --- | --- |
| A string | Stamp the envelope with this JWT. |
| null / undefined | Continue with anonymous tracking (no JWT in envelope). |
| Throws / rejects | Swallow, clear the cached JWT, continue anonymously. Debug-logged when debug: true. |
The tracker never blocks tracking on a token failure. Anonymous events still attribute via anonymousId, and when a valid token returns on the next refresh the middleware can reconcile via the shadow user. To stop tracking entirely on logout, call tracker.shutdown() from your logout handler.
Concurrency
If two flushes overlap, getToken is only called once — the second flush awaits the first refresh. Safe to call flush() rapidly without thrashing your auth provider.
Identity from a cookie (no browser JWT) — getUserId
Some hosts authenticate the user server-side (e.g. a WordPress/Auth0 code flow) and never expose a JWT to the page — they only set a JS-readable identity cookie. In that case use getUserId instead of (or alongside) getToken: it's a synchronous provider called at init(), and if it returns a value the tracker identify()s with it so every event carries context.userId.
const tracker = new ScoreTracker({
/* … */
getUserId: () => {
// The host's login cookie (not HttpOnly, so JS can read it).
const m = document.cookie.match(/(?:^|;\s*)auth0_user_id=([^;]+)/);
return m ? decodeURIComponent(m[1]) : undefined; // e.g. "auth0|6a39…"
},
});
await tracker.init();- Returns a value →
identify(userId)(events stampcontext.userId). - Returns
undefined→ stays anonymous. - Trust model: a cookie id is client-asserted, not cryptographically verified like a JWT. Prefer
getToken(a verified JWT) when the host can expose one; treatgetUserIdas the cookie-only fallback. The backend decides how much to trust an unverifiedcontext.userId. - Re-evaluate on login/logout by calling
init()again (oridentify()/shutdown()from your auth handlers).
Two domains, two instances
The Score platform treats each domain as a separate tracked surface. If your
property spans more than one domain — e.g. a main site and a stats/Match-Centre
subdomain — run one ScoreTracker instance per domain, each with its own
publishableKey and siteId. Don't share a single instance (or a single key)
across domains: the keys are what keep each domain's events attributed to the
right site, and the SDK stamps every envelope with the instance's own key.
Each domain's publishableKey and siteId come from provisioning that domain
in the admin app (WebsiteTagCreator → POST /api/tracking/sites/credentials),
which returns a siteKey and a siteCode. The mapping into SDK options is:
| Provisioning field | SDK option |
| --- | --- |
| siteKey (pk_…) | publishableKey |
| siteCode | siteId |
| apiEndpoint | endpoint |
Worked example: rugby.com + stats.rugby.com
Define each instance in its own module so the right one ships with each site's bundle. The two instances are fully independent — separate keys, separate batching, separate consent/token wiring — so data from the two domains stays separate.
// score.ts on rugby.com
import { ScoreTracker } from '@3halves-labs/score-tracker';
export const tracker = new ScoreTracker({
publishableKey: 'pk_xxxx…', // the siteKey provisioned for rugby.com
siteId: 'rugby.com-xxxxxx', // the siteCode provisioned for rugby.com
endpoint: 'https://<tracking-host>/api/tracking/website',
getConsent: () => (window.OnetrustActiveGroups ?? '').split(',').includes('C0002'),
getToken: async () => auth0.getTokenSilently().catch(() => undefined),
});// score.ts on stats.rugby.com (Match Centre)
import { ScoreTracker } from '@3halves-labs/score-tracker';
export const tracker = new ScoreTracker({
publishableKey: 'pk_yyyy…', // the siteKey provisioned for stats.rugby.com
siteId: 'stats.rugby.com-yyyyyy', // the siteCode provisioned for stats.rugby.com
endpoint: 'https://<tracking-host>/api/tracking/website',
getConsent: () => (window.OnetrustActiveGroups ?? '').split(',').includes('C0002'),
getToken: async () => auth0.getTokenSilently().catch(() => undefined),
});Wire each instance exactly as a single-domain integration would — see
Consent gating for the OneTrust getConsent recipe and
JWT / Auth0 for the getToken provider. Both must be set
per instance; there is no shared/global state between instances, so each one
resolves its own consent and token.
Notes:
- One key per domain. The
publishableKey/siteIdare tied to the domain they were provisioned for; the middleware validates the pair and rejects a mismatch. Reusing one domain's key on another domain will be rejected. - The
endpointis the same for both instances (the shared tracking ingestion host) — only the keys differ. - Consent and tokens are independent. Each instance resolves its own
getConsent/getToken; callingshutdown()on one does not affect the other.
Lifecycle methods
| Method | Purpose |
| --- | --- |
| new ScoreTracker(opts) | Construct (SSR-safe; no side effects). |
| await tracker.init({ getConsent?, getToken? }) | Recommended entry. Awaits consent + initial token, starts if consent granted. |
| tracker.start() | Sync legacy entry. Honors a sync getConsent; warns and skips on async. Does not pre-flight getToken — first flush will. |
| tracker.shutdown() | Tear down: clear timer, unbind listeners, restore history methods, beacon-flush queue, lock gate. Idempotent. |
| tracker.track(name, props?) | Custom event. Dropped silently if consent isn't granted. |
| tracker.page(props?) | Pageview. Dropped silently if consent isn't granted. |
| tracker.identify(userId, traits?, options?) | Bind events to a user identity. options.replayAnonymousEvents: true re-emits buffered pre-identify events with _backfill props. See Deferred identify replay. |
| tracker.alias(previousId, userId) | Bind a previous/anonymous id to a real userId. Emits $alias + rebinds userId. See Identity graph. |
| tracker.group(groupId, traits?) | Add/update a group membership; emits $group and stamps every subsequent event with context.groups. Multiple memberships allowed. |
| tracker.ungroup(groupId) | Remove a group membership; emits $ungroup. No-op if not a member. |
| tracker.appendLinker(url) | Append the cross-domain linker param to url if its hostname matches crossDomain.allowedDomains. Returns the URL unchanged otherwise. No-op when crossDomain is disabled. See Cross-domain identity stitching. |
| tracker.use((evt, next) => …) | Register a synchronous middleware that runs after built-in event processing and before queue push. Returns a remove() handle. See Middleware pipeline. |
| tracker.getLinkerValue() | Return the raw linker param value (base64url-encoded JSON of {aid, sid, ts}). Returns null when crossDomain is disabled. |
| tracker.setUserToken(jwt?) | Set the JWT manually. Note: if getToken is also set, the next flush will overwrite this. Use either setUserToken or getToken, not both. |
| tracker.flush() | Force-flush the queue. Calls getToken (if set) before posting. |
| tracker.setMatchContext(ctx \| null) | Imperative fallback for the matchContext provider. Useful for websocket / SSE driven match state. The constructor provider wins when both are set. Pass null to clear (e.g. full-time). |
| tracker.getEngagementScore() | Comp
