Fix analytics data-loss bugs and clean up the admin dashboard; v2.2.4
Publish Container / build (push) Successful in 16m3s
Publish Container / build (push) Successful in 16m3s
- Self-heal consent desync: if the server's consent cookie and the client's localStorage flag fall out of sync, the client now notices and stops silently sending events into a void. - Stop double-counting hard page loads in hit/visitor stats. - Flush pending disk writes on SIGTERM/SIGINT so redeploys don't drop the last few events. - Log rate-limiter drops once per window (diagnoses proxy misconfig). - Widen the bot-detection allowlist to avoid flagging real browsers (Chrome-iOS, Samsung Browser, etc.) as bots. - Stop merging distinct visitors who share an IP when a real session cookie is already present — only fall back to IP correlation when no cookie was sent at all. - Include analyticsEvents/episodePlays in backup snapshots and restore them, so a restore no longer wipes engagement analytics. - Regroup admin dashboard KPI cards by time scope (range vs all-time) and remove a duplicated Top Countries/States block. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8496e227b3
commit
d1a68754a1
9 files changed
+183
-45
No files matched your search
@@ -75,28 +75,39 @@ async function resolveGeo(ip, ipHash) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Simple in-memory rate limiter for pageview endpoint
|
||||
// Simple in-memory rate limiter for pageview endpoint.
|
||||
// This keys on req.ip, which collapses to one value for every visitor behind
|
||||
// a shared IP (a proxy in front of the app, a school/church network, CGNAT).
|
||||
// If TRUST_PROXY_HOPS is misconfigured for the real deployment topology,
|
||||
// req.ip silently becomes the proxy's IP for everyone and this limiter starts
|
||||
// dropping real traffic across all of them together. Log the first drop per
|
||||
// IP per window so that scenario shows up instead of vanishing silently.
|
||||
const pageviewHits = new Map()
|
||||
function pageviewAllowed(ip) {
|
||||
const now = Date.now()
|
||||
const windowMs = 60 * 1000
|
||||
const entry = pageviewHits.get(ip) ?? { count: 0, start: now }
|
||||
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now }
|
||||
const entry = pageviewHits.get(ip) ?? { count: 0, start: now, warned: false }
|
||||
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now; entry.warned = false }
|
||||
entry.count += 1
|
||||
pageviewHits.set(ip, entry)
|
||||
if (pageviewHits.size > 5000) {
|
||||
const cutoff = now - windowMs
|
||||
for (const [k, v] of pageviewHits) { if (v.start < cutoff) pageviewHits.delete(k) }
|
||||
}
|
||||
return entry.count <= 60
|
||||
const allowed = entry.count <= 60
|
||||
if (!allowed && !entry.warned) {
|
||||
entry.warned = true
|
||||
console.warn(`[analytics] pageview rate limit hit for an IP (${entry.count} requests/min) — if this fires often, check TRUST_PROXY_HOPS against the real deployment topology, since a misconfigured hop count collapses many visitors onto one IP here`)
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
export function recordVisitor(req, res, overridePath = null, overrideReferrer = null) {
|
||||
const cookies = parseCookies(req.headers.cookie)
|
||||
let visitorId = cookies[VISITOR_COOKIE]
|
||||
const hadCookie = Boolean(visitorId)
|
||||
if (!visitorId) {
|
||||
visitorId = randomUUID()
|
||||
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax; HttpOnly`)
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString()
|
||||
@@ -112,9 +123,18 @@ export function recordVisitor(req, res, overridePath = null, overrideReferrer =
|
||||
|
||||
const ipHash = createHash('sha256').update(ip).digest('hex')
|
||||
|
||||
const existingIdByIp = state.visitorStats.ipHashIndex[ipHash]
|
||||
if (existingIdByIp && existingIdByIp !== visitorId) {
|
||||
visitorId = existingIdByIp
|
||||
// Only fall back to IP-based correlation when the browser sent no cookie
|
||||
// at all — e.g. it never persisted (Safari ITP, a cookie-blocking
|
||||
// extension) — so that one real person doesn't get double-counted every
|
||||
// visit. If a cookie WAS sent, trust it: two different people sharing a
|
||||
// network (family wifi, a school or church LAN) must not be silently
|
||||
// merged into one visitor just because they share an IP, which would
|
||||
// corrupt page history and undercount unique visitors.
|
||||
if (!hadCookie) {
|
||||
const existingIdByIp = state.visitorStats.ipHashIndex[ipHash]
|
||||
if (existingIdByIp) {
|
||||
visitorId = existingIdByIp
|
||||
}
|
||||
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax; HttpOnly`)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user