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 };
+}