diff --git a/migrations/20260630_create_request_logs_table.sql b/migrations/20260630_create_request_logs_table.sql new file mode 100644 index 00000000..5eb03012 --- /dev/null +++ b/migrations/20260630_create_request_logs_table.sql @@ -0,0 +1,17 @@ + +-- Create request_logs table to track API requests +CREATE TABLE IF NOT EXISTS request_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + api_key_id UUID REFERENCES api_keys(id) ON DELETE CASCADE, + method VARCHAR(10) NOT NULL, + path TEXT NOT NULL, + status_code INTEGER, + latency_ms INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for efficient querying +CREATE INDEX IF NOT EXISTS idx_request_logs_api_key_id ON request_logs(api_key_id); +CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON request_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_request_logs_api_key_created ON request_logs(api_key_id, created_at); + diff --git a/src/index.ts b/src/index.ts index d95a5bfc..cc814f37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,6 +57,7 @@ import { import { requireAuth } from "./middleware/auth"; import { responseTime } from "./middleware/responseTime"; import { requestId } from "./middleware/requestId"; +import { requestLoggerMiddleware } from "./middleware/requestLogger"; import { readReplicaRoutingMiddleware } from "./middleware/readReplicaRouting"; import { dbConnectionLeakDetector } from "./middleware/dbConnectionLeakDetector"; import { i18nMiddleware } from "./utils/i18n"; @@ -173,6 +174,7 @@ app.use( // app.use(rateLimitMiddleware); app.use(responseTime); app.use(requestId); +app.use(requestLoggerMiddleware); app.use(readReplicaRoutingMiddleware); app.use(i18nMiddleware); app.use(dbConnectionLeakDetector); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 92c6a0f0..5b583fa8 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -90,7 +90,7 @@ export const requireAuth = async ( // 1. Look up from the database first (scoped keys) try { const result = await queryRead( - `SELECT permissions, is_active, expires_at + `SELECT id, permissions, is_active, expires_at FROM api_keys WHERE key = $1 LIMIT 1`, @@ -108,6 +108,7 @@ export const requireAuth = async ( (req as AuthRequest).user = { id: "api-key-user", role: "admin" }; (req as any).apiKeyPermissions = row.permissions; + (req as any).apiKeyId = row.id; // Store api key id for logging return next(); } } catch (err) { diff --git a/src/middleware/requestLogger.ts b/src/middleware/requestLogger.ts new file mode 100644 index 00000000..f6e2be1b --- /dev/null +++ b/src/middleware/requestLogger.ts @@ -0,0 +1,33 @@ + +import { Request, Response, NextFunction } from "express"; +import { pool } from "../config/database"; + +export function requestLoggerMiddleware( + req: Request, + res: Response, + next: NextFunction, +) { + const start = Date.now(); + + // Hook into response finish + res.on("finish", async () => { + const apiKeyId = (req as any).apiKeyId; + if (!apiKeyId) { + return; // Only log requests with API keys + } + + const latencyMs = Date.now() - start; + try { + await pool.query( + `INSERT INTO request_logs (api_key_id, method, path, status_code, latency_ms) + VALUES ($1, $2, $3, $4, $5)`, + [apiKeyId, req.method, req.originalUrl, res.statusCode, latencyMs], + ); + } catch (err) { + console.error("[requestLogger] Failed to log request:", err); + } + }); + + next(); +} + diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 9b902285..d48d8965 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,5 +1,6 @@ import { Router, Request, Response, NextFunction } from "express"; import * as StellarSdk from "stellar-sdk"; +import { getApiKeyAnalytics } from "../services/apiKeyAnalyticsService"; import { generateToken } from "../auth/jwt"; import { updateAdminNotesHandler, @@ -228,6 +229,34 @@ const paginate = (data: T[], page: number, limit: number) => { * ========================= */ +// GET /api/admin/api-keys/:id/analytics +router.get( + "/api-keys/:id/analytics", + requireAdmin, + logAdminAction("GET_API_KEY_ANALYTICS"), + async (req: Request, res: Response) => { + try { + const apiKeyId = req.params.id; + + // Parse optional date range from query params + const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined; + const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined; + + const analytics = await getApiKeyAnalytics(apiKeyId, startDate, endDate); + res.json(analytics); + } catch (err) { + console.error("Error fetching API key analytics:", err); + throw createError( + ERROR_CODES.INTERNAL_ERROR, + "Failed to retrieve API key analytics", + { + message: err instanceof Error ? err.message : "Unknown error", + }, + ); + } + }, +); + // GET /api/admin/metrics/transactions/resolution router.get( "/metrics/transactions/resolution", diff --git a/src/services/apiKeyAnalyticsService.ts b/src/services/apiKeyAnalyticsService.ts new file mode 100644 index 00000000..4d0ad162 --- /dev/null +++ b/src/services/apiKeyAnalyticsService.ts @@ -0,0 +1,114 @@ + +import { pool, queryRead } from "../config/database"; +import { redisClient } from "../config/redis"; + +const CACHE_TTL = 3600; // 1 hour in seconds + +function percentile(values: number[], p: number): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const index = Math.ceil((p / 100) * sorted.length) - 1; + return sorted[Math.max(0, index)]; +} + +export async function getApiKeyAnalytics( + apiKeyId: string, + startDate?: Date, + endDate?: Date, +): Promise<{ + dailyStats: Array<{ date: string; totalRequests: number; errorRate: number }>; + topEndpoints: Array<{ path: string; requestCount: number }>; + latency: { p50: number; p95: number; p99: number }; +}> { + // Check cache + const cacheKey = `api_key_analytics:${apiKeyId}`; + if (redisClient?.isOpen) { + const cached = await redisClient.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + } + + // Default start date is 30 days ago + const start = startDate || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const end = endDate || new Date(); + + // Daily stats query + const dailyStatsResult = await queryRead( + ` + SELECT + DATE(created_at) as date, + COUNT(*) as total_requests, + SUM(CASE WHEN status_code >= 400 THEN 1 ELSE 0 END) as error_count + FROM request_logs + WHERE api_key_id = $1 + AND created_at >= $2 + AND created_at <= $3 + GROUP BY DATE(created_at) + ORDER BY date + `, + [apiKeyId, start, end], + ); + + const dailyStats = dailyStatsResult.rows.map((row) => ({ + date: row.date.toISOString().split("T")[0], + totalRequests: Number(row.total_requests), + errorRate: + row.total_requests > 0 ? Number(row.error_count) / Number(row.total_requests) : 0, + })); + + // Top endpoints + const topEndpointsResult = await queryRead( + ` + SELECT + path, + COUNT(*) as request_count + FROM request_logs + WHERE api_key_id = $1 + AND created_at >= $2 + AND created_at <= $3 + GROUP BY path + ORDER BY request_count DESC + LIMIT 5 + `, + [apiKeyId, start, end], + ); + + const topEndpoints = topEndpointsResult.rows.map((row) => ({ + path: row.path, + requestCount: Number(row.request_count), + })); + + // Latency percentiles + const latencyResult = await queryRead( + ` + SELECT latency_ms + FROM request_logs + WHERE api_key_id = $1 + AND created_at >= $2 + AND created_at <= $3 + `, + [apiKeyId, start, end], + ); + + const latencies = latencyResult.rows.map((row) => Number(row.latency_ms)); + const latency = { + p50: percentile(latencies, 50), + p95: percentile(latencies, 95), + p99: percentile(latencies, 99), + }; + + const analytics = { + dailyStats, + topEndpoints, + latency, + }; + + // Cache the result + if (redisClient?.isOpen) { + await redisClient.set(cacheKey, JSON.stringify(analytics), { EX: CACHE_TTL }); + } + + return analytics; +} +