diff --git a/apps/aggregator/.env.example b/apps/aggregator/.env.example index a99f066..67e0b3c 100644 --- a/apps/aggregator/.env.example +++ b/apps/aggregator/.env.example @@ -5,5 +5,8 @@ PORT=3001 INGESTOR_WS_URL=ws://localhost:3000 INGESTOR_HTTP_URL=http://localhost:3000 +# Redis (optional; used for caching/session when set; health check verifies connectivity) +# REDIS_URL=redis://localhost:6379 + # Signer Service (for publishing aggregated data) SIGNER_URL=http://localhost:3002 diff --git a/apps/aggregator/README.md b/apps/aggregator/README.md index 8190595..bd6bc45 100644 --- a/apps/aggregator/README.md +++ b/apps/aggregator/README.md @@ -26,6 +26,19 @@ The Aggregator service is responsible for calculating a single consensus price p - Per-source weight configuration - Custom aggregation parameters +## API Endpoints (Health, Metrics & Debug) + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/health` | GET | Full health check. Returns **200** if all configured dependencies (Redis, Ingestor) are healthy, **503** otherwise. Used for overall service health. | +| `/ready` | GET | Readiness probe for Kubernetes. Same checks as `/health`; returns 200 when the service can accept traffic. | +| `/live` | GET | Liveness probe for Kubernetes. Returns 200 when the process is alive (no dependency checks). | +| `/status` | GET | Detailed system information: uptime, memory usage, dependency check results, and version. | +| `/metrics` | GET | Prometheus metrics in [exposition format](https://prometheus.io/docs/instrumenting/exposition_formats/). Scrape this endpoint for aggregation count, latency, errors, and default Node.js metrics. | +| `/debug/prices` | GET | Last aggregated and normalized prices held in memory. Useful for debugging without hitting external systems. | + +**Health checks**: When `REDIS_URL` or `INGESTOR_URL` are set, the health check verifies connectivity. If a configured dependency is unreachable, `/health` and `/ready` return 503. If not set, that dependency is skipped (not included in the check). + ## Architecture ``` @@ -43,6 +56,17 @@ aggregator/ │ │ └── trimmed-mean.aggregator.ts │ ├── services/ │ │ └── aggregation.service.ts # Main aggregation service +│ ├── health/ # Health checks (Terminus) +│ │ ├── health.controller.ts +│ │ └── indicators/ +│ │ ├── redis.health.ts +│ │ └── ingestor.health.ts +│ ├── metrics/ # Prometheus metrics +│ │ ├── metrics.controller.ts +│ │ └── metrics.service.ts +│ ├── debug/ # Debug endpoints +│ │ ├── debug.controller.ts +│ │ └── debug.service.ts │ ├── config/ │ │ └── source-weights.config.ts # Weight configuration │ └── app.module.ts diff --git a/apps/aggregator/package.json b/apps/aggregator/package.json index 91e53aa..cffe802 100644 --- a/apps/aggregator/package.json +++ b/apps/aggregator/package.json @@ -21,6 +21,10 @@ "@nestjs/core": "^10.0.0", "@nestjs/event-emitter": "^3.0.1", "@nestjs/platform-express": "^10.0.0", + "@nestjs/terminus": "^10.0.0", + "axios": "^1.6.0", + "ioredis": "^5.3.2", + "prom-client": "^15.1.0", "axios": "^1.13.4", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", diff --git a/apps/aggregator/src/app.module.ts b/apps/aggregator/src/app.module.ts index 2dd26e0..c54cbf1 100644 --- a/apps/aggregator/src/app.module.ts +++ b/apps/aggregator/src/app.module.ts @@ -1,15 +1,19 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { HttpModule } from '@nestjs/axios'; -import { EventEmitterModule } from '@nestjs/event-emitter'; -import { DataReceptionService } from './services/data-reception.service'; import { AggregationService } from './services/aggregation.service'; import { WeightedAverageAggregator } from './strategies/aggregators/weighted-average.aggregator'; import { MedianAggregator } from './strategies/aggregators/median.aggregator'; import { TrimmedMeanAggregator } from './strategies/aggregators/trimmed-mean.aggregator'; +import { HealthModule } from './health/health.module'; +import { MetricsModule } from './metrics/metrics.module'; +import { DebugModule } from './debug/debug.module'; @Module({ imports: [ + ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }), + HealthModule, + MetricsModule, + DebugModule, ConfigModule.forRoot({ isGlobal: true, }), diff --git a/apps/aggregator/src/debug/debug.controller.spec.ts b/apps/aggregator/src/debug/debug.controller.spec.ts new file mode 100644 index 0000000..b6abea5 --- /dev/null +++ b/apps/aggregator/src/debug/debug.controller.spec.ts @@ -0,0 +1,57 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DebugController } from './debug.controller'; +import { DebugService } from './debug.service'; + +describe('DebugController', () => { + let controller: DebugController; + let debugService: DebugService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [DebugController], + providers: [DebugService], + }).compile(); + + controller = module.get(DebugController); + debugService = module.get(DebugService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('GET /debug/prices', () => { + it('should return last prices from DebugService', () => { + const result = controller.getLastPrices(); + expect(result).toMatchObject({ + aggregated: expect.any(Object), + normalized: expect.any(Object), + updatedAt: expect.any(Number), + }); + expect(result.aggregated).toEqual({}); + expect(result.normalized).toEqual({}); + }); + + it('should return stored prices after they are set', () => { + debugService.setLastAggregated('AAPL', { + symbol: 'AAPL', + price: 150.25, + method: 'weighted-average', + confidence: 95, + metrics: { + standardDeviation: 0.05, + spread: 0.1, + sourceCount: 3, + variance: 0.0025, + }, + startTimestamp: 0, + endTimestamp: 0, + sources: ['S1', 'S2', 'S3'], + computedAt: Date.now(), + }); + const result = controller.getLastPrices(); + expect(Object.keys(result.aggregated)).toContain('AAPL'); + expect(result.aggregated['AAPL'].price).toBe(150.25); + }); + }); +}); diff --git a/apps/aggregator/src/debug/debug.controller.ts b/apps/aggregator/src/debug/debug.controller.ts new file mode 100644 index 0000000..a3dde99 --- /dev/null +++ b/apps/aggregator/src/debug/debug.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common'; +import { DebugService } from './debug.service'; + +/** + * Debug controller for development and troubleshooting. + * + * - GET /debug/prices - Returns last aggregated and normalized prices held in memory. + */ +@Controller('debug') +export class DebugController { + constructor(private readonly debugService: DebugService) {} + + /** + * Returns the last aggregated prices and last normalized prices per symbol. + * Useful for verifying recent aggregation results without hitting external systems. + */ + @Get('prices') + @HttpCode(HttpStatus.OK) + getLastPrices() { + return this.debugService.getLastPrices(); + } +} diff --git a/apps/aggregator/src/debug/debug.module.ts b/apps/aggregator/src/debug/debug.module.ts new file mode 100644 index 0000000..d638c6f --- /dev/null +++ b/apps/aggregator/src/debug/debug.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { DebugController } from './debug.controller'; +import { DebugService } from './debug.service'; + +@Module({ + controllers: [DebugController], + providers: [DebugService], + exports: [DebugService], +}) +export class DebugModule {} diff --git a/apps/aggregator/src/debug/debug.service.ts b/apps/aggregator/src/debug/debug.service.ts new file mode 100644 index 0000000..d7cd52b --- /dev/null +++ b/apps/aggregator/src/debug/debug.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { AggregatedPrice } from '../interfaces/aggregated-price.interface'; +import { NormalizedPrice } from '../interfaces/normalized-price.interface'; + +export interface LastPricesDto { + aggregated: Record; + normalized: Record; + updatedAt: number; +} + +/** + * In-memory store for last aggregated and normalized prices, used by the debug endpoint. + */ +@Injectable() +export class DebugService { + private lastAggregated: Map = new Map(); + private lastNormalized: Map = new Map(); + private updatedAt = 0; + + /** + * Record an aggregated result for a symbol (called by aggregation flow). + */ + setLastAggregated(symbol: string, result: AggregatedPrice): void { + this.lastAggregated.set(symbol, result); + this.updatedAt = Date.now(); + } + + /** + * Record normalized prices for a symbol (called before aggregation). + */ + setLastNormalized(symbol: string, prices: NormalizedPrice[]): void { + this.lastNormalized.set(symbol, [...prices]); + this.updatedAt = Date.now(); + } + + /** + * Get last aggregated and normalized prices for the debug endpoint. + */ + getLastPrices(): LastPricesDto { + const aggregated: Record = {}; + for (const [symbol, value] of this.lastAggregated) { + aggregated[symbol] = value; + } + const normalized: Record = {}; + for (const [symbol, value] of this.lastNormalized) { + normalized[symbol] = value; + } + return { + aggregated, + normalized, + updatedAt: this.updatedAt, + }; + } +} diff --git a/apps/aggregator/src/health/health.controller.spec.ts b/apps/aggregator/src/health/health.controller.spec.ts new file mode 100644 index 0000000..442ad53 --- /dev/null +++ b/apps/aggregator/src/health/health.controller.spec.ts @@ -0,0 +1,125 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ServiceUnavailableException } from '@nestjs/common'; +import { HealthCheckService, HealthCheckResult } from '@nestjs/terminus'; +import { HealthController } from './health.controller'; +import { RedisHealthIndicator } from './indicators/redis.health'; +import { IngestorHealthIndicator } from './indicators/ingestor.health'; + +describe('HealthController', () => { + let controller: HealthController; + let healthCheckService: HealthCheckService; + + const mockRedisHealthy = { + redis: { status: 'up' as const, message: 'Redis is reachable' }, + }; + const mockIngestorHealthy = { + ingestor: { status: 'up' as const, message: 'Ingestor is reachable' }, + }; + const healthyResult: HealthCheckResult = { + status: 'ok', + info: { ...mockRedisHealthy, ...mockIngestorHealthy }, + error: {}, + details: { ...mockRedisHealthy, ...mockIngestorHealthy }, + }; + const unhealthyResult: HealthCheckResult = { + status: 'error', + info: {}, + error: mockRedisHealthy, + details: { ...mockRedisHealthy }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [HealthController], + providers: [ + { + provide: HealthCheckService, + useValue: { + check: jest.fn(), + }, + }, + { + provide: RedisHealthIndicator, + useValue: { isHealthy: jest.fn().mockResolvedValue(mockRedisHealthy) }, + }, + { + provide: IngestorHealthIndicator, + useValue: { + isHealthy: jest.fn().mockResolvedValue(mockIngestorHealthy), + }, + }, + ], + }).compile(); + + controller = module.get(HealthController); + healthCheckService = module.get(HealthCheckService); + jest.mocked(healthCheckService.check).mockResolvedValue(healthyResult); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('GET /health', () => { + it('should return 200 and health result when all checks pass', async () => { + const result = await controller.check(); + expect(result).toEqual(healthyResult); + expect(result.status).toBe('ok'); + expect(healthCheckService.check).toHaveBeenCalledWith([ + expect.any(Function), + expect.any(Function), + ]); + }); + + it('should throw ServiceUnavailableException when a check fails', async () => { + jest.mocked(healthCheckService.check).mockResolvedValue(unhealthyResult); + await expect(controller.check()).rejects.toThrow(ServiceUnavailableException); + }); + }); + + describe('GET /ready', () => { + it('should return 200 and health result when ready', async () => { + const result = await controller.ready(); + expect(result).toEqual(healthyResult); + expect(result.status).toBe('ok'); + }); + + it('should throw ServiceUnavailableException when not ready', async () => { + jest.mocked(healthCheckService.check).mockResolvedValue(unhealthyResult); + await expect(controller.ready()).rejects.toThrow(ServiceUnavailableException); + }); + }); + + describe('GET /live', () => { + it('should return 200 with status ok', () => { + const result = controller.live(); + expect(result).toEqual({ status: 'ok' }); + }); + + it('should not call any health indicators', () => { + controller.live(); + expect(healthCheckService.check).not.toHaveBeenCalled(); + }); + }); + + describe('GET /status', () => { + it('should return detailed status with uptime, memory, and checks', async () => { + const result = await controller.status(); + expect(result).toMatchObject({ + status: 'ok', + checks: healthyResult, + }); + expect(typeof result.uptimeSeconds).toBe('number'); + expect(result.uptimeSeconds).toBeGreaterThanOrEqual(0); + expect(typeof result.timestamp).toBe('number'); + expect(result.version).toBeDefined(); + expect(result.memory).toMatchObject({ + rss: expect.any(Number), + heapTotal: expect.any(Number), + heapUsed: expect.any(Number), + external: expect.any(Number), + arrayBuffers: expect.any(Number), + }); + }); + }); +}); diff --git a/apps/aggregator/src/health/health.controller.ts b/apps/aggregator/src/health/health.controller.ts new file mode 100644 index 0000000..afe2e98 --- /dev/null +++ b/apps/aggregator/src/health/health.controller.ts @@ -0,0 +1,107 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + ServiceUnavailableException, +} from '@nestjs/common'; +import { + HealthCheckService, + HealthCheck, + HealthCheckResult, +} from '@nestjs/terminus'; +import { RedisHealthIndicator } from './indicators/redis.health'; +import { IngestorHealthIndicator } from './indicators/ingestor.health'; + +/** Start time for uptime calculation */ +const startTime = Date.now(); + +/** + * Health controller providing endpoints for Kubernetes probes and observability. + * + * - GET /health - Full health check (Redis, Ingestor). Returns 200 if OK, 503 if any dependency is down. + * - GET /ready - Readiness probe. Returns 200 when the service can accept traffic. + * - GET /live - Liveness probe. Returns 200 when the process is alive. + * - GET /status - Detailed system information for debugging. + */ +@Controller() +export class HealthController { + constructor( + private readonly health: HealthCheckService, + private readonly redis: RedisHealthIndicator, + private readonly ingestor: IngestorHealthIndicator, + ) {} + + /** + * Full health check. Verifies connectivity to Redis (if configured) and Ingestor (if configured). + * Returns 200 if all configured dependencies are healthy, 503 otherwise. + */ + @Get('health') + @HealthCheck() + @HttpCode(HttpStatus.OK) + async check(): Promise { + const result = await this.health.check([ + () => this.redis.isHealthy('redis'), + () => this.ingestor.isHealthy('ingestor'), + ]); + if (result.status === 'ok') { + return result; + } + throw new ServiceUnavailableException(result); + } + + /** + * Readiness probe. Used by Kubernetes to determine if the pod can receive traffic. + * Runs the same checks as /health. + */ + @Get('ready') + @HealthCheck() + @HttpCode(HttpStatus.OK) + async ready(): Promise { + const result = await this.health.check([ + () => this.redis.isHealthy('redis'), + () => this.ingestor.isHealthy('ingestor'), + ]); + if (result.status === 'ok') { + return result; + } + throw new ServiceUnavailableException(result); + } + + /** + * Liveness probe. Used by Kubernetes to determine if the pod should be restarted. + * Returns 200 if the process is running (no dependency checks). + */ + @Get('live') + @HttpCode(HttpStatus.OK) + live(): { status: string } { + return { status: 'ok' }; + } + + /** + * Detailed status endpoint with system information for debugging. + */ + @Get('status') + @HttpCode(HttpStatus.OK) + async status(): Promise<{ + status: string; + uptimeSeconds: number; + timestamp: number; + version: string; + memory: NodeJS.MemoryUsage; + checks: HealthCheckResult; + }> { + const checks = await this.health.check([ + () => this.redis.isHealthy('redis'), + () => this.ingestor.isHealthy('ingestor'), + ]); + return { + status: checks.status, + uptimeSeconds: (Date.now() - startTime) / 1000, + timestamp: Date.now(), + version: process.env.npm_package_version ?? '0.0.0', + memory: process.memoryUsage(), + checks, + }; + } +} diff --git a/apps/aggregator/src/health/health.module.ts b/apps/aggregator/src/health/health.module.ts new file mode 100644 index 0000000..8869f47 --- /dev/null +++ b/apps/aggregator/src/health/health.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { TerminusModule } from '@nestjs/terminus'; +import { HttpModule } from '@nestjs/axios'; +import { HealthController } from './health.controller'; +import { RedisHealthIndicator } from './indicators/redis.health'; +import { IngestorHealthIndicator } from './indicators/ingestor.health'; + +@Module({ + imports: [ + ConfigModule, + TerminusModule, + HttpModule.register({ + timeout: 5000, + maxRedirects: 0, + }), + ], + controllers: [HealthController], + providers: [RedisHealthIndicator, IngestorHealthIndicator], + exports: [RedisHealthIndicator, IngestorHealthIndicator], +}) +export class HealthModule {} diff --git a/apps/aggregator/src/health/indicators/ingestor.health.spec.ts b/apps/aggregator/src/health/indicators/ingestor.health.spec.ts new file mode 100644 index 0000000..5d01e6f --- /dev/null +++ b/apps/aggregator/src/health/indicators/ingestor.health.spec.ts @@ -0,0 +1,87 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { HttpService } from '@nestjs/axios'; +import { of, throwError } from 'rxjs'; +import { HealthCheckError } from '@nestjs/terminus'; +import { IngestorHealthIndicator } from './ingestor.health'; + +describe('IngestorHealthIndicator', () => { + let indicator: IngestorHealthIndicator; + let configService: ConfigService; + let httpService: HttpService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + IngestorHealthIndicator, + { + provide: ConfigService, + useValue: { get: jest.fn() }, + }, + { + provide: HttpService, + useValue: { get: jest.fn() }, + }, + ], + }).compile(); + + indicator = module.get(IngestorHealthIndicator); + configService = module.get(ConfigService); + httpService = module.get(HttpService); + }); + + it('should be defined', () => { + expect(indicator).toBeDefined(); + }); + + it('should return up with "not configured" when INGESTOR_URL is not set', async () => { + jest.mocked(configService.get).mockReturnValue(undefined); + const result = await indicator.isHealthy('ingestor'); + expect(result).toEqual({ + ingestor: { status: 'up', message: 'Ingestor not configured (skipped)' }, + }); + }); + + it('should return up when ingestor responds with 200', async () => { + jest.mocked(configService.get).mockReturnValue('http://localhost:3000'); + jest.mocked(httpService.get).mockReturnValue( + of({ + status: 200, + data: [], + statusText: 'OK', + headers: {}, + config: {} as never, + }), + ); + const result = await indicator.isHealthy('ingestor'); + expect(result).toEqual({ + ingestor: { status: 'up', message: 'Ingestor is reachable' }, + }); + expect(httpService.get).toHaveBeenCalledWith( + 'http://localhost:3000/prices/raw', + expect.objectContaining({ timeout: 5000, validateStatus: expect.any(Function) }), + ); + }); + + it('should throw HealthCheckError when ingestor returns 5xx', async () => { + jest.mocked(configService.get).mockReturnValue('http://localhost:3000'); + jest.mocked(httpService.get).mockReturnValue( + of({ + status: 503, + data: null, + statusText: 'Service Unavailable', + headers: {}, + config: {} as never, + }), + ); + await expect(indicator.isHealthy('ingestor')).rejects.toThrow(HealthCheckError); + }); + + it('should throw HealthCheckError when HTTP request fails', async () => { + jest.mocked(configService.get).mockReturnValue('http://localhost:3000'); + jest.mocked(httpService.get).mockReturnValue( + throwError(() => new Error('ECONNREFUSED')), + ); + await expect(indicator.isHealthy('ingestor')).rejects.toThrow(HealthCheckError); + }); +}); diff --git a/apps/aggregator/src/health/indicators/ingestor.health.ts b/apps/aggregator/src/health/indicators/ingestor.health.ts new file mode 100644 index 0000000..eb8ec5b --- /dev/null +++ b/apps/aggregator/src/health/indicators/ingestor.health.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + HealthIndicator, + HealthIndicatorResult, + HealthCheckError, +} from '@nestjs/terminus'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom, timeout } from 'rxjs'; + + +@Injectable() +export class IngestorHealthIndicator extends HealthIndicator { + private static readonly TIMEOUT_MS = 5000; + + constructor( + private readonly configService: ConfigService, + private readonly httpService: HttpService, + ) { + super(); + } + + async isHealthy(key: string): Promise { + const baseUrl = this.configService.get('INGESTOR_URL'); + if (!baseUrl) { + return { [key]: { status: 'up', message: 'Ingestor not configured (skipped)' } }; + } + + const url = baseUrl.replace(/\/$/, '') + '/prices/raw'; + try { + const response = await firstValueFrom( + this.httpService + .get(url, { + timeout: IngestorHealthIndicator.TIMEOUT_MS, + validateStatus: () => true, + }) + .pipe(timeout(IngestorHealthIndicator.TIMEOUT_MS)), + ); + if (response.status >= 200 && response.status < 400) { + return { [key]: { status: 'up', message: 'Ingestor is reachable' } }; + } + throw new HealthCheckError('Ingestor check failed', { + [key]: { status: 'down', message: `HTTP ${response.status}` }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + throw new HealthCheckError('Ingestor check failed', { + [key]: { status: 'down', message }, + }); + } + } +} diff --git a/apps/aggregator/src/health/indicators/redis.health.spec.ts b/apps/aggregator/src/health/indicators/redis.health.spec.ts new file mode 100644 index 0000000..0a621c8 --- /dev/null +++ b/apps/aggregator/src/health/indicators/redis.health.spec.ts @@ -0,0 +1,56 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { HealthCheckError } from '@nestjs/terminus'; +import { RedisHealthIndicator } from './redis.health'; + +describe('RedisHealthIndicator', () => { + let indicator: RedisHealthIndicator; + let configService: ConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RedisHealthIndicator, + { + provide: ConfigService, + useValue: { get: jest.fn() }, + }, + ], + }).compile(); + + indicator = module.get(RedisHealthIndicator); + configService = module.get(ConfigService); + }); + + it('should be defined', () => { + expect(indicator).toBeDefined(); + }); + + it('should return up with "not configured" when REDIS_URL is not set', async () => { + jest.mocked(configService.get).mockReturnValue(undefined); + const result = await indicator.isHealthy('redis'); + expect(result).toEqual({ + redis: { status: 'up', message: 'Redis not configured (skipped)' }, + }); + }); + + it('should return up with "not configured" when REDIS_URL is empty string', async () => { + jest.mocked(configService.get).mockReturnValue(''); + const result = await indicator.isHealthy('redis'); + expect(result).toEqual({ + redis: { status: 'up', message: 'Redis not configured (skipped)' }, + }); + }); + + it('should check Redis when REDIS_URL is set', async () => { + jest.mocked(configService.get).mockReturnValue('redis://localhost:6379'); + try { + const result = await indicator.isHealthy('redis'); + expect(result.redis).toBeDefined(); + expect(result.redis.status).toBe('up'); + } catch (err) { + expect(err).toBeInstanceOf(HealthCheckError); + expect((err as HealthCheckError).causes).toBeDefined(); + } + }); +}); diff --git a/apps/aggregator/src/health/indicators/redis.health.ts b/apps/aggregator/src/health/indicators/redis.health.ts new file mode 100644 index 0000000..a2114a1 --- /dev/null +++ b/apps/aggregator/src/health/indicators/redis.health.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + HealthIndicator, + HealthIndicatorResult, + HealthCheckError, +} from '@nestjs/terminus'; +import Redis from 'ioredis'; +@Injectable() +export class RedisHealthIndicator extends HealthIndicator { + constructor(private readonly configService: ConfigService) { + super(); + } + + async isHealthy(key: string): Promise { + const redisUrl = this.configService.get('REDIS_URL'); + if (!redisUrl) { + return { [key]: { status: 'up', message: 'Redis not configured (skipped)' } }; + } + + let redis: Redis | null = null; + try { + redis = new Redis(redisUrl, { + maxRetriesPerRequest: 1, + connectTimeout: 5000, + lazyConnect: true, + }); + await redis.connect(); + const pong = await redis.ping(); + await redis.quit(); + if (pong === 'PONG') { + return { [key]: { status: 'up', message: 'Redis is reachable' } }; + } + throw new HealthCheckError('Redis check failed', { + [key]: { status: 'down', message: 'PING did not return PONG' }, + }); + } catch (err) { + if (redis) { + try { + redis.disconnect(); + } catch { + // ignore + } + } + const message = err instanceof Error ? err.message : 'Unknown error'; + throw new HealthCheckError('Redis check failed', { + [key]: { status: 'down', message }, + }); + } + } +} diff --git a/apps/aggregator/src/metrics/metrics.controller.spec.ts b/apps/aggregator/src/metrics/metrics.controller.spec.ts new file mode 100644 index 0000000..097f8f2 --- /dev/null +++ b/apps/aggregator/src/metrics/metrics.controller.spec.ts @@ -0,0 +1,38 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MetricsController } from './metrics.controller'; +import { MetricsService } from './metrics.service'; + +describe('MetricsController', () => { + let controller: MetricsController; + let metricsService: MetricsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [MetricsController], + providers: [ + { + provide: MetricsService, + useValue: { + getMetrics: jest.fn().mockResolvedValue('# HELP dummy\n# TYPE dummy counter\ndummy 0'), + getContentType: jest.fn().mockReturnValue('text/plain; version=0.0.4; charset=utf-8'), + }, + }, + ], + }).compile(); + + controller = module.get(MetricsController); + metricsService = module.get(MetricsService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('GET /metrics', () => { + it('should return Prometheus metrics from MetricsService', async () => { + const result = await controller.getMetrics(); + expect(result).toContain('# HELP dummy'); + expect(metricsService.getMetrics).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/aggregator/src/metrics/metrics.controller.ts b/apps/aggregator/src/metrics/metrics.controller.ts new file mode 100644 index 0000000..ca545f3 --- /dev/null +++ b/apps/aggregator/src/metrics/metrics.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Header, HttpCode, HttpStatus } from '@nestjs/common'; +import { MetricsService } from './metrics.service'; + +/** + * Metrics controller exposing Prometheus metrics. + * + * - GET /metrics - Returns metrics in Prometheus exposition format for scraping. + */ +@Controller() +export class MetricsController { + constructor(private readonly metricsService: MetricsService) {} + + /** + * Prometheus metrics endpoint. Returns metrics in text format for Prometheus server to scrape. + */ + @Get('metrics') + @HttpCode(HttpStatus.OK) + @Header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8') + async getMetrics(): Promise { + return this.metricsService.getMetrics(); + } +} diff --git a/apps/aggregator/src/metrics/metrics.module.ts b/apps/aggregator/src/metrics/metrics.module.ts new file mode 100644 index 0000000..0403c34 --- /dev/null +++ b/apps/aggregator/src/metrics/metrics.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MetricsController } from './metrics.controller'; +import { MetricsService } from './metrics.service'; + +@Module({ + controllers: [MetricsController], + providers: [MetricsService], + exports: [MetricsService], +}) +export class MetricsModule {} diff --git a/apps/aggregator/src/metrics/metrics.service.spec.ts b/apps/aggregator/src/metrics/metrics.service.spec.ts new file mode 100644 index 0000000..178731a --- /dev/null +++ b/apps/aggregator/src/metrics/metrics.service.spec.ts @@ -0,0 +1,77 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MetricsService } from './metrics.service'; + +describe('MetricsService', () => { + let service: MetricsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [MetricsService], + }).compile(); + + service = module.get(MetricsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('recordAggregation', () => { + it('should record aggregation count and latency', async () => { + service.recordAggregation('weighted-average', 'AAPL', 0.05); + service.recordAggregation('weighted-average', 'AAPL', 0.1); + service.recordAggregation('median', 'GOOGL', 0.02); + + const metrics = await service.getMetrics(); + expect(metrics).toContain('aggregator_aggregations_total'); + expect(metrics).toContain('aggregator_aggregation_duration_seconds'); + expect(metrics).toContain('aggregator_aggregations_by_symbol_total'); + expect(metrics).toContain('method="weighted-average"'); + expect(metrics).toContain('method="median"'); + expect(metrics).toContain('symbol="AAPL"'); + expect(metrics).toContain('symbol="GOOGL"'); + }); + }); + + describe('recordError', () => { + it('should record aggregation errors', async () => { + service.recordError('weighted-average'); + service.recordError('weighted-average'); + service.recordError('median'); + + const metrics = await service.getMetrics(); + expect(metrics).toContain('aggregator_errors_total'); + expect(metrics).toContain('method="weighted-average"'); + expect(metrics).toContain('method="median"'); + }); + }); + + describe('getMetrics', () => { + it('should return Prometheus text format', async () => { + const metrics = await service.getMetrics(); + expect(typeof metrics).toBe('string'); + expect(metrics.length).toBeGreaterThan(0); + // Default Node.js metrics are also collected + expect( + metrics.includes('aggregator_') || metrics.includes('# HELP'), + ).toBe(true); + }); + }); + + describe('getContentType', () => { + it('should return Prometheus exposition content type', () => { + const contentType = service.getContentType(); + expect(contentType).toContain('text/plain'); + expect(contentType).toContain('charset=utf-8'); + }); + }); + + describe('getRegister', () => { + it('should return the Prometheus registry', () => { + const register = service.getRegister(); + expect(register).toBeDefined(); + expect(register.metrics).toBeDefined(); + expect(typeof register.metrics).toBe('function'); + }); + }); +}); diff --git a/apps/aggregator/src/metrics/metrics.service.ts b/apps/aggregator/src/metrics/metrics.service.ts new file mode 100644 index 0000000..4d0e8c3 --- /dev/null +++ b/apps/aggregator/src/metrics/metrics.service.ts @@ -0,0 +1,95 @@ +import { Injectable } from '@nestjs/common'; +import { + Registry, + Counter, + Histogram, + collectDefaultMetrics, +} from 'prom-client'; + +/** + * Service that registers and updates Prometheus metrics for the aggregator. + * Exposes aggregation count, latency, and errors. + */ +@Injectable() +export class MetricsService { + private readonly register: Registry; + + /** Total number of aggregation operations (single and batch) */ + readonly aggregationCount: Counter; + + /** Latency of aggregation in seconds */ + readonly aggregationLatency: Histogram; + + /** Total number of aggregation errors */ + readonly aggregationErrors: Counter; + + /** Throughput: aggregations per symbol (optional dimension) */ + readonly aggregationsBySymbol: Counter; + + constructor() { + this.register = new Registry(); + this.aggregationCount = new Counter({ + name: 'aggregator_aggregations_total', + help: 'Total number of aggregation operations', + labelNames: ['method'], + registers: [this.register], + }); + this.aggregationLatency = new Histogram({ + name: 'aggregator_aggregation_duration_seconds', + help: 'Aggregation operation duration in seconds', + labelNames: ['method'], + buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1], + registers: [this.register], + }); + this.aggregationErrors = new Counter({ + name: 'aggregator_errors_total', + help: 'Total number of aggregation errors', + labelNames: ['method'], + registers: [this.register], + }); + this.aggregationsBySymbol = new Counter({ + name: 'aggregator_aggregations_by_symbol_total', + help: 'Total aggregations per symbol', + labelNames: ['symbol', 'method'], + registers: [this.register], + }); + collectDefaultMetrics({ register: this.register, prefix: 'aggregator_' }); + } + + /** + * Record a successful aggregation with duration. + */ + recordAggregation(method: string, symbol: string, durationSeconds: number): void { + this.aggregationCount.inc({ method }, 1); + this.aggregationLatency.observe({ method }, durationSeconds); + this.aggregationsBySymbol.inc({ symbol, method }, 1); + } + + /** + * Record an aggregation error. + */ + recordError(method: string): void { + this.aggregationErrors.inc({ method }, 1); + } + + /** + * Get the Prometheus registry for scraping. + */ + getRegister(): Registry { + return this.register; + } + + /** + * Get metrics in Prometheus text format. + */ + async getMetrics(): Promise { + return this.register.metrics(); + } + + /** + * Get content type for Prometheus exposition format. + */ + getContentType(): string { + return this.register.contentType; + } +} diff --git a/apps/aggregator/src/services/aggregation.service.ts b/apps/aggregator/src/services/aggregation.service.ts index 1a8d42c..289ec81 100644 --- a/apps/aggregator/src/services/aggregation.service.ts +++ b/apps/aggregator/src/services/aggregation.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Optional, Inject } from '@nestjs/common'; import { NormalizedPrice } from '../interfaces/normalized-price.interface'; import { AggregatedPrice } from '../interfaces/aggregated-price.interface'; import { IAggregator } from '../interfaces/aggregator.interface'; @@ -6,6 +6,8 @@ import { WeightedAverageAggregator } from '../strategies/aggregators/weighted-av import { MedianAggregator } from '../strategies/aggregators/median.aggregator'; import { TrimmedMeanAggregator } from '../strategies/aggregators/trimmed-mean.aggregator'; import { getSourceWeight } from '../config/source-weights.config'; +import { MetricsService } from '../metrics/metrics.service'; +import { DebugService } from '../debug/debug.service'; /** * Configuration options for the aggregation service @@ -38,7 +40,10 @@ export class AggregationService { private readonly logger = new Logger(AggregationService.name); private readonly aggregators: Map; - constructor() { + constructor( + @Optional() private readonly metricsService?: MetricsService, + @Optional() private readonly debugService?: DebugService, + ) { // Initialize all aggregation strategies this.aggregators = new Map(); this.aggregators.set('weighted-average', new WeightedAverageAggregator()); @@ -60,82 +65,96 @@ export class AggregationService { prices: NormalizedPrice[], options: AggregationOptions = {}, ): AggregatedPrice { + const startTime = Date.now(); + const method: 'weighted-average' | 'median' | 'trimmed-mean' = + options.method ?? 'weighted-average'; const { minSources = 3, timeWindowMs = 30000, - method = 'weighted-average', customWeights, trimPercentage = 0.2, } = options; - // Validate inputs - this.validateInputs(symbol, prices, minSources); + try { + // Validate inputs + this.validateInputs(symbol, prices, minSources); - // Filter prices within time window - const now = Date.now(); - const windowStart = now - timeWindowMs; - const recentPrices = prices.filter(p => p.timestamp >= windowStart); + // Filter prices within time window + const now = Date.now(); + const windowStart = now - timeWindowMs; + const recentPrices = prices.filter(p => p.timestamp >= windowStart); - // Check minimum sources after filtering - if (recentPrices.length < minSources) { - throw new Error( - `Insufficient recent sources for ${symbol}. Required: ${minSources}, Found: ${recentPrices.length}`, - ); - } - - // Get aggregator strategy - let aggregator = this.aggregators.get(method); - - // Special handling for trimmed-mean with custom percentage - if (method === 'trimmed-mean' && trimPercentage !== 0.2) { - aggregator = new TrimmedMeanAggregator(trimPercentage); - } - - if (!aggregator) { - throw new Error(`Unknown aggregation method: ${method}`); - } - - // Prepare weights - const weights = this.prepareWeights(recentPrices, customWeights); - - // Calculate consensus price - const consensusPrice = aggregator.aggregate(recentPrices, weights); - - // Calculate confidence metrics - const metrics = this.calculateMetrics(recentPrices); + // Check minimum sources after filtering + if (recentPrices.length < minSources) { + throw new Error( + `Insufficient recent sources for ${symbol}. Required: ${minSources}, Found: ${recentPrices.length}`, + ); + } - // Calculate confidence score (0-100) - const confidence = this.calculateConfidence(metrics, recentPrices.length); + // Get aggregator strategy + let aggregator = this.aggregators.get(method); - // Get time range - const timestamps = recentPrices.map(p => p.timestamp); - const startTimestamp = Math.min(...timestamps); - const endTimestamp = Math.max(...timestamps); + // Special handling for trimmed-mean with custom percentage + if (method === 'trimmed-mean' && trimPercentage !== 0.2) { + aggregator = new TrimmedMeanAggregator(trimPercentage); + } - // Get unique sources - const sources = [...new Set(recentPrices.map(p => p.source))]; - - const result: AggregatedPrice = { - symbol, - price: consensusPrice, - method, - confidence, - metrics: { - ...metrics, - sourceCount: recentPrices.length, - }, - startTimestamp, - endTimestamp, - sources, - computedAt: Date.now(), - }; + if (!aggregator) { + throw new Error(`Unknown aggregation method: ${method}`); + } - this.logger.log( - `Aggregated ${symbol}: $${consensusPrice.toFixed(2)} ` + - `(method: ${method}, confidence: ${confidence.toFixed(1)}%, sources: ${sources.length})`, - ); + // Prepare weights + const weights = this.prepareWeights(recentPrices, customWeights); + + // Calculate consensus price + const consensusPrice = aggregator.aggregate(recentPrices, weights); + + // Calculate confidence metrics + const metrics = this.calculateMetrics(recentPrices); + + // Calculate confidence score (0-100) + const confidence = this.calculateConfidence(metrics, recentPrices.length); + + // Get time range + const timestamps = recentPrices.map(p => p.timestamp); + const startTimestamp = Math.min(...timestamps); + const endTimestamp = Math.max(...timestamps); + + // Get unique sources + const sources = [...new Set(recentPrices.map(p => p.source))]; + + const result: AggregatedPrice = { + symbol, + price: consensusPrice, + method, + confidence, + metrics: { + ...metrics, + sourceCount: recentPrices.length, + }, + startTimestamp, + endTimestamp, + sources, + computedAt: Date.now(), + }; + + this.logger.log( + `Aggregated ${symbol}: $${consensusPrice.toFixed(2)} ` + + `(method: ${method}, confidence: ${confidence.toFixed(1)}%, sources: ${sources.length})`, + ); - return result; + this.debugService?.setLastNormalized(symbol, recentPrices); + this.debugService?.setLastAggregated(symbol, result); + this.metricsService?.recordAggregation( + method, + symbol, + (Date.now() - startTime) / 1000, + ); + return result; + } catch (err) { + this.metricsService?.recordError(method); + throw err; + } } /** diff --git a/package-lock.json b/package-lock.json index 1085bb0..edb3ef7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,10 @@ "@nestjs/core": "^10.0.0", "@nestjs/event-emitter": "^3.0.1", "@nestjs/platform-express": "^10.0.0", + "@nestjs/terminus": "^10.0.0", + "axios": "^1.6.0", + "ioredis": "^5.3.2", + "prom-client": "^15.1.0", "axios": "^1.13.4", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", @@ -4309,6 +4313,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ioredis/commands": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -5311,6 +5321,76 @@ "dev": true, "license": "MIT" }, + "node_modules/@nestjs/terminus": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-10.3.0.tgz", + "integrity": "sha512-vOJGCwt1OgrFuuxWQwPoaHqy9m9CfIk2qMUX2mosZLK5dFVJSEjHXrklkh3/Fw9PiUnfzvYFfiAdJRzUaxx+5Q==", + "license": "MIT", + "dependencies": { + "boxen": "5.1.2", + "check-disk-space": "3.4.0" + }, + "peerDependencies": { + "@grpc/grpc-js": "*", + "@grpc/proto-loader": "*", + "@mikro-orm/core": "*", + "@mikro-orm/nestjs": "*", + "@nestjs/axios": "^1.0.0 || ^2.0.0 || ^3.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "@nestjs/microservices": "^9.0.0 || ^10.0.0", + "@nestjs/mongoose": "^9.0.0 || ^10.0.0", + "@nestjs/sequelize": "^9.0.0 || ^10.0.0", + "@nestjs/typeorm": "^9.0.0 || ^10.0.0", + "@prisma/client": "*", + "mongoose": "*", + "reflect-metadata": "0.1.x || 0.2.x", + "rxjs": "7.x", + "sequelize": "*", + "typeorm": "*" + }, + "peerDependenciesMeta": { + "@grpc/grpc-js": { + "optional": true + }, + "@grpc/proto-loader": { + "optional": true + }, + "@mikro-orm/core": { + "optional": true + }, + "@mikro-orm/nestjs": { + "optional": true + }, + "@nestjs/axios": { + "optional": true + }, + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/mongoose": { + "optional": true + }, + "@nestjs/sequelize": { + "optional": true + }, + "@nestjs/typeorm": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "mongoose": { + "optional": true + }, + "sequelize": { + "optional": true + }, + "typeorm": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.22.tgz", @@ -5421,6 +5501,15 @@ "npm": ">=5.0.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@oracle-stocks/aggregator": { "resolved": "apps/aggregator", "link": true @@ -6761,6 +6850,15 @@ "ajv": "^6.9.1" } }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -6804,7 +6902,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7289,6 +7386,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -7340,6 +7443,57 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/boxen": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", + "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.0", + "camelcase": "^6.2.0", + "chalk": "^4.1.0", + "cli-boxes": "^2.2.1", + "string-width": "^4.2.2", + "type-fest": "^0.20.2", + "widest-line": "^3.1.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -7593,6 +7747,15 @@ "dev": true, "license": "MIT" }, + "node_modules/check-disk-space": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/check-disk-space/-/check-disk-space-3.4.0.tgz", + "integrity": "sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -7664,6 +7827,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" "node_modules/class-transformer": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", @@ -7782,6 +7955,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -8396,6 +8578,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -10623,6 +10814,30 @@ "node": ">= 0.4" } }, + "node_modules/ioredis": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz", + "integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -10857,7 +11072,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12604,6 +12818,18 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -13749,6 +13975,19 @@ "dev": true, "license": "MIT" }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -13959,6 +14198,27 @@ "node": ">=8" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.1.14", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", @@ -14675,6 +14935,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -14733,7 +14999,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -14771,7 +15036,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/string.prototype.includes": { @@ -14891,7 +15155,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -15048,6 +15311,15 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/terser": { "version": "5.46.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", @@ -15718,7 +15990,6 @@ "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -16465,6 +16736,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "license": "MIT", + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",