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;