Analytics architecture and workflows
Keystone lists, how a hit becomes a visit, the report engine, goals, funnels, segments, public and shared dashboards, imports, deletion, GraphQL operations, and access control.
Application surfaces
| Surface | Location | Notes |
|---|---|---|
| Tracker | public/assets/script.js | Served at /assets/script.js; posts to /api/track |
| Collector | app/api/track/route.ts, features/keystone/lib/analytics/collector.ts | Validates a hit and writes at most one visit update and one event |
| Report engine | features/keystone/lib/analytics/engine.ts, features/lib/analytics/* | SQL reports, dimensions, filters, periods |
| Operator dashboard | app/dashboard/(admin)/platform/**, features/platform | Sites, Shared Links, Store Settings, API Keys |
| Generic model pages | app/dashboard/(admin)/[listKey]/**, features/dashboard | Keystone list and item screens for every list |
| Public site | app/(storefront)/(main)/**, features/storefront | Public dashboard list, /sites/[slug], /share/[slug] |
| GraphQL | /api/graphql | Keystone CRUD where access allows, plus custom queries and mutations |
| Other HTTP | /api/mcp-transport/[transport], /api/completion | MCP tools and dashboard AI chat |
Data model
Lists live in features/keystone/models.
| List | What it holds |
|---|---|
Site | name, domain (unique hostname; subdomains count), slug (public address), publicId (generated site_... id used by the tracker, read-only), timezone, currency, status (active / paused / archived), isPublic, description |
AnalyticsSession | One visit: hashed visitorId, optional distinctId, hostname, entry and exit path, referrer, five UTM fields, browser, OS, device, screen class, language, country, region, city, pageviews, customEvents, durationSeconds, isBounce, startedAt, lastSeenAt |
AnalyticsEvent | One hit: type (pageview / custom / vitals), name, path, title, referrer, properties JSON, revenueAmount and revenueCurrency, lcp, inp, cls, fcp, ttfb, occurredAt, and a unique eventKey for idempotency |
Goal | type pageview with a pathPattern starting with / (* matches anything), or type event with an eventName |
Funnel, FunnelStep | An ordered list of goals from the same site; steps carry a position |
Segment | A named, saved list of filters (dimension, operator, value) |
SharedLink | A private read-only link: generated slug, optional bcrypt password, isActive, expiresAt, viewCount, lastViewedAt, createdBy, and a virtual isUsable |
StoreSettings | Singleton with the name, tagline, description, website, contact email, and sanitized SVG logo shown on the public site |
User, Role, ApiKey | People, role permission checkboxes, and scoped API keys |
Site relationships to visits, events, goals, funnels, segments, and shared links are read-only from the site side; you create those records against the site.
Workflow: a hit becomes a visit
- The script sends
{ type, payload }with the site'spublicId, page URL (hash removed), referrer, title, screen size, language, timezone, and an optional identity set byidentify(). /api/trackrejects bodies over 32 KB and non-JSON bodies, and ignores bots by User-Agent (an empty User-Agent counts as a bot).- The site is looked up by
publicId. If the request has anOriginheader, its hostname must be the site's domain or a subdomain, or the request gets a 403. - Hits for sites that are not
active, or whose URL hostname is outside the domain, are accepted and ignored (202). - The visitor hash is
sha256(daily salt, site, IP, User-Agent). The daily salt comes fromSESSION_SECRETand the UTC date. The IP is taken from the firstX-Forwarded-Forentry, thenX-Real-IP. - Inside a transaction with an advisory lock per visitor, an open visit (last seen within 30 minutes) is extended, or a new visit is created with attribution, device, language, and country.
- One
AnalyticsEventrow is written. If the sender gave aneventId, a repeat with the same id returnsduplicate: trueand writes nothing.
Custom event properties are limited to 30 keys and 8 KB. Keys that look like password, secret, token, cookie, card, cvv, ssn, or email are refused. Revenue must be a decimal amount, and its currency must match the site's currency.
Workflow: reading reports
Every report takes a period (today, yesterday, 24h, 7d, 30d, month, last_month, 6mo, 12mo, or custom with from and to; the default is 30d) in the site's timezone, and up to ten filters. Filter dimensions are page, entry_page, exit_page, hostname, event, source, utm_source, utm_medium, utm_campaign, utm_content, utm_term, country, region, city, browser, os, device, screen, and language; operators are is, is_not, and contains. In the dashboard, clicking a row adds a filter, and the filters can be saved as a Segment.
- Overview: visitors, visits, pageviews, views per visit, bounce rate, and visit duration, with the previous period and a time series.
- Breakdown: top values of one dimension, up to 100 rows per page, with optional search.
- Realtime: visitors active in the last five minutes and pageviews per minute for the last thirty. The dashboard page refreshes every 15 seconds.
- Visits: a paged list of visits and one visit's event timeline.
- Goals: conversions, conversion rate, and revenue per goal.
- Funnels: visits that reached each step in order within one visit.
- Events: custom events and their property values.
- Performance: p75 LCP, INP, CLS, FCP, and TTFB, overall and per page.
Workflow: goals, funnels, and segments
- Create goals on the site's Goals tab (
createGoal). Deleting a goal also deletes the funnel steps that use it. - Build a funnel on the Funnels tab from at least two goals (
createFunnel, thencreateFunnelSteps). A step must use a goal from the funnel's own site. - Save the current filters as a segment from the dashboard filter bar (
createSegment). A segment needs at least one valid filter.
Workflow: public dashboards and shared links
- Public dashboard: turn on
isPublicfor a site in Settings (needscanManageSharedLinks). The site then appears on/and at/sites/<slug>. Archived sites are never shown. - Shared link: create one in Settings with a name and optional password of at least 8 characters. The link is
/share/<slug>, where the slug is a generated secret. Turn it off withisActiveor delete it.expiresAtcan be set through GraphQL or the genericSharedLinkpage. - Password unlock:
unlockSharedLinkchecks the password and returns an HMAC token. The public site stores it in an httpOnly cookie scoped to/share/<slug>for 30 days. Changing the password invalidates the token. - What viewers see:
publicDashboardserves only aggregate reports (site,overview,breakdown,realtime,goals,funnels,funnel,vitals). Visit lists and visit timelines are never public. Eachsitecall through a shared link adds one toviewCount. Shared-link pages are markednoindex.
Workflow: import and deletion
importSiteSessions(siteId, sessions)takes 1 to 500 visits per call, each with akeyand 1 to 50 events. Ids are derived from the site and key, so re-sending the same keys is skipped. Onboarding uses this for demo traffic.deleteVisitorData(siteId, distinctId)deletes every visit and event recorded under an identifier the site sent withidentify(). It does not find visits without adistinctId.deleteSiteWithData(siteId)deletes a site with its events, visits, funnel steps, funnels, goals, segments, and shared links in one transaction. The dashboard asks you to type the site name first.
Custom GraphQL operations
Queries:
| Operation | Purpose |
|---|---|
siteOverview(siteId, period, from, to, filters) | Headline totals, previous period, time series |
siteBreakdown(siteId, dimension, ..., limit, offset, search) | Top values of one dimension |
siteRealtime(siteId) | Live visitors and per-minute pageviews |
siteSessions(siteId, ..., limit, offset), siteSession(siteId, sessionId) | Visit list and one visit's timeline |
siteGoals(siteId, ...) | Goal conversions and revenue |
siteFunnel(funnelId, ...) | Funnel steps and drop-off |
siteVitals(siteId, ...) | Core Web Vitals at p75 |
siteEventProperties(siteId, eventName, ...) | Property values for one custom event |
siteSummaries | Every readable site with 30-day visitors and live visitors |
publicSites | Public sites with 30-day visitors, for the home page |
publicDashboard(siteSlug or shareSlug, token, report, ...) | Aggregate report for a public site or shared link |
redirectToInit | Whether no users exist yet |
Mutations: importSiteSessions, deleteVisitorData, deleteSiteWithData, unlockSharedLink, ensureOnboardingRecord, updateActiveUser. All report results are returned as JSON, so a client must validate their shape itself.
The dashboard also uses Keystone CRUD operations: createSite, updateSite, createGoal, deleteGoal, createFunnel, createFunnelSteps, deleteFunnel, deleteFunnelSteps, createSegment, deleteSegment, createSharedLink, updateSharedLink, deleteSharedLink, createStoreSettings, updateStoreSettings, and the ApiKey list operations.
Access control
Dashboard visibility is not the control. Every list and custom operation checks role permissions on the server.
Site,AnalyticsSession,AnalyticsEvent,Goal,Funnel,FunnelStep, andSegment: read withcanReadSites; create, update, and delete withcanManageSites.Site.isPublicand all ofSharedLink:canManageSharedLinks.StoreSettings: anyone can read; changes needcanManageSettings.Role: anyone can read; changes needcanManageRoles.User: signed-in users see themselves, or everyone withcanSeeOtherPeople; role and active status needcanManagePeople.ApiKey: a signed-in human can manage only their own keys. API-key sessions cannot manage keys. Scopes must already be granted by the owner's role:read_analyticsmaps tocanReadSites,write_sitestocanReadSitesandcanManageSites,write_shared_linkstocanReadSitesandcanManageSharedLinks.- Custom report queries check
canReadSitesand load the site through list access.publicDashboard,publicSites, andunlockSharedLinkare public by design and use elevated access internally.
Getting started with Analytics
Clone the source, configure PostgreSQL, sessions, and S3 storage, apply migrations, create the first admin, load demo data or add a site, install the script, and deploy.
External dashboards
Give an LLM the Analytics dashboard skill to adapt the built-in operator experience or create a separate client.