Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 | /** * Lightweight heartbeat endpoint for session tracking * * POST /api/heartbeat * * Tracks active user sessions using in-memory storage. * Sessions are considered active if they've sent a heartbeat within the last 60 seconds. * This endpoint is designed to be minimal-impact on performance. */ import { NextResponse } from 'next/server' import { metrics } from '@/lib/metrics' // In-memory session tracking (resets on server restart, which is fine for metrics) interface SessionData { lastSeen: number startTime: number path?: string } const activeSessions = new Map<string, SessionData>() // Session timeout in milliseconds (60 seconds) const SESSION_TIMEOUT_MS = 60_000 // Cleanup interval (every 30 seconds) const CLEANUP_INTERVAL_MS = 30_000 // Track unique visitors (approximate, daily reset) const uniqueVisitorHashes = new Set<string>() let lastVisitorReset = Date.now() // Periodic cleanup of stale sessions let cleanupInterval: ReturnType<typeof setInterval> | null = null function ensureCleanupRunning() { if (cleanupInterval) return cleanupInterval = setInterval(() => { const now = Date.now() let activeCount = 0 for (const [sessionId, data] of activeSessions.entries()) { if (now - data.lastSeen > SESSION_TIMEOUT_MS) { // Session ended - record duration const durationSeconds = (data.lastSeen - data.startTime) / 1000 if (durationSeconds > 0) { metrics.sessions.duration.observe(durationSeconds) } activeSessions.delete(sessionId) } else { activeCount++ } } // Update active sessions gauge metrics.sessions.active.set(activeCount) // Reset unique visitors daily if (now - lastVisitorReset > 24 * 60 * 60 * 1000) { uniqueVisitorHashes.clear() lastVisitorReset = now } metrics.sessions.uniqueVisitors.set(uniqueVisitorHashes.size) }, CLEANUP_INTERVAL_MS) } // Simple hash function for session anonymization function hashString(str: string): string { let hash = 0 for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i) hash = (hash << 5) - hash + char hash = hash & hash } return hash.toString(36) } export async function POST(request: Request) { ensureCleanupRunning() try { const body = await request.json().catch(() => ({})) const { sessionId, path } = body as { sessionId?: string; path?: string } if (!sessionId) { return NextResponse.json({ ok: false }, { status: 400 }) } const now = Date.now() const existing = activeSessions.get(sessionId) if (existing) { // Update existing session existing.lastSeen = now if (path && path !== existing.path) { existing.path = path metrics.sessions.pageViews.inc({ path: normalizePath(path) }) } } else { // New session activeSessions.set(sessionId, { lastSeen: now, startTime: now, path, }) // Track unique visitor (using hashed session ID) const visitorHash = hashString(sessionId) uniqueVisitorHashes.add(visitorHash) if (path) { metrics.sessions.pageViews.inc({ path: normalizePath(path) }) } } // Return minimal response return NextResponse.json({ ok: true }) } catch { return NextResponse.json({ ok: false }, { status: 500 }) } } // Normalize paths to avoid high cardinality // e.g., /practice/abc123 -> /practice/[id] function normalizePath(path: string): string { return path .replace(/\/[a-f0-9]{8,}/gi, '/[id]') // UUIDs and hex IDs .replace(/\/\d+/g, '/[id]') // Numeric IDs .replace(/\?.*$/, '') // Remove query strings .slice(0, 50) // Limit length } |