Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions migrations/20260630_create_request_logs_table.sql
Original file line number Diff line number Diff line change
@@ -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);

2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -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) {
Expand Down
33 changes: 33 additions & 0 deletions src/middleware/requestLogger.ts
Original file line number Diff line number Diff line change
@@ -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();
}

29 changes: 29 additions & 0 deletions src/routes/admin.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -228,6 +229,34 @@ const paginate = <T>(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",
Expand Down
114 changes: 114 additions & 0 deletions src/services/apiKeyAnalyticsService.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Loading