From 29488e32d2226189562b593fedb85f08bc666686 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 7 Aug 2025 16:16:40 +0000 Subject: [PATCH] feat: Optimize state collection and display This commit optimizes the statistics collection and display feature by refactoring the way data is stored and retrieved from Redis. - Replaced expensive `KEYS` calls with more efficient Redis data structures (Hashes, Sets, and Sorted Sets). - Aggregated statistics are now updated in real-time using atomic `HINCRBY` and `ZINCRBY` operations. - The stats API endpoint now reads from these aggregated structures, significantly improving performance. - Removed frontend polling and added API caching to reduce unnecessary requests. --- app/api/stats/route.ts | 139 +++++++++------------------------------- app/stats/page.tsx | 5 -- lib/utils/rate-limit.ts | 66 ++++++++++--------- 3 files changed, 66 insertions(+), 144 deletions(-) diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts index c91a5d92a..9f2f48c66 100644 --- a/app/api/stats/route.ts +++ b/app/api/stats/route.ts @@ -2,6 +2,8 @@ import { getRedisClient } from '@/lib/redis/config' import { getModelUsageStats } from '@/lib/utils/rate-limit' import { NextResponse } from 'next/server' +export const revalidate = 60 // Revalidate every 60 seconds + export async function GET(req: Request) { try { const { searchParams } = new URL(req.url) @@ -9,151 +11,72 @@ export async function GET(req: Request) { const redis = await getRedisClient() if (!redis) { - return NextResponse.json( - { error: 'Redis not available' }, - { status: 500 } - ) + return NextResponse.json({ error: 'Redis not available' }, { status: 500 }) } - const now = Date.now() - let startTime: number + const now = new Date() let hours: number - switch (range) { case '7d': - startTime = now - (7 * 24 * 60 * 60 * 1000) hours = 7 * 24 break case '30d': - startTime = now - (30 * 24 * 60 * 60 * 1000) hours = 30 * 24 break default: // 24h - startTime = now - (24 * 60 * 60 * 1000) hours = 24 break } - // Get real user statistics from Redis - const userIds = new Set() - const authenticatedUsers = new Set() - const anonymousUsers = new Set() - - // Get all rate limit keys to extract user information - try { - const allKeys = await redis.keys('rate_limit:*') - - for (const key of allKeys) { - const parts = key.split(':') - if (parts.length >= 3) { - if (parts[1] === 'anonymous') { - const userId = parts[2] - anonymousUsers.add(userId) - userIds.add(userId) - } else { - const userId = parts[1] - authenticatedUsers.add(userId) - userIds.add(userId) - } - } - } - } catch (error) { - console.warn('Could not get user data from Redis:', error) - } + // Get user statistics + const authenticatedUsers = await redis.scard('stats:users:authenticated') + const anonymousUsers = await redis.scard('stats:users:anonymous') + const totalUsers = authenticatedUsers + anonymousUsers - // Get real hourly request data + // Get hourly request data const hourlyRequests: Array<{ hour: string; count: number }> = [] - const hourlyData: { [key: string]: number } = {} - - // Generate hourly buckets - for (let i = hours - 1; i >= 0; i--) { - const hourTime = now - (i * 60 * 60 * 1000) - const hourKey = new Date(hourTime).toISOString().slice(0, 13) + ':00:00.000Z' - hourlyData[hourKey] = 0 - } - - // Count real requests from Redis - try { - const allKeys = await redis.keys('rate_limit:*') - for (const key of allKeys) { - const requests = await redis.zrange(key, 0, -1) - for (const req of requests) { - const reqTime = parseInt(req) - if (reqTime >= startTime) { - const hourKey = new Date(reqTime).toISOString().slice(0, 13) + ':00:00.000Z' - if (hourlyData[hourKey] !== undefined) { - hourlyData[hourKey]++ - } - } - } - } - } catch (error) { - console.warn('Could not get hourly data from Redis:', error) - } - - // Convert to array format - for (const [hour, count] of Object.entries(hourlyData)) { - hourlyRequests.push({ hour, count }) + const hourlyData = (await redis.hgetall('stats:requests:hourly')) || {} + for (let i = 0; i < hours; i++) { + const hourTime = new Date(now.getTime() - i * 60 * 60 * 1000) + const hourKey = hourTime.toISOString().slice(0, 13) // YYYY-MM-DDTHH + hourlyRequests.push({ + hour: `${hourKey}:00:00.000Z`, // The frontend expects this format + count: parseInt(hourlyData[hourKey] || '0'), + }) } - - // Sort by time in descending order (newest first) hourlyRequests.sort((a, b) => new Date(b.hour).getTime() - new Date(a.hour).getTime()) - // Get real daily request data + + // Get daily request data const dailyRequests: Array<{ date: string; count: number }> = [] - const dailyData: { [key: string]: number } = {} - + const dailyData = (await redis.hgetall('stats:requests:daily')) || {} const days = range === '30d' ? 30 : range === '7d' ? 7 : 1 - - // Generate daily buckets - for (let i = days - 1; i >= 0; i--) { - const dayTime = now - (i * 24 * 60 * 60 * 1000) - const dayKey = new Date(dayTime).toISOString().slice(0, 10) - dailyData[dayKey] = 0 - } - // Count real daily requests from Redis - try { - const allKeys = await redis.keys('rate_limit:*') - for (const key of allKeys) { - const requests = await redis.zrange(key, 0, -1) - for (const req of requests) { - const reqTime = parseInt(req) - if (reqTime >= startTime) { - const dayKey = new Date(reqTime).toISOString().slice(0, 10) - if (dailyData[dayKey] !== undefined) { - dailyData[dayKey]++ - } - } - } - } - } catch (error) { - console.warn('Could not get daily data from Redis:', error) + for (let i = 0; i < days; i++) { + const dayTime = new Date(now.getTime() - i * 24 * 60 * 60 * 1000) + const dayKey = dayTime.toISOString().slice(0, 10) + dailyRequests.push({ + date: dayKey, + count: parseInt(dailyData[dayKey] || '0'), + }) } - - // Convert to array format - for (const [date, count] of Object.entries(dailyData)) { - dailyRequests.push({ date, count }) - } - - // Sort by date in descending order (newest first) dailyRequests.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) - // Get real model usage statistics from Redis + // Get model usage statistics const topModels = await getModelUsageStats() // Calculate total requests const totalRequests = hourlyRequests.reduce((sum, hour) => sum + hour.count, 0) const stats = { - totalUsers: userIds.size, + totalUsers, totalRequests, hourlyRequests, dailyRequests, topModels, userTypes: { - authenticated: authenticatedUsers.size, - anonymous: anonymousUsers.size + authenticated: authenticatedUsers, + anonymous: anonymousUsers } } diff --git a/app/stats/page.tsx b/app/stats/page.tsx index dc170eb32..6cfca0b58 100644 --- a/app/stats/page.tsx +++ b/app/stats/page.tsx @@ -62,11 +62,6 @@ export default function StatsPage() { fetchStats() }, [fetchStats]) - // Auto-refresh every 30 seconds - useEffect(() => { - const interval = setInterval(fetchStats, 30000) - return () => clearInterval(interval) - }, [fetchStats]) const formatTime = (timeString: string) => { const date = new Date(timeString) diff --git a/lib/utils/rate-limit.ts b/lib/utils/rate-limit.ts index ce839f84f..bd752070c 100644 --- a/lib/utils/rate-limit.ts +++ b/lib/utils/rate-limit.ts @@ -179,6 +179,7 @@ export async function checkRateLimit( // Add current request to both sorted sets await redis.zadd(hourlyKey, now, now.toString()) await redis.zadd(quotaKey, now, now.toString()) + await trackRequest(redis, userId, isAuthenticated) // Remove old entries outside the window (simplified approach) const hourlyRequestsToRemove = hourlyRequests.filter(req => { @@ -220,6 +221,7 @@ export async function checkRateLimit( // Add current request to the sorted set await redis.zadd(key, now, now.toString()) + await trackRequest(redis, userId, isAuthenticated) // Remove old entries outside the window const requestsToRemove = requests.filter(req => { @@ -243,6 +245,27 @@ export async function checkRateLimit( } } +async function trackRequest( + redis: RedisWrapper, + userId: string, + isAuthenticated: boolean +) { + try { + const now = new Date() + const hour_key = now.toISOString().slice(0, 13) + const day_key = now.toISOString().slice(0, 10) + const userType = isAuthenticated ? 'authenticated' : 'anonymous' + + const pipeline = redis.multi() + pipeline.hincrby('stats:requests:hourly', hour_key, 1) + pipeline.hincrby('stats:requests:daily', day_key, 1) + pipeline.sadd(`stats:users:${userType}`, userId) + await pipeline.exec() + } catch (error) { + console.error('Failed to track request:', error) + } +} + // Get user's current usage statistics export async function getUserUsageStats( userId: string, @@ -344,27 +367,13 @@ export async function resetUserRateLimit(userId: string, modelId?: string): Prom export async function trackModelUsage(modelId: string): Promise { const redis = await initializeRedisClient() if (!redis) { - console.log('❌ Redis not available for model usage tracking') return } - const now = Date.now() - const modelUsageKey = `model_usage:${modelId}` - try { - // Add current usage to the model's sorted set - await redis.zadd(modelUsageKey, now, now.toString()) - - // Keep only the last 30 days of data (cleanup old entries) - const thirtyDaysAgo = now - (30 * 24 * 60 * 60 * 1000) - const oldEntries = await redis.zrangebyscore(modelUsageKey, 0, thirtyDaysAgo) - for (const entry of oldEntries) { - await redis.zrem(modelUsageKey, entry) - } - - console.log(`✅ Tracked model usage for ${modelId}`) + await redis.zincrby('stats:models', 1, modelId) } catch (error) { - console.error('❌ Error tracking model usage:', error) + console.error('Error tracking model usage:', error) } } @@ -372,28 +381,23 @@ export async function trackModelUsage(modelId: string): Promise { export async function getModelUsageStats(): Promise> { const redis = await initializeRedisClient() if (!redis) { - console.log('❌ Redis not available for model usage stats') return [] } try { - const modelKeys = await redis.keys('model_usage:*') - const modelStats: Array<{ model: string; count: number }> = [] + // Get top 50 models by usage + const modelStats = await redis.zrevrange('stats:models', 0, 49, 'WITHSCORES') - for (const key of modelKeys) { - const modelId = key.replace('model_usage:', '') - const count = await redis.zcard(key) - if (count > 0) { - modelStats.push({ model: modelId, count }) - } + const result: Array<{ model: string; count: number }> = [] + for (let i = 0; i < modelStats.length; i += 2) { + result.push({ + model: modelStats[i], + count: parseInt(modelStats[i + 1]) + }) } - - // Sort by count in descending order - modelStats.sort((a, b) => b.count - a.count) - - return modelStats + return result } catch (error) { - console.error('❌ Error getting model usage stats:', error) + console.error('Error getting model usage stats:', error) return [] } } \ No newline at end of file