Add comprehensive visitor engagement tracking and scroll-to-top on navigation
Tracks scroll depth (25/50/75/90%), time on page, UTM parameters, outbound link clicks, search queries, audio pause/completion/listen time, and 404s. Logged-in study users are now tied to their visitor record and surfaced in the admin recent visits table. Scroll position resets on every route change. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { requireAdminAuth, isValidAdminSession } from '../auth.js'
|
||||
import { getClientIp, hasVisitorConsent, setConsentCookie, parseCookies } from '../helpers.js'
|
||||
import { getStudyUserFromRequest } from '../study-helpers.js'
|
||||
import {
|
||||
VISITOR_COOKIE,
|
||||
MAX_RECENT_VISITS,
|
||||
} from '../config.js'
|
||||
import { state } from '../state.js'
|
||||
import { queueVisitorStatsWrite, queueHitStatsWrite, normalizeMessageType, recordEpisodePlay } from '../data.js'
|
||||
import { queueVisitorStatsWrite, queueHitStatsWrite, normalizeMessageType, recordEpisodePlay, recordAnalyticsEvent } from '../data.js'
|
||||
import {
|
||||
detectBot,
|
||||
sanitizeUserAgent,
|
||||
@@ -88,6 +89,10 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
|
||||
const ip = getClientIp(req)
|
||||
const ua = sanitizeUserAgent(req.get('user-agent'))
|
||||
const device = detectDevice(ua)
|
||||
const studyUser = getStudyUserFromRequest(req)
|
||||
const studyIdentity = studyUser
|
||||
? { userId: studyUser.id, username: studyUser.username, displayName: studyUser.displayName ?? studyUser.username }
|
||||
: null
|
||||
|
||||
const ipHash = createHash('sha256').update(ip).digest('hex')
|
||||
const geo = await resolveGeo(ip)
|
||||
@@ -113,6 +118,11 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
|
||||
const prevHistory = existingVisitor?.pageHistory ?? []
|
||||
const pageHistory = [...prevHistory, { at: nowIso, path: pathKey, referrer }].slice(-100)
|
||||
|
||||
const knownIdentities = existingVisitor?.knownIdentities ?? []
|
||||
if (studyIdentity && !knownIdentities.some(i => i.userId === studyIdentity.userId)) {
|
||||
knownIdentities.push(studyIdentity)
|
||||
}
|
||||
|
||||
state.visitorStats.visitors[visitorId] = {
|
||||
visitorId, ip, ipHash,
|
||||
firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso,
|
||||
@@ -124,6 +134,7 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
|
||||
userAgents,
|
||||
device,
|
||||
pageHistory,
|
||||
...(knownIdentities.length > 0 ? { knownIdentities } : {}),
|
||||
}
|
||||
|
||||
state.visitorStats.totalVisits += 1
|
||||
@@ -133,6 +144,7 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
|
||||
at: nowIso, visitorId, ip, path: pathKey, referrer, device,
|
||||
country: geo.country, state: geo.state, county: geo.county, city: geo.city,
|
||||
returningVisitor: isReturning, visitCount: nextVisitCount,
|
||||
...(studyIdentity ? { studyUser: studyIdentity } : {}),
|
||||
})
|
||||
state.visitorStats.recentVisits = state.visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS)
|
||||
|
||||
@@ -154,6 +166,19 @@ export function register(app) {
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/analytics/event', (req, res) => {
|
||||
if (isValidAdminSession(req)) { res.json({ ok: false, reason: 'admin' }); return }
|
||||
if (!hasVisitorConsent(req)) { res.json({ ok: false, reason: 'no-consent' }); return }
|
||||
const ua = req.get('user-agent') ?? ''
|
||||
const { isBot } = detectBot(ua)
|
||||
if (isBot) { res.json({ ok: false, reason: 'bot' }); return }
|
||||
const type = typeof req.body?.type === 'string' ? req.body.type : ''
|
||||
const ALLOWED_TYPES = ['scroll_depth', 'time_on_page', 'outbound_click', 'utm', 'search_query', 'not_found', 'audio_pause', 'audio_completion', 'audio_listen_time']
|
||||
if (!ALLOWED_TYPES.includes(type)) { res.status(400).json({ ok: false, reason: 'invalid-type' }); return }
|
||||
recordAnalyticsEvent(type, req.body)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post('/api/analytics/pageview', async (req, res) => {
|
||||
if (isValidAdminSession(req)) {
|
||||
res.json({ ok: false, reason: 'admin' }); return
|
||||
@@ -340,6 +365,34 @@ export function register(app) {
|
||||
last30Days: buildLastNDaysStats(30).map(item => ({ day: item.day, plays: data.byDay?.[item.day] ?? 0 })),
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total),
|
||||
engagement: {
|
||||
scrollDepth: Object.entries(state.analyticsEvents.scrollDepth ?? {})
|
||||
.map(([path, marks]) => ({ path, ...marks }))
|
||||
.sort((a, b) => (b[90] ?? 0) - (a[90] ?? 0))
|
||||
.slice(0, 20),
|
||||
timeOnPage: Object.entries(state.analyticsEvents.timeOnPage ?? {})
|
||||
.map(([path, { totalSeconds, count }]) => ({ path, avgSeconds: count > 0 ? Math.round(totalSeconds / count) : 0, count }))
|
||||
.sort((a, b) => b.avgSeconds - a.avgSeconds)
|
||||
.slice(0, 20),
|
||||
topOutboundClicks: Object.entries(state.analyticsEvents.outboundClicks ?? {})
|
||||
.map(([url, count]) => ({ url, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 20),
|
||||
topUTMSources: Object.entries(state.analyticsEvents.utmSources ?? {})
|
||||
.map(([source, count]) => ({ source, count }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
topSearchQueries: Object.entries(state.analyticsEvents.searchQueries ?? {})
|
||||
.map(([query, count]) => ({ query, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 30),
|
||||
top404s: Object.entries(state.analyticsEvents.notFound ?? {})
|
||||
.map(([path, count]) => ({ path, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 20),
|
||||
audioEvents: Object.entries(state.analyticsEvents.audioEvents ?? {})
|
||||
.map(([title, data]) => ({ title, ...data }))
|
||||
.sort((a, b) => b.completions - a.completions),
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user