📈 feat: Add Prometheus Metrics Endpoint + AWS Credential Providers (#13111)

* 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
This commit is contained in:
Danny Avila 2026-05-13 16:49:25 -04:00 committed by GitHub
parent 83ea3efbc1
commit 34dd8d5f2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 549 additions and 50 deletions

View file

@ -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",

View file

@ -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);

View file

@ -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 '<!DOCTYPE html><html><head><title>LibreChat</title></head><body><div id="root"></div></body></html>';
}
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'),
'<!DOCTYPE html><html><head><title>LibreChat</title></head><body><div id="root"></div></body></html>',
);
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.');
}

212
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -1,5 +1,6 @@
export * from './service';
export * from './config';
export * from './metrics';
export * from './permissions';
export * from './cdn';
export * from './checks';

View file

@ -0,0 +1,47 @@
/// <reference types="jest" />
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);
});
});

View file

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