All files / web/src/app/api/heartbeat route.ts

0% Statements 0/132
0% Branches 0/1
0% Functions 0/1
0% Lines 0/132

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
}