Analytics Module Analysis (#559 / #569)
Purpose
The analytics module (custom_analytics) implements a self-hosted, privacy-focused web analytics system for JYT's website-builder platform. It tracks pageviews and custom events, derives session-based engagement metrics, runs daily aggregations, and exposes a rich query layer for the admin dashboard (#569, OpenPanel-style visitor UI). Ingestion supports a synchronous write-through path and an optional Redis-backed batch path (ANALYTICS_BATCH_INGEST) that decouples the visitor request from the DB write.
Data Models
Three MikroORM models are defined under apps/backend/src/modules/analytics/models/. All three carry a website_id text column (a loose foreign key); only AnalyticsEvent has a formal Medusa defineLink to Website explicitly.
analytics_event (apps/backend/src/modules/analytics/models/analytics-event.ts)
Stores individual pageview and custom_event rows — the raw fact table.
| Field | Type | Notes |
|---|---|---|
id | model.id().primaryKey() | Auto-generated |
event_id | model.text().nullable() | Client-supplied idempotency key for batch-ingest dedupe. Added by apps/backend/src/modules/analytics/migrations/Migration20260619000000.ts. Null for the synchronous /web/analytics/track path. |
website_id | model.text() | FK to Website (via apps/backend/src/links/website-analytics-link.ts read-only link) |
event_type | model.enum(["pageview", "custom_event"]).default("pageview") | |
event_name | model.text().nullable() | For custom events |
pathname | model.text() | URL path |
referrer | model.text().nullable() | Full referrer URL |
referrer_source | model.text().nullable() | Derived: "google", "direct", "facebook", domain, etc. |
visitor_id | model.text() | Hashed fingerprint (client-generated) |
session_id | model.text() | Client-generated session ID |
user_agent | model.text().nullable() | Raw UA string |
browser | model.text().nullable() | Parsed: "Chrome", "Firefox", etc. |
os | model.text().nullable() | Parsed: "macOS", "Windows", etc. |
device_type | model.enum(["desktop","mobile","tablet","unknown"]).default("unknown") | |
country | model.text().nullable() | Country-level only (privacy) |
utm_source/medium/campaign/term/content | model.text().nullable() | UTM parameters |
query_string | model.text().nullable() | Full URL query string |
is_404 | model.boolean().default(false) | Error tracking |
metadata | model.json().nullable() | Arbitrary extra data (also carries locale for language/region analytics) |
timestamp | model.dateTime() | Event time |
Indexes (composite):
[website_id, timestamp]— primary query axis[website_id, pathname, timestamp]— page breakdown[session_id]— session lookup[visitor_id]— visitor lookup[website_id, event_type, timestamp]— event type filtering[event_id]— idempotency dedupe
analytics_session (apps/backend/src/modules/analytics/models/analytics-session.ts)
Derived session rows (upserted by trackAnalyticsEventWorkflow). No formal defineLink to Website — resolved at runtime via container.
| Field | Type | Notes |
|---|---|---|
id | model.id().primaryKey() | |
website_id | model.text() | |
session_id | model.text().unique() | Client-generated |
visitor_id | model.text() | |
entry_page | model.text() | First pageview pathname |
exit_page | model.text().nullable() | Last pageview pathname |
pageviews | model.number().default(1) | Incremented per event |
duration_seconds | model.number().nullable() | |
is_bounce | model.boolean().default(false) | True when single pageview |
referrer | model.text().nullable() | |
referrer_source | model.text().nullable() | |
country | model.text().nullable() | |
device_type | model.text().nullable() | |
browser | model.text().nullable() | |
os | model.text().nullable() | |
utm_source/medium/campaign/term/content | model.text().nullable() | First-touch attribution — captured on session creation, NOT updated on subsequent events |
started_at | model.dateTime() | |
ended_at | model.dateTime().nullable() | |
last_activity_at | model.dateTime() | Updated on every event |
Unique index on session_id; composite indexes on [website_id, started_at], [website_id, is_bounce], [utm_campaign] (for ad-planning attribution).
analytics_daily_stats (apps/backend/src/modules/analytics/models/analytics-daily-stats.ts)
Pre-computed daily rollup, populated by aggregate-daily-analytics cron job. No formal defineLink to Website.
| Field | Type | Notes |
|---|---|---|
id | model.id().primaryKey() | |
website_id | model.text() | |
date | model.dateTime() | Date part only (time zeroed) |
pageviews | model.number().default(0) | |
unique_visitors | model.number().default(0) | |
sessions | model.number().default(0) | |
bounce_rate | model.float().default(0) | Percentage |
avg_session_duration | model.float().default(0) | Seconds |
top_pages | model.json().nullable() | [{item, count}] |
top_referrers | model.json().nullable() | [{item, count}] |
top_countries | model.json().nullable() | [{item, count}] |
desktop/mobile/tablet_visitors | model.number().default(0) | |
browser_stats | model.json().nullable() | [{item, count}] |
os_stats | model.json().nullable() | [{item, count}] |
Unique composite index on [website_id, date]; index on [date].
Ingestion Path
Entry: POST /web/analytics/track
Source: apps/backend/src/api/web/analytics/track/route.ts:172 (POST handler, AUTHENTICATE = false at line 256)
Public, unauthenticated endpoint. Accepts a TrackEventSchema-validated body (Zod, at line 90). The handler captures user-agent and x-forwarded-for / remoteAddress from request headers (lines 181–182).
Hybrid batching path (lines 190–228)
When ANALYTICS_BATCH_INGEST env flag is "1"/"true"/"yes"/"on" (checked at apps/backend/src/modules/analytics/lib/ingest-buffer.ts:54) AND the event is NOT a heartbeat (checked at ingest-buffer.ts:63), the event is LPUSHed to a Redis list key analytics:ingest:buffer (producer at ingest-buffer.ts:129). The request returns 200 {"success": true, "message": "Event queued"} without touching the DB.
Key detail: the full user_agent and ip_address are captured in the Medusa request and serialised into the buffer payload (ingest-buffer.ts:23 — BufferedAnalyticsEvent type includes user_agent, ip_address, timezone, locale, country). This means the async drain path retains the same device/geo fidelity as the synchronous path (unlike the now-removed Cloudflare edge worker, per the docblock at ingest-buffer.ts:13).
If Redis push fails (e.g. Redis down), the handler falls through to the synchronous path (line 221 — console.error then writes through). Events are NEVER silently dropped.
Synchronous write-through path (lines 230–238)
Runs trackAnalyticsEventWorkflow directly. This path is taken when:
- Batch ingest is disabled
- The event is a heartbeat (
custom_eventwithevent_name === "heartbeat") - Redis buffer push fails
trackAnalyticsEventWorkflow
Source: apps/backend/src/workflows/analytics/track-analytics-event.ts:285
Two-step Medusa workflow:
Step 1 — createAnalyticsEventStep (line 125):
- Parses
user_agentvia inlineparseUserAgent()(line 14): detects browser, OS, device type - Extracts
referrer_sourcevia inlineextractReferrerSource()(line 44): maps referrer hostname to known sources (google, bing, facebook, twitter, etc.) or the raw domain - Resolves country via
resolveEventCountry()fromapps/backend/src/modules/analytics/lib/country-from-timezone.ts:171: Precedence: 1) explicitcountryparam (from proxy/edge), 2) browser IANA timezone → country lookup viaTIMEZONE_COUNTRYmap (country-from-timezone.ts:28), 3) GeoIP-of-IP fallback viageoip-lite(track-analytics-event.ts:73) - Carries browser
localeinto eventmetadata(line 148 — stored as JSON blob; never updated) - Creates
AnalyticsEventrow viaanalyticsService.createAnalyticsEvents() - Emits
analytics_event.createdevent on the event bus (line 180)
Step 2 — updateSessionStep (line 196):
- Looks up existing session by
session_idviaanalyticsService.listAndCountAnalyticsSessions - Session found (existing): updates
exit_page, incrementspageviews, setslast_activity_at, setsis_bounce = false. UTM fields are first-touch only — backfilled only if the session'sutm_campaignis not already set (line 228) - Session not found (new): creates session with
pageviews: 1,is_bounce: true, captures UTM params from the first pageview - Duration is NOT computed here — kept as
nullinitially
Compensation: both steps have rollback handlers that call softDeleteAnalyticsEvents / deleteAnalyticsSessions on failure.
Redis batch drain job
Source: apps/backend/src/jobs/drain-analytics-buffer.ts:34
Medusa cron job scheduled at * * * * * (every minute). Config at line 103.
- Guards with
locking.execute(LOCK_KEY = "analytics-buffer-drain")to prevent overlapping ticks / multi-instance double-processing - RPOPs up to
ANALYTICS_DRAIN_MAX(default 500, line 28) entries from the Redis list - Calls
orderAndDedupeBuffer()(ingest-buffer.ts:75) to sort ascending by timestamp and drop duplicateevent_ids within the batch — ensures session entry/exit/bounce computation stays correct - Iterates each event and runs
trackAnalyticsEventWorkflow— the identical workflow used by the synchronous path (line 53) - Per-event failures do NOT abort the batch (line 80 —
failed++, logged, continues) - Lock contention skips the tick (line 95 — logged as warning, next tick retries)
Sessionization
Sessions are not derived in a batch job. They are upserted synchronously in trackAnalyticsEventWorkflow's updateSessionStep (track-analytics-event.ts:196). Each incoming event either creates a new session or updates the existing one for that session_id.
Key behaviours:
is_bouncestarts astrueon creation, set tofalseas soon as a second pageview arrives for the samesession_id(line 225)exit_pageis updated on every event to the latestpathnameduration_secondsis NOT computed in real-time — it staysnulland is only computed in the daily stats rollup (fromstarted_attoended_at, presumably)- UTM fields are first-touch: only set on session creation; subsequent events with different UTM values are ignored (
track-analytics-event.ts:228—!(session as any).utm_campaign && input.utm_campaignguard)
No separate session-migration or session-end job exists (unverified — there may be a post-processing step for ended_at/duration that I did not find; the backfill-bounce-rate.ts script suggests some fields are backfilled post-hoc).
Daily Stats Rollup
aggregate-daily-analytics job
Source: apps/backend/src/jobs/aggregate-daily-analytics.ts
Medusa cron scheduled at 0 1 * * * (daily at 1 AM), config at line 61.
- Gets
yesterdaydate (midnight, line 22) - Calls
listWebsitesWithActivity()(apps/backend/src/modules/analytics/lib/compute-daily-stats.ts:136) — queries events and sessions for the day, collects distinctwebsite_ids - For each website, calls
computeDailyStatsForWebsite()(compute-daily-stats.ts:48):- Lists analytics events with
select: ["website_id", "event_type", "visitor_id", "session_id", "pathname", "referrer_source"],take: 100000 - Lists analytics sessions with
select: ["visitor_id", "is_bounce", "duration_seconds", "device_type", "browser", "os", "country"],take: 100000 - Computes:
pageviewscount,unique_visitors(Set of visitor_ids from events),session_count,bounce_rate, average session duration, device splits (visitorCountByDevice),top_pages/top_referrers/top_countries/browser_stats/os_statsviatopItems()helper (frequencies, top 10 by default) - If both events and sessions are empty for the day, returns
null(no row written)
- Lists analytics events with
- Upserts the row: if an
analytics_daily_statsrow already exists for thatwebsite_id + date, callsupdateAnalyticsDailyStats; otherwisecreateAnalyticsDailyStats
The unique composite index on [website_id, date] enforces one row per website per day.
Read / Query Path
Admin API endpoints
GET /admin/websites/:id/analytics — Website Analytics Overview
Source: apps/backend/src/api/admin/websites/[id]/analytics/route.ts:45
Calls getWebsiteAnalyticsOverviewWorkflow (apps/backend/src/workflows/analytics/get-website-analytics-overview.ts:184):
- Uses
query.graph()via the read-only module link (apps/backend/src/links/website-analytics-link.ts) to fetch website + itsanalytics_event.*in one query - Filters events client-side by date range, UTM filters, pathname substring, and QR key/value
- Computes event-level stats (total pageviews, unique visitors/sessions, recent 100 events)
- Also fetches
analytics_sessionrows via service direct (best-effort, ignores errors) and computes session engagement metrics viacomputeSessionMetrics()(apps/backend/src/workflows/analytics/session-metrics-lib.ts:42):total_sessions,bounce_rate,avg_session_duration,pages_per_session,views_per_visitor - Best-effort: session metrics degrade to zero on error, never throw
GET /admin/websites/:id/analytics/sessions — Paginated Sessions List (#569 S7a)
Source: apps/backend/src/api/admin/websites/[id]/analytics/sessions/route.ts:33
Calls getWebsiteSessionsWorkflow (apps/backend/src/workflows/analytics/reports/get-website-sessions.ts:76):
- Paginated via
resolveSessionListParams()(apps/backend/src/workflows/analytics/reports/sessions-list-lib.ts:97): clamps limit to [1, 100], whitelistsorder_bytoSESSION_ORDER_FIELDS(started_at, last_activity_at, ended_at, duration_seconds, pageviews), defaults tostarted_at DESC - Sessions fetched directly from
custom_analyticsservice (no module link exists for analytics_session → website) - Best-effort: errors return empty
{ sessions: [], count: 0 }
GET /admin/websites/:id/analytics/pages — Session Entry/Exit Pages (#569 S2)
Source: apps/backend/src/api/admin/websites/[id]/analytics/pages/route.ts:35
Calls getSessionPagesWorkflow (apps/backend/src/workflows/analytics/reports/get-session-pages.ts:79):
- Accepts optional
dimension("entry_page" | "exit_page"); both are returned by default - Fetches sessions directly via
analyticsService.listAnalyticsSessions()withselect: ["visitor_id", "entry_page", "exit_page"],take: 100000 - Delegates to
computeSessionPageBreakdown()(apps/backend/src/workflows/analytics/reports/session-pages-lib.ts:82): groups sessions by page dimension, sorts by count desc, caps atlimit(default 20, max 100) - Best-effort: errors return empty zeroed breakdowns
GET /admin/websites/:id/analytics/outbound — Outbound Links (#569 S5a)
Source: apps/backend/src/api/admin/websites/[id]/analytics/outbound/route.ts:24
Calls getOutboundLinksWorkflow (apps/backend/src/workflows/analytics/reports/get-outbound-links.ts:55):
- Filters
analytics_eventrows whereevent_name === "link_out" - Groups by
metadata.href(the outbound URL) viacomputeOutboundLinks()(apps/backend/src/workflows/analytics/reports/outbound-links-lib.ts:65)
GET /admin/analytics-events/breakdown — Event Breakdown (#559 slice 3)
Source: apps/backend/src/api/admin/analytics-events/breakdown/route.ts:39
Calls getAnalyticsBreakdownWorkflow (apps/backend/src/workflows/analytics/reports/get-analytics-breakdown.ts:74):
- Required
website_id+dimension(one of 15: country, device_type, browser, os, referrer_source, referrer, utm_source/medium/campaign/term/content, pathname, is_404, event_type, event_name) - Optional composable equality filters on any filterable field (passed as query params)
- Fetches raw events then delegates to
applyEventFilters()+computeBreakdown()inapps/backend/src/workflows/analytics/reports/breakdown-lib.ts:140,166 computeBreakdown()groups by dimension value, computes count + unique_visitors + percentage per bucket, caps atlimit(default 20, max 100)- Uses
normalizeFieldValue()(line 122) for canonical grouping: nullreferrer_source→ "direct", nullcountry→ "unknown",is_404boolean → "true"/"false", etc. - Pure framework-free lib, unit-tested in isolation
GET /admin/analytics-events/stats — Aggregated Stats
Source: apps/backend/src/api/admin/analytics-events/stats/route.ts:60
Calls getAnalyticsStatsWorkflow (apps/backend/src/workflows/analytics/reports/get-analytics-stats.ts:174):
- Returns:
overview(totals),top_pages(top 10),referrer_sources(top 10 with percentage),devices,browsers,operating_systems(top 10 each) - All computed in-memory from the fetched event list (no aggregation queries)
GET /admin/analytics-events/timeseries — Time Series
Source: apps/backend/src/api/admin/analytics-events/timeseries/route.ts:173
Calls getAnalyticsTimeseriesWorkflow (apps/backend/src/workflows/analytics/reports/get-analytics-timeseries.ts:124):
- Required
website_id+ date range; optionalinterval("hour" or "day", default "day") - Fetches all events in range, groups by rounded timestamp (ISO day or hour key), fills missing intervals with zero data points
- Returns
{ timestamp, pageviews, custom_events, total_events, unique_visitors, unique_sessions }per bucket
GET /admin/analytics/live — Real-Time SSE
Source: apps/backend/src/api/admin/analytics/live/route.ts:178
Server-Sent Events endpoint:
- Registers the response in the in-memory
analyticsConnectionsMap (shared withapps/backend/src/subscribers/analytics-realtime.ts:13) - Sends initial
connectedevent with live stats computed viacomputeLiveStats()(apps/backend/src/api/admin/analytics/live/lib.ts:78):currentVisitors: distinctsession_idwithin the last 5-minute windowuniqueVisitors: distinctvisitor_idin same windowrecentEvents: newest-first, capped at 10activePages: visitor's latest page → counted → top 5 by count
- 30-second heartbeat to keep connection alive
- Periodic DB-refreshed snapshot re-pushed every
ANALYTICS_LIVE_REFRESH_MS(default 15s, min 5s, env-configurable) so multi-instance deployments self-heal - The
analytics_realtimesubscriber (apps/backend/src/subscribers/analytics-realtime.ts:15) listens foranalytics_event.createdevents and broadcastsnew_eventSSE messages to all connected clients for thatwebsite_id
Public frontend read
GET /web/website/:domain/marketing/metrics
Source: apps/backend/src/api/web/website/[domain]/marketing/metrics/route.ts:119
Reads from analytics_daily_stats via graph query (the analytics_daily_stats entity) — sums unique_visitors and pageviews across the window for a marketing-site headline.
Gotchas / Invariants
-
Only AnalyticsEvent has a module link.
AnalyticsSessionandAnalyticsDailyStatscarrywebsite_idtext columns but have nodefineLinkto Website. All session/daily-stats workflows resolve viacontainer.resolve(ANALYTICS_MODULE)and query the models directly. Source:apps/backend/src/links/website-analytics-link.ts:15— only referencesanalyticsEvent, plus the model files' comments atanalytics-session.ts:7andanalytics-daily-stats.ts:7. -
First-touch UTM attribution on sessions.
track-analytics-event.ts:228guard!(session as any).utm_campaignmeans UTM fields are written once on session creation and never overwritten by subsequent events with different UTM values. -
Duration is NOT computed in real-time. Session
duration_secondsis set tonullon creation and never updated by the track workflow. It is only populated in rollups (theaggregate-daily-analyticsjob reads session rows butduration_secondsstays as stored — the backfill script atapps/backend/src/scripts/backfill-bounce-rate.tssuggests this has been a known gap). -
Redis buffer is at-most-once.
drainBuffer()(ingest-buffer.ts:144) usesRPOPwhich removes entries from Redis before they are persisted. A crash between RPOP and the DB write causes data loss. The docblock at line 141 explicitly notes this and mentions an LMOVE-based processing queue as the upgrade path. -
Heartbeats ALWAYS bypass the buffer.
track/route.ts:191checksisHeartbeatEvent()first; heartbeats always take the synchronous write path so the live-visitor count stays real-time regardless of the drain cadence.ingest-buffer.ts:63documents this. -
Batch drain orderAndDedupeBuffer() sorts ascending by timestamp. This ensures that when a batch is drained out of arrival order, session entry/exit/bounce computation remains correct. Source:
ingest-buffer.ts:70docblock + line 87. -
Events breakdown is purely in-memory.
getAnalyticsBreakdownStep(get-analytics-breakdown.ts:30) fetches ALL matching events and delegates to the purecomputeBreakdown()function. There is no SQL-level GROUP BY — the entire aggregation happens client-side. This has scaling implications for large websites with millions of events. -
Daily stats also fetch all events + sessions up to
take: 100000. BothcomputeDailyStatsForWebsiteandgetWebsiteAnalyticsOverviewStephardcodetake: 100_000. Events/sessions beyond this cap are silently ignored in the aggregation. Source:compute-daily-stats.ts:72,91andget-website-analytics-overview.ts:131. -
Session page breakdown fetches ALL sessions with
take: 100000.get-session-pages.ts:61— same hardcoded 100k limit. -
Live SSE only works within a single instance's push pool. The
analyticsConnectionsin-memory Map (analytics-realtime.ts:13) only receives events processed by the same Fargate instance. The periodic DB-refreshed snapshot (live/route.ts:225) is the multi-instance self-heal mechanism. -
updateAnalyticsSessionscompensation deletes rather than restores. The compensation handler intrack-analytics-event.ts:276callsanalyticsService.deleteAnalyticsSessions(sessionId)— meaning a failed step 2 after a successful step 1 leaves the event in place but removes the session even if it was an update, not a create. -
Events are soft-deleted. Both
createAnalyticsEventStepcompensation (create-analytics-event.ts:36) anddeleteAnalyticsEventWorkflow(delete-analytics-event.ts:20) callsoftDeleteAnalyticsEvents/deleteAnalyticsEvents, which the MedusaMedusaServiceimplements as soft-deletes (setsdeleted_at). The indexes in migrations confirm this:WHERE deleted_at IS NULLfilters. -
country-from-timezone.tshas curated coverage. TheTIMEZONE_COUNTRYmap at line 28 covers India (primary), US, Canada, MX, BR, AR, CL, CO, PE, VE, major European, Middle East, South/Central/East/Southeast Asia, Oceania, and Africa. Unknown zones returnnull, gracefully falling through to the GeoIP tier.
Open Questions / (unverified)
- Session ended_at / duration computation: I found no workflow that computes
ended_atorduration_secondsfor sessions after the fact. Thebackfill-bounce-rate.tsscript suggests some backfilling has been necessary, but the normal path for these fields is unclear. They may be left asnull/ended_atindefinite. - AnalyticsEvent link is read-only. The
defineLinkatapps/backend/src/links/website-analytics-link.ts:25setsreadOnly: true. This is by design — the link is for graph queries only, not cross-module writes. - Migration counts: There are many
.snapshot-*JSON files undermigrations/suggesting iterative schema changes, but I only read the most recent migration file (Migration20260619000000.ts). The full schema evolution is outside scope. - The
update-analytics-event.tsworkflow exists but I did not verify where it is called from (likely the admin CRUD endpoints for editing events).