ReviewOS

stacks/ts-analytics

Roadmap: reach top-tier analytics — link clicks, SPA pageviews, origins (gap analysis + phased plan)

#44
Closed glennmichael123 opened this 9 days ago ·
9 days ago

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 on load/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 /t endpoint.

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 featureStatusEvidence
Automatic pageview on loadHAVEAnalytics.ts:3255-3267
SPA / history-API nav trackingMISSING (served)served script has no pushState hook; only optional hashchange (3256). Full impl sits unused in tracking-script.ts:404-417
Cookieless unique-visitor hashPARTIALhashVisitorId works but salt is predictable analytics-${date} (dynamodb.ts:342); isUnique derived from session-existence (collect.ts:199), not the hash
Outbound link clicksPARTIAL/BROKENlistener 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 trackingMISSINGno extension match in any script or collect.ts
Mailto / tel click trackingMISSINGnone
Internal link-click trackingMISSINGoutbound listener only fires for hostname !== location.hostname (3261)
UTM / campaign attributionPARTIALsource/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 groupingPARTIAL6-domain hardcoded list (geolocation.ts:104-120)
Referrer-spam filteringMISSINGno spam list at ingest
Bot/crawler filteringBROKENisBot defined (user-agent.ts:69) but never called in collect.ts; bots counted
Geo countryHAVEcollect.ts:175-178
Geo region/cityBROKENheader parsers exist (geolocation.ts:125-139) + /regions /cities endpoints, but collect.ts never persists them → empty reports
Device / browser / OSHAVEparseUserAgent, /devices /browsers
Custom events & propertiesPARTIALevent branch persists name+value (collect.ts:253-267) but properties dropped for normal events (only outbound stores props, 301)
Events reportBROKENstats.ts:631 reads e.eventName; field is name → all events bucket as unknown
Goals / conversionsHAVElib/goals.ts wired in collect.ts:240,279
FunnelsHAVEhandlers/funnels.ts
Sessions / bounce / durationHAVEcollect.ts:204-238; duration is last-event-based
Scroll depthMISSING (served)impl unused in tracking-script.ts:421-455; no scroll-event report
Entry/exit pagesHAVE (no prominent UI)handlers/sessions.ts /entry-exit
Real-time / liveHAVE/realtime /live; scans recent items
Time-series + comparisonHAVEstats.handleGetTimeSeries/Comparison
Retention / cohortsMISSING/retention is data-retention settings, not cohort analysis
Segments / multi-dim filtersPARTIALonly country honored (stats.ts:414,467); device/browser/source ignored; resources/stores/filters.ts is client table search/sort only
Privacy: cookieless defaultHAVE (served)served script is cookieless; the cookie-setting code lives only in unused tracking-script.ts:218-282
DNT / consentPARTIALDNT supported but OFF on served route; no consent-mode
Beacon + unload flushMISSING (served)served t() is XHR-only (Analytics.ts:3244-3253), no sendBeacon/pagehide flush → unload events drop
Aggregation pipeline / rollupsDORMANTAnalyticsAggregator/AggregationPipeline/Stats entities defined in Analytics.ts + model-connector.ts, never written/read; dashboards recompute from raw every request
SQS pageview consistencyBROKENconsumer 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, alertsHAVE (PostHog-tier breadth)respective handlers/*
Session replay, cross-session identityMISSINGidentify 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.

  1. Events report field bug — src/handlers/stats.ts:631 change e.eventName → e.name. Unblocks the entire Events panel.
  2. SQS pageview prefix — deploy/sqs-consumer-handler.ts:273 PV# → PAGEVIEW# (and gsi1sk PATH#... to match orm/index.ts:167). Without this, SQS-mode loses all pageviews from dashboards.
  3. Bot filtering — call isBot(userAgent) (src/utils/user-agent.ts:69) at top of handleCollect and early-return 204 for bots before any write.
  4. Region/city persistence — in collect.ts pageview branch, call getRegionFromHeaders/getCityFromHeaders and add region/city to PageViewModel.record + Session. Unblocks /regions /cities.

A. Outbound + internal + download + mailto/tel clicks (one unified click listener).

  • SCRIPT (Analytics.ts served generator): add ONE delegated click listener on document that resolves e.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}}. Use navigator.sendBeacon here (port from tracking-script.ts:251-265) so the navigation doesn't kill the request. Turn this on by default in views.ts:323.
  • INGESTION (collect.ts): add a click branch (or extend outbound) that stores a LinkClick with kind, url, text, targetPath. Add SK prefix CLICK# in orm/index.ts. Keep existing outbound for back-compat.
  • DASHBOARD: new handleGetClicks (group by url+kind, top N, with kind filter) + /api/sites/{id}/clicks route + a LinkClicksPanel.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-417 into 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 /pages already 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-ids gclid/fbclid/msclkid (or just send location.href and parse server-side — already done for source/medium/campaign).
  • INGESTION (collect.ts:190-192): extract utm_term/utm_content/gclid/fbclid into PageView + Session (ORM fields already exist at orm/index.ts:116-117; add click-id fields). Expand parseReferrerSource (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: /campaigns exists — extend its aggregation to surface term/content and click-id attribution; referrers panel benefits automatically.

Phase 2 — Engagement + reliability (standard tier)

  1. Beacon + unload flush everywhere — replace served t() XHR with sendBeacon + XHR fallback; flush a session_end with duration on pagehide/visibilitychange:hidden. Fixes dropped unload events (vitals CLS/INP already rely on visibilitychange).
  2. Scroll depth + time-on-page — port tracking-script.ts:421-477; add a lightweight engagement event branch in collect.ts; add a Scroll/Engagement report (or fold into Pages).
  3. Custom-event properties for ALL events — collect.ts:253-267 currently drops properties for non-outbound events; persist properties on every CustomEvent. Surface in the (now-fixed) Events panel with a property breakdown.
  4. Real multi-dimension filters — extend stats.ts to honor device/browser/source/path/os/utm query params (today only country, lines 414/467) and wire resources/stores/filters.ts to 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)

  1. Activate the aggregation pipeline — wire AnalyticsAggregator/AggregationPipeline (defined in Analytics.ts/model-connector.ts) so dashboards read STATS/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.
  2. Retention/cohorts — new handler keyed on visitor hash across day buckets.
  3. Cross-session identity — persist userId from identify onto events/sessions (currently client-memory only) to unlock people analytics. Optional given the privacy positioning.
  4. 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) vs tracking-script.ts (rich, unwired) vs generateMinimalTrackingScript. Decide on ONE. Recommended: keep the served Analytics.ts generator as the base (it has vitals/errors), and port SPA/clicks/scroll/beacon from tracking-script.ts into 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) isUnique is 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 in tracking-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. isBot exists but bots currently inflate sessions/uniques/bounce. Gate at the very top of handleCollect. 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 in tracking-script.ts:95-105 already 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).

9 days ago

Closing — this roadmap is delivered and was superseded by the 68-issue Fathom+Sentry backlog (epics #105–#112, all eight now closed).

The three named wants shipped long ago and everything around them since: link/outbound click tracking + reports, SPA pageviews, referrer origins/channels + full UTM, engagement, event properties, filters — then the full product pivot: auth + multi-tenancy, per-project Settings (script tag + DSN), Sentry-tier error tracking through source-map symbolication, background jobs, pre-aggregation rollups, and a measured scaling story. The standing tail is #53 (OAuth, stretch) and the cross-repo items #41/#42.

Sign in to comment on this issue.