trackhome
v4.0.10
Published
Tiny browser SDK for trackhome — a self-hosted, tags-only event/click tracker.
Maintainers
Readme
trackhome
Tiny browser SDK for trackhome — a self-hosted, tags-only event/click tracker.
- One-line setup:
init('YOUR_TRACKHOME_URL')and you're done. - Auto-collects page views on init and on every SPA route change (React Router, Next.js, Vue Router — all work without extra config).
- Tags-only model: every event is
{ tags, ts?, value? }with exactly oneevent:<name>tag (e.g.event:page_view,event:click). No separate type field. - Privacy-friendly: visitor identity is a client-generated id (base58, stored in
localStorage) sent via theX-Trackhome-Visitorheader. No cookies. IP/UA are hashed on the server, never stored raw. - Zero dependencies, ~2 KB gzipped (UMD), works with or without a bundler.
- Fail-safe: tracking APIs (
init,track,event,page,identify,flush) never throw into your app — network/ingest errors are throttled toconsole.warn, events are re-queued (capped) and retried on the next flush. - Auto-enriched server-side: every event also gets
device:*,os:*,country:*,lang:*tags derived from request headers, so dashboards break down by these for free.
Install
npm install trackhome
# or: pnpm add trackhome / yarn add trackhomeQuick start (3 ways)
1. Bundler (webpack, Vite, esbuild, …)
import { init } from 'trackhome';
// That's it. Fires event:page_view immediately + on every SPA navigation.
init('YOUR_TRACKHOME_URL');2. Build-less <script> tag (from your trackhome server)
<script src="YOUR_TRACKHOME_URL/t.js"></script>
<script>
trackhome.init('YOUR_TRACKHOME_URL');
</script>Replace YOUR_TRACKHOME_URL with the base URL of your self-hosted trackhome instance (no trailing slash).
Custom events
import { track, event, identify } from 'trackhome';
// Primary API — tags must include exactly one event: tag
track(['event:signup_click', 'tier:pro', 'cta:header']);
// Ergonomic helper — adds event: prefix for you
event('purchase', ['plan:annual']);
identify('user_42'); // → event:identify + user:user_42API
init(endpointOrConfig)
init('YOUR_TRACKHOME_URL') // shorthand
init({ // full config
endpoint: 'YOUR_TRACKHOME_URL',
projectKey: 'YOUR_PROJECT_KEY', // optional: multi-tenant project key
tags: ['app:marketing'], // persistent tags on every event
autoPageView: true, // default true — see below
batchSize: 20, // max events per flush
flushIntervalMs: 5_000, // flush cadency in ms
disabled: false, // kill switch for dev
})Idempotent — calling init() twice is a no-op.
autoPageView (default: true) — fires event:page_view tagged page:/current/path:
- immediately on init
- on
history.pushState/replaceState(catches React Router, Vue Router, Next.js navigation) - on
popstate(back/forward buttons) - on
hashchange
So for many sites, the one-liner init(url) is all you need — the SDK collects traffic without any manual track() calls. Pass autoPageView: false if you have your own router hook or want to call page() manually.
track(tags, value?)
track(['event:signup_click', 'tier:pro']);
track(['event:page_view']); // value optionalTags must include exactly one event:<name> tag. Persistent tags (set via init({tags}) / setTags / addTag) are merged in. Duplicates are deduped.
event(name, tags?, value?)
event('signup_click', ['tier:pro']);
// equivalent to track(['event:signup_click', 'tier:pro'])page(name?, tags?)
page() // → event:page_view with page:<current-path>
page('pricing') // → event:page_view with page:pricing
page('/blog/post-1') // → event:page_view with page:/blog/post-1Useful when you want to control page-view timing yourself (e.g. after a custom route transition).
identify(userId, tags?)
identify('user_42');
// → emits tags: ['event:identify', 'user:user_42', ...persistentTags]This is just tags — it's the same as event('identify', ['user:user_42']). The server doesn't have a separate user table; downstream dashboards can group by user:* tags.
setTags(tags) / addTag(tag)
Replace / append to the persistent tag set.
setTags(['app:marketing', 'variant:b']);
addTag('flow:onboarding');flush({ useBeacon? })
Manually flush the pending queue. Auto-flushes on:
- batch full (default 20 events)
- timer (default every 5s)
visibilitychangeto hiddenbeforeunloadpagehide
You only need to call this yourself in tests or for at-exit guarantees. It never throws — failed ingests log a throttled warning and re-queue events (oldest dropped if the queue exceeds ~500 while ingest is down).
Error handling
All browser tracking calls are wrapped in internal try/catch and will not crash your app if:
- the ingest endpoint is down or returns 4xx/5xx
fetch/sendBeaconfails (offline, CORS, ad blockers)- auto-page-view hooks or attribution parsing hit an edge case
Warnings are logged at most once every 30 seconds via console.warn('[trackhome] …'). The queue caps at ~500 events; if ingest stays down, oldest events are dropped to bound memory.
createLink() is server-side admin API — it may still throw on misconfiguration or API errors (call from Node with try/catch).
Server-side admin: create links
Provide secretKey_im_not_frontend (your trackhome SECRET_KEY) for admin calls. Never use in browser/frontend code.
import { init, createLink } from 'trackhome';
init({
endpoint: 'YOUR_TRACKHOME_URL',
secretKey_im_not_frontend: process.env.TRACKHOME_SECRET_KEY,
});
const link = await createLink({
url: 'https://example.com/landing',
tags: ['campaign:email'],
expirationMs: 86_400_000,
});
// link.short_url, link.key, link.expires_at, ...Or pass credentials per call via the second argument. Omit key for a random slug. Uses X-Secret-Key → POST /api/links.
Auto-tags (server-side, zero-config)
Every event sent to the trackhome server is enriched with tags derived from the HTTP request — callers never set these:
| Tag | Source | Example |
|---|---|---|
| device:mobile | device:desktop | device:tablet | User-Agent regex | device:mobile |
| os:android | os:ios | os:macos | os:windows | os:linux | os:chromeos | User-Agent | os:ios |
| country:xx | cf-ipcountry / x-vercel-ip-country / x-aws-region (2-letter ISO) | country:us |
| lang:xx | Accept-Language (primary prefix) | lang:en |
This means a dashboard can break down traffic by device, OS, country, language for free — no extra client config. Caller-supplied tags of the same name win on collision.
Server
This package is just the client SDK. Deploy your own trackhome server (Fastify + ClickHouse) to receive events and serve the dashboard. The server also hosts this SDK at /t.js.
Session recording (server-driven)
Session recording (DOM replay via rrweb) is always server-driven — the SDK has no static recording config. Recording starts/stops automatically based on rules configured in the dashboard:
- Every
POST /evresponse may include arecording: { sampleRate, maskAllInputs, ... }field (or omit it). - When present, the SDK lazy-imports
rrweband starts recording with the descriptor's knobs. - When absent on a subsequent response, the SDK stops any active recording.
Visitors who never match an admin rule never download rrweb — zero bytes on the wire, zero parse cost. The customer's init() call never touches recording config; admins manage rules in the dashboard (targeting selector + sample rate per rule).
Deterministic sampling: rules use fnv1a32(visitor_id, rule_id) % 100 < sampleRate, so the same visitor is always sampled the same way across sessions. The server pre-buckets and only sends the descriptor to sampled-in visitors; the SDK re-checks client-side as defense-in-depth.
PII safety defaults: maskAllInputs: true (form input values show as ***) and blockSelector: [data-block-record] (CSS selector for fully-excluded regions). Both are configurable per rule in the dashboard.
Recording dependencies (installed automatically)
rrweb ships as a regular dependency of this package — when you npm install trackhome, you get everything you need for recordings out of the box. No peer dep negotiation, no bundler IgnorePlugin configuration, no "install this if you want recordings" dance.
The dynamic import('rrweb') stays lazy — visitors who never match an admin recording rule never download the rrweb chunk. Tree-shaking still works: bundlers split rrweb into its own chunk that only loads on demand. Consumers who never enable recording rules pay only the ~14 KB base SDK cost.
Note on gzip: recording batches are compressed via the browser's native CompressionStream API (universal since Chrome 80+, Firefox 113+, Safari 16.4+). No fflate or other polyfill peer dep required.
For advanced manual control (rare), import from the sub-entry:
import { startRecording, stopRecording } from 'trackhome/recording';This bypasses the server-driven model — use only if you know what you're doing.
License
MIT
