@syntarie/tracking
v0.3.0
Published
Syntarie tracking SDK — browser-first, tiny, privacy-respecting analytics.
Maintainers
Readme
@syntarie/tracking
Browser-first, tiny, privacy-respecting analytics SDK for leatmap.
- < 5 kB gzip core (CP §1.5).
- Opt-in modules for click, scroll, engaged-time, vitals, errors, offline queue, retries.
- Lazy-loaded
replaysub-bundle for session and error replay (~30 kB gzip, fetched only when imported). - ESM only. Targets Safari 15+ and every evergreen browser.
Performance budgets (enforced in CI — TRK-305)
These are hard gates. A PR that exceeds them fails the sdk-perf job and cannot merge.
Bundle size (gzip, per entry)
| chunk | budget | what it ships |
| --- | ---: | --- |
| index.js (core) | 5200 B | init, queue, transport, pageview, consent, identify, session, sampler, region resolver |
| vitals.js | 1024 B | LCP / FID / CLS web-vitals |
| errors.js | 1024 B | window.onerror + unhandledrejection |
| network-errors.js | 1500 B | fetch + XHR wrap |
| clicks.js | 1500 B | click delegation + selector builder |
| scroll.js | 1500 B | scroll-depth buckets |
| heatmap.js | 2000 B | click coords + scroll buckets for heatmap rendering |
| engaged-time.js | 1500 B | visible time + idle pause |
| offline-queue.js | 1500 B | IndexedDB persistence |
| retry.js | 1500 B | lazy retry orchestrator |
| feedback.js | 4000 B | NPS + CSAT widget (shadow-DOM, ARIA) |
| feature-flags.js | 500 B | subpath re-export wrapper |
| feature-flags-init.js | 3000 B | eval engine + cache + fetch loop (lazy) |
| replay.js | 35 000 B | rrweb + recorder + redact + chunker + sampler (lazy) |
Aggregate gate: every customer-loadable entry combined (excluding replay.js and the lazy feature-flag init chunk) must stay under 12 000 B gzip. This stops a future change from stacking five "1.5 kB opt-in" subpaths into a customer-visible regression that no per-entry check would catch.
Init time (cold + warm, measured via headless Chromium)
| metric | budget | measurement |
| --- | ---: | --- |
| Cold init() | < 50 ms | first call on a freshly loaded page, median of 5 samples |
| Warm init() | < 10 ms | second call short-circuits via config !== null; median of 20 samples |
Both gates run against the built artifact under dist/, not source. Bundle size is asserted by sdk/scripts/check-bundle-size.ts (also from sdk/test-built/bundle.test.ts during pnpm test); init time runs under the CI sdk-perf job via sdk/scripts/check-init-time.ts.
The size gate runs in two modes:
- CI (
sdk-perfjob) — builds the PR and a siblingorigin/maincheckout, then renders the PR comment as agzip / main / Δtable so reviewers see signed byte deltas vs the merge target. A core-bundle Δ above +250 B raises a::warningannotation but does not fail the build; only an absolute budget breach blocks merge. - Local (
pnpm size) — nomainto compare against, so the script falls back toheadroom = budget − current. Same gate, fewer columns.
# Local re-runs (assume a fresh build):
pnpm -F @syntarie/tracking build
pnpm -F @syntarie/tracking size # bundle size (headroom mode)
pnpm -F @syntarie/tracking bench:init # init time (requires `playwright install chromium`)Install
pnpm add @syntarie/trackingInitialise
Get an API key from app.leatmap.com → Settings → API keys. Your site id is shown next to it on the install page (for a fresh workspace it equals your workspace id).
import { init, grantConsent } from '@syntarie/tracking';
init({
apiKey: 'lk_live_xxx',
host: 'https://collect.leatmap.com',
siteId: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
});
// The hosted collector enforces consent server-side and rejects
// requests without a non-empty consent token. See Consent below.
grantConsent('granted');apiKey is the value the leatmap dashboard issues you. It is sent verbatim as the bearer on every collector request; the bearer is stored as a hash server-side; the collector resolves your workspace from the matching hash on each request.
siteId is the site's UUID (36 characters). When set alongside apiKey, the SDK sends it as the X-Site-Id header on every request. The hosted collector requires that header on keyed requests and answers 401 missing_site_id without it, so omitting siteId means no event ever lands.
Track custom events
import { track } from '@syntarie/tracking';
track('checkout_completed', { plan: 'pro', amount_cents: 1999 });Identify a user
import { identify } from '@syntarie/tracking';
identify('user-1234', { email: '[email protected]', plan: 'pro' });Consent
import { grantConsent, revokeConsent } from '@syntarie/tracking';
grantConsent('granted'); // start sending events, with a consent token
revokeConsent(); // purge queue and stopWhen defaultConsent is 'unknown' (the default), events accumulate in memory but never leave the SDK until the host page calls grantConsent().
The hosted collector at collect.leatmap.com additionally requires a non-empty X-Consent token on every request and rejects requests without one. The SDK only attaches that header when grantConsent() was called with a token argument: grantConsent('granted') works, and so does passing the token your own consent UI produced (the collector stores it as proof of consent). Note that defaultConsent: 'granted' alone does NOT attach a token, so against the hosted collector you must still call grantConsent('<token>') once after init().
Coverage tracking (pageKey)
The SDK can stamp a server-anchored page_key on every event so the dashboard's coverage report (TRK-262) can show you what your current analytics tool is hiding — events that fired versus renders that never made it through.
Two ways to wire it:
// 1. Auto-detect from a meta tag your SSR layer renders.
// Recommended — works with the @syntarie/tracking-node SSR helper.
init({
apiKey: 'lk_live_xxx',
host: 'https://collect.leatmap.com',
siteId: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
});
// init() reads <meta name="leatmap-page-key" content="..."> on its own.
// 2. Pass it explicitly.
init({
apiKey: 'lk_live_xxx',
host: 'https://collect.leatmap.com',
siteId: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
pageKey: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
});When neither source is available the field is dropped from the wire payload — events still flow, they just land in the dashboard's unknown bucket. See docs/coverage-tracking for the SSR template that mints + threads the key.
Opt-in modules
Each lives at its own subpath import so the core stays small.
import { installVitals } from '@syntarie/tracking/vitals';
import { installErrors } from '@syntarie/tracking/errors';
import { installClicks } from '@syntarie/tracking/clicks';
import { installScroll } from '@syntarie/tracking/scroll';
import { installEngagedTime } from '@syntarie/tracking/engaged-time';
init({
apiKey: 'lk_live_xxx',
host: 'https://collect.leatmap.com',
siteId: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
});
installVitals();
installErrors();
installClicks();
installScroll();
installEngagedTime();Session replay (opt-in, lazy-loaded)
import { init, getAnonId, getSessionId } from '@syntarie/tracking';
init({
apiKey: 'lk_live_xxx',
host: 'https://collect.leatmap.com',
siteId: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
});
const { startReplay, startErrorReplay } = await import(
'@syntarie/tracking/replay',
);
startReplay({
apiKey: 'lk_live_xxx',
host: 'https://collect.leatmap.com',
anonId: getAnonId(),
sessionId: getSessionId(),
replaySampleRate: 0.05,
});Migrating from siteId-as-key
Pre-v1 the SDK accepted siteId instead of apiKey, as the bearer. That legacy alias (a siteId WITHOUT an apiKey) keeps working for one release window — it triggers a one-time console.warn and will be removed in v2.0.0. Get an API key from the dashboard and rename the field:
-init({ siteId: 'abc123', host: 'https://collect.leatmap.com' });
+init({
+ apiKey: 'lk_live_xxx',
+ host: 'https://collect.leatmap.com',
+ siteId: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
+});Note the field name survived with a new meaning: a siteId passed ALONGSIDE apiKey is the modern contract, your site's 36-char UUID sent as the X-Site-Id header (required by the hosted collector, see Initialise above). Only the keyless form is deprecated.
Privacy
- Default consent is
'unknown'— no network egress without an explicitgrantConsent(). - Honours the browser's
Do Not Tracksignal by default. Disable withrespectDnt: false. cookielessmode swaps the persistent anon id for a daily-rotating fingerprint of UA, screen size, and timezone.
License
MIT.
