mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
📈 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:
parent
83ea3efbc1
commit
34dd8d5f2a
8 changed files with 549 additions and 50 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
164
api/server/index.metrics.spec.js
Normal file
164
api/server/index.metrics.spec.js
Normal 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
212
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * from './service';
|
||||
export * from './config';
|
||||
export * from './metrics';
|
||||
export * from './permissions';
|
||||
export * from './cdn';
|
||||
export * from './checks';
|
||||
|
|
|
|||
47
packages/api/src/app/metrics.spec.ts
Normal file
47
packages/api/src/app/metrics.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
159
packages/api/src/app/metrics.ts
Normal file
159
packages/api/src/app/metrics.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue