npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@3halves-labs/score-tracker

v1.19.2

Published

Lightweight React-friendly website tracking SDK for Score Anything Loyalty. Batching, session/UTM, SPA route, scroll, video, and custom events.

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 + _originalEventId for warehouse joining
  • ✅ Delivery guarantees — critical events get flush priority, bypass visibility-pause, and emit delivery_failed on age-eviction
  • ✅ Cross-domain identity stitching — opt-in linker param carries anonymousId / sessionId between 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
  • ✅ 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-sponsor tagging + IntersectionObserver
  • tracker.inspect() debug snapshot — queue/consent/identity/persistence/engagement/sponsorship

Install

npm i @3halves-labs/score-tracker
# or
pnpm add @3halves-labs/score-tracker

Quick 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>
  );
}

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 next init(). It clears the timer, unbinds all listeners, restores history.pushState / history.replaceState, best-effort flushes the queue with navigator.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 getConsent throws 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 regiongetRegion() → 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-QCquebec (rest of Canada → gdpr); any UScpra; BRlgpd; 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 getConsent predicate 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: cpra defaults advertising:false, so events declaring requires: ['advertising'] are dropped until the user opts in.
  • Retention cap — clamps queueStorage.maxAgeHours down (never up). With privacy: 'gdpr' a configured 72 h durable queue is capped to 24 h.
  • Fingerprinting — strips the fingerprint-adjacent envelope signals screen and locale. (ua is 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.

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'] });

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 later

Where 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.match already stamped (from matchContext / setMatchContext).
  • context.groups already stamped (from current memberships).
  • delivery already resolved (from per-call options or defaultDelivery).
  • The requires field 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 toward middleware.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-vitals

If 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 (floor minBatchSize, default 1). The backend is struggling; ease up.
  • Latency < lowLatencyMs (default 500) → double the current batch size (cap at batch.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 call

What '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_failed path.
  • #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_failed itself declares requires: ['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 maxAgeHours expires.
  • Critical-only persistence when global queueStorage is in-memory. Durability still requires queueStorage: { 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):

  • SDKversion, started
  • Queuesize, dropped, maxSize, persistence (memory / indexeddb / unavailable), and if adaptive batching is on: adaptive.current, adaptive.latencyMs, adaptive.paused
  • Consentgranted, hasPredicate, per-category flags (analytics / personalization / advertising), dropped (events blocked by requires)
  • IdentityanonymousId, 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/false is shown.
  • The panel uses inline styles + a <style> block scoped to its own #score-debug-overlay id — 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 setInterval is registered, no keydown listener is attached.
  • The DebugOverlay class 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 off

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 pii and piiDenylist are set, pii wins 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-op

At-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 email scrubs email inside traits exactly like anywhere else.
  • $alias, $group, and $ungroup events declare requires: ['analytics'] — they respect category-aware consent like any other built-in event.
  • inspect().groups exposes { count, ids } only. The traits map is deliberately not exposed via inspect() 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:

  1. The linker payload decodes cleanly to { aid, sid, ts }.
  2. document.referrer's hostname matches allowedDomains (the *.rugby.com wildcard form matches any subdomain but not the apex).
  3. The encoded ts is within freshnessMs of 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' — matches a.rugby.com, b.c.rugby.com, etc., but not the apex rugby.com itself. 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 off

adopted: 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() returns null.

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 in inspect().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:

  1. The buffer is drained.
  2. Each buffered event is re-emitted as a NEW envelope event (fresh id), preserving the original occurredAt, props, and context (so at-event-time match state and group memberships are kept).
  3. Two extra props are stamped: _backfill: true and _originalEventId: <orig.id>.
  4. The envelope context.userId reflects 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 yet

dropped 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.

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 (WebsiteTagCreatorPOST /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 / siteId are 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 endpoint is 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; calling shutdown() 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() | Composite 0–100 engagement score from passive signals. Returns null below minimum signal (10 s dwell or 1 interaction). See Engagement scoring. | | tracker.inspect() | Read-only snapshot of internal state (queue size, drops, consent, persistence, match-source, engagement counters, identity, options). Safe to call anytime; never mutates. The JWT itself is not exposed, only identity.hasJwt. |


Durable queue (offline, reload, stadium WiFi)

The default in-memory queue is fine for most sites — events flush every few seconds and any survivors lost on tab close are usually acceptable. For higher-stakes use cases (live match commerce, in-stadium fan apps, mobile web with flaky connectivity) the SDK can persist its queue to IndexedDB, so events survive reloads, tab crashes, and connection blackouts.

Opt in via queueStorage:

const tracker = new ScoreTracker({
  publishableKey: 'pub_abc123',
  endpoint: 'https://middleware.example.com/api/tracking/website',
  siteId: 'mysite',
  queueStorage: {
    type: 'indexeddb',
    maxAgeHours: 72,        // drop events older than this on next init() (default 72)
    maxEvents: 5000,        // hard cap on persisted rows; oldest dropped first (default 5000)
    dbName: 'score-tracker' // optional; namespace if you run multiple trackers per origin
  },
});

await tracker.init();

What this guarantees

  • Write-through on every event. Each track() / page() schedules a fire-and-forget IndexedDB write. The very small window between the in-memory push and the disk write means most events survive a crash, but a hard kill milliseconds after track() may lose the last few.
  • Delete on ack. Successfully POSTed events are removed from IndexedDB. Failed posts stay durable, so a init() after the user reloads will re-attempt them.
  • Age-based eviction. On init(), anything older than maxAgeHours is dropped before rehydration — no stale events linger forever.
  • Size cap. On init(), the store is trimmed to maxEvents (newest kept). Drops count toward inspect().queue.dropped.
  • Survives across instances. Open a new ScoreTracker with the same dbName after a reload — init() rehydrates any pending events into the in-memory queue and resumes from there.

Graceful fallback

If IndexedDB is unavailable — SSR, private-mode Safari, browser extension blocking, quota exceeded on open — the SDK logs a debug warning, falls back to in-memory, and continues tracking. You can detect this state:

const snap = tracker.inspect();
snap.queue.persistence === 'indexeddb'    // durable
snap.queue.persistence === 'memory'       // requested memory, running memory
snap.queue.persistence === 'unavailable'  // requested IndexedDB but fell back

When to use what

| Use case | Recommended | | --- | --- | | Standard product analytics | queueStorage omitted (in-memory) | | Commerce / ticketing pages | { type: 'indexeddb' } | | Live-match in-app fan UX | { type: 'indexeddb', maxEvents: 10000 } | | Iframe / multiple trackers per origin | { type: 'indexeddb', dbName: 'unique-per-tracker' } |

The durable queue does not change the consent or PII contract. Events dropped by getConsent are still dropped, never persisted. The PII denylist is applied at envelope-build time, so what's on disk includes raw props — clear the queue (close the tab + await indexedDB.deleteDatabase(name)) if you need a hard wipe.


Match-state context enrichment

For sports / fan-engagement properties, attach live match state to every event so the warehouse can correlate commerce, engagement, sponsor exposure, and churn against game minute + score state.

import { ScoreTracker, type MatchContext } from '@3halves-labs/score-tracker';

let liveMatch: MatchContext | null = null;

const tracker = new ScoreTracker({
  publishableKey: 'pub_abc123',
  endpoint: 'https://middleware.example.com/api/tracking/website',
  siteId: 'mysite',
  // Sync provider — called on every enqueue, so it must return immediately.
  // For async data (websocket, polling fetch), keep a cached value in scope
  // and have the provider return it. Return null when no match is relevant.
  matchContext: () => liveMatch,
});

await tracker.init();

// Poll the match-clock endpoint every 15s. Whatever this updates is what the
// next track() call will stamp on the event.
setInterval(async () => {
  try {
    const res = await fetch('/api/live/match-clock?fixtureId=12345');
    const data = await res.json();
    liveMatch = {
      matchId: data.fixtureId,
      competition: 'URC',
      gameMinute: data.gameMinute,
      homeTeam: data.homeTeam,
      awayTeam: data.awayTeam,
      homeScore: data.homeScore,
      awayScore: data.awayScore,
      scoreState: data.homeScore > data.awayScore ? 'leading' : data.homeScore < data.awayScore ? 'trailing' : 'level',
    };
  } catch { /* keep the last good value */ }
}, 15_000);

Every event now ships with a context.match block on the envelope's event:

{
  "type": "track",
  "name": "shop_view",
  "occurredAt": "...",
  "context": {
    "match": {
      "matchId": "12345",
      "competition": "URC",
      "gameMinute": 67,
      "homeTeam": "Munster",
      "awayTeam": "Leinster",
      "homeScore": 17,
      "awayScore": 14,
      "scoreState": "leading"
    }
  }
}

At-event-time guarantee

The provider is called at enqueue time, not at flush time. A track('add_to_cart') fired during a 67th-minute lead locks gameMinute: 67, scoreState: 'leading' onto the event even if it doesn't flush until the 71st minute (when the lead has flipped). This matters for warehouse analysis where "what was the game state at the moment of conversion" is the question being asked.

This also means: if you're using the durable queue, match context survives the reload with the event — when a queued event rehydrates on the next init(), it still carries the at-event-time match state.

Imperative setter (websocket / SSE)

If you don't want a polling provider, set context imperatively:

const tracker = new ScoreTracker({ /* …no matchContext… */ });
await tracker.init();

// Wire to your live data stream:
liveWebsocket.on('match_update', payload => {
  tracker.setMatchContext({
    matchId: payload.id,
    gameMinute: payload.minute,
    scoreState: payload.state,
  });
});

liveWebsocket.on('full_time', () => tracker.setMatchContext(null));

If both matchContext (constructor option) and setMatchContext() are used, the constructor provider wins — pick one. Use tracker.inspect().match to verify which path is active:

tracker.inspect().match
// → { hasProvider: true,  hasManualValue: false }   provider configured
// → { hasProvider: false, hasManualValue: true  }   only setMatchContext used
// → { hasProvider: false, hasManualValue: false }   no match context attached

Zero overhead when unused

If neither matchContext nor setMatchContext is configured, events carry no context.match field at all — no callback runs, no object allocation, no envelope bloat. Sites that don't care about match state see exactly the same shape they did before this feature.


Engagement scoring

tracker.getEngagementScore() exposes a passive 0–100 score derived from signals the SDK is already collecting — useful for loyalty tier calculation, sponsor inventory valuation, retention models, and superfan identification. No extra API calls; no consumer wiring required, the signals are populated by the same listeners that drive scroll/video/page tracking.

const s = tracker.getEngagementScore();
if (s) {
  console.log(`engagement: ${s.score}/100`, s.subScores);
  // engagement: 64/100 { dwell: 35, scrollDepth: 80, interactions: 75, videoCompletion: 50, returnFrequency: 50 }
}

Returns null until either ≥ 10 s of dwell or ≥ 1 interaction has accumulated — below that, the signal is too noisy to model.

Public formula

Five sub-scores, each 0–100, combine into a composite. The weights and per-sub-score math are frozen — changes require a major bump.

| Signal | Weight | Sub-score formula | |---|---|---| | dwell | 25% | round(dwellSec / 300 × 100), capped at 100. (5 minutes of active tracking = max.) | | scrollDepth | 20% | Maximum scroll % observed on the page. | | interactions | 20% | round(log10(count) × 50 + 25) for count ≥ 1, capped at 100. (1 → 25, 10 → 75, 100+ → 100.) | | videoCompletion | 15% | Maximum video % observed across <video> events. ended → 100. | | returnFrequency | 20% | round(log2(sessionCount) × 25), capped at 100. (1 session → 0, 2 → 25, 4 → 50, 16+ → 100.) |

composite = 0.25·dwell + 0.20·scrollDepth + 0.20·interactions + 0.15·videoCompletion + 0.20·returnFrequency

Sub-scores and raw counters

The EngagementScore return type exposes everything the formula touches so warehouse consumers can re-weight for their own model rather than trusting the composite:

{
  score: 64,                            // composite 0–100
  subScores: {                          // each 0–100, unweighted
    dwell: 35,
    scrollDepth: 80,
    interactions: 75,
    videoCompletion: 50,
    returnFrequency: 50,
  },
  raw: {                                // underlying counters
    dwellSec: 105,
    interactionCount