From 45e1816b9ace80498aecc52f6cd560b7c715ec91 Mon Sep 17 00:00:00 2001 From: JamesVictor-O Date: Fri, 30 Jan 2026 12:53:54 +0100 Subject: [PATCH 1/6] feat(aggregator): add health, metrics and debug dependencies - @nestjs/terminus, @nestjs/axios, @nestjs/config for health checks - prom-client for Prometheus metrics - ioredis for Redis health indicator --- apps/aggregator/package.json | 6 + package-lock.json | 418 ++++++++++++++++++++++++++++++----- 2 files changed, 372 insertions(+), 52 deletions(-) diff --git a/apps/aggregator/package.json b/apps/aggregator/package.json index b723d58..531bc30 100644 --- a/apps/aggregator/package.json +++ b/apps/aggregator/package.json @@ -15,9 +15,15 @@ "test:cov": "jest --coverage" }, "dependencies": { + "@nestjs/axios": "^3.0.0", "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "@nestjs/terminus": "^10.0.0", + "axios": "^1.6.0", + "ioredis": "^5.3.2", + "prom-client": "^15.1.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1" }, diff --git a/package-lock.json b/package-lock.json index ba4040a..7f45e20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,9 +24,15 @@ "version": "0.0.0", "license": "MIT", "dependencies": { + "@nestjs/axios": "^3.0.0", "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.0.0", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", + "@nestjs/terminus": "^10.0.0", + "axios": "^1.6.0", + "ioredis": "^5.3.2", + "prom-client": "^15.1.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1" }, @@ -137,7 +143,6 @@ "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -184,7 +189,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -282,7 +286,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -485,7 +488,6 @@ "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -683,7 +685,6 @@ "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -730,7 +731,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -828,7 +828,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -1031,7 +1030,6 @@ "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1377,7 +1375,6 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1435,7 +1432,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -1543,7 +1539,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -1840,7 +1835,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -1853,7 +1847,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2028,7 +2021,6 @@ "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2075,7 +2067,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -2173,7 +2164,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2376,7 +2366,6 @@ "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2574,7 +2563,6 @@ "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2621,7 +2609,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -2719,7 +2706,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2922,7 +2908,6 @@ "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3256,7 +3241,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -3859,6 +3843,7 @@ "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", @@ -3874,6 +3859,7 @@ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@eslint/core": "^0.17.0" }, @@ -3887,6 +3873,7 @@ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -3900,6 +3887,7 @@ "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -3924,6 +3912,7 @@ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -3937,6 +3926,7 @@ "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -3950,6 +3940,7 @@ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } @@ -3960,6 +3951,7 @@ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" @@ -3974,6 +3966,7 @@ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=18.18.0" } @@ -3984,6 +3977,7 @@ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" @@ -4036,6 +4030,7 @@ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=18.18" }, @@ -4044,6 +4039,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", @@ -4836,12 +4837,22 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@nestjs/axios": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.1.3.tgz", + "integrity": "sha512-RZ/63c1tMxGLqyG3iOCVt7A72oy4x1eM6QEhd4KzCYpaVWW0igq0WSREeRoEZhIxRcZfDfIIkvsOMiM7yfVGZQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "axios": "^1.3.1", + "rxjs": "^6.0.0 || ^7.0.0" + } + }, "node_modules/@nestjs/common": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz", "integrity": "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", @@ -4888,7 +4899,6 @@ "integrity": "sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -4980,6 +4990,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", @@ -5090,6 +5170,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 @@ -6277,7 +6366,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6302,6 +6390,7 @@ "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.13.0" }, @@ -6351,7 +6440,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6415,6 +6503,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", @@ -6458,7 +6555,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" @@ -6727,7 +6823,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/available-typed-arrays": { @@ -6756,6 +6851,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", + "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -6933,6 +7039,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", @@ -6984,6 +7096,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", @@ -7028,7 +7191,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7238,6 +7400,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", @@ -7309,6 +7480,18 @@ "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/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -7410,6 +7593,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", @@ -7450,7 +7642,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -8020,12 +8211,20 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "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", @@ -8402,7 +8601,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8645,7 +8843,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8830,6 +9027,7 @@ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -8847,6 +9045,7 @@ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -8860,6 +9059,7 @@ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", @@ -9229,6 +9429,7 @@ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "flat-cache": "^4.0.0" }, @@ -9323,6 +9524,7 @@ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" @@ -9338,6 +9540,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -9417,7 +9639,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -9924,7 +10145,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -10202,6 +10422,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", @@ -10436,7 +10680,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" @@ -10896,7 +11139,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -12178,6 +12420,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", @@ -13265,7 +13519,6 @@ "integrity": "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13324,6 +13577,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", @@ -13363,6 +13629,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -13528,6 +13800,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", @@ -13796,7 +14089,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -14245,6 +14537,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", @@ -14303,7 +14601,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", @@ -14341,7 +14638,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": { @@ -14461,7 +14757,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" @@ -14618,6 +14913,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", @@ -14689,7 +14993,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14871,7 +15174,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15290,7 +15592,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" @@ -15402,7 +15703,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15802,6 +16102,7 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -15814,7 +16115,8 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", @@ -15822,6 +16124,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -15836,6 +16139,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -15845,7 +16149,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/webpack/node_modules/schema-utils": { "version": "4.3.3", @@ -15853,6 +16158,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -16023,6 +16329,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", @@ -16322,7 +16640,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -16420,7 +16737,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -16663,7 +16979,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -16761,7 +17076,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", From 6cd0e8a450139174287d92308c53792db7cff5ce Mon Sep 17 00:00:00 2001 From: JamesVictor-O Date: Fri, 30 Jan 2026 12:54:00 +0100 Subject: [PATCH 2/6] feat(aggregator): add health module with Terminus - /health: full check (Redis, Ingestor), returns 200 or 503 - /ready: readiness probe for Kubernetes - /live: liveness probe - /status: detailed system info (uptime, memory, checks) - RedisHealthIndicator: ping when REDIS_URL is set - IngestorHealthIndicator: GET ingestor when INGESTOR_URL is set - Health controller and module with tests --- .../src/health/health.controller.spec.ts | 125 ++++++++++++++++++ .../src/health/health.controller.ts | 107 +++++++++++++++ apps/aggregator/src/health/health.module.ts | 22 +++ .../health/indicators/ingestor.health.spec.ts | 87 ++++++++++++ .../src/health/indicators/ingestor.health.ts | 52 ++++++++ .../health/indicators/redis.health.spec.ts | 56 ++++++++ .../src/health/indicators/redis.health.ts | 51 +++++++ 7 files changed, 500 insertions(+) create mode 100644 apps/aggregator/src/health/health.controller.spec.ts create mode 100644 apps/aggregator/src/health/health.controller.ts create mode 100644 apps/aggregator/src/health/health.module.ts create mode 100644 apps/aggregator/src/health/indicators/ingestor.health.spec.ts create mode 100644 apps/aggregator/src/health/indicators/ingestor.health.ts create mode 100644 apps/aggregator/src/health/indicators/redis.health.spec.ts create mode 100644 apps/aggregator/src/health/indicators/redis.health.ts 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 }, + }); + } + } +} From 1cf6aa90dcb5f5c0f401745b1360d54854396ba6 Mon Sep 17 00:00:00 2001 From: JamesVictor-O Date: Fri, 30 Jan 2026 12:54:04 +0100 Subject: [PATCH 3/6] feat(aggregator): add Prometheus metrics module - /metrics: Prometheus exposition format - MetricsService: aggregation count, latency histogram, errors, by-symbol - Default Node.js metrics with aggregator_ prefix - Metrics controller and service with tests --- .../src/metrics/metrics.controller.spec.ts | 38 ++++++++ .../src/metrics/metrics.controller.ts | 22 +++++ apps/aggregator/src/metrics/metrics.module.ts | 10 ++ .../src/metrics/metrics.service.spec.ts | 77 +++++++++++++++ .../aggregator/src/metrics/metrics.service.ts | 95 +++++++++++++++++++ 5 files changed, 242 insertions(+) create mode 100644 apps/aggregator/src/metrics/metrics.controller.spec.ts create mode 100644 apps/aggregator/src/metrics/metrics.controller.ts create mode 100644 apps/aggregator/src/metrics/metrics.module.ts create mode 100644 apps/aggregator/src/metrics/metrics.service.spec.ts create mode 100644 apps/aggregator/src/metrics/metrics.service.ts 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; + } +} From e1ce1d70eb158139d3fbc33db34c9a0a80b869a0 Mon Sep 17 00:00:00 2001 From: JamesVictor-O Date: Fri, 30 Jan 2026 12:54:07 +0100 Subject: [PATCH 4/6] feat(aggregator): add debug module with last-prices endpoint - /debug/prices: returns last aggregated and normalized prices in memory - DebugService: in-memory store for last prices per symbol - Debug controller and service with tests --- .../src/debug/debug.controller.spec.ts | 57 +++++++++++++++++++ apps/aggregator/src/debug/debug.controller.ts | 22 +++++++ apps/aggregator/src/debug/debug.module.ts | 10 ++++ apps/aggregator/src/debug/debug.service.ts | 54 ++++++++++++++++++ 4 files changed, 143 insertions(+) create mode 100644 apps/aggregator/src/debug/debug.controller.spec.ts create mode 100644 apps/aggregator/src/debug/debug.controller.ts create mode 100644 apps/aggregator/src/debug/debug.module.ts create mode 100644 apps/aggregator/src/debug/debug.service.ts 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, + }; + } +} From 4525e3b8ec6a60a632ad9cb9ace969f7a3a75cc6 Mon Sep 17 00:00:00 2001 From: JamesVictor-O Date: Fri, 30 Jan 2026 12:54:11 +0100 Subject: [PATCH 5/6] feat(aggregator): wire health, metrics and debug into app - AppModule: import ConfigModule, HealthModule, MetricsModule, DebugModule - AggregationService: optional MetricsService and DebugService injection - Record aggregation metrics (count, latency, errors) and last prices on aggregate() --- apps/aggregator/src/app.module.ts | 11 +- .../src/services/aggregation.service.ts | 147 ++++++++++-------- 2 files changed, 93 insertions(+), 65 deletions(-) diff --git a/apps/aggregator/src/app.module.ts b/apps/aggregator/src/app.module.ts index e840075..ed0a125 100644 --- a/apps/aggregator/src/app.module.ts +++ b/apps/aggregator/src/app.module.ts @@ -1,11 +1,20 @@ import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; 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: [], + imports: [ + ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env' }), + HealthModule, + MetricsModule, + DebugModule, + ], controllers: [], providers: [ AggregationService, 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; + } } /** From ee2d7d2391ce535fa656b439167b1bc0a5127489 Mon Sep 17 00:00:00 2001 From: JamesVictor-O Date: Fri, 30 Jan 2026 12:54:14 +0100 Subject: [PATCH 6/6] docs(aggregator): document health, metrics and debug endpoints - README: API endpoints table (health, ready, live, status, metrics, debug/prices) - README: update architecture with health, metrics, debug folders - .env.example: REDIS_URL and INGESTOR_URL for health checks --- apps/aggregator/.env.example | 5 ++++- apps/aggregator/README.md | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/aggregator/.env.example b/apps/aggregator/.env.example index 47189c3..7609d4b 100644 --- a/apps/aggregator/.env.example +++ b/apps/aggregator/.env.example @@ -1,8 +1,11 @@ # Server Configuration PORT=3001 -# Ingestor Services (for receiving raw data) +# Ingestor Service (for health check and receiving raw data) # INGESTOR_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 47b9766..a1258da 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