activity-tracker-sdk
v1.2.1
Published
JavaScript/TypeScript SDK for Activity Tracker — page views, visitor identity, source → destination journeys and custom events
Maintainers
Readme
activity-tracker-sdk
JavaScript/TypeScript SDK for Activity Tracker. Records page views, source → destination journeys, visitor identity and custom events from any web app.
Works with Nuxt 3/4, Vue 3, React, or plain JavaScript. No runtime dependencies, ships CJS + ESM + type declarations.
Installation
npm install activity-tracker-sdk
# or
yarn add activity-tracker-sdkFor the simplest possible install — no build step, no package — use the one-line snippet instead, which the admin panel generates for each site under Admin → Integrations:
<script async src="https://track.example.com/api/tracker/activity-tracker.js" data-site-key="pk_..."></script>That covers page views and SPA navigations. Reach for this package when you also need identify() and named events.
Quick start
import { ActivityTracker } from 'activity-tracker-sdk'
const tracker = ActivityTracker.init({
publicKey: 'pk_your_public_key',
apiUrl: 'https://track.example.com/api',
})
// Attach who they are — everything they did anonymously merges onto this person
tracker.identify({
name: 'Jane Doe',
email: '[email protected]',
phone: '01712345678',
externalId: 'user_1042',
})
// Record a business event
tracker.track('signup_completed', { plan: 'pro' })ActivityTracker.init() constructs the tracker and starts it in one call. Construct it yourself when you want to decide later — after a consent prompt, say — and call start() when you are ready:
const tracker = new ActivityTracker({ publicKey, apiUrl })
onConsent(() => tracker.start())Page views are recorded automatically, including SPA navigations through the History API. Pass autoTrackPageViews: false and call tracker.page() yourself if your router needs to normalise the path first.
Configuration
| Option | Default | Purpose |
| --- | --- | --- |
| publicKey | — | Required. From Admin → Integrations in the admin panel. |
| apiUrl | — | Required. e.g. https://track.example.com/api |
| source | inferred | web, app or server. The server rejects a surface the site is not configured for and falls back to its default. |
| device | detected | Force desktop, mobile or tablet instead of detecting it — for a shell the browser cannot describe honestly, such as Electron or a kiosk. |
| deviceModel | detected | Hardware model, e.g. Pixel 8. Detected from Client Hints on Android; set it for a shell that knows better, which also skips the detection. |
| fetch | global fetch | Custom fetch implementation — for tests, or a runtime without a global. |
| batchSize | 10 | Events buffered before an automatic flush. |
| flushInterval | 15000 | Milliseconds between automatic flushes. |
| autoTrackPageViews | true | Patch the History API and record navigations. |
| sessionTimeout | 1800000 | Idle gap after which a new session starts. |
| disableStorage | false | Skip persisting ids — every load looks like a new person. For consent-gated setups. |
| onError | — | Called with the failure each time a flush does not land. The events stay queued for the next attempt, unless the server refused them outright — see below. |
apiUrl points at the API root, not the ingest path — the SDK posts to POST {apiUrl}/tracker/collect and authenticates with the public key in the X-Site-Key header. If the site has allowedOrigins configured, requests from any other origin are rejected, so add every domain you serve from.
API
tracker.identify(identity) // attach name / email / phone / externalId / traits
tracker.track(name, metadata?) // named business event
tracker.page(path?, title?) // page view (automatic unless turned off)
tracker.click(name, metadata?)
tracker.formSubmit(name, metadata?)
tracker.clearIdentity() // stop attaching the identity, keep the device
tracker.reset() // logout — new anonymous visitor
tracker.start() // start the flush timer and page listeners
await tracker.flush() // send what is buffered
await tracker.stop() // stop timers and flush
tracker.identity // current identity, or null
tracker.sessionId // current session id
tracker.anonymousId // pseudonymous device id
tracker.pending // events waiting to be sentLogging out
tracker.reset()reset() is the logout call. It drops the identity, the session and the anonymous device id, so everything after it is a brand-new anonymous visitor. Rotating the device id is the part that matters: the server keeps anon:<id> as an alias on the record it belonged to, so a tracker that kept the same one would file the next person on a shared browser under the one who just logged out.
Logging back in needs nothing special — call identify() again:
tracker.identify({ email: '[email protected]' })The server resolves the visitor by email, phone or externalId before it ever looks at the device, so a returning person is matched back to their existing record with all of their history, and the anonymous activity from the logged-out stretch stays on its own.
Events already queued when you call reset() keep the identity they were recorded with — they happened while the visitor was signed in, and that is how they should land.
If you only want to stop attaching the identity without starting a new visitor, use clearIdentity(). It keeps the device and session, so later events still resolve to the same person server-side — right for "stop labelling this", wrong for a shared device.
If someone else logs in without logging out
identify() handles it. When it is handed an identity that belongs to a different person than the one already stored, it rotates the anonymous device id first, exactly as reset() would.
That matters because the server keeps anon:<id> as an alias on whoever the device belonged to. Without the rotation, the second person's activity merges into the first person's record — irreversibly, since the server does not retro-split. Sessions expire, cookies get cleared, browsers get shared; a clean logout cannot be assumed.
"Different person" is judged on externalId when both identities carry one, falling back to email. A changed email under the same externalId is the same person, so it does not split. Phone is not used — the same number arrives formatted too many ways, and a false split is worse than a missed one. If your app identifies people by phone, pass an externalId as well.
Identifying from anonymous is unaffected: with no previous identity there is nobody to confuse, so the browsing that led up to the login still merges onto the person.
Nothing throws into your application once the tracker exists. The constructor is the one exception — it throws ActivityTrackerError when publicKey or apiUrl is missing, or when there is no fetch to use, so a misconfiguration surfaces at startup rather than silently recording nothing.
A flush posts in chunks of 100, the largest batch the API accepts. A chunk that fails for a reason a retry could fix — the network, a timeout, a 5xx, a 429 — goes back to the front of the queue and stops the run, so the next flush retries it in order. A chunk the server refuses outright — a 400 from an invalid event, a 401 from a rejected key, a 403 from a blocked origin — is reported through onError and dropped, because retrying it forever would stall every event recorded after it. The queue is capped at 500 events either way.
Every event carries an eventKey for idempotency, so a beacon retried by the browser is not counted twice. When the page is hidden or torn down the buffer is flushed through navigator.sendBeacon, preceded by a session_end event carrying the time spent on the last page.
The class is safe to import under SSR: every browser API is behind a guard, and the flush timer is unref'd so it never holds a Node process open.
Vue 3
// main.ts
import { initActivityTracker } from 'activity-tracker-sdk/vue'
initActivityTracker({
publicKey: import.meta.env.VITE_ACTIVITY_PUBLIC_KEY,
apiUrl: import.meta.env.VITE_ACTIVITY_API_URL,
})// anywhere — including stores and plain modules
import { useActivityTracker } from 'activity-tracker-sdk/vue'
useActivityTracker().track('added_to_cart', { sku: 'ABC-1' })The tracker is a module singleton rather than an app-context provide, so it is reachable from stores and route guards that have no component instance to inject from. useActivityTracker() throws if initActivityTracker() has not run; getActivityTracker() returns null instead, and identifyVisitor(identity) is a shorthand for getActivityTracker()?.identify(...).
To report the router's resolved path instead of the raw URL:
import { createActivityRouterHook } from 'activity-tracker-sdk/vue'
router.afterEach(createActivityRouterHook())The hook sends to.fullPath and uses to.meta.title as the page title when a route defines one.
Nuxt 3/4
// nuxt.config.ts
runtimeConfig: {
public: {
activityPublicKey: process.env.NUXT_PUBLIC_ACTIVITY_PUBLIC_KEY,
activityApiUrl: process.env.NUXT_PUBLIC_ACTIVITY_API_URL,
},
}// app/plugins/activity.client.ts
import { createActivityPlugin } from 'activity-tracker-sdk/nuxt'
export default defineNuxtPlugin(createActivityPlugin())createActivityPlugin() returns the plugin function; wrap it in defineNuxtPlugin so Nuxt registers it as a first-class plugin. Exporting the bare function works, but Nuxt warns about it (NUXT_B2007).
Client-only on purpose: the tracker reads localStorage and the History API, and a server-rendered pass has neither — nor a real visitor to record. The plugin reads the two runtime config keys shown above; rename them, or pass extra tracker options, through its argument:
export default createActivityPlugin({
publicKeyKey: 'trackingKey',
apiUrlKey: 'trackingApiUrl',
config: { batchSize: 25 },
})If either key is empty the plugin warns and leaves tracking off rather than failing the app. Page views come from Nuxt's own page:finish hook, which reports the resolved path.
<script setup>
const { $activityTracker } = useNuxtApp()
$activityTracker.track('viewed_pricing')
</script>getActivityTracker() from activity-tracker-sdk/nuxt returns the same instance outside a component.
Exports
| Entry | Contents |
| --- | --- |
| activity-tracker-sdk | ActivityTracker, ActivityTrackerError, and every type below |
| activity-tracker-sdk/vue | initActivityTracker, useActivityTracker, getActivityTracker, createActivityRouterHook, identifyVisitor |
| activity-tracker-sdk/nuxt | createActivityPlugin, getActivityTracker |
Exported types: ActivityTrackerConfig, ActivityEvent, ActivityType, EventSource, VisitorIdentity, CollectResponse, plus the read-back shapes SiteInfo, Visitor, AnalyticsSummary and ReferrerType.
Reading data back
This SDK writes only. Pulling analytics and visitors back out runs through the Integration API with the site's secret key, which must never reach a browser:
curl https://track.example.com/api/integration/v1/analytics \
-H "Authorization: Bearer <your-secret-key>"
curl "https://track.example.com/api/integration/v1/visitors?identified=true&limit=50" \
-H "Authorization: Bearer <your-secret-key>"The AnalyticsSummary, Visitor and SiteInfo types are exported so a server-side consumer can type those responses without redeclaring them.
What the SDK reports about the device
The server classifies the device by parsing the user agent, which is right for most browsers and wrong for the ones that misreport themselves. The SDK fills those gaps from what only the browser can see, and stays quiet otherwise — it sends device only where it knows something the parse cannot say:
| Case | What the user agent says | What the SDK sends |
| --- | --- | --- |
| iPad, iPadOS 13+ | a desktop macOS browser | tablet, plus os: iOS |
| Android tablet | Android, without the Mobile token phones carry | tablet |
| Unrecognised touchscreen | nothing about the device | mobile or tablet by the screen's shortest side, split at 600 CSS pixels |
| Ordinary desktop browser | a desktop browser | nothing — the server's parse stands |
| Server-side rendering | — | nothing; there is no device to report |
The iPad is the one that mattered: since iPadOS 13 Safari sends a desktop macOS user agent, so every iPad was counted as a desktop visitor. navigator.maxTouchPoints is the only thing that gives it away, and it is only readable from the page.
The hardware model
The SDK also reports deviceModel — Pixel 8, SM-X700 — where the browser will say. That comes from navigator.userAgentData.getHighEntropyValues(['model']), which is the only source a page has for it, and it is a narrow one:
- Android Chromium answers with the model.
- Desktop Chromium answers with an empty string.
- Safari and Firefox expose no
userAgentDataat all, so an iPhone or an iPad never reports a model.
Treat a blank as this browser would not say, not as an unknown device.
The call is a promise, so the SDK asks once at startup and caches the answer rather than asking per event — the first event or two may go out without a model, which beats delaying a page view for one. It is asked for only on a phone or tablet: desktop answers empty anyway, so requesting it there would take a high-entropy hint for nothing, and high-entropy hints are what a fingerprinting audit looks at. A page whose Permissions-Policy withholds ch-ua-model simply gets a blank, never an error.
Set deviceModel in the config to report one yourself — for a Capacitor or Electron shell that knows the hardware — which skips the request entirely.
Staying quiet is deliberate rather than a gap. The API prefers a reported device over the one it parses, so guessing here would override a full user-agent database with something smaller. Set device in the config if you need to override it outright.
Privacy
The SDK sends no IP address and no raw user agent — the server derives geography from the request itself and stores only a per-site salted hash of the address, so the same visitor on two tracked properties does not produce a cross-client join key. Identity is only ever what you pass to identify().
What it does read about the device is the user agent, touch-point count and screen size already available to any script on the page — see above.
Development
Requires Node.js 18+.
yarn install
yarn test # vitest
yarn typecheck # tsc --noEmit
yarn build # tsup → dist/ (cjs + esm + d.ts)tsconfig.json goes past strict: noUncheckedIndexedAccess,
noImplicitOverride, noFallthroughCasesInSwitch, noUnusedLocals,
noUnusedParameters and useUnknownInCatchVariables are all on. The shipped
code passes them, so keep them on — they are what catches an unchecked index or
a silently-any catch before it reaches a consumer.
Publishing a release is documented in PUBLISHING.md.
