From 406dc05f57981e700db0e0fa6010776ddedf8199 Mon Sep 17 00:00:00 2001 From: Aboyeji Isaac Date: Tue, 20 Oct 2026 12:10:00 +0100 Subject: [PATCH 01/37] Made a little harmless changes --- .commit_message.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.commit_message.txt b/.commit_message.txt index cd3b5d9..27f0bef 100644 --- a/.commit_message.txt +++ b/.commit_message.txt @@ -1,5 +1,7 @@ feat(#112): Implement Socket.IO load tests for 10,000 concurrent connections + + - Add metrics collection service (/api/v1/socket-metrics) with real-time latency tracking - Create k6 load test scenario with 10k VU ramp profile and latency assertions - Implement optimized data seeding scripts (default 25/25/50, 10k variants) From 872a2f6a65f410c0bda57bfbf258c5367c7edba9 Mon Sep 17 00:00:00 2001 From: Aboyeji-Isaac <141873116+Aboyeji-Isaac@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:45:21 +0100 Subject: [PATCH 02/37] feat(metrics): add Prometheus metrics collector --- src/services/prometheusMetricsService.ts | 221 +++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 src/services/prometheusMetricsService.ts diff --git a/src/services/prometheusMetricsService.ts b/src/services/prometheusMetricsService.ts new file mode 100644 index 0000000..ff67ff7 --- /dev/null +++ b/src/services/prometheusMetricsService.ts @@ -0,0 +1,221 @@ +import mongoose from 'mongoose'; +import { redisClient } from '../config/redis'; +import { getAllCircuitBreakerStatuses } from '../utils/circuitBreaker'; +import socketMetricsService from './socketMetricsService'; +import { getIndexerLagSnapshot } from './monitorService'; + +const LATENCY_BUCKETS_SECONDS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]; + +interface HistogramSnapshot { + count: number; + sumSeconds: number; + bucketCounts: number[]; +} + +interface MongoPoolSnapshot { + total: number; + available: number; + introspectionAvailable: boolean; +} + +const escapeLabel = (value: string): string => + value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); + +const labelsToString = (labels: Record): string => + '{' + Object.entries(labels).map(([key, value]) => key + '="' + escapeLabel(String(value)) + '"').join(',') + '}'; + +const safeMetricLabel = (value: string): string => + value.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 100) || 'unknown'; + +const readMongoPoolSnapshot = (): MongoPoolSnapshot => { + const connection = mongoose.connection; + const topology = (connection.getClient() as unknown as { + topology?: { + s?: { + servers?: Map; + }; + }; + }).topology; + + const servers = topology?.s?.servers; + if (!servers || typeof servers.values !== 'function') { + return { total: 0, available: 0, introspectionAvailable: false }; + } + + let total = 0; + let available = 0; + for (const server of servers.values()) { + const pool = server.s?.pool; + if (!pool) continue; + total += pool.totalConnectionCount ?? 0; + available += pool.availableConnectionCount ?? 0; + } + + return { total, available, introspectionAvailable: true }; +}; + +/** + * Collects process-local HTTP metrics and snapshots of existing operational + * dependencies. Request labels use Express route templates rather than raw + * URLs so identifiers and arbitrary query strings never become metric labels. + */ +class PrometheusMetricsService { + private readonly requestCounters = new Map(); + private readonly requestHistograms = new Map(); + + public recordRequest(method: string, route: string, statusCode: number, durationMs: number): void { + const normalizedMethod = method.toUpperCase(); + const normalizedRoute = route || 'unmatched'; + const counterKey = JSON.stringify([normalizedMethod, normalizedRoute, statusCode]); + this.requestCounters.set(counterKey, (this.requestCounters.get(counterKey) ?? 0) + 1); + + const histogramKey = JSON.stringify([normalizedMethod, normalizedRoute]); + let histogram = this.requestHistograms.get(histogramKey); + if (!histogram) { + histogram = { + count: 0, + sumSeconds: 0, + bucketCounts: LATENCY_BUCKETS_SECONDS.map(() => 0), + }; + this.requestHistograms.set(histogramKey, histogram); + } + + const durationSeconds = Math.max(0, durationMs) / 1000; + histogram.count += 1; + histogram.sumSeconds += durationSeconds; + LATENCY_BUCKETS_SECONDS.forEach((boundary, index) => { + if (durationSeconds <= boundary) histogram!.bucketCounts[index] += 1; + }); + } + + public async render(): Promise { + const lines: string[] = []; + const gauge = (name: string, help: string, value: number): void => { + lines.push( + '# HELP ' + name + ' ' + help, + '# TYPE ' + name + ' gauge', + name + ' ' + (Number.isFinite(value) ? value : 0), + ); + }; + + lines.push( + '# HELP swiftchain_http_requests_total Total HTTP requests served by route.', + '# TYPE swiftchain_http_requests_total counter', + ); + for (const [key, value] of this.requestCounters) { + const [method, route, statusCode] = JSON.parse(key) as [string, string, number]; + lines.push( + 'swiftchain_http_requests_total' + + labelsToString({ method, route, status_code: statusCode }) + + ' ' + value, + ); + } + + lines.push( + '# HELP swiftchain_http_request_duration_seconds HTTP request duration in seconds.', + '# TYPE swiftchain_http_request_duration_seconds histogram', + ); + for (const [key, histogram] of this.requestHistograms) { + const [method, route] = JSON.parse(key) as [string, string]; + LATENCY_BUCKETS_SECONDS.forEach((boundary, index) => { + lines.push( + 'swiftchain_http_request_duration_seconds_bucket' + + labelsToString({ method, route, le: boundary }) + + ' ' + histogram.bucketCounts[index], + ); + }); + lines.push( + 'swiftchain_http_request_duration_seconds_bucket' + + labelsToString({ method, route, le: '+Inf' }) + ' ' + histogram.count, + 'swiftchain_http_request_duration_seconds_sum' + + labelsToString({ method, route }) + ' ' + histogram.sumSeconds, + 'swiftchain_http_request_duration_seconds_count' + + labelsToString({ method, route }) + ' ' + histogram.count, + ); + } + + gauge('swiftchain_process_uptime_seconds', 'Process uptime in seconds.', process.uptime()); + const memory = process.memoryUsage(); + gauge('swiftchain_process_resident_memory_bytes', 'Resident process memory in bytes.', memory.rss); + gauge('swiftchain_process_heap_used_bytes', 'Node.js heap memory used in bytes.', memory.heapUsed); + gauge('swiftchain_process_heap_total_bytes', 'Node.js heap memory allocated in bytes.', memory.heapTotal); + + const mongoPool = readMongoPoolSnapshot(); + gauge( + 'swiftchain_mongodb_connection_ready', + 'Whether the Mongoose connection is connected.', + mongoose.connection.readyState === 1 ? 1 : 0, + ); + gauge( + 'swiftchain_mongodb_pool_stats_available', + 'Whether MongoDB driver pool statistics could be read from the active topology.', + mongoPool.introspectionAvailable ? 1 : 0, + ); + lines.push( + '# HELP swiftchain_mongodb_pool_connections MongoDB pool connections by state.', + '# TYPE swiftchain_mongodb_pool_connections gauge', + 'swiftchain_mongodb_pool_connections' + labelsToString({ state: 'total' }) + ' ' + mongoPool.total, + 'swiftchain_mongodb_pool_connections' + labelsToString({ state: 'available' }) + ' ' + mongoPool.available, + 'swiftchain_mongodb_pool_connections' + + labelsToString({ state: 'in_use' }) + ' ' + Math.max(0, mongoPool.total - mongoPool.available), + ); + + let redisHealthy = 0; + if (redisClient.status === 'ready') { + try { + redisHealthy = (await redisClient.ping()) === 'PONG' ? 1 : 0; + } catch { + redisHealthy = 0; + } + } + gauge('swiftchain_redis_healthy', 'Whether Redis responds to a PING command.', redisHealthy); + + const breakers = getAllCircuitBreakerStatuses(); + lines.push( + '# HELP swiftchain_circuit_breaker_open Whether a named circuit breaker is open or half-open.', + '# TYPE swiftchain_circuit_breaker_open gauge', + ); + for (const breaker of breakers) { + lines.push( + 'swiftchain_circuit_breaker_open' + + labelsToString({ name: safeMetricLabel(breaker.name), state: breaker.state }) + + ' ' + (breaker.state === 'closed' ? 0 : 1), + ); + } + + try { + const lag = await getIndexerLagSnapshot(); + gauge('swiftchain_indexer_lag_available', 'Whether indexer lag could be measured.', lag ? 1 : 0); + if (lag) { + gauge( + 'swiftchain_indexer_lag_ledgers', + 'Number of ledgers the indexer trails the network by.', + lag.lagLedgers, + ); + } + } catch { + gauge('swiftchain_indexer_lag_available', 'Whether indexer lag could be measured.', 0); + } + + const socketMetrics = socketMetricsService.getMetrics(); + gauge('swiftchain_socket_connected_sockets', 'Currently connected Socket.IO clients.', socketMetrics.connectedSockets); + gauge('swiftchain_socket_total_connections', 'Total Socket.IO connections since process start.', socketMetrics.totalConnections); + gauge('swiftchain_socket_total_disconnections', 'Total Socket.IO disconnections since process start.', socketMetrics.totalDisconnections); + gauge('swiftchain_socket_messages_processed', 'Total Socket.IO messages processed since process start.', socketMetrics.messagesProcessed); + gauge('swiftchain_socket_message_latency_p50_ms', 'Socket.IO message latency p50 in milliseconds.', socketMetrics.messageLatencyMs.p50); + gauge('swiftchain_socket_message_latency_p95_ms', 'Socket.IO message latency p95 in milliseconds.', socketMetrics.messageLatencyMs.p95); + gauge('swiftchain_socket_message_latency_p99_ms', 'Socket.IO message latency p99 in milliseconds.', socketMetrics.messageLatencyMs.p99); + + return lines.join('\n') + '\n'; + } +} + +export const prometheusMetricsService = new PrometheusMetricsService(); +export default prometheusMetricsService; From eea14ab7d5341c074982550b89f310356705e58b Mon Sep 17 00:00:00 2001 From: Aboyeji-Isaac <141873116+Aboyeji-Isaac@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:45:31 +0100 Subject: [PATCH 03/37] feat(metrics): expose read-only indexer lag snapshot --- src/services/monitorService.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/services/monitorService.ts b/src/services/monitorService.ts index 5418c0c..ca34728 100644 --- a/src/services/monitorService.ts +++ b/src/services/monitorService.ts @@ -161,6 +161,34 @@ export const checkIndexerLag = async (): Promise => { return result; }; +/** + * Read-only indexer lag snapshot for scrape-based monitoring. + * + * Unlike checkIndexerLag(), this helper does not create checkpoints, persist + * alerts, or send webhooks, so frequent metrics scrapes have no write side effects. + */ +export interface IndexerLagSnapshot { + network: string; + processedLedger: number; + networkLedger: number; + lagLedgers: number; +} + +export const getIndexerLagSnapshot = async (): Promise => { + const status = await IndexerStatus.findOne({ network: stellarConfig.network }); + if (!status) { + return null; + } + + const networkLedger = await sorobanService.getLatestLedger(); + return { + network: status.network, + processedLedger: status.lastProcessedLedger, + networkLedger, + lagLedgers: Math.max(0, networkLedger - status.lastProcessedLedger), + }; +}; + // ─── Alert history ───────────────────────────────────────────────────────────── /** From 4fe40f54fce0e1cd1fb74e9731bdae13ce82c76e Mon Sep 17 00:00:00 2001 From: Aboyeji-Isaac <141873116+Aboyeji-Isaac@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:45:39 +0100 Subject: [PATCH 04/37] feat(metrics): record HTTP request metrics --- src/middleware/prometheusMetricsMiddleware.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/middleware/prometheusMetricsMiddleware.ts diff --git a/src/middleware/prometheusMetricsMiddleware.ts b/src/middleware/prometheusMetricsMiddleware.ts new file mode 100644 index 0000000..09aa10a --- /dev/null +++ b/src/middleware/prometheusMetricsMiddleware.ts @@ -0,0 +1,31 @@ +import { Request, Response, NextFunction } from 'express'; +import prometheusMetricsService from '../services/prometheusMetricsService'; + +/** + * Records completed HTTP requests using route templates to avoid unbounded + * cardinality from identifiers and query parameters. + */ +const prometheusMetricsMiddleware = ( + req: Request, + res: Response, + next: NextFunction, +): void => { + const path = req.path; + if (path === '/metrics' || path === '/api/v1/metrics' || path === '/api/v1/metrics/') { + next(); + return; + } + + const startedAt = process.hrtime.bigint(); + res.once('finish', () => { + const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000; + const routePath = req.route && typeof req.route.path === 'string' + ? (req.baseUrl + req.route.path) || req.route.path + : 'unmatched'; + prometheusMetricsService.recordRequest(req.method, routePath, res.statusCode, durationMs); + }); + + next(); +}; + +export default prometheusMetricsMiddleware; From a4777f223ce161b3b4aa07a0b78431708a5e0cde Mon Sep 17 00:00:00 2001 From: Aboyeji-Isaac <141873116+Aboyeji-Isaac@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:45:50 +0100 Subject: [PATCH 05/37] feat(metrics): add Prometheus metrics controller --- src/controllers/metricsController.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/controllers/metricsController.ts diff --git a/src/controllers/metricsController.ts b/src/controllers/metricsController.ts new file mode 100644 index 0000000..cad0d22 --- /dev/null +++ b/src/controllers/metricsController.ts @@ -0,0 +1,23 @@ +import { Request, Response, NextFunction } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import prometheusMetricsService from '../services/prometheusMetricsService'; + +/** + * Exposes operational metrics in Prometheus text exposition format. + * Authentication and feature gating are enforced by metricsRoutes. + */ +export const getPrometheusMetrics = async ( + _req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const output = await prometheusMetricsService.render(); + res + .status(StatusCodes.OK) + .setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8'); + res.send(output); + } catch (error) { + next(error); + } +}; From a601585061c790dee0764fd337b47319b9364cc6 Mon Sep 17 00:00:00 2001 From: Aboyeji-Isaac <141873116+Aboyeji-Isaac@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:45:52 +0100 Subject: [PATCH 06/37] feat(metrics): protect Prometheus metrics route --- src/routes/metricsRoutes.ts | 46 +++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/routes/metricsRoutes.ts diff --git a/src/routes/metricsRoutes.ts b/src/routes/metricsRoutes.ts new file mode 100644 index 0000000..6ee4056 --- /dev/null +++ b/src/routes/metricsRoutes.ts @@ -0,0 +1,46 @@ +import { Router, Response, NextFunction, Request } from 'express'; +import authenticate from '../middleware/authenticate'; +import requireRole from '../middleware/requireRole'; +import { UserRole } from '../interfaces/IUser'; +import env from '../config/env'; +import { getPrometheusMetrics } from '../controllers/metricsController'; + +const router = Router(); + +/** + * @openapi + * /v1/metrics: + * get: + * tags: [Monitoring] + * summary: Export operational metrics in Prometheus format + * description: Admin-only endpoint. Disabled unless ENABLE_METRICS=true. + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Prometheus text exposition + * content: + * text/plain: + * schema: + * type: string + * 401: + * description: Authentication required + * 403: + * description: Admin role required + * 404: + * description: Metrics endpoint is disabled + */ +const requireMetricsEnabled = (_req: Request, res: Response, next: NextFunction): void => { + if (!env.ENABLE_METRICS) { + res.status(404).end(); + return; + } + next(); +}; + +router.use(requireMetricsEnabled); +router.use(authenticate); +router.use(requireRole(UserRole.ADMIN)); +router.get('/', getPrometheusMetrics); + +export default router; From 8ece1e583618affee9c48790a8d1e50a26687c9b Mon Sep 17 00:00:00 2001 From: Aboyeji-Isaac <141873116+Aboyeji-Isaac@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:45:59 +0100 Subject: [PATCH 07/37] feat(metrics): register versioned metrics route --- src/routes/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/routes/index.ts b/src/routes/index.ts index d866373..a0dc123 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -10,6 +10,7 @@ import fleetRoutes from './fleetRoutes'; import disputeRoutes from './disputeRoutes'; import eventLogRoutes from './eventLogRoutes'; import monitorRoutes from './monitorRoutes'; +import metricsRoutes from './metricsRoutes'; import profileRoutes from './profileRoutes'; import notificationRoutes from './notificationRoutes'; import healthRoutes from './healthRoutes'; @@ -43,6 +44,7 @@ router.use('/v1/fleets', fleetRoutes); router.use('/v1/disputes', disputeRoutes); router.use('/v1/eventlog', eventLogRoutes); router.use('/v1/monitor', monitorRoutes); +router.use('/v1/metrics', metricsRoutes); router.use('/v1/profile', profileRoutes); router.use('/v1/notifications', notificationRoutes); router.use('/v1/health', healthRoutes); From 5a8ff22f1a93f193eb3e2786a0179b708f429413 Mon Sep 17 00:00:00 2001 From: Aboyeji-Isaac <141873116+Aboyeji-Isaac@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:46:06 +0100 Subject: [PATCH 08/37] feat(metrics): add validated metrics feature flag --- src/config/env.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/config/env.ts b/src/config/env.ts index a542efb..ec6fe8a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -18,6 +18,7 @@ interface EnvConfig { CORS_ORIGIN: string; RATE_LIMIT_WINDOW_MS: number; RATE_LIMIT_MAX_REQUESTS: number; + ENABLE_METRICS: boolean; DISPUTE_NOTIFICATION_WEBHOOK_URL: string; UPLOAD_STORAGE_DRIVER: string; UPLOAD_LOCAL_DIR: string; @@ -229,6 +230,7 @@ const envSchema = z.object({ CORS_ORIGIN: z.string().default('*'), RATE_LIMIT_WINDOW_MS: numeric(z.coerce.number().int().min(1000).default(900000)), RATE_LIMIT_MAX_REQUESTS: numeric(z.coerce.number().int().min(1).default(100)), + ENABLE_METRICS: z.preprocess(blankToUndefined, z.enum(['true', 'false']).default('false').transform((value) => value === 'true')), DISPUTE_NOTIFICATION_WEBHOOK_URL: z.string().default(''), // ── Uploads / storage ────────────────────────────────────────────────────── From ae50e03e589fe955b48edb9325cd0dfe225f6419 Mon Sep 17 00:00:00 2001 From: Aboyeji-Isaac <141873116+Aboyeji-Isaac@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:46:13 +0100 Subject: [PATCH 09/37] docs(metrics): document ENABLE_METRICS configuration --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index 3f957cc..920a513 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,9 @@ RATE_LIMIT_WINDOW_MS=900000 # Maximum requests per client per window before a 429 is returned. [default: 100] RATE_LIMIT_MAX_REQUESTS=100 +## Prometheus metrics endpoint (disabled by default; admin JWT required) +ENABLE_METRICS=false + # Outbound webhook that receives dispute lifecycle notifications. Empty string # (the default) disables notifications. [default: "" — set to enable] # NOTE: declared in the schema but not yet read by any service; wire it up in From 44334bc23694e49de748bd0fa21c199bf9f3e1db Mon Sep 17 00:00:00 2001 From: Aboyeji-Isaac <141873116+Aboyeji-Isaac@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:46:18 +0100 Subject: [PATCH 10/37] feat(metrics): mount protected Prometheus endpoint --- src/app.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app.ts b/src/app.ts index eabf7fa..784b877 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,8 @@ import rateLimit from 'express-rate-limit'; import swaggerUi from 'swagger-ui-express'; import routes from './routes'; +import metricsRoutes from './routes/metricsRoutes'; +import prometheusMetricsMiddleware from './middleware/prometheusMetricsMiddleware'; import logger from './config/logger'; import { sendError } from './utils/responseWrapper'; import { connectDatabase } from './config/database'; @@ -37,6 +39,7 @@ app.use(compression()); // Track in-flight requests and reject new ones during graceful shutdown. app.use(requestTracker); app.use(requestLogger); +app.use(prometheusMetricsMiddleware); // Swagger UI needs inline