BLUF. Every public host writes events into our [Database] and serves https://<that-host>/insights for that hostname only. Map and phone taps are first-class events, wrapped so the server sees them. Count real people only. Do not pay an analytics company for “top tier.” Pay only if you need completed calls, which a click is not. This is directive v64 rule 6 / R13.
Live: https://projects.jbnx.io/framework/tracking-doctrine Customer report: https://projects.jbnx.io/framework/monthly-funnel-proof
Copy the block. After the first time, put the same text in AGENTS.md / CLAUDE.md as a pointer (this URL), not a second bible. Long process stays here; always-apply files stay short.
TRACKING — mandatory on every public site we touch. Spec:
https://projects.jbnx.io/framework/tracking-doctrine
https://projects.jbnx.io/framework/monthly-funnel-proof
1. Source of truth is OUR [Database], never a third-party dashboard.
Do not add Google Analytics, Tag Manager, Clarity, Meta Pixel,
PostHog, Mixpanel, or any collector as the number we report.
Optional internal tools are allowed only if events still land
in our DB first.
2. Every public hostname must already have, or you must add:
a) Server/edge log of each real document request → `analytics.hits`
(host, path, status, referrer_host, utm_source/medium/campaign/
content/term, click ids, sid). Set sid as HttpOnly cookie on
OUR domain (Lax, 90 days). Hash or drop raw IP; keep country.
b) Same-origin POST /collect (or sendBeacon) → `analytics.events`
for in-page actions. No third-party host.
c) tel: and maps/directions links go through first-party wrappers
/go/call and /go/map (then 302). Server writes the click only
when it is a real user navigation — not curl, not prefetch.
d) On signup, booking, payment, lead: copy sid + utms onto THAT
row in the product database. Finished outcomes are those rows.
3. Required event names (no extras without a named funnel stage):
page_view, cta_click, outbound, click_to_call, click_to_map,
form_start, form_finish. Never send form field values, names,
emails, or phone numbers in the event payload.
One page_view per document request. Do not also fire a JS
page_view for the same load.
4. Every public host serves GET /insights showing ONLY that Host's
rows (e.g. marcobros.localfave.org/insights). Monthly snapshots
may also render in the billing login. Same tables. No third-party
dashboard as the page. The page must say bots, local testers,
and Insights-page views are dropped.
5. We claim only tagged landings (our UTM or click-id). Direct and
unknown stay in an “unattributed” column. No modeled conversions.
6. Count only real people. Drop, and never show:
- bots, empty UA, crawlers, curl/wget/python/node-fetch
- local/private IPs and localhost referrers
- /insights, /collect, /api/*, /go/*, robots, health, *-probe
- prefetch / prerender / HEAD
- test labels: verify, prodit, done, x
- /go/call and /go/map that are not a user navigation
(sec-fetch-user: ?1, or dest=document + mode=navigate,
or no sec-fetch headers on an old browser)
7. If a host ships without a–d, the job is not done. Verify:
GET /insights → 200, that host only, and that GET does not
increment page views. A scripted GET /go/call still 302s and
must NOT increment the call number. A bot POST /collect
returns {ignored:true} (or no new row).
Do not curl /go/call as proof — that is how we faked calls.
That is the whole instruction. If they ignore it, the live URL is the argument, not a longer paste.
This is the quality rule. A number on /insights is a lie if it includes us, bots, or the page checking itself.
Drop on write (do not insert) and drop on read (Insights SQL / page must filter the same list):
| Drop | How you know |
|---|---|
| Bot / crawler / script | Empty UA, or UA matching bot/spider/crawl/curl/wget/python/node-fetch/healthcheck/headless. Do not treat the Worker→portal hop UA (Cloudflare-Workers) as the visitor — use body.ua. |
| Local tester | 127.0.0.1, localhost, RFC1918, ip_kind=local, local referrer |
| Ops / this page | page_view on /insights, /collect, /api/, /go/, robots, sitemap, favicon, /healthz, *-probe (including /post-worker-probe) |
| Prefetch | Purpose: prefetch, prerender, HEAD |
| Fake tap | /go/call or /go/map without a user navigation. Curl and link prefetch still 302. They do not increment. |
| Test label | verify, prodit, done, x (and test when it is our verify string, not a UI place name like header / page) |
place=header and place=page are real UI. They are not test labels.
Verify without poisoning the report
curl https://<host>/insights → 200. That request must not add a page view.curl https://<host>/go/call?... → 302. That request must not add a call.POST /collect → {ignored:true} (or 204 and no new row)./insights is 200 and a scripted tap does not move the call number.One schema, append-only. Suggested names (do not invent a second):
| Table | What lands | Who writes it |
|---|---|---|
analytics.hits | Every real document request | Edge / app middleware |
analytics.events | Clicks, SPA views, /go/* wrappers | Same-origin /collect or the wrapper |
| Product tables | Finished outcomes (booking, user, payment, lead) | The product, with sid / utm columns |
analytics.month_snapshots | Frozen month per product | Job on the 1st |
Do not store: raw IP, full user-agent forever, form fields, session replay, staff passwords. Country and a daily-rotated hash are enough for “uniques” if you ever need them internally. They are not a customer headline.
The existing POST /api/1099/visit-beacon is geo only for #/map. It does not replace hits / events. Keep it as a coarse map feed if you want; wire the real funnel to the tables above.
Collect and show on that domain. Directive v64: every public host we ship has /insights scoped to that Host, counting real people only.
| Surface | URL shape | Audience |
|---|---|---|
| Collector (invisible) | https://<host>/collect and /go/call /go/map | Browser only |
| Live insights | https://<host>/insights — this hostname only | Owner / us / customer |
| Example | https://marcobros.localfave.org/insights | Marco Bros rows only |
| Customer monthly proof | Billing login freeze (same tables, month snapshot) | Paying customer |
| Internal desk | portal #/map (geo only, not the funnel) | Us |
Sibling subdomains never share a page. other.localfave.org/insights must not see Marco Bros. Do not invent stats. as a second host — /insights on the real host is the address.
Filter every query by host = request.hostname. That is the whole multi-tenant rule.
Every Insights page prints the quality line: bots, local testers, this page, prefetch, and scripted /go/* taps are dropped.
These are the local-business funnel (nail booking, “call us,” “directions”). Treat them as stage-2 events, not as finished customers.
Phone
tel: link and a wrapper: /go/call?to=e164&place=header.click_to_call only for a user navigation, then 302 to tel:+1….Maps / directions
/go/map?dest=…&app=google|apple.click_to_map only for a user navigation, then 302 to the maps URL.Why wrap instead of only JavaScript
Ad blockers and in-app browsers eat listeners. A first-party /go/* link is a normal document request. Also put data- + beacon on the same link for SPA pages. Two writes, same sid; dedupe on (sid, name, minute).
Never put a raw tel: or maps.google.com in the header without the wrapper.
Do not verify by curling the wrapper. That is how agents inflated call counts. The redirect working is not the same as a customer tap.
For trustworthy monthly proof stored in our database: yes. This is the top of the stack that still belongs to you.
What would be “more” and is not better for this goal:
| Temptation | What you get | Why we skip it |
|---|---|---|
| Google Analytics 360, Adobe, Mixpanel paid | Prettier attribution UI | Data not ours; customer cannot audit; modeled gaps |
| Clarity / FullStory / Hotjar | Recordings, heatmaps | Not an outcome; PII risk; not on the invoice |
| Device fingerprinting | Stickier uniques | Hostile; consent; we do not need it |
| Public per-domain dashboards | Looks like a product | Two numbers; leaks; support burden |
The honest ceiling above this doctrine is identity you already have: they logged in, they paid, they booked. That is already in the product tables. Tracking does not get better than joining sid to that row.
No analytics vendor. Not Google, Microsoft, PostHog Cloud, Mixpanel, Amplitude, Adobe, Contentsquare. Paying them buys their UI and their lock-in. It does not make the customer report more true.
The one paid thing that can be worth it: completed-call tracking, and only if a product’s North Star is the phone.
call_started, call_answered, duration), is an outcome.Ads platforms (search/social) get conversion pings from our server only when we are buying ads and the customer asked for that. Those pings are copies. The booking row remains the original.
A site is tracked when, on the live host:
analytics.hits. A curl does not.click_to_call via /go/call. A scripted GET does not.click_to_map via /go/map. Prefetch does not.sid and utms.GET /insights is 200, that host only, and does not increment page views.One live check each. No second agent to re-verify. Do not curl /go/call as the check.