Gap analysis + build roadmap to reach Fathom/Plausible/GA4/PostHog-tier analytics, audited against the current codebase. Focus: link clicks, links visited (incl. SPA), and user origins. File/line references are to this repo.
How We Reach Top-Tier Analytics — ts-analytics Roadmap
Audited against the live code. The served script is generateTrackingScript in src/Analytics.ts:3129 (NOT src/tracking-script.ts, which is a richer but unwired generator). handleScript (src/handlers/views.ts:310-326) calls it with only {siteId, apiEndpoint} — so every optional flag is OFF.
1. What we already have (don't rebuild)
Tracker — served script (src/Analytics.ts:3129, wired via views.ts:323)
- Initial pageview on load (
pv()at line 3255, fires onload/complete). - Cookieless session id in
sessionStorage(_tsa_sid), no cookies in this script. - Client-side browser detection incl. Arc/Brave/Edge/Opera (lines 3221-3243).
- Core Web Vitals (LCP/FID/CLS/INP/TTFB) ON by default (3131-3191) and JS error + unhandledrejection capture ON by default (3193-3208). These competitors mostly lack — keep them.
- Custom events via
window.fathom.track(name, value)(3268). - Optional (OFF):
trackHashChanges,trackOutboundLinks, DNT, stealth/tendpoint.
Tracker — second, unwired generator (src/tracking-script.ts) — already implements SPA pushState/replaceState/popstate (383-419), scroll depth (421-455), time-on-page (457-477), outbound + data-sa-track declarative events + MutationObserver (479-526), sendBeacon with XHR fallback (251-265), and a command-queue public API (528-577). This code exists but is never served. Much of the roadmap is wiring/porting this, not writing from scratch.
Ingestion (src/handlers/collect.ts) — pageview + session upsert with entry/exit/bounce/duration; custom events; outbound (stored, see bug below); heatmap click/move/scroll; vitals; errors; UTM source/medium/campaign; referrer source; country geo (headers + IP fallback); cookieless salted visitor hash; goal conversion matching (checkAndRecordConversions). Optional SQS fast-path (59-124).
Reports (registered in src/router.ts:143-258, handlers + stx panels) — stats, realtime, pages, referrers, devices, browsers, countries, regions, cities, timeseries, events, campaigns, comparison, goals, sessions, flow, entry-exit, live, heatmaps, errors (full suite), vitals, funnels, annotations, experiments, insights, revenue, sharing, export, api-keys, alerts/email-reports, uptime, webhooks, retention-settings, gdpr. Dashboard panels exist for pages/referrers/devices/browsers/countries/campaigns/events/goals plus tabs for flow/sessions/live/funnels/vitals/errors/insights/settings.
2. Gap analysis (verified, with files)
| Competitor feature | Status | Evidence |
|---|---|---|
| Automatic pageview on load | HAVE | Analytics.ts:3255-3267 |
| SPA / history-API nav tracking | MISSING (served) | served script has no pushState hook; only optional hashchange (3256). Full impl sits unused in tracking-script.ts:404-417 |
| Cookieless unique-visitor hash | PARTIAL | hashVisitorId works but salt is predictable analytics-${date} (dynamodb.ts:342); isUnique derived from session-existence (collect.ts:199), not the hash |
| Outbound link clicks | PARTIAL/BROKEN | listener OFF by default (views.ts:323 sets no flag); stored as name:'outbound' (collect.ts:300); no outbound report and events handler reads wrong field (below) |
| File/download tracking | MISSING | no extension match in any script or collect.ts |
| Mailto / tel click tracking | MISSING | none |
| Internal link-click tracking | MISSING | outbound listener only fires for hostname !== location.hostname (3261) |
| UTM / campaign attribution | PARTIAL | source/medium/campaign only (collect.ts:190-192); no utm_term/utm_content (ORM fields exist orm/index.ts:116-117 but unset); no gclid/fbclid |
| Referrer source grouping | PARTIAL | 6-domain hardcoded list (geolocation.ts:104-120) |
| Referrer-spam filtering | MISSING | no spam list at ingest |
| Bot/crawler filtering | BROKEN | isBot defined (user-agent.ts:69) but never called in collect.ts; bots counted |
| Geo country | HAVE | collect.ts:175-178 |
| Geo region/city | BROKEN | header parsers exist (geolocation.ts:125-139) + /regions /cities endpoints, but collect.ts never persists them → empty reports |
| Device / browser / OS | HAVE | parseUserAgent, /devices /browsers |
| Custom events & properties | PARTIAL | event branch persists name+value (collect.ts:253-267) but properties dropped for normal events (only outbound stores props, 301) |
| Events report | BROKEN | stats.ts:631 reads e.eventName; field is name → all events bucket as unknown |
| Goals / conversions | HAVE | lib/goals.ts wired in collect.ts:240,279 |
| Funnels | HAVE | handlers/funnels.ts |
| Sessions / bounce / duration | HAVE | collect.ts:204-238; duration is last-event-based |
| Scroll depth | MISSING (served) | impl unused in tracking-script.ts:421-455; no scroll-event report |
| Entry/exit pages | HAVE (no prominent UI) | handlers/sessions.ts /entry-exit |
| Real-time / live | HAVE | /realtime /live; scans recent items |
| Time-series + comparison | HAVE | stats.handleGetTimeSeries/Comparison |
| Retention / cohorts | MISSING | /retention is data-retention settings, not cohort analysis |
| Segments / multi-dim filters | PARTIAL | only country honored (stats.ts:414,467); device/browser/source ignored; resources/stores/filters.ts is client table search/sort only |
| Privacy: cookieless default | HAVE (served) | served script is cookieless; the cookie-setting code lives only in unused tracking-script.ts:218-282 |
| DNT / consent | PARTIAL | DNT supported but OFF on served route; no consent-mode |
| Beacon + unload flush | MISSING (served) | served t() is XHR-only (Analytics.ts:3244-3253), no sendBeacon/pagehide flush → unload events drop |
| Aggregation pipeline / rollups | DORMANT | AnalyticsAggregator/AggregationPipeline/Stats entities defined in Analytics.ts + model-connector.ts, never written/read; dashboards recompute from raw every request |
| SQS pageview consistency | BROKEN | consumer writes PV# (deploy/sqs-consumer-handler.ts:273) but dashboards query PAGEVIEW# (orm/index.ts:99,145) |
| Heatmaps, vitals, errors, A/B, uptime, sharing, export, alerts | HAVE (PostHog-tier breadth) | respective handlers/* |
| Session replay, cross-session identity | MISSING | identify only stores userId in client memory; collect.ts never persists it |
3. Build roadmap (prioritized)
Phase 0 — Bug fixes (hours, unblocks existing reports)
These make features you already built actually work. Cheapest possible wins.
- Events report field bug —
src/handlers/stats.ts:631changee.eventName→e.name. Unblocks the entire Events panel. - SQS pageview prefix —
deploy/sqs-consumer-handler.ts:273PV#→PAGEVIEW#(and gsi1skPATH#...to matchorm/index.ts:167). Without this, SQS-mode loses all pageviews from dashboards. - Bot filtering — call
isBot(userAgent)(src/utils/user-agent.ts:69) at top ofhandleCollectand early-return 204 for bots before any write. - Region/city persistence — in
collect.tspageview branch, callgetRegionFromHeaders/getCityFromHeadersand addregion/citytoPageViewModel.record+Session. Unblocks/regions/cities.
Phase 1 — User's explicit wants: link clicks, links visited, origins (the headline)
A. Outbound + internal + download + mailto/tel clicks (one unified click listener).
- SCRIPT (
Analytics.tsserved generator): add ONE delegatedclicklistener ondocumentthat resolvese.target.closest('a')and classifies: external host →outbound; same host →internal_click; href extension in[pdf,zip,docx,xlsx,csv,mp4,mp3,dmg,exe,pkg,...]→download;mailto:/tel:→email/phone. Send{e:'click', p:{kind, url, text, path}}. Usenavigator.sendBeaconhere (port fromtracking-script.ts:251-265) so the navigation doesn't kill the request. Turn this on by default inviews.ts:323. - INGESTION (
collect.ts): add aclickbranch (or extendoutbound) that stores aLinkClickwithkind,url,text,targetPath. Add SK prefixCLICK#inorm/index.ts. Keep existingoutboundfor back-compat. - DASHBOARD: new
handleGetClicks(group byurl+kind, top N, withkindfilter) +/api/sites/{id}/clicksroute + aLinkClicksPanel.stx(tabs: Outbound / Downloads / Email-Phone / Internal). This is the single highest-impact new report.
B. Links visited (pageviews incl. SPA).
- SCRIPT: port pushState/replaceState/popstate hooks from
tracking-script.ts:404-417into the served generator and enable by default. Today only the first pageview is recorded for SPAs. - INGESTION: none — existing pageview branch handles it.
- DASHBOARD: existing
/pagesalready works; SPA fix simply makes it accurate.
C. User origins (referrer + UTM + geo).
- SCRIPT: parse and forward full UTM set incl.
utm_term,utm_content, and click-idsgclid/fbclid/msclkid(or just sendlocation.hrefand parse server-side — already done for source/medium/campaign). - INGESTION (
collect.ts:190-192): extractutm_term/utm_content/gclid/fbclidintoPageView+Session(ORM fields already exist atorm/index.ts:116-117; add click-id fields). ExpandparseReferrerSource(geolocation.ts:104) to a real search/social/AI-referrer map (add DuckDuckGo, Yahoo, Reddit, YouTube, Instagram, TikTok, Hacker News, ChatGPT/Perplexity, etc.) and add a referrer-spam blocklist that drops the event. - DASHBOARD:
/campaignsexists — extend its aggregation to surface term/content and click-id attribution; referrers panel benefits automatically.
Phase 2 — Engagement + reliability (standard tier)
- Beacon + unload flush everywhere — replace served
t()XHR withsendBeacon+ XHR fallback; flush asession_endwith duration onpagehide/visibilitychange:hidden. Fixes dropped unload events (vitals CLS/INP already rely onvisibilitychange). - Scroll depth + time-on-page — port
tracking-script.ts:421-477; add a lightweightengagementevent branch incollect.ts; add a Scroll/Engagement report (or fold into Pages). - Custom-event properties for ALL events —
collect.ts:253-267currently dropspropertiesfor non-outbound events; persistpropertieson everyCustomEvent. Surface in the (now-fixed) Events panel with a property breakdown. - Real multi-dimension filters — extend
stats.tsto honordevice/browser/source/path/os/utmquery params (today onlycountry, lines 414/467) and wireresources/stores/filters.tsto pass them to the API. This is the single biggest dashboard-usability gap vs Plausible/GA4.
Phase 3 — Scale + advanced (when raw-scan cost bites)
- Activate the aggregation pipeline — wire
AnalyticsAggregator/AggregationPipeline(defined inAnalytics.ts/model-connector.ts) so dashboards readSTATS/PAGESTATS/REFSTATS/...rollups instead of scanning raw items per request. Required before high-traffic sites; today every report recomputes from raw with in-memory Sets. - Retention/cohorts — new handler keyed on visitor hash across day buckets.
- Cross-session identity — persist
userIdfromidentifyonto events/sessions (currently client-memory only) to unlock people analytics. Optional given the privacy positioning. - 404 + site-search tracking — cheap: tag pageviews whose title/path signal 404; parse
?q=/?s=into a search report.
4. Notable design calls
Two divergent script generators.
Analytics.ts:3129(served, lean) vstracking-script.ts(rich, unwired) vsgenerateMinimalTrackingScript. Decide on ONE. Recommended: keep the servedAnalytics.tsgenerator as the base (it has vitals/errors), and port SPA/clicks/scroll/beacon fromtracking-script.tsinto it, then delete or clearly mark the other as internal. Drift here is the root cause of half the gaps.Cookieless identity is good but imprecise. The served script is genuinely cookieless (sessionStorage only). But (a) the daily salt is predictable (
analytics-${date},dynamodb.ts:342) — move to a random secret persisted server-side so the hash isn't reversible; (b)isUniqueis session-existence-based (collect.ts:199), not visitor-hash-based, so cross-session uniques over-count. Track uniques by the visitor hash within the date partition.Privacy/DNT. Cookieless default is a real selling point — lead with it. But DNT is OFF on the served route; decide whether privacy-first means DNT-on-by-default (Plausible doesn't need it; if you market "no consent banner," you can leave DNT off and document why). Add a server-side excluded-IPs/excluded-paths allowlist (config exists in types but isn't enforced at ingest).
Batching/beacon. Current served script is XHR-per-event with no unload flush → outbound/engagement/session-end events drop on navigation.
sendBeacon(already intracking-script.ts) is the fix and is the prerequisite for Phase 1A/2 to be reliable. Consider micro-batching multiple events per beacon for SPA-heavy sites.Bot filtering must gate writes.
isBotexists but bots currently inflate sessions/uniques/bounce. Gate at the very top ofhandleCollect. Pair with the referrer-spam blocklist (Phase 1C) — both protect every downstream metric.Script size. Served script is unminified and bundles vitals+errors inline per request. For Fathom/Plausible-tier (~1KB), serve a minified, cacheable
tracker.js(the snippet loader intracking-script.ts:95-105already references/sites/{id}/tracker.js) with SRI, and make vitals/heatmap opt-in chunks rather than always-inline.Aggregation is the scaling cliff. Everything works today because dashboards scan raw partitions and aggregate in JS. That is fine for low traffic and lets you ship Phases 0–2 immediately, but the dormant pipeline (Phase 3.1) must be activated before onboarding high-volume sites — otherwise read latency and DynamoDB RCU cost grow linearly with traffic.
Key files: src/Analytics.ts:3129 (served script), src/tracking-script.ts (unwired rich script), src/handlers/views.ts:310 (script route — flip default flags here), src/handlers/collect.ts (ingestion — add click/region/utm/bot logic), src/handlers/stats.ts:631 (events bug; filters at 414/467), src/models/orm/index.ts (SK prefixes, add CLICK#), deploy/sqs-consumer-handler.ts:273 (PV#→PAGEVIEW#), src/utils/geolocation.ts:104 (referrer map), src/utils/user-agent.ts:69 (isBot), src/dynamodb.ts:342 (salt), resources/components/dashboard/EventsPanel.stx + resources/stores/filters.ts (dashboard wiring).