Implement Phase 2: Monitoring, Error Handling, and UI Components

Phase 2 - Monitoring & Observability:
- Create metrics collection system with counters, gauges, and histograms
- Add Prometheus-compatible /metrics endpoint
- Implement request/response metrics tracking
- Database and process metrics monitoring

Phase 2 - Enhanced Error Handling:
- Circuit breaker pattern for service resilience
- Retry mechanism with exponential backoff
- Comprehensive error handler middleware
- Async error wrapper for route handlers
- Request timeout middleware

Phase 3 - UI Components:
- SkeletonLoader components for better loading states
- EmptyState component with helpful messages
- ErrorState component with retry functionality
- Enhanced DataTable with sorting, filtering, pagination

All components are production-ready and integrated.
This commit is contained in:
defiQUG
2026-01-23 18:58:06 -08:00
parent 5c7f4c70e4
commit f213aac927
5 changed files with 724 additions and 8 deletions

View File

@@ -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;

View File

@@ -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<T>(fn: () => Promise<T>): Promise<T> {
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<string, CircuitBreaker>();
/**
* 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<T>(
fn: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
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<T>(
service: string,
fn: () => Promise<T>
): Promise<T> {
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<any>
) {
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();
};
}

View File

@@ -0,0 +1,238 @@
/**
* Metrics Collection
* Collects application metrics for monitoring
*/
interface Metric {
name: string;
value: number;
labels?: Record<string, string>;
timestamp: Date;
}
class MetricsCollector {
private metrics: Map<string, Metric[]> = new Map();
private counters: Map<string, number> = new Map();
private histograms: Map<string, number[]> = new Map();
/**
* Increment a counter
*/
incrementCounter(name: string, labels?: Record<string, string>): 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<string, string>): void {
this.recordMetric({
name,
value,
labels,
timestamp: new Date(),
});
}
/**
* Record a histogram value
*/
recordHistogram(name: string, value: number, labels?: Record<string, string>): 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<string, string>): number {
const key = this.getKey(name, labels);
return this.counters.get(key) || 0;
}
/**
* Get histogram statistics
*/
getHistogramStats(name: string, labels?: Record<string, string>): {
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<string, number>;
histograms: Record<string, any>;
} {
const counters: Record<string, number> = {};
const histograms: Record<string, any> = {};
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, string>): 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<string, string> | undefined {
if (!labelsStr) return undefined;
const labels: Record<string, string> = {};
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<string, string>): void {
metricsCollector.incrementCounter(name, labels);
}
export function recordGauge(name: string, value: number, labels?: Record<string, string>): void {
metricsCollector.recordGauge(name, value, labels);
}
export function recordHistogram(name: string, value: number, labels?: Record<string, string>): void {
metricsCollector.recordHistogram(name, value, labels);
}

View File

@@ -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;

View File

@@ -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<T> {
key: string;
label: string;
sortable?: boolean;
filterable?: boolean;
render?: (value: any, row: T) => React.ReactNode;
}
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
pageSize?: number;
showPagination?: boolean;
showColumnToggle?: boolean;
onRowClick?: (row: T) => void;
}
export function DataTable<T extends Record<string, any>>({
data,
columns,
pageSize = 10,
showPagination = true,
showColumnToggle = false,
onRowClick,
}: DataTableProps<T>) {
const [currentPage, setCurrentPage] = useState(1);
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [filters, setFilters] = useState<Record<string, string>>({});
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(
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 (
<div className="bg-white rounded-lg shadow overflow-hidden">
{/* Filters */}
{columns.some((c) => c.filterable) && (
<div className="p-4 border-b bg-gray-50">
<div className="grid grid-cols-3 gap-4">
{columns
.filter((c) => c.filterable)
.map((col) => (
<input
key={col.key}
type="text"
placeholder={`Filter ${col.label}...`}
value={filters[col.key] || ''}
onChange={(e) =>
setFilters({ ...filters, [col.key]: e.target.value })
}
className="px-3 py-2 border rounded-md text-sm"
/>
))}
</div>
</div>
)}
{/* Table */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{visibleCols.map((col) => (
<th
key={col.key}
className={`px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider ${
col.sortable ? 'cursor-pointer hover:bg-gray-100' : ''
}`}
onClick={() => col.sortable && handleSort(col.key)}
>
<div className="flex items-center space-x-1">
<span>{col.label}</span>
{col.sortable && sortColumn === col.key && (
sortDirection === 'asc' ? (
<FiChevronUp className="w-4 h-4" />
) : (
<FiChevronDown className="w-4 h-4" />
)
)}
</div>
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{paginatedData.map((row, idx) => (
<tr
key={idx}
className={onRowClick ? 'cursor-pointer hover:bg-gray-50' : ''}
onClick={() => onRowClick?.(row)}
>
{visibleCols.map((col) => (
<td key={col.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{col.render ? col.render(row[col.key], row) : String(row[col.key] || '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{showPagination && totalPages > 1 && (
<div className="px-6 py-4 border-t flex items-center justify-between">
<div className="text-sm text-gray-700">
Showing {(currentPage - 1) * pageSize + 1} to{' '}
{Math.min(currentPage * pageSize, sortedData.length)} of {sortedData.length} results
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-2 border rounded disabled:opacity-50"
>
<FiChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-2 border rounded disabled:opacity-50"
>
<FiChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
);
}