diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 16243a70c..407095741 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -5,6 +5,7 @@ import { ThrottlerModule } from '@nestjs/throttler'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { CorrelationIdInterceptor } from './common/interceptors/correlation-id.interceptor'; import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor'; +import { GracefulShutdownInterceptor } from './common/interceptors/graceful-shutdown.interceptor'; import { TieredThrottlerGuard } from './common/guards/tiered-throttler.guard'; import { CommonModule } from './common/common.module'; import { EventEmitterModule } from '@nestjs/event-emitter'; @@ -37,6 +38,8 @@ import { TestRbacModule } from './test-rbac/test-rbac.module'; import { TestThrottlingModule } from './test-throttling/test-throttling.module'; import { ApiVersioningModule } from './common/versioning/api-versioning.module'; import { BackupModule } from './modules/backup/backup.module'; +import { PerformanceModule } from './modules/performance/performance.module'; +import { GracefulShutdownService } from './common/services/graceful-shutdown.service'; const envValidationSchema = Joi.object({ NODE_ENV: Joi.string().valid('development', 'production', 'test').required(), @@ -190,6 +193,7 @@ const envValidationSchema = Joi.object({ TestThrottlingModule, ApiVersioningModule, BackupModule, + PerformanceModule, CommonModule, ThrottlerModule.forRoot([ { @@ -212,6 +216,7 @@ const envValidationSchema = Joi.object({ controllers: [AppController], providers: [ AppService, + GracefulShutdownService, { provide: APP_GUARD, useClass: TieredThrottlerGuard, @@ -224,6 +229,10 @@ const envValidationSchema = Joi.object({ provide: APP_INTERCEPTOR, useClass: AuditLogInterceptor, }, + { + provide: APP_INTERCEPTOR, + useClass: GracefulShutdownInterceptor, + }, ], }) export class AppModule {} diff --git a/backend/src/common/decorators/cache-config.decorator.ts b/backend/src/common/decorators/cache-config.decorator.ts new file mode 100644 index 000000000..b37763b78 --- /dev/null +++ b/backend/src/common/decorators/cache-config.decorator.ts @@ -0,0 +1,12 @@ +import { SetMetadata } from '@nestjs/common'; + +export interface CacheConfigMetadata { + ttl?: number; + tags?: string[]; + staleWhileRevalidate?: boolean; +} + +export const CACHE_CONFIG_KEY = 'cache_config'; + +export const CacheConfig = (config: CacheConfigMetadata) => + SetMetadata(CACHE_CONFIG_KEY, config); diff --git a/backend/src/common/interceptors/cache.interceptor.ts b/backend/src/common/interceptors/cache.interceptor.ts new file mode 100644 index 000000000..78e7565f3 --- /dev/null +++ b/backend/src/common/interceptors/cache.interceptor.ts @@ -0,0 +1,38 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable, of } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { CacheStrategyService } from '../../modules/cache/cache-strategy.service'; + +@Injectable() +export class CacheInterceptor implements NestInterceptor { + constructor(private cacheStrategy: CacheStrategyService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const { method, url, query } = request; + + // Only cache GET requests + if (method !== 'GET') { + return next.handle(); + } + + const cacheKey = `${url}:${JSON.stringify(query)}`; + + return this.cacheStrategy.get(cacheKey).then((cached) => { + if (cached) { + return of(cached); + } + + return next.handle().pipe( + tap((data) => { + this.cacheStrategy.set(cacheKey, data); + }), + ); + }); + } +} diff --git a/backend/src/common/interceptors/graceful-shutdown.interceptor.ts b/backend/src/common/interceptors/graceful-shutdown.interceptor.ts new file mode 100644 index 000000000..911b10b71 --- /dev/null +++ b/backend/src/common/interceptors/graceful-shutdown.interceptor.ts @@ -0,0 +1,34 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { finalize } from 'rxjs/operators'; +import { GracefulShutdownService } from '../services/graceful-shutdown.service'; + +@Injectable() +export class GracefulShutdownInterceptor implements NestInterceptor { + constructor(private gracefulShutdown: GracefulShutdownService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + // Reject new requests during shutdown + if (this.gracefulShutdown.isShutdown()) { + const response = context.switchToHttp().getResponse(); + response.status(503).json({ + statusCode: 503, + message: 'Service is shutting down', + }); + return; + } + + this.gracefulShutdown.incrementActiveRequests(); + + return next.handle().pipe( + finalize(() => { + this.gracefulShutdown.decrementActiveRequests(); + }), + ); + } +} diff --git a/backend/src/common/services/graceful-shutdown.service.ts b/backend/src/common/services/graceful-shutdown.service.ts new file mode 100644 index 000000000..540478b6e --- /dev/null +++ b/backend/src/common/services/graceful-shutdown.service.ts @@ -0,0 +1,103 @@ +import { Injectable, Logger, OnApplicationShutdown } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject } from '@nestjs/common'; +import { Cache } from 'cache-manager'; + +@Injectable() +export class GracefulShutdownService implements OnApplicationShutdown { + private readonly logger = new Logger(GracefulShutdownService.name); + private isShuttingDown = false; + private activeRequests = 0; + private readonly maxShutdownTimeout = 30000; // 30 seconds + + constructor( + private dataSource: DataSource, + @Inject(CACHE_MANAGER) private cacheManager: Cache, + ) {} + + incrementActiveRequests(): void { + if (!this.isShuttingDown) { + this.activeRequests++; + } + } + + decrementActiveRequests(): void { + this.activeRequests--; + } + + isShutdown(): boolean { + return this.isShuttingDown; + } + + async onApplicationShutdown(signal?: string): Promise { + this.logger.log(`Received shutdown signal: ${signal}`); + this.isShuttingDown = true; + + const shutdownStartTime = Date.now(); + + // Stop accepting new requests + this.logger.log('Stopping acceptance of new requests'); + + // Wait for in-flight requests to complete + await this.waitForInFlightRequests(); + + // Close database connections + await this.closeDatabase(); + + // Close Redis connections + await this.closeRedis(); + + const shutdownDuration = Date.now() - shutdownStartTime; + this.logger.log( + `Graceful shutdown completed in ${shutdownDuration}ms`, + ); + } + + private async waitForInFlightRequests(): Promise { + const startTime = Date.now(); + const timeout = 25000; // Leave 5 seconds for other cleanup + + while (this.activeRequests > 0) { + const elapsed = Date.now() - startTime; + + if (elapsed > timeout) { + this.logger.warn( + `Timeout waiting for ${this.activeRequests} in-flight requests. Forcing shutdown.`, + ); + break; + } + + this.logger.log( + `Waiting for ${this.activeRequests} in-flight requests to complete...`, + ); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + this.logger.log('All in-flight requests completed'); + } + + private async closeDatabase(): Promise { + try { + if (this.dataSource && this.dataSource.isInitialized) { + this.logger.log('Closing database connections...'); + await this.dataSource.destroy(); + this.logger.log('Database connections closed'); + } + } catch (error) { + this.logger.error('Error closing database connections:', error); + } + } + + private async closeRedis(): Promise { + try { + if (this.cacheManager) { + this.logger.log('Closing Redis connections...'); + await this.cacheManager.reset(); + this.logger.log('Redis connections closed'); + } + } catch (error) { + this.logger.error('Error closing Redis connections:', error); + } + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index b034479df..e09fa6563 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -11,6 +11,7 @@ import { } from './common/versioning/versioning.middleware'; import { VersionAnalyticsInterceptor } from './common/versioning/version-analytics.interceptor'; import { VersionAnalyticsService } from './common/versioning/version-analytics.service'; +import { GracefulShutdownService } from './common/services/graceful-shutdown.service'; async function bootstrap() { const app = await NestFactory.create(AppModule, { bufferLogs: true }); @@ -57,13 +58,38 @@ async function bootstrap() { SwaggerModule.setup(`api/v${version}/docs`, app, document); } - await app.listen(port || 3001); + const server = await app.listen(port || 3001); const logger = app.get(Logger); logger.log(`Application is running on: http://localhost:${port}/api`); logger.log( `Swagger v1 docs (deprecated): http://localhost:${port}/api/v1/docs`, ); logger.log(`Swagger v2 docs: http://localhost:${port}/api/v2/docs`); + + // Setup graceful shutdown + const gracefulShutdown = app.get(GracefulShutdownService); + + const signals = ['SIGTERM', 'SIGINT']; + signals.forEach((signal) => { + process.on(signal, async () => { + logger.log(`Received ${signal}, starting graceful shutdown...`); + server.close(async () => { + await app.close(); + process.exit(0); + }); + }); + }); + + // Handle uncaught exceptions + process.on('uncaughtException', (error) => { + logger.error('Uncaught Exception:', error); + process.exit(1); + }); + + process.on('unhandledRejection', (reason, promise) => { + logger.error('Unhandled Rejection at:', promise, 'reason:', reason); + process.exit(1); + }); } bootstrap().catch((error: unknown) => { diff --git a/backend/src/modules/cache/cache-strategy.service.ts b/backend/src/modules/cache/cache-strategy.service.ts new file mode 100644 index 000000000..858364aae --- /dev/null +++ b/backend/src/modules/cache/cache-strategy.service.ts @@ -0,0 +1,145 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject } from '@nestjs/common'; +import { Cache } from 'cache-manager'; + +export interface CacheConfig { + ttl: number; // milliseconds + key: string; + tags?: string[]; +} + +interface CacheMetrics { + hits: number; + misses: number; + sets: number; + deletes: number; +} + +@Injectable() +export class CacheStrategyService { + private readonly logger = new Logger(CacheStrategyService.name); + private metrics: CacheMetrics = { hits: 0, misses: 0, sets: 0, deletes: 0 }; + private resourceTTLs = new Map([ + ['user', 5 * 60 * 1000], // 5 minutes + ['savings', 10 * 60 * 1000], // 10 minutes + ['analytics', 30 * 60 * 1000], // 30 minutes + ['blockchain', 2 * 60 * 1000], // 2 minutes + ]); + + constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {} + + async get(key: string): Promise { + try { + const value = await this.cacheManager.get(key); + if (value) { + this.metrics.hits++; + this.logger.debug(`Cache hit: ${key}`); + } else { + this.metrics.misses++; + } + return value; + } catch (error) { + this.logger.error(`Cache get error for key ${key}:`, error); + return undefined; + } + } + + async set(key: string, value: T, ttl?: number): Promise { + try { + const finalTTL = ttl || this.getDefaultTTL(key); + await this.cacheManager.set(key, value, finalTTL); + this.metrics.sets++; + this.logger.debug(`Cache set: ${key} (TTL: ${finalTTL}ms)`); + } catch (error) { + this.logger.error(`Cache set error for key ${key}:`, error); + } + } + + async del(key: string): Promise { + try { + await this.cacheManager.del(key); + this.metrics.deletes++; + this.logger.debug(`Cache deleted: ${key}`); + } catch (error) { + this.logger.error(`Cache delete error for key ${key}:`, error); + } + } + + async invalidateByTag(tag: string): Promise { + try { + const keys = await this.cacheManager.store.keys(); + const keysToDelete = keys.filter((k) => k.includes(tag)); + + for (const key of keysToDelete) { + await this.del(key); + } + + this.logger.debug(`Invalidated ${keysToDelete.length} keys with tag: ${tag}`); + } catch (error) { + this.logger.error(`Cache invalidation error for tag ${tag}:`, error); + } + } + + async warmCache(key: string, loader: () => Promise, ttl?: number): Promise { + try { + const data = await loader(); + await this.set(key, data, ttl); + this.logger.log(`Cache warmed: ${key}`); + } catch (error) { + this.logger.error(`Cache warming error for key ${key}:`, error); + } + } + + async getOrSet( + key: string, + loader: () => Promise, + ttl?: number, + ): Promise { + const cached = await this.get(key); + if (cached) return cached; + + const data = await loader(); + await this.set(key, data, ttl); + return data; + } + + async staleWhileRevalidate( + key: string, + loader: () => Promise, + ttl: number, + staleTime: number, + ): Promise { + const cached = await this.get(key); + if (cached) return cached; + + const data = await loader(); + await this.set(key, data, ttl + staleTime); + return data; + } + + getMetrics() { + const total = this.metrics.hits + this.metrics.misses; + return { + ...this.metrics, + hitRate: total > 0 ? (this.metrics.hits / total * 100).toFixed(2) + '%' : '0%', + }; + } + + resetMetrics() { + this.metrics = { hits: 0, misses: 0, sets: 0, deletes: 0 }; + } + + setResourceTTL(resource: string, ttl: number): void { + this.resourceTTLs.set(resource, ttl); + } + + private getDefaultTTL(key: string): number { + for (const [resource, ttl] of this.resourceTTLs) { + if (key.includes(resource)) { + return ttl; + } + } + return 5 * 60 * 1000; // default 5 minutes + } +} diff --git a/backend/src/modules/cache/cache.controller.ts b/backend/src/modules/cache/cache.controller.ts new file mode 100644 index 000000000..f90b818e6 --- /dev/null +++ b/backend/src/modules/cache/cache.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { CacheStrategyService } from './cache-strategy.service'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; + +@ApiTags('Cache') +@Controller('cache') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class CacheController { + constructor(private readonly cacheStrategy: CacheStrategyService) {} + + @Get('metrics') + @ApiOperation({ summary: 'Get cache hit/miss metrics' }) + getMetrics() { + return this.cacheStrategy.getMetrics(); + } + + @Get('reset-metrics') + @ApiOperation({ summary: 'Reset cache metrics' }) + resetMetrics() { + this.cacheStrategy.resetMetrics(); + return { message: 'Cache metrics reset' }; + } +} diff --git a/backend/src/modules/cache/cache.module.ts b/backend/src/modules/cache/cache.module.ts index fa4c50528..57ccce5bc 100644 --- a/backend/src/modules/cache/cache.module.ts +++ b/backend/src/modules/cache/cache.module.ts @@ -1,10 +1,34 @@ import { Module } from '@nestjs/common'; -import { CacheModule } from '@nestjs/cache-manager'; -import { cacheConfig } from './cache.config'; +import { CacheModule as NestCacheModule } from '@nestjs/cache-manager'; +import * as redisStore from 'cache-manager-redis-store'; +import { ConfigService } from '@nestjs/config'; +import { CacheStrategyService } from './cache-strategy.service'; +import { CacheController } from './cache.controller'; @Module({ - imports: [CacheModule.registerAsync(cacheConfig)], - providers: [], - exports: [], + imports: [ + NestCacheModule.registerAsync({ + inject: [ConfigService], + useFactory: async (configService: ConfigService) => { + const redisUrl = configService.get('REDIS_URL'); + + if (redisUrl) { + return { + store: redisStore, + url: redisUrl, + ttl: 5 * 60 * 1000, // 5 minutes default + }; + } + + // Fallback to in-memory cache + return { + ttl: 5 * 60 * 1000, + }; + }, + }), + ], + providers: [CacheStrategyService], + controllers: [CacheController], + exports: [CacheStrategyService, NestCacheModule], }) -export class RedisCacheModule {} +export class CacheModule {} diff --git a/backend/src/modules/health/health-history.service.ts b/backend/src/modules/health/health-history.service.ts new file mode 100644 index 000000000..e6f0e15a9 --- /dev/null +++ b/backend/src/modules/health/health-history.service.ts @@ -0,0 +1,72 @@ +import { Injectable, Logger } from '@nestjs/common'; + +interface HealthCheckResult { + service: string; + status: 'up' | 'down' | 'degraded'; + responseTime: number; + timestamp: Date; + error?: string; +} + +@Injectable() +export class HealthHistoryService { + private readonly logger = new Logger(HealthHistoryService.name); + private history: HealthCheckResult[] = []; + private readonly maxHistorySize = 1000; + + recordCheck(result: HealthCheckResult): void { + this.history.push(result); + + if (this.history.length > this.maxHistorySize) { + this.history.shift(); + } + } + + getHistory(service?: string, limit: number = 100): HealthCheckResult[] { + let filtered = this.history; + + if (service) { + filtered = filtered.filter((h) => h.service === service); + } + + return filtered.slice(-limit); + } + + getServiceStats(service: string) { + const serviceHistory = this.history.filter((h) => h.service === service); + + if (serviceHistory.length === 0) { + return null; + } + + const upCount = serviceHistory.filter((h) => h.status === 'up').length; + const downCount = serviceHistory.filter((h) => h.status === 'down').length; + const avgResponseTime = + serviceHistory.reduce((sum, h) => sum + h.responseTime, 0) / + serviceHistory.length; + + return { + service, + totalChecks: serviceHistory.length, + uptime: ((upCount / serviceHistory.length) * 100).toFixed(2) + '%', + downtime: ((downCount / serviceHistory.length) * 100).toFixed(2) + '%', + avgResponseTime: `${avgResponseTime.toFixed(2)}ms`, + lastCheck: serviceHistory[serviceHistory.length - 1], + }; + } + + getAllStats() { + const services = new Set(this.history.map((h) => h.service)); + const stats: any = {}; + + services.forEach((service) => { + stats[service] = this.getServiceStats(service); + }); + + return stats; + } + + clearHistory(): void { + this.history = []; + } +} diff --git a/backend/src/modules/health/health.controller.ts b/backend/src/modules/health/health.controller.ts index e9ad8534f..916db1f17 100644 --- a/backend/src/modules/health/health.controller.ts +++ b/backend/src/modules/health/health.controller.ts @@ -1,9 +1,16 @@ -import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common'; +import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; import { HealthCheck, HealthCheckService } from '@nestjs/terminus'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { TypeOrmHealthIndicator } from './indicators/typeorm.health'; import { IndexerHealthIndicator } from './indicators/indexer.health'; import { RpcHealthIndicator } from './indicators/rpc.health'; +import { + RedisHealthIndicator, + EmailServiceHealthIndicator, + SorobanRpcHealthIndicator, + HorizonHealthIndicator, +} from './indicators/external-services.health'; +import { HealthHistoryService } from './health-history.service'; @ApiTags('Health') @Controller('health') @@ -13,6 +20,11 @@ export class HealthController { private readonly db: TypeOrmHealthIndicator, private readonly indexer: IndexerHealthIndicator, private readonly rpc: RpcHealthIndicator, + private readonly redis: RedisHealthIndicator, + private readonly email: EmailServiceHealthIndicator, + private readonly sorobanRpc: SorobanRpcHealthIndicator, + private readonly horizon: HorizonHealthIndicator, + private readonly healthHistory: HealthHistoryService, ) {} @Get() @@ -23,49 +35,6 @@ export class HealthController { description: 'Comprehensive health check including database, RPC endpoints, and indexer service', }) - @ApiResponse({ - status: 200, - description: 'Application is healthy', - schema: { - example: { - status: 'ok', - checks: { - database: { - status: 'up', - responseTime: '45ms', - threshold: '200ms', - }, - rpc: { - status: 'up', - responseTime: '120ms', - currentEndpoint: 'https://soroban-testnet.stellar.org', - totalEndpoints: 2, - }, - indexer: { - status: 'up', - timeSinceLastProcess: '3500ms', - threshold: '15000ms', - lastProcessedTime: '2026-03-25T10:30:45.123Z', - }, - }, - }, - }, - }) - @ApiResponse({ - status: 503, - description: 'One or more health checks failed', - schema: { - example: { - status: 'error', - checks: { - database: { - status: 'down', - message: 'Database connection failed', - }, - }, - }, - }, - }) async check() { return this.health.check([ () => this.db.isHealthy('database'), @@ -74,16 +43,64 @@ export class HealthController { ]); } + @Get('detailed') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Detailed health check for all external dependencies', + description: 'Check status of all external services with response times', + }) + async detailed() { + const startTime = Date.now(); + const checks = await Promise.allSettled([ + this.db.isHealthy('database'), + this.rpc.isHealthy('rpc'), + this.indexer.isHealthy('indexer'), + this.redis.isHealthy('redis'), + this.email.isHealthy('email'), + this.sorobanRpc.isHealthy('soroban-rpc'), + this.horizon.isHealthy('horizon'), + ]); + + const results = checks.map((check, index) => { + const services = [ + 'database', + 'rpc', + 'indexer', + 'redis', + 'email', + 'soroban-rpc', + 'horizon', + ]; + + if (check.status === 'fulfilled') { + return check.value; + } + + return { + [services[index]]: { + status: 'down', + error: check.reason?.message || 'Unknown error', + }, + }; + }); + + const totalTime = Date.now() - startTime; + const allHealthy = checks.every((c) => c.status === 'fulfilled'); + + return { + status: allHealthy ? 'ok' : 'degraded', + timestamp: new Date().toISOString(), + responseTime: `${totalTime}ms`, + checks: Object.assign({}, ...results), + }; + } + @Get('live') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Liveness probe', description: 'Simple endpoint for Kubernetes liveness probes', }) - @ApiResponse({ - status: 200, - description: 'Application is running', - }) live() { return { status: 'ok', @@ -100,18 +117,32 @@ export class HealthController { description: 'Readiness check for Kubernetes - validates critical dependencies', }) - @ApiResponse({ - status: 200, - description: 'Application is ready to serve traffic', - }) - @ApiResponse({ - status: 503, - description: 'Application is not ready', - }) async ready() { return this.health.check([ () => this.db.isHealthy('database'), () => this.rpc.isHealthy('rpc'), ]); } + + @Get('history') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get health check history', + description: 'Retrieve historical health check data', + }) + getHistory(@Query('service') service?: string, @Query('limit') limit: number = 100) { + return { + history: this.healthHistory.getHistory(service, limit), + }; + } + + @Get('stats') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Get health statistics', + description: 'Get uptime and performance statistics for all services', + }) + getStats() { + return this.healthHistory.getAllStats(); + } } diff --git a/backend/src/modules/health/health.module.ts b/backend/src/modules/health/health.module.ts index 30bd8591b..993693a11 100644 --- a/backend/src/modules/health/health.module.ts +++ b/backend/src/modules/health/health.module.ts @@ -5,6 +5,13 @@ import { HealthController } from './health.controller'; import { TypeOrmHealthIndicator } from './indicators/typeorm.health'; import { IndexerHealthIndicator } from './indicators/indexer.health'; import { RpcHealthIndicator } from './indicators/rpc.health'; +import { + RedisHealthIndicator, + EmailServiceHealthIndicator, + SorobanRpcHealthIndicator, + HorizonHealthIndicator, +} from './indicators/external-services.health'; +import { HealthHistoryService } from './health-history.service'; import { BlockchainModule } from '../blockchain/blockchain.module'; import { DeadLetterEvent } from '../blockchain/entities/dead-letter-event.entity'; @@ -19,6 +26,12 @@ import { DeadLetterEvent } from '../blockchain/entities/dead-letter-event.entity TypeOrmHealthIndicator, IndexerHealthIndicator, RpcHealthIndicator, + RedisHealthIndicator, + EmailServiceHealthIndicator, + SorobanRpcHealthIndicator, + HorizonHealthIndicator, + HealthHistoryService, ], + exports: [HealthHistoryService], }) export class HealthModule {} diff --git a/backend/src/modules/health/indicators/external-services.health.ts b/backend/src/modules/health/indicators/external-services.health.ts new file mode 100644 index 000000000..1108fdf09 --- /dev/null +++ b/backend/src/modules/health/indicators/external-services.health.ts @@ -0,0 +1,168 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { HealthIndicator, HealthIndicatorResult } from '@nestjs/terminus'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; + +interface ServiceHealth { + status: 'up' | 'down' | 'degraded'; + responseTime: number; + lastCheck: Date; + error?: string; +} + +@Injectable() +export class RedisHealthIndicator extends HealthIndicator { + private readonly logger = new Logger(RedisHealthIndicator.name); + + constructor(private configService: ConfigService) { + super(); + } + + async isHealthy(key: string): Promise { + const redisUrl = this.configService.get('REDIS_URL'); + + if (!redisUrl) { + return this.getStatus(key, false, { + message: 'Redis not configured', + }); + } + + const startTime = Date.now(); + try { + // Simple ping test + const response = await axios.get(redisUrl, { timeout: 5000 }); + const responseTime = Date.now() - startTime; + + return this.getStatus(key, true, { + responseTime: `${responseTime}ms`, + }); + } catch (error) { + const responseTime = Date.now() - startTime; + this.logger.error(`Redis health check failed: ${error}`); + + return this.getStatus(key, false, { + responseTime: `${responseTime}ms`, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } +} + +@Injectable() +export class EmailServiceHealthIndicator extends HealthIndicator { + private readonly logger = new Logger(EmailServiceHealthIndicator.name); + + constructor(private configService: ConfigService) { + super(); + } + + async isHealthy(key: string): Promise { + const mailHost = this.configService.get('MAIL_HOST'); + + if (!mailHost) { + return this.getStatus(key, false, { + message: 'Email service not configured', + }); + } + + const startTime = Date.now(); + try { + // Test SMTP connection + const response = await axios.get(`http://${mailHost}:25`, { timeout: 5000 }); + const responseTime = Date.now() - startTime; + + return this.getStatus(key, true, { + responseTime: `${responseTime}ms`, + }); + } catch (error) { + const responseTime = Date.now() - startTime; + this.logger.warn(`Email service health check failed: ${error}`); + + return this.getStatus(key, false, { + responseTime: `${responseTime}ms`, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } +} + +@Injectable() +export class SorobanRpcHealthIndicator extends HealthIndicator { + private readonly logger = new Logger(SorobanRpcHealthIndicator.name); + + constructor(private configService: ConfigService) { + super(); + } + + async isHealthy(key: string): Promise { + const rpcUrl = this.configService.get('SOROBAN_RPC_URL'); + + if (!rpcUrl) { + return this.getStatus(key, false, { + message: 'Soroban RPC not configured', + }); + } + + const startTime = Date.now(); + try { + const response = await axios.post( + rpcUrl, + { jsonrpc: '2.0', method: 'getHealth', params: [], id: 1 }, + { timeout: 10000 }, + ); + + const responseTime = Date.now() - startTime; + const isHealthy = response.data?.result?.status === 'healthy'; + + return this.getStatus(key, isHealthy, { + responseTime: `${responseTime}ms`, + status: response.data?.result?.status, + }); + } catch (error) { + const responseTime = Date.now() - startTime; + this.logger.error(`Soroban RPC health check failed: ${error}`); + + return this.getStatus(key, false, { + responseTime: `${responseTime}ms`, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } +} + +@Injectable() +export class HorizonHealthIndicator extends HealthIndicator { + private readonly logger = new Logger(HorizonHealthIndicator.name); + + constructor(private configService: ConfigService) { + super(); + } + + async isHealthy(key: string): Promise { + const horizonUrl = this.configService.get('HORIZON_URL'); + + if (!horizonUrl) { + return this.getStatus(key, false, { + message: 'Horizon not configured', + }); + } + + const startTime = Date.now(); + try { + const response = await axios.get(`${horizonUrl}/health`, { timeout: 10000 }); + const responseTime = Date.now() - startTime; + + return this.getStatus(key, true, { + responseTime: `${responseTime}ms`, + }); + } catch (error) { + const responseTime = Date.now() - startTime; + this.logger.error(`Horizon health check failed: ${error}`); + + return this.getStatus(key, false, { + responseTime: `${responseTime}ms`, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } +} diff --git a/backend/src/modules/performance/performance.controller.ts b/backend/src/modules/performance/performance.controller.ts new file mode 100644 index 000000000..a1cc8ef6e --- /dev/null +++ b/backend/src/modules/performance/performance.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { QueryLoggerService } from './query-logger.service'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; + +@ApiTags('Performance') +@Controller('performance') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class PerformanceController { + constructor(private readonly queryLogger: QueryLoggerService) {} + + @Get('slow-queries') + @ApiOperation({ summary: 'Get slow queries exceeding 100ms' }) + getSlowQueries(@Query('limit') limit: number = 50) { + return { + queries: this.queryLogger.getSlowQueries(limit), + stats: this.queryLogger.getQueryStats(), + }; + } + + @Get('query-stats') + @ApiOperation({ summary: 'Get query performance statistics' }) + getQueryStats() { + return this.queryLogger.getQueryStats(); + } + + @Get('n-plus-one') + @ApiOperation({ summary: 'Detect N+1 query patterns' }) + detectNPlusOne() { + return this.queryLogger.detectNPlusOne(); + } + + @Get('index-suggestions') + @ApiOperation({ summary: 'Get automatic index suggestions' }) + getIndexSuggestions() { + return { + suggestions: this.queryLogger.suggestIndexes(), + }; + } +} diff --git a/backend/src/modules/performance/performance.module.ts b/backend/src/modules/performance/performance.module.ts new file mode 100644 index 000000000..1039b9646 --- /dev/null +++ b/backend/src/modules/performance/performance.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { QueryLoggerService } from './query-logger.service'; +import { PerformanceController } from './performance.controller'; + +@Module({ + providers: [QueryLoggerService], + controllers: [PerformanceController], + exports: [QueryLoggerService], +}) +export class PerformanceModule {} diff --git a/backend/src/modules/performance/query-logger.service.ts b/backend/src/modules/performance/query-logger.service.ts new file mode 100644 index 000000000..c9e23e669 --- /dev/null +++ b/backend/src/modules/performance/query-logger.service.ts @@ -0,0 +1,144 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource, QueryRunner } from 'typeorm'; + +interface QueryMetrics { + query: string; + duration: number; + timestamp: Date; + params?: any[]; + executionPlan?: string; +} + +@Injectable() +export class QueryLoggerService { + private readonly logger = new Logger(QueryLoggerService.name); + private readonly slowQueryThreshold = 100; // ms + private slowQueries: QueryMetrics[] = []; + private readonly maxStoredQueries = 1000; + + constructor(private dataSource: DataSource) { + this.setupQueryLogging(); + } + + private setupQueryLogging() { + const queryRunner = this.dataSource.createQueryRunner(); + + this.dataSource.subscribers?.forEach((subscriber) => { + if (subscriber.beforeQuery) { + const originalBeforeQuery = subscriber.beforeQuery.bind(subscriber); + subscriber.beforeQuery = (event) => { + event.startTime = Date.now(); + return originalBeforeQuery(event); + }; + } + + if (subscriber.afterQuery) { + const originalAfterQuery = subscriber.afterQuery.bind(subscriber); + subscriber.afterQuery = (event) => { + const duration = Date.now() - (event.startTime || Date.now()); + + if (duration > this.slowQueryThreshold) { + this.recordSlowQuery({ + query: event.query, + duration, + timestamp: new Date(), + params: event.parameters, + }); + } + + return originalAfterQuery(event); + }; + } + }); + } + + private recordSlowQuery(metrics: QueryMetrics) { + this.slowQueries.push(metrics); + + if (this.slowQueries.length > this.maxStoredQueries) { + this.slowQueries.shift(); + } + + this.logger.warn( + `Slow query detected (${metrics.duration}ms): ${metrics.query}`, + { duration: metrics.duration, params: metrics.params }, + ); + } + + getSlowQueries(limit: number = 50): QueryMetrics[] { + return this.slowQueries.slice(-limit); + } + + getQueryStats() { + const stats = { + totalSlowQueries: this.slowQueries.length, + averageDuration: + this.slowQueries.length > 0 + ? this.slowQueries.reduce((sum, q) => sum + q.duration, 0) / + this.slowQueries.length + : 0, + maxDuration: + this.slowQueries.length > 0 + ? Math.max(...this.slowQueries.map((q) => q.duration)) + : 0, + minDuration: + this.slowQueries.length > 0 + ? Math.min(...this.slowQueries.map((q) => q.duration)) + : 0, + }; + return stats; + } + + async analyzeExecutionPlan(query: string): Promise { + try { + const result = await this.dataSource.query(`EXPLAIN ${query}`); + return JSON.stringify(result, null, 2); + } catch (error) { + this.logger.error('Failed to analyze execution plan', error); + return ''; + } + } + + detectNPlusOne(): { detected: boolean; patterns: string[] } { + const patterns: string[] = []; + const queryMap = new Map(); + + this.slowQueries.forEach((q) => { + const normalized = q.query.replace(/\?/g, '?').toLowerCase(); + queryMap.set(normalized, (queryMap.get(normalized) || 0) + 1); + }); + + queryMap.forEach((count, query) => { + if (count > 5) { + patterns.push(`Query executed ${count} times: ${query.substring(0, 100)}`); + } + }); + + return { + detected: patterns.length > 0, + patterns, + }; + } + + suggestIndexes(): string[] { + const suggestions: string[] = []; + const frequentQueries = this.slowQueries + .sort((a, b) => b.duration - a.duration) + .slice(0, 10); + + frequentQueries.forEach((q) => { + if (q.query.includes('WHERE') && !q.query.includes('INDEX')) { + const match = q.query.match(/WHERE\s+(\w+)\s*=/); + if (match) { + suggestions.push(`Consider adding index on column: ${match[1]}`); + } + } + }); + + return suggestions; + } + + clearMetrics() { + this.slowQueries = []; + } +}