import { type NextRequest, NextResponse } from 'next/server' import { getRawClient } from '@/lib/redis' const ALLOWED_EVENTS = ['install', 'remove', 'init', 'update', 'search'] as const const DAY_SECONDS = 86_400 const TTL_DAYS = 90 const TTL_SECONDS = TTL_DAYS * DAY_SECONDS const RATE_LIMIT_MAX = 60 const RATE_LIMIT_WINDOW_SECONDS = 60 /** * Extract client IP from request headers for rate limiting. */ function getRateLimitKey(request: NextRequest): string { const forwarded = request.headers.get('x-forwarded-for') const realIP = request.headers.get('x-real-ip') const ip = forwarded ? forwarded.split(',')[0].trim() : realIP || 'unknown' return ip } /** * Check rate limit using Redis INCR + EXPIRE for serverless-safe windowed limiting. * Returns null (allowed) or the number of seconds until the window resets. */ async function checkRateLimit( clientIp: string ): Promise<{ allowed: boolean; retryAfterSeconds?: number }> { const redis = getRawClient() if (!redis) { return { allowed: true } } const key = `telemetry:rate:${clientIp}` const count = await redis.incr(key) if (count === 1) { await redis.expire(key, RATE_LIMIT_WINDOW_SECONDS) } if (count > RATE_LIMIT_MAX) { const ttl = await redis.ttl(key) return { allowed: false, retryAfterSeconds: ttl > 0 ? ttl : RATE_LIMIT_WINDOW_SECONDS } } return { allowed: true } } /** * Return today's date in YYYY-MM-DD format for Redis key partitioning. */ function todayKey(): string { return new Date().toISOString().slice(0, 10) } /** * Type guard to validate that a value is a known telemetry event. */ function isValidEvent(value: unknown): value is (typeof ALLOWED_EVENTS)[number] { if (typeof value !== 'string') return false return ALLOWED_EVENTS.some(e => e === value) } /** * Handle CLI telemetry POST requests. */ export async function POST(request: NextRequest) { try { const clientIp = getRateLimitKey(request) const rateLimit = await checkRateLimit(clientIp) if (!rateLimit.allowed) { return NextResponse.json( { ok: false, error: 'Rate limit exceeded' }, { status: 429, headers: { 'Retry-After': (rateLimit.retryAfterSeconds ?? RATE_LIMIT_WINDOW_SECONDS).toString() } } ) } const body = await request.json() const { event, skills, agents } = body if (!isValidEvent(event)) { return NextResponse.json({ ok: false, error: 'Invalid event type' }, { status: 400 }) } const redis = getRawClient() if (!redis) { return NextResponse.json({ ok: true }) } const date = todayKey() const pipeline = redis.pipeline() // Per-event type daily counter pipeline.hincrby(`telemetry:events:${date}`, event, 1) // Per-skill counters const slugs = skills && typeof skills === 'string' ? skills .split(',') .map(s => s.trim()) .filter(Boolean) : [] for (const slug of slugs) { pipeline.hincrby(`telemetry:daily:${date}`, slug, 1) pipeline.hincrby('telemetry:skills:total', slug, 1) } // Per-agent-per-skill counters (e.g. telemetry:skills:agents:stripe → { cursor: 5, claude-code: 3 }) if (event === 'install' && slugs.length > 0 && agents && typeof agents === 'string') { const agentNames = agents .split(',') .map(a => a.trim()) .filter(Boolean) for (const slug of slugs) { for (const agent of agentNames) { pipeline.hincrby(`telemetry:skills:agents:${slug}`, agent, 1) } } } // Set TTL on daily keys pipeline.expire(`telemetry:events:${date}`, TTL_SECONDS) pipeline.expire(`telemetry:daily:${date}`, TTL_SECONDS) await pipeline.exec() return NextResponse.json({ ok: true }) } catch { return NextResponse.json({ ok: true }) } }