Visit logger: who opened it, from where, on what
A visit log answers a sales question, not an analytics one: did this person open the thing we sent, when, from where, and on what? One row per real page view, attributed to a resource and, when known, to a person, plus a row at each lifecycle moment worth a fingerprint. The insight that shapes the module is that a request is not a visit. Prefetches, assets, mail scanners, staff previews and reloads all arrive as requests. Every rule here exists to filter them out before anyone gets told "the customer opened it".
Written by the engineer who has shipped this module. The earlier implementation it was audited against was a visit log on a sales site, behind the shared links a team sends customers and on its sign-in routes. provenance.md is the ledger: what the audit changed and how the templates verify it, what was kept deliberately, and what was designed here and has never run in production.
When to use
- A shared demo, proposal, quote or report must show who opened it and when, and notify someone the first time; a sign-up should record where and on what.
- An existing visit log needs auditing: false "opened" pings, a wrong first-opened date, visitors counted twice.
When NOT to use
| Instead of this | Use |
|---|---|
| Anonymous traffic, funnels, dashboards | Vercel Web Analytics, PostHog, Plausible. This logs attributed visits to specific resources |
| Clicks, scroll depth, time on page | a client analytics SDK. This is server-side and sees requests only |
| Cookie consent / ePrivacy banner | the host's consent manager. This sets no cookies, but an IP is still personal data |
| Blocking bots or fraud | Vercel BotID / WAF, Cloudflare Bot Management. isBot here labels; it never blocks |
| Gating the whole site | site-pin-gate; gating one resource is the host's auth or token layer |
Architecture
request --> route handler / Server Component (the host authorises the viewer)
|
+- isPageView(headers)? .. no ...> serve, log nothing
| prefetch, <Link> prefetch, iframe, asset, Server Action
|
+- describeVisitor(request, edge) synchronous: IP, geo, UA, isBot
|
+- after() ......................> response already sent
recordPageVisit(store, visit)
+- bot or internal .> insert only, never announce
+- readPriorVisits .> assessVisit: first? new visitor? back?
+- insert ..........> announce(assessment, fingerprint)?
admin page --> readVisitSummary --> groupVisitsIntoSessions --> <VisitHistory>
--> readVisitorEvents / findOriginEvent ---------> <VisitorActivity>
Critical facts
- A request is not a visit. Only document navigations and App Router
client navigations count. Browser prefetch and prerender,
<Link>prefetches, mail previews, iframes, assets and Server Actions are dropped before a fingerprint is taken. - The framework's
isBotis a crawler list. Next'suserAgent().isBotpasses headless Chrome, curl, python-requests and requests with no user agent at all, which are the clients that follow links in emails. - Geolocation is only as good as the edge you trust. Vercel overwrites
x-forwarded-for; behind Cloudflare in front of Vercel it holds Cloudflare's address; self-hosted it is whatever the client sent. Region is an ISO 3166-2 code, not a name. Cloudflare's UTF-8 city names arrive decoded as Latin-1. eqnever matches NULL. A visitor with no IP or no user agent is "new" on every page view unless matched withIS NULL, and the announcement throttle quietly stops working.- A bounded read cannot know the first visit. Past the limit, ask for it separately and show the counts as a lower bound.
- Supabase creates the user when the link is requested.
created_atis not the first sign-in;email_confirmed_atis.
Hard rules
Never let tracking touch the response. Read the fingerprint synchronously, defer the store, the write and the announcement with
after(), and never throw from the write path.
Never announce a bot or an internal visit, and never let one count as a prior visit. A staff preview must not swallow the customer's first-open ping.
Never trust
x-forwarded-foroff the platform that overwrites it. Pick theEdgeHeadersadapter for the edge the request really came through.
Never grant the tables to client roles. The server alone reads and writes tracking rows. Revoke explicitly: Supabase's default privileges grant every new table to
anon.
Never render a failed read as an empty history. "Could not load" and "Not opened yet" are different facts to the person deciding whether to call.
Never store an IP without a purpose and a retention period. Ship the purge with the table, and say so in the privacy policy.
Quick start
- Probe the host, fill the seams, confirm the rename: adaptation.md.
- Copy the types and the fingerprint, and pick the edge adapter: fingerprint.md.
- Create the tables or the Firestore indexes: data-model.md.
- Copy the rules, the write and read paths, and one store: rules.md, recording.md, stores.md.
- Call it from the resource's route or page, and from sign-in: capture.md.
- Add the history panels to the admin page: admin-ui.md.
- Schedule retention, write the erasure path, run the checks: operations.md.
- Run the suites in the host's runner: testing.md.
Reference directory
| Scenario | Trigger keywords | Reference |
|---|---|---|
| Fitting it into an app | seam, rename, host probe, Vercel, Cloudflare, self-hosted, strings, i18n | adaptation.md |
| What one request tells you | describeVisitor, x-forwarded-for, x-vercel-ip-city, cf-ipcity, userAgent, isBot, headless, Sec-Fetch-Dest, prefetch, rsc | fingerprint.md |
| Tables, columns, indexes, access | page_visits, visitor_events, migration, RLS, REVOKE, anon, Firestore indexes | data-model.md |
| What counts, when to announce | session window, sitting, first visit, new visitor, throttle, signed up from, origin event | rules.md |
| Writing and reading | recordPageVisit, after(), trackPageVisit, readVisitSummary, truncated, failed, Slack, announce | recording.md |
| Backends | Supabase, supabase-js, service role, Firestore, firebase-admin, NULL, memory store | stores.md |
| Where to call it | route handler, Server Component, headers(), sign-in, magic link, auth callback, signInWithOtp, email_confirmed_at | capture.md |
| The admin panels | visit history, activity, badge, bot row, "first opened", duration | admin-ui.md |
| Running it | GDPR, retention, purge, erasure, pg_cron, mail scanner, Safe Links, internal traffic, debugging | operations.md |
| Proving it | vitest, bun test, test cases, fixtures | testing.md |
| What the audit changed and why | provenance, defect, audit, kept deliberately, upgrading an existing log | provenance.md |
Part of the Timerise Skills index, which lists the sibling skills.