Visitor tracking
stitchkit/tracking is the browser half of visitor tracking — the outbox, the
visit lease, the page-leave beacon, visible time, scroll milestones,
declarative clicks, attribution — and stitchkit/tracking/server is the set of
decisions a tracking backend makes about what arrives. Neither has an event
vocabulary of its own, a database, or a React component: those are the
application’s, and the boundary is deliberate. → ADR 0166
Both entrypoints are evolving.
What the framework owns, and what you do
| Framework | Application |
|---|---|
| the event envelope, the batch, the dispositions, the visit entry and lease schemas | the event types, their metadata, their labels |
| the tab-shared outbox with reserved sequences and a short flush lease | which storage adapter (IndexedDB in a tab) |
| the page-leave beacon that arrives, and its queued insurance copy | the session, the router, the React provider |
visible time, scroll milestones, data-track clicks, UTM first/current touch |
the referrer → source map, the attribute names |
dispositionTrackingBatch, issueVisitLease, activeIntervalOf, presence |
the tables, the transaction, adoption, geo, reports |
Contract
import { createTrackingContract } from 'stitchkit/tracking'
export const tracking = createTrackingContract({
scope: 'public',
eventTypes: ['PAGE_VIEW', 'PAGE_LEAVE', 'SCROLL_DEPTH', 'SESSION_HEARTBEAT', 'CLICK', 'OUTBOUND_CLICK', 'INTERACTION', 'ITEM_VIEW'],
// eventExtras: z.object({ locale: z.string() }), // fields you carry beside the envelope
})
Two operations: bootstrap (POST /visit) issues or renews a visit lease,
track (POST /events) receives a batch. track declares
safelistedBody: true, because the page-leave event is a string beacon — the
only body a document that is being unloaded can deliver to another origin —
and that in turn requires an explicit cors.origin allow-list on the server.
Read safelisted request bodies
before deploying: it is one rule, and it is the one that keeps the beacon path
from being a CSRF hole.
scope: 'public' is the usual choice: the landing page is read by anonymous
visitors, and their path is part of the funnel. Identity comes from the cookie
on the server side; the client claims nothing about itself.
Browser
import {
browserTrackingHost, CONVENTIONAL_TRACKING_EVENT_TYPES, createTrackingClient,
createTrackingOutbox, indexedDbOutboxStorage,
} from 'stitchkit/tracking'
// Only the types the browser writes — a server-side event must not be trackable here.
type BrowserEvents = Pick<EventMetadataMap, ClientTrackingEventType>
const client = createTrackingClient<BrowserEvents>({
host: browserTrackingHost(),
buildId: env.NEXT_PUBLIC_BUILD_ID, // a typed env accessor; the client refuses `undefined`
builtin: CONVENTIONAL_TRACKING_EVENT_TYPES, // your names for the events the client emits
bootstrap: (entry) => api.tracking.bootstrap(entry),
deliver: (batch) => api.tracking.track(batch), // or a socket first, HTTP as the fallback
unloadUrl: urls.tracking.track(),
outbox: createTrackingOutbox(indexedDbOutboxStorage('my-app-tracking')),
referrerMap: [{ pattern: /t\.me|telegram\.org/, source: 'telegram', medium: 'social' }],
isAction: isInteractionAction,
onVisit: (visitId) => rum.setVisit(visitId),
})
EventMetadataMap is your { [type]: metadata } map; track is typed by it:
client.track('ITEM_VIEW', { itemId, itemTitle })
client.track('SIGN_OUT')
Two knobs match the client to your schema: batchSize is the contract’s
maxEventsPerBatch (a larger batch is a 400 the outbox can never retire),
and decorate adds your eventExtras — a locale, an area, or the identity a
client that cannot send headers on unload has to carry in the body — to every
event as it is minted, so the outbox, the beacon and deliver all see it:
createTrackingClient<BrowserEvents, TrackingEventEnvelope<keyof BrowserEvents> & { locale: string }>({
decorate: (event) => ({ ...event, locale: currentLocale() }),
// …
})
A bootstrap operation that needs extra fields of its own (a locale, a RUM
flag) is not the factory’s: build the entry schema with
createTrackingSchemas(…).entry.extend({ … }) and declare that endpoint yourself.
The client owns the mechanics that were the same in every application that
wrote them: the visit lease and its renewal after a long sleep, a pending queue
until the visit exists, synchronous event identity from a block of reserved
sequence numbers, the outbox and its flush lease, one bounded delivery retry,
the page-leave beacon and its queued copy, additive visible time, scroll
milestones, data-track / data-track-action clicks, heartbeats. It does not
own the router or React — tell it about navigation, and wrap it yourself:
const TrackerContext = createContext<TrackFn<BrowserEvents> | null>(null)
export function TrackingProvider({ children }: { children: React.ReactNode }) {
const [client] = useState(() => createTrackingClient<BrowserEvents>({ /* as above */ }))
const pathname = usePathname()
const search = useSearchParams().toString()
useEffect(() => client.start(), [client])
useEffect(() => client.onNavigate(pathname, search ? `?${search}` : ''), [client, pathname, search])
return <TrackerContext.Provider value={client.track}>{children}</TrackerContext.Provider>
}
export const useTracker = () => useContext(TrackerContext) ?? (() => { throw new Error('outside TrackingProvider') })()
A component that records “opened X” can mount twice on one page; keep that
memory at document scope with createOncePerPage, not in a ref.
Why these details are not optional
Four of them were found the expensive way, by consuming applications, and each is a test in this repository that reddens when the mechanism is put back:
- the beacon body is a string — a
Blobtypedapplication/jsonreportstrueand dies on the preflight it cannot have; - the event gets its sequence number before it is written — awaiting the
outbox inside
pagehideloses the event with the document; - the flush lease is short and released on leave — a ten-second lease a dying document kept delayed the next document’s first flush by ten seconds;
oncePerPagelives at document scope — a remounted component has fresh refs.
Server
The server half is pure. Read from your database, decide, write:
import { activeIntervalOf, dispositionTrackingBatch, hashTrackingEvent, issueVisitLease } from 'stitchkit/tracking/server'
track: async ({ input, ipAddress, userAgent }) => {
const actorOwnerId = session?.userId ?? null
const visits = await db.visit.findMany({ where: { id: { in: input.events.map((e) => e.visitId) } } })
const stored = await db.trackingEvent.findMany({ where: { clientEventId: { in: input.events.map((e) => e.eventId) } } })
const decided = dispositionTrackingBatch({
events: input.events,
visits: visits.map((v) => ({ id: v.id, browserStreamId: v.browserStreamId, ownerId: v.userId })),
existing: new Map(stored.map((e) => [e.clientEventId, e.clientPayloadHash])),
actorOwnerId,
userAgent,
})
const now = new Date()
await db.$transaction(async (tx) => {
for (const visitId of decided.adoptable) await adoptVisit(tx, visitId, actorOwnerId, now)
await tx.trackingEvent.createMany({
data: decided.accepted.map((event, ordinal) => ({
...yourColumns(event), clientPayloadHash: hashTrackingEvent(event), serverItemOrdinal: ordinal, createdAt: now,
})),
})
for (const event of decided.accepted) {
const interval = activeIntervalOf(event, now)
if (interval) await tx.activeTimeInterval.upsert(/* by interval.intervalId */)
}
})
return { accepted: decided.accepted.length, dispositions: decided.dispositions }
}
hashTrackingEvent is sha256(JSON.stringify(parsedEvent)) with no key
sorting — exactly what applications already store, so a migration does not
spend a week reporting conflicts. decided.conflicts names duplicates whose
payload changed; log them.
issueVisitLease runs the visit algorithm over a store you implement — six
methods over your tables, inside a lineage lock you hold (pg_advisory_xact_lock
over the browser stream id is the usual one):
const store: TrackingVisitStore<Prisma.TransactionClient> = {
withLineageLock: (lineage, fn) => db.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${lineage}))`
return fn(tx)
}),
findActive: (tx, q) => tx.visit.findFirst({ where: { /* q.browserStreamId, q.cutoff, ownership */ } }),
touch: (tx, id, now, health) => tx.visit.update({ where: { id }, data: { lastActivityAt: now, ...health } }),
adopt: (tx, visit, ownerId, now) => /* assign the owner, back-fill events, record the merge */,
endOpen: (tx, lineage, now) => tx.visit.updateMany({ where: { browserStreamId: lineage, endedAt: null }, data: { endedAt: now } }),
create: (tx, visit, health) => tx.visit.create({ data: { id: visit.id, ...deviceGeoAndSource(visit) , ...health } }),
}
bootstrap: ({ input, userAgent }) =>
issueVisitLease(store, { ownerId: session?.userId ?? null, userAgent }, input, { ownership: 'adopting' })
ownership is the one policy two applications disagreed on: 'adopting'
continues an anonymous visit and gives it to the caller who signs in — the
path from the landing page to the first signed-in action becomes one path; 'owned' never
continues an anonymous visit, for an application where every visitor already
has an identity. A bot gets a lease that no store holds, so its events are
identity-invalid and nothing of it reaches a report.
createPresenceRegistry is who is here now, in this process: touch it from
track, read it for a live feed. It is honestly empty after a restart.
Not here
GeoIP, event labels, funnels, reports, the analytics page, Socket.IO push into an admin UI, RUM — application code, and the reason two applications’ tracking looks different even though their mechanics are now one.