From 34dd8d5f2ac8024c177be73fb1a0a5227b796c66 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 13 May 2026 16:49:25 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=88=20feat:=20Add=20Prometheus=20Metri?= =?UTF-8?q?cs=20Endpoint=20+=20AWS=20Credential=20Providers=20(#13111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add prometheus metrics endpoint * fix: format metrics route spec * chore: update dependencies in package.json and package-lock.json correctly - Bump `@smithy/core` to version 3.24.1 - Update `@aws-sdk/credential-providers` to version 3.1045.0 - Reintroduce `prom-client` dependency in package.json - Remove unnecessary dependencies from package.json * chore: import order * fix: declare s3 presigner peer dependency * fix: normalize shared link metrics path * fix: bound metrics path labels * fix: tighten metrics auth and peers * fix: collapse partial metrics paths --- api/package.json | 2 + api/server/index.js | 11 +- api/server/index.metrics.spec.js | 164 +++++++++++++++++++++ package-lock.json | 212 ++++++++++++++++++++------- packages/api/package.json | 3 + packages/api/src/app/index.ts | 1 + packages/api/src/app/metrics.spec.ts | 47 ++++++ packages/api/src/app/metrics.ts | 159 ++++++++++++++++++++ 8 files changed, 549 insertions(+), 50 deletions(-) create mode 100644 api/server/index.metrics.spec.js create mode 100644 packages/api/src/app/metrics.spec.ts create mode 100644 packages/api/src/app/metrics.ts diff --git a/api/package.json b/api/package.json index eb4cf2a191..80c3e5fd9b 100644 --- a/api/package.json +++ b/api/package.json @@ -39,6 +39,7 @@ "@aws-sdk/client-cloudfront": "^3.1042.0", "@aws-sdk/client-s3": "^3.980.0", "@aws-sdk/cloudfront-signer": "^3.1036.0", + "@aws-sdk/credential-providers": "^3.1045.0", "@aws-sdk/s3-request-presigner": "^3.758.0", "@azure/identity": "^4.13.1", "@azure/search-documents": "^12.0.0", @@ -106,6 +107,7 @@ "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", "pdfjs-dist": "^5.4.624", + "prom-client": "^15.1.3", "rate-limit-redis": "^4.2.0", "sanitize-html": "^2.13.0", "sharp": "^0.33.5", diff --git a/api/server/index.js b/api/server/index.js index 6bc4a131e6..a8146fbc1c 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -13,6 +13,7 @@ const { logger, runAsSystem } = require('@librechat/data-schemas'); const { isEnabled, apiNotFound, + createMetrics, ErrorController, memoryDiagnostics, performStartupChecks, @@ -20,8 +21,8 @@ const { GenerationJobManager, createStreamServices, initializeFileStorage, - updateInterfacePermissions, preAuthTenantMiddleware, + updateInterfacePermissions, } = require('@librechat/api'); const { connectDb, indexSync } = require('~/db'); const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager'); @@ -53,6 +54,11 @@ const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default const app = express(); const startServer = async () => { + const { metricsMiddleware, metricsRouter } = createMetrics(); + if (!process.env.METRICS_SECRET) { + logger.warn('[metrics] METRICS_SECRET is not set - /metrics will return 401 for all requests'); + } + if (typeof Bun !== 'undefined') { axios.defaults.headers.common['Accept-Encoding'] = 'gzip'; } @@ -107,6 +113,7 @@ const startServer = async () => { app.get('/health', (_req, res) => res.status(200).send('OK')); /* Middleware */ + app.use(metricsMiddleware); app.use(noIndex); app.use(express.json({ limit: '3mb' })); app.use(express.urlencoded({ extended: true, limit: '3mb' })); @@ -199,6 +206,8 @@ const startServer = async () => { app.use('/api/tags', routes.tags); app.use('/api/mcp', routes.mcp); + app.use('/metrics', metricsRouter); + /** 404 for unmatched API routes */ app.use('/api', apiNotFound); diff --git a/api/server/index.metrics.spec.js b/api/server/index.metrics.spec.js new file mode 100644 index 0000000000..3f29347292 --- /dev/null +++ b/api/server/index.metrics.spec.js @@ -0,0 +1,164 @@ +const fs = require('fs'); +const request = require('supertest'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const mongoose = require('mongoose'); + +jest.mock('~/server/services/Config', () => ({ + loadCustomConfig: jest.fn(() => Promise.resolve({})), + getAppConfig: jest.fn().mockResolvedValue({ + paths: { + uploads: '/tmp', + dist: '/tmp/dist', + fonts: '/tmp/fonts', + assets: '/tmp/assets', + }, + fileStrategy: 'local', + imageOutputType: 'PNG', + }), + setCachedTools: jest.fn(), +})); + +jest.mock('~/app/clients/tools', () => ({ + createOpenAIImageTools: jest.fn(() => []), + createYouTubeTools: jest.fn(() => []), + manifestToolMap: {}, + toolkits: [], +})); + +jest.mock('~/config', () => ({ + createMCPServersRegistry: jest.fn(), + createMCPManager: jest.fn().mockResolvedValue({ + getAppToolFunctions: jest.fn().mockResolvedValue({}), + }), +})); + +describe('Server metrics route', () => { + jest.setTimeout(30_000); + + let mongoServer; + let app; + + const originalReadFileSync = fs.readFileSync; + + beforeAll(() => { + fs.readFileSync = function (filepath, options) { + if (filepath.includes('index.html')) { + return 'LibreChat
'; + } + return originalReadFileSync(filepath, options); + }; + }); + + afterAll(() => { + fs.readFileSync = originalReadFileSync; + }); + + beforeAll(async () => { + const fs = require('fs'); + const path = require('path'); + + const dirs = ['/tmp/dist', '/tmp/fonts', '/tmp/assets']; + dirs.forEach((dir) => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + }); + + fs.writeFileSync( + path.join('/tmp/dist', 'index.html'), + 'LibreChat
', + ); + + mongoServer = await MongoMemoryServer.create(); + process.env.MONGO_URI = mongoServer.getUri(); + process.env.PORT = '0'; + app = require('~/server'); + + await healthCheckPoll(app); + }); + + afterEach(() => { + delete process.env.METRICS_SECRET; + }); + + afterAll(async () => { + await mongoServer.stop(); + await mongoose.disconnect(); + }); + + it('returns 401 at /metrics when METRICS_SECRET is unset', async () => { + const response = await request(app).get('/metrics'); + expect(response.status).toBe(401); + }); + + it('returns 401 at /metrics when no token provided', async () => { + process.env.METRICS_SECRET = 'test-secret'; + + const response = await request(app).get('/metrics'); + + expect(response.status).toBe(401); + }); + + it('returns 401 at /metrics when wrong token provided', async () => { + process.env.METRICS_SECRET = 'test-secret'; + + const response = await request(app).get('/metrics').set('Authorization', 'Bearer wrong-token'); + + expect(response.status).toBe(401); + }); + + it('returns 401 at /metrics when the bearer scheme is omitted', async () => { + process.env.METRICS_SECRET = 'test-secret'; + + const response = await request(app).get('/metrics').set('Authorization', 'test-secret'); + + expect(response.status).toBe(401); + }); + + it('returns 401 at /metrics for non-bearer auth schemes', async () => { + process.env.METRICS_SECRET = 'test-secret'; + + const response = await request(app).get('/metrics').set('Authorization', 'Basic test-secret'); + + expect(response.status).toBe(401); + }); + + it('exposes Prometheus metrics at /metrics with correct bearer token', async () => { + process.env.METRICS_SECRET = 'test-secret'; + + const response = await request(app).get('/metrics').set('Authorization', 'Bearer test-secret'); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toMatch(/text\/plain/); + expect(response.text).toMatch(/^# HELP /m); + expect(response.text).toMatch(/^# TYPE /m); + }); + + it('accepts lowercase bearer scheme at /metrics', async () => { + process.env.METRICS_SECRET = 'test-secret'; + + const response = await request(app).get('/metrics').set('Authorization', 'bearer test-secret'); + + expect(response.status).toBe(200); + }); +}); + +async function healthCheckPoll(app, retries = 0) { + const maxRetries = Math.floor(10000 / 30); + try { + const response = await request(app).get('/health'); + if (response.status === 200) { + return; + } + } catch { + // Ignore connection errors during polling. + } + + if (retries < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, 30)); + await healthCheckPoll(app, retries + 1); + return; + } + + throw new Error('App did not become healthy within 10 seconds.'); +} diff --git a/package-lock.json b/package-lock.json index 51d6fa062a..dbc9fb994c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "@aws-sdk/client-cloudfront": "^3.1042.0", "@aws-sdk/client-s3": "^3.980.0", "@aws-sdk/cloudfront-signer": "^3.1036.0", + "@aws-sdk/credential-providers": "^3.1045.0", "@aws-sdk/s3-request-presigner": "^3.758.0", "@azure/identity": "^4.13.1", "@azure/search-documents": "^12.0.0", @@ -121,6 +122,7 @@ "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", "pdfjs-dist": "^5.4.624", + "prom-client": "^15.1.3", "rate-limit-redis": "^4.2.0", "sanitize-html": "^2.13.0", "sharp": "^0.33.5", @@ -2248,6 +2250,85 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/client-cognito-identity": { + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1045.0.tgz", + "integrity": "sha512-3OEn8zvtfJoN0jFfjVJ9jF2GVRDL3IjDfk6CAgVTAqjfCVjajiUD0iFAGQ4cOzdcv1LGsZ0b/snJDWalY3OePQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", + "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@smithy/util-utf8": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.3.1.tgz", + "integrity": "sha512-FtRrSnriXtOs4+J8/y9SbQ1xmN71hrOsN/YJr5PQQj5nR1l7YNkGS/TEk4gr0WN7gyrUqw8/RFaYVjI18732ZA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/client-kendra": { "version": "3.1041.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-kendra/-/client-kendra-3.1041.0.tgz", @@ -2600,6 +2681,22 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/credential-provider-cognito-identity": { + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.31.tgz", + "integrity": "sha512-W5JtzDp3ejzhOOknXlnt+vJsNN2GZdAcBK+hR7HQ1DCacXqS0UpmnIyihIU7CK0IB+XYWeBaN3bBv4pXavp7Vg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/credential-provider-env": { "version": "3.972.34", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.34.tgz", @@ -2758,6 +2855,37 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/credential-providers": { + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1045.0.tgz", + "integrity": "sha512-J+it58HUGyMIAquB6pWtvmO4m0E/gQ/Tz9Xcoogk3Rety13likU5U8HioeIgE+aN1DDOAB//MARoIdLZS1Mpfw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.1045.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-cognito-identity": "^3.972.31", + "@aws-sdk/credential-provider-env": "^3.972.34", + "@aws-sdk/credential-provider-http": "^3.972.36", + "@aws-sdk/credential-provider-ini": "^3.972.38", + "@aws-sdk/credential-provider-login": "^3.972.38", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/credential-provider-process": "^3.972.34", + "@aws-sdk/credential-provider-sso": "^3.972.38", + "@aws-sdk/credential-provider-web-identity": "^3.972.38", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/eventstream-handler-node": { "version": "3.972.14", "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.14.tgz", @@ -19476,58 +19604,13 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.17", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.17.tgz", - "integrity": "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==", + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.1.tgz", + "integrity": "sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.14", + "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core/node_modules/@smithy/is-array-buffer": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", - "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core/node_modules/@smithy/util-buffer-from": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", - "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core/node_modules/@smithy/util-utf8": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", - "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -23208,6 +23291,12 @@ "node": ">=8" } }, + "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/bluebird": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", @@ -37995,6 +38084,19 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "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/promise.series": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/promise.series/-/promise.series-0.2.0.tgz", @@ -41740,6 +41842,15 @@ } } }, + "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/teex": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", @@ -44571,6 +44682,8 @@ "@aws-sdk/client-cloudfront": "^3.1042.0", "@aws-sdk/client-s3": "^3.980.0", "@aws-sdk/cloudfront-signer": "^3.1036.0", + "@aws-sdk/credential-providers": "^3.1045.0", + "@aws-sdk/s3-request-presigner": "^3.758.0", "@azure/identity": "^4.13.1", "@azure/search-documents": "^12.0.0", "@azure/storage-blob": "^12.30.0", @@ -44603,6 +44716,7 @@ "mongoose": "^8.23.1", "node-fetch": "2.7.0", "pdfjs-dist": "^5.4.624", + "prom-client": "^15.1.3", "rate-limit-redis": "^4.2.0", "sanitize-html": "^2.13.0", "sharp": "^0.33.5", diff --git a/packages/api/package.json b/packages/api/package.json index 34a06401cb..1168c21bc6 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -93,6 +93,8 @@ "@aws-sdk/client-cloudfront": "^3.1042.0", "@aws-sdk/client-s3": "^3.980.0", "@aws-sdk/cloudfront-signer": "^3.1036.0", + "@aws-sdk/credential-providers": "^3.1045.0", + "@aws-sdk/s3-request-presigner": "^3.758.0", "@azure/identity": "^4.13.1", "@azure/search-documents": "^12.0.0", "@azure/storage-blob": "^12.30.0", @@ -125,6 +127,7 @@ "mongoose": "^8.23.1", "node-fetch": "2.7.0", "pdfjs-dist": "^5.4.624", + "prom-client": "^15.1.3", "rate-limit-redis": "^4.2.0", "sanitize-html": "^2.13.0", "sharp": "^0.33.5", diff --git a/packages/api/src/app/index.ts b/packages/api/src/app/index.ts index 8d8802f016..3bb619ab13 100644 --- a/packages/api/src/app/index.ts +++ b/packages/api/src/app/index.ts @@ -1,5 +1,6 @@ export * from './service'; export * from './config'; +export * from './metrics'; export * from './permissions'; export * from './cdn'; export * from './checks'; diff --git a/packages/api/src/app/metrics.spec.ts b/packages/api/src/app/metrics.spec.ts new file mode 100644 index 0000000000..6bb66ed278 --- /dev/null +++ b/packages/api/src/app/metrics.spec.ts @@ -0,0 +1,47 @@ +/// +import { normalizePath } from './metrics'; + +describe('normalizePath', () => { + it.each([ + // Known high-cardinality routes + ['/api/messages/507f1f77bcf86cd799439011', '/api/messages/#id'], + ['/api/messages/507f1f77bcf86cd799439011/507f1f77bcf86cd799439012', '/api/messages/#id/#id'], + ['/api/messages/artifact/507f1f77bcf86cd799439012', '/api/messages/artifact/#id'], + ['/api/convos/507f1f77bcf86cd799439011', '/api/convos/#id'], + ['/api/files/507f1f77bcf86cd799439011', '/api/files/#id'], + ['/api/agents/507f1f77bcf86cd799439011', '/api/agents/#id'], + ['/api/assistants/507f1f77bcf86cd799439011', '/api/assistants/#id'], + ['/api/share/some-token-value', '/api/share/#token'], + ['/share/shareId-with_nanoidChars', '/share/#id'], + ['/share/shareId-with_nanoidChars/edit', '/share/#id/edit'], + // Known API routes with dynamic IDs + ['/api/tags/507f1f77bcf86cd799439011', '/api/tags/#id'], + ['/api/tags/507F1F77BCF86CD799439011', '/api/tags/#id'], + ['/api/tools/507f1f77bcf86cd799439011', '/api/tools/#id'], + ['/api/runs/507f1f77bcf86cd799439011', '/api/runs/#id'], + // Catch-all: UUID in unknown routes + ['/api/tools/123e4567-e89b-12d3-a456-426614174000', '/api/tools/#id'], + ['/api/sessions/123E4567-E89B-12D3-A456-426614174000', '/api/sessions/#id'], + // Multiple dynamic segments + [ + '/api/convos/507f1f77bcf86cd799439011/messages/507f1f77bcf86cd799439012', + '/api/convos/#id/messages/#id', + ], + // Static paths are not modified + ['/api/auth/login', '/api/auth/login'], + ['/api/config', '/api/config'], + ['/health', '/health'], + ['/metrics', '/metrics'], + ['/', '/'], + // Unknown/user-generated routes collapse into bounded label buckets + ['/api/not-a-real-route/user-generated-value', '/api/#path'], + ['/images/user-123/avatar-1700000000000.png', '/images/#path'], + ['/avatars/user-123/avatar-1700000000000.png', '/avatars/#path'], + ['/t/tenant-a/images/user-123/avatar-1700000000000.png', '/t/#path'], + ['/unknown/shareId-with_nanoidChars', '/#path'], + ['/api/messages/507f1f77bcf86cd799439011/user-generated-value/extra', '/api/#path'], + ['/api/messages/artifact/507f1f77bcf86cd799439012/user-generated-value', '/api/#path'], + ])('normalizes %s -> %s', (input: string, normalized: string) => { + expect(normalizePath(input)).toBe(normalized); + }); +}); diff --git a/packages/api/src/app/metrics.ts b/packages/api/src/app/metrics.ts new file mode 100644 index 0000000000..8089a9c3a1 --- /dev/null +++ b/packages/api/src/app/metrics.ts @@ -0,0 +1,159 @@ +import { timingSafeEqual } from 'crypto'; +import { Router } from 'express'; +import { Registry, collectDefaultMetrics, Counter, Histogram } from 'prom-client'; +import { logger } from '@librechat/data-schemas'; +import type { Request, Response, NextFunction, RequestHandler } from 'express'; + +const PATH_NORMALIZATIONS: [RegExp, string][] = [ + [/^\/api\/messages\/artifact\/[^/]+(?=\/|$)/, '/api/messages/artifact/#id'], + [/^\/api\/messages\/[^/]+\/[^/]+(?=\/|$)/, '/api/messages/#id/#id'], + [/^\/api\/convos\/[^/]+\/messages\/[^/]+(?=\/|$)/, '/api/convos/#id/messages/#id'], + [/^\/api\/messages\/[^/]+(?=\/|$)/, '/api/messages/#id'], + [/^\/api\/convos\/[^/]+(?=\/|$)/, '/api/convos/#id'], + [/^\/api\/files\/[^/]+(?=\/|$)/, '/api/files/#id'], + [/^\/api\/agents\/[^/]+(?=\/|$)/, '/api/agents/#id'], + [/^\/api\/assistants\/[^/]+(?=\/|$)/, '/api/assistants/#id'], + [/^\/api\/share\/[^/]+(?=\/|$)/, '/api/share/#token'], + [/^\/share\/[^/]+(?=\/|$)/, '/share/#id'], + [/^\/api\/(tags|tools|runs|sessions)\/[0-9a-f]{24}(?=\/|$)/i, '/api/$1/#id'], + [ + /^\/api\/(tools|sessions)\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\/|$)/i, + '/api/$1/#id', + ], +]; + +const STATIC_PATHS = new Set(['/', '/health', '/metrics', '/api/auth/login', '/api/config']); + +const LOW_CARDINALITY_PATHS: RegExp[] = [ + /^\/api\/messages\/#id$/, + /^\/api\/messages\/#id\/#id$/, + /^\/api\/messages\/artifact\/#id$/, + /^\/api\/convos\/#id$/, + /^\/api\/convos\/#id\/messages\/#id$/, + /^\/api\/(files|agents|assistants|tags|tools|runs|sessions)\/#id$/, + /^\/api\/share\/#token$/, + /^\/share\/#id(?:\/edit)?$/, +]; + +const isLowCardinalityPath = (path: string): boolean => + STATIC_PATHS.has(path) || LOW_CARDINALITY_PATHS.some((pattern) => pattern.test(path)); + +const normalizeKnownPath = (path: string): string => { + for (const [pattern, replacement] of PATH_NORMALIZATIONS) { + if (pattern.test(path)) { + return path.replace(pattern, replacement); + } + } + + return path; +}; + +const normalizeUnknownPath = (path: string): string => { + if (STATIC_PATHS.has(path)) { + return path; + } + + if (path === '/api' || path.startsWith('/api/')) { + return '/api/#path'; + } + + if (path === '/images' || path.startsWith('/images/')) { + return '/images/#path'; + } + + if (path === '/avatars' || path.startsWith('/avatars/')) { + return '/avatars/#path'; + } + + if (path === '/t' || path.startsWith('/t/')) { + return '/t/#path'; + } + + return '/#path'; +}; + +export const normalizePath = (rawPath: string): string => { + const [pathWithoutQuery] = rawPath.split('?'); + const path = pathWithoutQuery.startsWith('/') ? pathWithoutQuery : `/${pathWithoutQuery}`; + const normalized = normalizeKnownPath(path || '/'); + + if (isLowCardinalityPath(normalized)) { + return normalized; + } + + return normalizeUnknownPath(path); +}; + +export interface PrometheusMetrics { + metricsMiddleware: (req: Request, res: Response, next: NextFunction) => void; + metricsRouter: Router; +} + +export function createMetrics(): PrometheusMetrics { + const registry = new Registry(); + collectDefaultMetrics({ register: registry }); + + const httpRequests = new Counter({ + name: 'http_requests_total', + help: 'Total HTTP requests', + labelNames: ['method', 'path', 'status'] as const, + registers: [registry], + }); + + const httpDuration = new Histogram({ + name: 'http_request_duration_seconds', + help: 'HTTP request latency in seconds', + labelNames: ['method', 'path', 'status'] as const, + buckets: [0.05, 0.1, 0.3, 0.5, 1, 2, 5], + registers: [registry], + }); + + const metricsMiddleware = (req: Request, res: Response, next: NextFunction): void => { + const end = httpDuration.startTimer(); + res.on('finish', () => { + const labels = { method: req.method, path: normalizePath(req.path), status: res.statusCode }; + httpRequests.inc(labels); + end(labels); + }); + next(); + }; + + const metricsRouter = Router(); + const metricsHandler: RequestHandler = (req, res): void => { + const secret = process.env.METRICS_SECRET; + const auth = req.headers['authorization']; + if (!secret || !auth) { + res.status(401).end(); + return; + } + const bearerToken = auth.match(/^bearer\s+(.+)$/i); + if (!bearerToken) { + res.status(401).end(); + return; + } + + const token = bearerToken[1]; + const encode = (s: string) => new TextEncoder().encode(s); + const expected = encode(secret); + const actual = encode(token); + if (expected.byteLength !== actual.byteLength || !timingSafeEqual(expected, actual)) { + res.status(401).end(); + return; + } + + void registry + .metrics() + .then((metrics) => { + res.set('Content-Type', registry.contentType); + res.end(metrics); + }) + .catch((err) => { + logger.error('[metrics] Failed to collect metrics:', err); + res.status(500).end(); + }); + }; + + metricsRouter.get('/', metricsHandler); + + return { metricsMiddleware, metricsRouter }; +}