| | import { Redis } from '@upstash/redis'; |
| |
|
| | interface EndpointStats { |
| | totalRequests: number; |
| | successRequests: number; |
| | failedRequests: number; |
| | lastAccessed: number; |
| | } |
| |
|
| | interface VisitorData { |
| | timestamp: number; |
| | count: number; |
| | } |
| |
|
| | interface IPFailureTracking { |
| | count: number; |
| | resetTime: number; |
| | } |
| |
|
| | interface GlobalStats { |
| | totalRequests: number; |
| | totalSuccess: number; |
| | totalFailed: number; |
| | uniqueVisitors: Set<string>; |
| | endpoints: Map<string, EndpointStats>; |
| | startTime: number; |
| | visitorsByDay: Map<string, Set<string>>; |
| | } |
| |
|
| | interface SerializedStats { |
| | totalRequests: number; |
| | totalSuccess: number; |
| | totalFailed: number; |
| | uniqueVisitors: string[]; |
| | endpoints: Record<string, EndpointStats>; |
| | startTime: number; |
| | visitorsByDay: Record<string, string[]>; |
| | } |
| |
|
| | class StatsTracker { |
| | private stats: GlobalStats; |
| | private ipFailures: Map<string, IPFailureTracking>; |
| | private readonly MAX_FAILS_PER_IP = 1; |
| | private readonly FAIL_WINDOW_MS = 12 * 60 * 60 * 1000; |
| | private redis: Redis | null = null; |
| | private saveTimeout: NodeJS.Timeout | null = null; |
| | private readonly REDIS_KEY = 'api-stats:global'; |
| |
|
| | constructor() { |
| | this.stats = { |
| | totalRequests: 0, |
| | totalSuccess: 0, |
| | totalFailed: 0, |
| | uniqueVisitors: new Set(), |
| | endpoints: new Map(), |
| | startTime: Date.now(), |
| | visitorsByDay: new Map(), |
| | }; |
| | this.ipFailures = new Map(); |
| |
|
| | if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) { |
| | this.redis = new Redis({ |
| | url: process.env.UPSTASH_REDIS_REST_URL, |
| | token: process.env.UPSTASH_REDIS_REST_TOKEN, |
| | }); |
| | console.log('Redis initialized for persistent stats'); |
| | } else { |
| | console.warn('Redis not configured - stats will be in-memory only'); |
| | } |
| | |
| | setInterval(() => { |
| | const now = Date.now(); |
| | this.ipFailures.forEach((tracking, ip) => { |
| | if (now > tracking.resetTime) { |
| | this.ipFailures.delete(ip); |
| | } |
| | }); |
| | }, 5 * 60 * 1000); |
| | } |
| |
|
| | async loadStats(): Promise<void> { |
| | if (!this.redis) { |
| | console.log('No Redis configured, starting with fresh stats'); |
| | return; |
| | } |
| |
|
| | try { |
| | const data = await this.redis.get<SerializedStats>(this.REDIS_KEY); |
| | |
| | if (!data) { |
| | console.log('No existing stats found in Redis, starting fresh'); |
| | return; |
| | } |
| |
|
| | this.stats.totalRequests = data.totalRequests || 0; |
| | this.stats.totalSuccess = data.totalSuccess || 0; |
| | this.stats.totalFailed = data.totalFailed || 0; |
| | this.stats.uniqueVisitors = new Set(data.uniqueVisitors || []); |
| | this.stats.startTime = data.startTime || Date.now(); |
| |
|
| | this.stats.endpoints = new Map(); |
| | if (data.endpoints) { |
| | Object.entries(data.endpoints).forEach(([endpoint, stats]) => { |
| | this.stats.endpoints.set(endpoint, stats); |
| | }); |
| | } |
| |
|
| | this.stats.visitorsByDay = new Map(); |
| | if (data.visitorsByDay) { |
| | Object.entries(data.visitorsByDay).forEach(([date, ips]) => { |
| | this.stats.visitorsByDay.set(date, new Set(ips)); |
| | }); |
| | } |
| |
|
| | console.log(`Stats loaded from Redis: ${this.stats.totalRequests} total requests`); |
| | } catch (error) { |
| | console.error('Error loading stats from Redis:', error); |
| | } |
| | } |
| |
|
| | private async saveStats(): Promise<void> { |
| | if (!this.redis) { |
| | return; |
| | } |
| |
|
| | try { |
| | const serialized: SerializedStats = { |
| | totalRequests: this.stats.totalRequests, |
| | totalSuccess: this.stats.totalSuccess, |
| | totalFailed: this.stats.totalFailed, |
| | uniqueVisitors: Array.from(this.stats.uniqueVisitors), |
| | startTime: this.stats.startTime, |
| | endpoints: {}, |
| | visitorsByDay: {}, |
| | }; |
| |
|
| | this.stats.endpoints.forEach((stats, endpoint) => { |
| | serialized.endpoints[endpoint] = stats; |
| | }); |
| |
|
| | this.stats.visitorsByDay.forEach((ips, date) => { |
| | serialized.visitorsByDay[date] = Array.from(ips); |
| | }); |
| |
|
| | await this.redis.set(this.REDIS_KEY, serialized); |
| | |
| | |
| | |
| | } catch (error) { |
| | console.error('Error saving stats to Redis:', error); |
| | } |
| | } |
| |
|
| | private scheduleSave(): void { |
| | if (this.saveTimeout) { |
| | clearTimeout(this.saveTimeout); |
| | } |
| |
|
| | this.saveTimeout = setTimeout(() => { |
| | this.saveStats(); |
| | }, 5000); |
| | } |
| |
|
| | trackRequest(endpoint: string, statusCode: number, clientIp: string): boolean { |
| | const now = Date.now(); |
| | |
| | const isFailed = statusCode >= 500; |
| | |
| | if (isFailed) { |
| | const ipTracking = this.ipFailures.get(clientIp); |
| | |
| | if (!ipTracking) { |
| | this.ipFailures.set(clientIp, { |
| | count: 1, |
| | resetTime: now + this.FAIL_WINDOW_MS, |
| | }); |
| | } else { |
| | if (now > ipTracking.resetTime) { |
| | ipTracking.count = 1; |
| | ipTracking.resetTime = now + this.FAIL_WINDOW_MS; |
| | } else { |
| | if (ipTracking.count >= this.MAX_FAILS_PER_IP) { |
| | return false; |
| | } |
| | ipTracking.count++; |
| | } |
| | } |
| | } else { |
| | const ipTracking = this.ipFailures.get(clientIp); |
| | if (ipTracking && ipTracking.count > 0) { |
| | ipTracking.count--; |
| | } |
| | } |
| |
|
| | const isSuccess = statusCode >= 200 && statusCode < 400; |
| | const isServerError = statusCode >= 500; |
| |
|
| | if (isSuccess || isServerError) { |
| | this.stats.totalRequests++; |
| | |
| | if (isSuccess) { |
| | this.stats.totalSuccess++; |
| | } else if (isServerError) { |
| | this.stats.totalFailed++; |
| | } |
| | } |
| | |
| | this.stats.uniqueVisitors.add(clientIp); |
| |
|
| | const dateKey = new Date(now).toISOString().split('T')[0]; |
| | if (!this.stats.visitorsByDay.has(dateKey)) { |
| | this.stats.visitorsByDay.set(dateKey, new Set()); |
| | } |
| | this.stats.visitorsByDay.get(dateKey)!.add(clientIp); |
| |
|
| | if (isSuccess || isServerError) { |
| | if (!this.stats.endpoints.has(endpoint)) { |
| | this.stats.endpoints.set(endpoint, { |
| | totalRequests: 0, |
| | successRequests: 0, |
| | failedRequests: 0, |
| | lastAccessed: now, |
| | }); |
| | } |
| |
|
| | const endpointStats = this.stats.endpoints.get(endpoint)!; |
| | endpointStats.totalRequests++; |
| | endpointStats.lastAccessed = now; |
| |
|
| | if (isSuccess) { |
| | endpointStats.successRequests++; |
| | } else if (isServerError) { |
| | endpointStats.failedRequests++; |
| | } |
| | } |
| | |
| | this.scheduleSave(); |
| | |
| | return true; |
| | } |
| |
|
| | getGlobalStats() { |
| | const uptime = Date.now() - this.stats.startTime; |
| | const uptimeHours = Math.floor(uptime / (1000 * 60 * 60)); |
| | const uptimeDays = Math.floor(uptimeHours / 24); |
| |
|
| | return { |
| | totalRequests: this.stats.totalRequests, |
| | totalSuccess: this.stats.totalSuccess, |
| | totalFailed: this.stats.totalFailed, |
| | uniqueVisitors: this.stats.uniqueVisitors.size, |
| | successRate: this.stats.totalRequests > 0 |
| | ? ((this.stats.totalSuccess / this.stats.totalRequests) * 100).toFixed(2) |
| | : "0.00", |
| | uptime: { |
| | ms: uptime, |
| | hours: uptimeHours, |
| | days: uptimeDays, |
| | formatted: uptimeDays > 0 |
| | ? `${uptimeDays}d ${uptimeHours % 24}h` |
| | : `${uptimeHours}h`, |
| | }, |
| | persistenceEnabled: this.redis !== null, |
| | }; |
| | } |
| |
|
| | getVisitorChartData(days: number = 30): VisitorData[] { |
| | const now = new Date(); |
| | const data: VisitorData[] = []; |
| |
|
| | for (let i = days - 1; i >= 0; i--) { |
| | const date = new Date(now); |
| | date.setDate(date.getDate() - i); |
| | const dateKey = date.toISOString().split('T')[0]; |
| | |
| | const visitors = this.stats.visitorsByDay.get(dateKey); |
| | const timestamp = date.getTime(); |
| | |
| | data.push({ |
| | timestamp, |
| | count: visitors ? visitors.size : 0, |
| | }); |
| | } |
| |
|
| | return data; |
| | } |
| |
|
| | getEndpointStats(endpoint: string) { |
| | return this.stats.endpoints.get(endpoint) || null; |
| | } |
| |
|
| | getAllEndpointStats() { |
| | const result: Record<string, EndpointStats> = {}; |
| | this.stats.endpoints.forEach((stats, endpoint) => { |
| | result[endpoint] = stats; |
| | }); |
| | return result; |
| | } |
| | |
| | getTopEndpoints(limit: number = 10) { |
| | return Array.from(this.stats.endpoints.entries()) |
| | .map(([endpoint, stats]) => ({ endpoint, ...stats })) |
| | .sort((a, b) => b.totalRequests - a.totalRequests) |
| | .slice(0, limit); |
| | } |
| |
|
| | async reset() { |
| | this.stats = { |
| | totalRequests: 0, |
| | totalSuccess: 0, |
| | totalFailed: 0, |
| | uniqueVisitors: new Set(), |
| | endpoints: new Map(), |
| | startTime: Date.now(), |
| | visitorsByDay: new Map(), |
| | }; |
| | this.ipFailures.clear(); |
| | await this.saveStats(); |
| | } |
| |
|
| | async shutdown(): Promise<void> { |
| | if (this.saveTimeout) { |
| | clearTimeout(this.saveTimeout); |
| | } |
| | await this.saveStats(); |
| | console.log('Stats saved on shutdown'); |
| | } |
| | } |
| |
|
| | let statsTracker: StatsTracker; |
| |
|
| | export async function initStatsTracker() { |
| | statsTracker = new StatsTracker(); |
| | await statsTracker.loadStats(); |
| | return statsTracker; |
| | } |
| |
|
| | export function getStatsTracker() { |
| | if (!statsTracker) { |
| | throw new Error("StatsTracker not initialized. Call initStatsTracker() first."); |
| | } |
| | return statsTracker; |
| | } |