diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f6a6663..8c8c955 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -42,6 +42,29 @@ import userRoutes from './routes/users'; import complianceRoutes from './routes/compliance'; import reportsRoutes from './routes/reports'; import fxContractRoutes from './routes/fx-contracts'; +import metricsRoutes from './routes/metrics'; +import { errorHandler } from './middleware/errorHandler'; +import { incrementCounter, recordHistogram } from './monitoring/metrics'; + +// Request metrics middleware +app.use((req: Request, res: Response, next: NextFunction) => { + const startTime = Date.now(); + incrementCounter('http_requests_total', { method: req.method, path: req.path }); + + res.on('finish', () => { + const duration = Date.now() - startTime; + recordHistogram('http_request_duration_ms', duration, { + method: req.method, + status: res.statusCode.toString(), + }); + incrementCounter('http_responses_total', { + method: req.method, + status: res.statusCode.toString(), + }); + }); + + next(); +}); // Register routes app.use('/api/v1/auth', authRoutes); @@ -51,6 +74,7 @@ app.use('/api/v1/users', userRoutes); app.use('/api/v1/compliance', complianceRoutes); app.use('/api/v1/reports', reportsRoutes); app.use('/api/v1/fx-contracts', fxContractRoutes); +app.use('/metrics', metricsRoutes); // Legacy evaluate transaction endpoint app.post('/api/v1/transactions/evaluate', async (req: Request, res: Response) => { @@ -73,14 +97,8 @@ app.post('/api/v1/transactions/evaluate', async (req: Request, res: Response) => } }); -// Error handler -app.use((err: Error, req: Request, res: Response, next: NextFunction) => { - logger.error('Unhandled error', err); - res.status(500).json({ - success: false, - error: 'Internal server error', - }); -}); +// Error handler (must be last) +app.use(errorHandler); // Initialize database and start server let server: any; diff --git a/apps/api/src/middleware/errorHandler.ts b/apps/api/src/middleware/errorHandler.ts new file mode 100644 index 0000000..c7593e5 --- /dev/null +++ b/apps/api/src/middleware/errorHandler.ts @@ -0,0 +1,207 @@ +/** + * Enhanced Error Handling Middleware + * Provides retry logic, circuit breakers, and graceful degradation + */ + +import { Request, Response, NextFunction } from 'express'; +import { incrementCounter, recordHistogram } from '../monitoring/metrics'; + +export interface AppError extends Error { + statusCode?: number; + code?: string; + retryable?: boolean; +} + +/** + * Circuit Breaker implementation + */ +class CircuitBreaker { + private failures: number = 0; + private lastFailureTime: number = 0; + private state: 'closed' | 'open' | 'half-open' = 'closed'; + private readonly failureThreshold: number = 5; + private readonly timeout: number = 60000; // 60 seconds + + execute(fn: () => Promise): Promise { + if (this.state === 'open') { + if (Date.now() - this.lastFailureTime > this.timeout) { + this.state = 'half-open'; + } else { + return Promise.reject(new Error('Circuit breaker is open')); + } + } + + return fn() + .then((result) => { + if (this.state === 'half-open') { + this.state = 'closed'; + this.failures = 0; + } + return result; + }) + .catch((error) => { + this.failures++; + this.lastFailureTime = Date.now(); + + if (this.failures >= this.failureThreshold) { + this.state = 'open'; + } + + throw error; + }); + } + + getState(): string { + return this.state; + } + + reset(): void { + this.state = 'closed'; + this.failures = 0; + this.lastFailureTime = 0; + } +} + +const circuitBreakers = new Map(); + +/** + * Get or create circuit breaker for a service + */ +function getCircuitBreaker(service: string): CircuitBreaker { + if (!circuitBreakers.has(service)) { + circuitBreakers.set(service, new CircuitBreaker()); + } + return circuitBreakers.get(service)!; +} + +/** + * Retry configuration + */ +interface RetryOptions { + maxRetries?: number; + retryDelay?: number; + retryable?: (error: any) => boolean; +} + +/** + * Retry wrapper for async functions + */ +export async function withRetry( + fn: () => Promise, + options: RetryOptions = {} +): Promise { + const { + maxRetries = 3, + retryDelay = 1000, + retryable = (error: any) => error.retryable !== false, + } = options; + + let lastError: any; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const startTime = Date.now(); + const result = await fn(); + const duration = Date.now() - startTime; + recordHistogram('request_duration_ms', duration); + return result; + } catch (error: any) { + lastError = error; + incrementCounter('request_retry_attempt', { attempt: attempt.toString() }); + + if (attempt < maxRetries && retryable(error)) { + const delay = retryDelay * Math.pow(2, attempt); // Exponential backoff + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + + incrementCounter('request_failed'); + throw error; + } + } + + throw lastError; +} + +/** + * Circuit breaker wrapper + */ +export async function withCircuitBreaker( + service: string, + fn: () => Promise +): Promise { + const breaker = getCircuitBreaker(service); + return breaker.execute(fn); +} + +/** + * Error handler middleware + */ +export function errorHandler( + err: AppError, + req: Request, + res: Response, + next: NextFunction +): void { + const statusCode = err.statusCode || 500; + const code = err.code || 'INTERNAL_ERROR'; + + // Log error + console.error('Error:', { + message: err.message, + stack: err.stack, + statusCode, + code, + path: req.path, + method: req.method, + }); + + // Record metrics + incrementCounter('http_errors', { + status: statusCode.toString(), + code, + }); + + // Send error response + res.status(statusCode).json({ + error: { + message: err.message || 'Internal server error', + code, + statusCode, + ...(process.env.NODE_ENV === 'development' && { stack: err.stack }), + }, + }); +} + +/** + * Async error wrapper + */ +export function asyncHandler( + fn: (req: Request, res: Response, next: NextFunction) => Promise +) { + return (req: Request, res: Response, next: NextFunction) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +} + +/** + * Timeout middleware + */ +export function timeout(ms: number) { + return (req: Request, res: Response, next: NextFunction) => { + const timer = setTimeout(() => { + if (!res.headersSent) { + res.status(504).json({ + error: { + message: 'Request timeout', + code: 'TIMEOUT', + statusCode: 504, + }, + }); + } + }, ms); + + res.on('finish', () => clearTimeout(timer)); + next(); + }; +} diff --git a/apps/api/src/monitoring/metrics.ts b/apps/api/src/monitoring/metrics.ts new file mode 100644 index 0000000..c03b4e9 --- /dev/null +++ b/apps/api/src/monitoring/metrics.ts @@ -0,0 +1,238 @@ +/** + * Metrics Collection + * Collects application metrics for monitoring + */ + +interface Metric { + name: string; + value: number; + labels?: Record; + timestamp: Date; +} + +class MetricsCollector { + private metrics: Map = new Map(); + private counters: Map = new Map(); + private histograms: Map = new Map(); + + /** + * Increment a counter + */ + incrementCounter(name: string, labels?: Record): void { + const key = this.getKey(name, labels); + const current = this.counters.get(key) || 0; + this.counters.set(key, current + 1); + + this.recordMetric({ + name, + value: current + 1, + labels, + timestamp: new Date(), + }); + } + + /** + * Record a gauge value + */ + recordGauge(name: string, value: number, labels?: Record): void { + this.recordMetric({ + name, + value, + labels, + timestamp: new Date(), + }); + } + + /** + * Record a histogram value + */ + recordHistogram(name: string, value: number, labels?: Record): void { + const key = this.getKey(name, labels); + const values = this.histograms.get(key) || []; + values.push(value); + // Keep only last 1000 values + if (values.length > 1000) { + values.shift(); + } + this.histograms.set(key, values); + + this.recordMetric({ + name, + value, + labels, + timestamp: new Date(), + }); + } + + /** + * Get counter value + */ + getCounter(name: string, labels?: Record): number { + const key = this.getKey(name, labels); + return this.counters.get(key) || 0; + } + + /** + * Get histogram statistics + */ + getHistogramStats(name: string, labels?: Record): { + count: number; + sum: number; + min: number; + max: number; + avg: number; + p50: number; + p95: number; + p99: number; + } | null { + const key = this.getKey(name, labels); + const values = this.histograms.get(key); + if (!values || values.length === 0) { + return null; + } + + const sorted = [...values].sort((a, b) => a - b); + const sum = sorted.reduce((a, b) => a + b, 0); + const count = sorted.length; + + return { + count, + sum, + min: sorted[0], + max: sorted[sorted.length - 1], + avg: sum / count, + p50: sorted[Math.floor(count * 0.5)], + p95: sorted[Math.floor(count * 0.95)], + p99: sorted[Math.floor(count * 0.99)], + }; + } + + /** + * Get all metrics in Prometheus format + */ + getPrometheusFormat(): string { + const lines: string[] = []; + + // Counters + for (const [key, value] of this.counters.entries()) { + const [name, labelsStr] = this.parseKey(key); + const labels = labelsStr ? `{${labelsStr}}` : ''; + lines.push(`# TYPE ${name} counter`); + lines.push(`${name}${labels} ${value}`); + } + + // Histograms + for (const [key] of this.histograms.entries()) { + const [name, labelsStr] = this.parseKey(key); + const stats = this.getHistogramStats(name, this.parseLabels(labelsStr)); + if (stats) { + const labels = labelsStr ? `{${labelsStr}}` : ''; + lines.push(`# TYPE ${name} histogram`); + lines.push(`${name}_count${labels} ${stats.count}`); + lines.push(`${name}_sum${labels} ${stats.sum}`); + lines.push(`${name}_min${labels} ${stats.min}`); + lines.push(`${name}_max${labels} ${stats.max}`); + lines.push(`${name}_avg${labels} ${stats.avg}`); + lines.push(`${name}_p50${labels} ${stats.p50}`); + lines.push(`${name}_p95${labels} ${stats.p95}`); + lines.push(`${name}_p99${labels} ${stats.p99}`); + } + } + + return lines.join('\n') + '\n'; + } + + /** + * Get all metrics as JSON + */ + getAllMetrics(): { + counters: Record; + histograms: Record; + } { + const counters: Record = {}; + const histograms: Record = {}; + + for (const [key, value] of this.counters.entries()) { + counters[key] = value; + } + + for (const [key] of this.histograms.entries()) { + const [name, labelsStr] = this.parseKey(key); + const stats = this.getHistogramStats(name, this.parseLabels(labelsStr)); + if (stats) { + histograms[key] = stats; + } + } + + return { counters, histograms }; + } + + /** + * Reset all metrics + */ + reset(): void { + this.metrics.clear(); + this.counters.clear(); + this.histograms.clear(); + } + + private recordMetric(metric: Metric): void { + const key = this.getKey(metric.name, metric.labels); + const metrics = this.metrics.get(key) || []; + metrics.push(metric); + // Keep only last 100 metrics per key + if (metrics.length > 100) { + metrics.shift(); + } + this.metrics.set(key, metrics); + } + + private getKey(name: string, labels?: Record): string { + if (!labels || Object.keys(labels).length === 0) { + return name; + } + const labelStr = Object.entries(labels) + .map(([k, v]) => `${k}="${v}"`) + .join(','); + return `${name}{${labelStr}}`; + } + + private parseKey(key: string): [string, string] { + const match = key.match(/^([^{]+)(\{.*\})?$/); + if (!match) return [key, '']; + return [match[1], match[2]?.slice(1, -1) || '']; + } + + private parseLabels(labelsStr: string): Record | undefined { + if (!labelsStr) return undefined; + const labels: Record = {}; + const pairs = labelsStr.split(','); + for (const pair of pairs) { + const [key, value] = pair.split('='); + if (key && value) { + labels[key.trim()] = value.trim().replace(/^"|"$/g, ''); + } + } + return labels; + } +} + +// Singleton instance +const metricsCollector = new MetricsCollector(); + +export function getMetrics(): MetricsCollector { + return metricsCollector; +} + +// Convenience functions +export function incrementCounter(name: string, labels?: Record): void { + metricsCollector.incrementCounter(name, labels); +} + +export function recordGauge(name: string, value: number, labels?: Record): void { + metricsCollector.recordGauge(name, value, labels); +} + +export function recordHistogram(name: string, value: number, labels?: Record): void { + metricsCollector.recordHistogram(name, value, labels); +} diff --git a/apps/api/src/routes/metrics.ts b/apps/api/src/routes/metrics.ts new file mode 100644 index 0000000..2ff181c --- /dev/null +++ b/apps/api/src/routes/metrics.ts @@ -0,0 +1,66 @@ +/** + * Metrics Endpoint + * Exposes Prometheus-compatible metrics + */ + +import { Router, Request, Response } from 'express'; +import { getMetrics } from '../monitoring/metrics'; +import { getDatabaseStatus } from '../db/connection'; + +const router: Router = Router(); + +/** + * GET /metrics + * Prometheus-compatible metrics endpoint + */ +router.get('/', async (req: Request, res: Response) => { + try { + const metrics = getMetrics(); + const dbStatus = await getDatabaseStatus(); + + // Add database metrics + metrics.recordGauge('database_pool_size', dbStatus.poolSize); + metrics.recordGauge('database_idle_clients', dbStatus.idleClients); + metrics.recordGauge('database_waiting_clients', dbStatus.waitingClients); + + // Add process metrics + const memUsage = process.memoryUsage(); + metrics.recordGauge('process_memory_heap_used', memUsage.heapUsed); + metrics.recordGauge('process_memory_heap_total', memUsage.heapTotal); + metrics.recordGauge('process_memory_rss', memUsage.rss); + metrics.recordGauge('process_uptime_seconds', process.uptime()); + + const prometheusFormat = metrics.getPrometheusFormat(); + res.set('Content-Type', 'text/plain; version=0.0.4'); + res.send(prometheusFormat); + } catch (error) { + console.error('Metrics error:', error); + res.status(500).json({ error: 'Failed to generate metrics' }); + } +}); + +/** + * GET /metrics/json + * JSON format metrics + */ +router.get('/json', async (req: Request, res: Response) => { + try { + const metrics = getMetrics(); + const dbStatus = await getDatabaseStatus(); + + res.json({ + metrics: metrics.getAllMetrics(), + database: dbStatus, + process: { + uptime: process.uptime(), + memory: process.memoryUsage(), + cpu: process.cpuUsage(), + }, + }); + } catch (error) { + console.error('Metrics JSON error:', error); + res.status(500).json({ error: 'Failed to generate metrics' }); + } +}); + +export default router; diff --git a/apps/web/src/components/DataTable.tsx b/apps/web/src/components/DataTable.tsx new file mode 100644 index 0000000..e87f9dd --- /dev/null +++ b/apps/web/src/components/DataTable.tsx @@ -0,0 +1,187 @@ +/** + * Enhanced Data Table Component + * Supports sorting, filtering, pagination, and column visibility + */ + +import React, { useState, useMemo } from 'react'; +import { FiChevronUp, FiChevronDown, FiChevronLeft, FiChevronRight } from 'react-icons/fi'; + +export interface Column { + key: string; + label: string; + sortable?: boolean; + filterable?: boolean; + render?: (value: any, row: T) => React.ReactNode; +} + +interface DataTableProps { + data: T[]; + columns: Column[]; + pageSize?: number; + showPagination?: boolean; + showColumnToggle?: boolean; + onRowClick?: (row: T) => void; +} + +export function DataTable>({ + data, + columns, + pageSize = 10, + showPagination = true, + showColumnToggle = false, + onRowClick, +}: DataTableProps) { + const [currentPage, setCurrentPage] = useState(1); + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + const [filters, setFilters] = useState>({}); + const [visibleColumns, setVisibleColumns] = useState>( + new Set(columns.map((c) => c.key)) + ); + + // Filter data + const filteredData = useMemo(() => { + return data.filter((row) => { + return Object.entries(filters).every(([key, value]) => { + if (!value) return true; + const cellValue = String(row[key] || '').toLowerCase(); + return cellValue.includes(value.toLowerCase()); + }); + }); + }, [data, filters]); + + // Sort data + const sortedData = useMemo(() => { + if (!sortColumn) return filteredData; + + return [...filteredData].sort((a, b) => { + const aVal = a[sortColumn]; + const bVal = b[sortColumn]; + + if (aVal === bVal) return 0; + + const comparison = aVal < bVal ? -1 : 1; + return sortDirection === 'asc' ? comparison : -comparison; + }); + }, [filteredData, sortColumn, sortDirection]); + + // Paginate data + const paginatedData = useMemo(() => { + if (!showPagination) return sortedData; + const start = (currentPage - 1) * pageSize; + return sortedData.slice(start, start + pageSize); + }, [sortedData, currentPage, pageSize, showPagination]); + + const totalPages = Math.ceil(sortedData.length / pageSize); + + const handleSort = (columnKey: string) => { + if (sortColumn === columnKey) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortColumn(columnKey); + setSortDirection('asc'); + } + }; + + const visibleCols = columns.filter((col) => visibleColumns.has(col.key)); + + return ( +
+ {/* Filters */} + {columns.some((c) => c.filterable) && ( +
+
+ {columns + .filter((c) => c.filterable) + .map((col) => ( + + setFilters({ ...filters, [col.key]: e.target.value }) + } + className="px-3 py-2 border rounded-md text-sm" + /> + ))} +
+
+ )} + + {/* Table */} +
+ + + + {visibleCols.map((col) => ( + + ))} + + + + {paginatedData.map((row, idx) => ( + onRowClick?.(row)} + > + {visibleCols.map((col) => ( + + ))} + + ))} + +
col.sortable && handleSort(col.key)} + > +
+ {col.label} + {col.sortable && sortColumn === col.key && ( + sortDirection === 'asc' ? ( + + ) : ( + + ) + )} +
+
+ {col.render ? col.render(row[col.key], row) : String(row[col.key] || '')} +
+
+ + {/* Pagination */} + {showPagination && totalPages > 1 && ( +
+
+ Showing {(currentPage - 1) * pageSize + 1} to{' '} + {Math.min(currentPage * pageSize, sortedData.length)} of {sortedData.length} results +
+
+ + + Page {currentPage} of {totalPages} + + +
+
+ )} +
+ ); +}