⚖️ feat: Add Operational Prometheus Metrics (#13265)

* add operational prometheus metrics

* fix metrics review findings

* fix metrics edge cases

* gate mongoose metrics instrumentation

* gate metrics setup when unconfigured
This commit is contained in:
Danny Avila 2026-05-22 20:47:41 -04:00 • committed by GitHub
parent bd64251eb9
commit 2ce3921f78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 967 additions and 59 deletions

View file

@ -1,10 +1,12 @@
require('dotenv').config();
const { isEnabled } = require('@librechat/api');
const { isEnabled, instrumentMongooseQueryMetrics } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const mongoose = require('mongoose');
const MONGO_URI = process.env.MONGO_URI;
instrumentMongooseQueryMetrics(mongoose);
if (!MONGO_URI) {
throw new Error('Please define the MONGO_URI environment variable');
}

View file

@ -72,16 +72,18 @@ describe('Server metrics route', () => {
mongoServer = await MongoMemoryServer.create();
process.env.MONGO_URI = mongoServer.getUri();
process.env.PORT = '0';
process.env.METRICS_SECRET = 'test-secret';
app = require('~/server');
await healthCheckPoll(app);
});
afterEach(() => {
delete process.env.METRICS_SECRET;
process.env.METRICS_SECRET = 'test-secret';
});
afterAll(async () => {
delete process.env.METRICS_SECRET;
await mongoServer.stop();
await mongoose.disconnect();
});

View file

@ -1,5 +1,19 @@
/// <reference types="jest" />
import { normalizePath } from './metrics';
import { EventEmitter } from 'events';
import express from 'express';
import type { Request, Response } from 'express';
import {
createMetrics,
instrumentMongooseQueryMetrics,
normalizePath,
recordGenerationJob,
recordGenerationStreamResumePendingEvents,
recordGenerationStreamSubscription,
recordOpenIDUserLookup,
setGenerationJobsInFlight,
} from './metrics';
const request = require('supertest') as (app: express.Express) => any;
describe('normalizePath', () => {
it.each([
@ -9,8 +23,17 @@ describe('normalizePath', () => {
['/api/messages/artifact/507f1f77bcf86cd799439012', '/api/messages/artifact/#id'],
['/api/convos/507f1f77bcf86cd799439011', '/api/convos/#id'],
['/api/files/507f1f77bcf86cd799439011', '/api/files/#id'],
['/api/files/507f1f77bcf86cd799439011/preview', '/api/files/#id/preview'],
['/api/files/download/user-123/file-456', '/api/files/download/#id/#id'],
['/api/files/download-url/user-123/file-456', '/api/files/download-url/#id/#id'],
['/api/files/code/download/session-123/file-456', '/api/files/code/download/#id/#id'],
['/api/agents/507f1f77bcf86cd799439011', '/api/agents/#id'],
['/api/agents/chat/stream/stream-123', '/api/agents/chat/stream/#id'],
['/api/agents/chat/status/convo-123', '/api/agents/chat/status/#id'],
['/api/agents/v1/chat/completions', '/api/agents/v1/chat/completions'],
['/api/agents/v1/responses', '/api/agents/v1/responses'],
['/api/assistants/507f1f77bcf86cd799439011', '/api/assistants/#id'],
['/api/skills/507f1f77bcf86cd799439011/files/reference.md', '/api/skills/#id/files'],
['/api/share/some-token-value', '/api/share/#token'],
['/share/shareId-with_nanoidChars', '/share/#id'],
['/share/shareId-with_nanoidChars/edit', '/share/#id/edit'],
@ -45,3 +68,316 @@ describe('normalizePath', () => {
expect(normalizePath(input)).toBe(normalized);
});
});
describe('createMetrics', () => {
afterEach(() => {
delete process.env.METRICS_SECRET;
});
it('uses a no-op middleware and unauthorized router when metrics are not configured', async () => {
const app = express();
const { metricsMiddleware, metricsRouter } = createMetrics();
const res = new EventEmitter();
const next = jest.fn();
metricsMiddleware(
{ headers: {}, method: 'GET', path: '/api/slow-response' } as Request,
res as unknown as Response,
next,
);
app.use('/metrics', metricsRouter);
await request(app).get('/metrics').expect(401);
expect(next).toHaveBeenCalledTimes(1);
expect(res.listenerCount('finish')).toBe(0);
expect(res.listenerCount('close')).toBe(0);
});
it('tracks request counts, in-flight gauges, and request body sizes', async () => {
const app = express();
process.env.METRICS_SECRET = 'test-secret';
const { metricsMiddleware, metricsRouter } = createMetrics();
app.use(metricsMiddleware);
app.use(express.text({ type: '*/*' }));
app.post('/api/files/:id', (_req, res) => {
res.status(201).send('ok');
});
app.use('/metrics', metricsRouter);
await request(app)
.post('/api/files/507f1f77bcf86cd799439011')
.set('Content-Type', 'text/plain')
.send('x'.repeat(42))
.expect(201);
const response = await request(app)
.get('/metrics')
.set('Authorization', 'Bearer test-secret')
.expect(200);
expect(response.text).toMatch(
/http_requests_total\{method="POST",path="\/api\/files\/#id",status="201"\} 1/,
);
expect(response.text).toMatch(
/http_requests_in_flight\{method="POST",path="\/api\/files\/#id"\} 0/,
);
expect(response.text).toMatch(
/http_request_body_bytes_count\{method="POST",path="\/api\/files\/#id"\} 1/,
);
expect(response.text).toMatch(
/http_request_body_bytes_sum\{method="POST",path="\/api\/files\/#id"\} 42/,
);
});
it('tracks SSE stream counts, active gauges, and stream duration', async () => {
const app = express();
process.env.METRICS_SECRET = 'test-secret';
const { metricsMiddleware, metricsRouter } = createMetrics();
app.use(metricsMiddleware);
app.get('/api/agents/chat/stream/:streamId', (_req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.write('event: message\ndata: {}\n\n');
res.end();
});
app.use('/metrics', metricsRouter);
await request(app).get('/api/agents/chat/stream/stream-123').expect(200);
const response = await request(app)
.get('/metrics')
.set('Authorization', 'Bearer test-secret')
.expect(200);
expect(response.text).toMatch(
/sse_streams_total\{method="GET",path="\/api\/agents\/chat\/stream\/#id",status="200"\} 1/,
);
expect(response.text).toMatch(
/sse_streams_in_flight\{method="GET",path="\/api\/agents\/chat\/stream\/#id"\} 0/,
);
expect(response.text).toMatch(
/sse_stream_duration_seconds_count\{method="GET",path="\/api\/agents\/chat\/stream\/#id",status="200"\} 1/,
);
});
it('tracks upload counts, active gauges, duration, and upload bytes', async () => {
const app = express();
process.env.METRICS_SECRET = 'test-secret';
const { metricsMiddleware, metricsRouter } = createMetrics();
app.use(metricsMiddleware);
app.post('/api/files', (_req, res) => {
res.status(201).send('ok');
});
app.use('/metrics', metricsRouter);
await request(app)
.post('/api/files')
.attach('file', Buffer.from('uploaded body'), 'test.txt')
.expect(201);
const response = await request(app)
.get('/metrics')
.set('Authorization', 'Bearer test-secret')
.expect(200);
expect(response.text).toMatch(
/upload_requests_total\{method="POST",path="\/api\/files",status="201"\} 1/,
);
expect(response.text).toMatch(
/upload_requests_in_flight\{method="POST",path="\/api\/files"\} 0/,
);
expect(response.text).toMatch(
/upload_request_duration_seconds_count\{method="POST",path="\/api\/files",status="201"\} 1/,
);
expect(response.text).toMatch(/upload_bytes_total\{method="POST",path="\/api\/files"\} \d+/);
});
it('does not track non-upload methods on upload paths as uploads', async () => {
const app = express();
process.env.METRICS_SECRET = 'test-secret';
const { metricsMiddleware, metricsRouter } = createMetrics();
app.use(metricsMiddleware);
app.delete('/api/files', (_req, res) => {
res.status(204).end();
});
app.use('/metrics', metricsRouter);
await request(app).delete('/api/files').expect(204);
const response = await request(app)
.get('/metrics')
.set('Authorization', 'Bearer test-secret')
.expect(200);
expect(response.text).not.toMatch(/upload_requests_total\{method="DELETE"/);
expect(response.text).not.toMatch(/upload_bytes_total\{method="DELETE"/);
});
it('labels requests closed before finish as client-aborted', async () => {
const app = express();
process.env.METRICS_SECRET = 'test-secret';
const { metricsMiddleware, metricsRouter } = createMetrics();
const headers = new Map<string, unknown>();
const res = Object.assign(new EventEmitter(), {
destroyed: false,
getHeader: (name: string) => headers.get(name.toLowerCase()),
setHeader(name: string, value: unknown) {
headers.set(name.toLowerCase(), value);
return this;
},
statusCode: 200,
writableEnded: false,
writeHead() {
return this;
},
});
const next = jest.fn();
metricsMiddleware(
{ headers: {}, method: 'GET', path: '/api/slow-response' } as Request,
res as unknown as Response,
next,
);
res.emit('close');
app.use('/metrics', metricsRouter);
const response = await request(app)
.get('/metrics')
.set('Authorization', 'Bearer test-secret')
.expect(200);
expect(next).toHaveBeenCalledTimes(1);
expect(response.text).toMatch(
/http_requests_total\{method="GET",path="\/api\/#path",status="499"\} 1/,
);
expect(response.text).not.toMatch(
/http_requests_total\{method="GET",path="\/api\/#path",status="200"\}/,
);
});
it('tracks OpenID user lookup outcomes and latency', async () => {
const app = express();
process.env.METRICS_SECRET = 'test-secret';
const { metricsRouter } = createMetrics();
app.use('/metrics', metricsRouter);
recordOpenIDUserLookup('found', 0.2);
const response = await request(app)
.get('/metrics')
.set('Authorization', 'Bearer test-secret')
.expect(200);
expect(response.text).toMatch(/openid_user_lookup_total\{result="found"\} 1/);
expect(response.text).toMatch(/openid_user_lookup_duration_seconds_count\{result="found"\} 1/);
expect(response.text).toMatch(/openid_user_lookup_duration_seconds_sum\{result="found"\} 0.2/);
});
it('tracks mongoose query counts and latency by model and operation', async () => {
class FakeQuery {
model = { modelName: 'User' };
op = 'findOne';
exec() {
return Promise.resolve({ _id: 'user-1' });
}
}
const app = express();
process.env.METRICS_SECRET = 'test-secret';
const { metricsRouter } = createMetrics();
app.use('/metrics', metricsRouter);
instrumentMongooseQueryMetrics({ Query: FakeQuery } as never);
await new FakeQuery().exec();
const response = await request(app)
.get('/metrics')
.set('Authorization', 'Bearer test-secret')
.expect(200);
expect(response.text).toMatch(
/mongoose_queries_total\{model="User",operation="findOne",status="success"\} 1/,
);
expect(response.text).toMatch(
/mongoose_query_duration_seconds_count\{model="User",operation="findOne",status="success"\} 1/,
);
});
it('does not instrument mongoose queries when metrics are not configured', async () => {
class FakeQuery {
model = { modelName: 'User' };
op = 'findOne';
exec() {
return Promise.resolve({ _id: 'user-1' });
}
}
const originalExec = FakeQuery.prototype.exec;
instrumentMongooseQueryMetrics({ Query: FakeQuery } as never);
expect(FakeQuery.prototype.exec).toBe(originalExec);
});
it('tracks mongoose query errors thrown before a promise is returned', async () => {
class ThrowingQuery {
model = { modelName: 'User' };
op = 'findOne';
exec() {
throw new Error('sync database error');
}
}
const app = express();
process.env.METRICS_SECRET = 'test-secret';
const { metricsRouter } = createMetrics();
app.use('/metrics', metricsRouter);
instrumentMongooseQueryMetrics({ Query: ThrowingQuery } as never);
expect(() => new ThrowingQuery().exec()).toThrow('sync database error');
const response = await request(app)
.get('/metrics')
.set('Authorization', 'Bearer test-secret')
.expect(200);
expect(response.text).toMatch(
/mongoose_queries_total\{model="User",operation="findOne",status="error"\} 1/,
);
expect(response.text).toMatch(
/mongoose_query_duration_seconds_count\{model="User",operation="findOne",status="error"\} 1/,
);
});
it('tracks generation job and stream resume metrics', async () => {
const app = express();
process.env.METRICS_SECRET = 'test-secret';
const { metricsRouter } = createMetrics();
app.use('/metrics', metricsRouter);
recordGenerationJob('memory', 'created');
setGenerationJobsInFlight('memory', 2);
recordGenerationStreamSubscription('redis', 'resume', 'not_found');
recordGenerationStreamSubscription('redis', 'resume_state', 'missing');
recordGenerationStreamResumePendingEvents('memory', 3);
const response = await request(app)
.get('/metrics')
.set('Authorization', 'Bearer test-secret')
.expect(200);
expect(response.text).toMatch(/generation_jobs_total\{store="memory",result="created"\} 1/);
expect(response.text).toMatch(/generation_jobs_in_flight\{store="memory"\} 2/);
expect(response.text).toMatch(
/generation_stream_subscriptions_total\{store="redis",type="resume",result="not_found"\} 1/,
);
expect(response.text).toMatch(
/generation_stream_subscriptions_total\{store="redis",type="resume_state",result="missing"\} 1/,
);
expect(response.text).toMatch(
/generation_stream_resume_pending_events_total\{store="memory"\} 3/,
);
});
});

View file

@ -1,10 +1,18 @@
import { timingSafeEqual } from 'crypto';
import { Router } from 'express';
import { Registry, collectDefaultMetrics, Counter, Histogram } from 'prom-client';
import { Registry, collectDefaultMetrics, Counter, Gauge, Histogram } from 'prom-client';
import { logger } from '@librechat/data-schemas';
import type { Request, Response, NextFunction, RequestHandler } from 'express';
import type { Mongoose } from 'mongoose';
const PATH_NORMALIZATIONS: [RegExp, string][] = [
[/^\/api\/agents\/chat\/stream\/[^/]+(?=\/|$)/, '/api/agents/chat/stream/#id'],
[/^\/api\/agents\/chat\/status\/[^/]+(?=\/|$)/, '/api/agents/chat/status/#id'],
[/^\/api\/files\/code\/download\/[^/]+\/[^/]+(?=\/|$)/, '/api/files/code/download/#id/#id'],
[/^\/api\/files\/download-url\/[^/]+\/[^/]+(?=\/|$)/, '/api/files/download-url/#id/#id'],
[/^\/api\/files\/download\/[^/]+\/[^/]+(?=\/|$)/, '/api/files/download/#id/#id'],
[/^\/api\/files\/[^/]+\/preview(?=\/|$)/, '/api/files/#id/preview'],
[/^\/api\/skills\/[^/]+\/files(?:\/.*)?(?=\/|$)/, '/api/skills/#id/files'],
[/^\/api\/messages\/artifact\/[^/]+(?=\/|$)/, '/api/messages/artifact/#id'],
[/^\/api\/messages\/[^/]+\/[^/]+(?=\/|$)/, '/api/messages/#id/#id'],
[/^\/api\/convos\/[^/]+\/messages\/[^/]+(?=\/|$)/, '/api/convos/#id/messages/#id'],
@ -22,9 +30,39 @@ const PATH_NORMALIZATIONS: [RegExp, string][] = [
],
];
const STATIC_PATHS = new Set(['/', '/health', '/metrics', '/api/auth/login', '/api/config']);
const STATIC_PATHS = new Set([
'/',
'/health',
'/metrics',
'/api/auth/login',
'/api/config',
'/api/agents/chat/abort',
'/api/agents/chat/active',
'/api/agents/v1/chat/completions',
'/api/agents/v1/responses',
'/api/files',
'/api/files/config',
'/api/files/images',
'/api/files/images/avatar',
'/api/files/speech/stt',
]);
const UPLOAD_PATHS = new Set([
'/api/files',
'/api/files/images',
'/api/files/images/avatar',
'/api/files/speech/stt',
'/api/skills/#id/files',
]);
const UPLOAD_METHODS = new Set(['POST', 'PUT', 'PATCH']);
const LOW_CARDINALITY_PATHS: RegExp[] = [
/^\/api\/agents\/chat\/stream\/#id$/,
/^\/api\/agents\/chat\/status\/#id$/,
/^\/api\/files\/#id\/preview$/,
/^\/api\/files\/(code\/download|download-url|download)\/#id\/#id$/,
/^\/api\/skills\/#id\/files$/,
/^\/api\/messages\/#id$/,
/^\/api\/messages\/#id\/#id$/,
/^\/api\/messages\/artifact\/#id$/,
@ -89,7 +127,215 @@ export interface PrometheusMetrics {
metricsRouter: Router;
}
export type OpenIDUserLookupResult = 'found' | 'not_found' | 'migration' | 'auth_failed' | 'error';
export type GenerationJobStore = 'memory' | 'redis';
export type GenerationJobResult = 'created' | 'completed' | 'error' | 'aborted' | 'abort_failed';
export type GenerationStreamSubscriptionType = 'initial' | 'resume' | 'resume_state';
export type GenerationStreamSubscriptionResult =
| 'success'
| 'not_found'
| 'error'
| 'found'
| 'missing';
type OpenIDUserLookupMetrics = {
recordLookup: (result: OpenIDUserLookupResult, durationSeconds: number) => void;
};
let openIDUserLookupMetrics: OpenIDUserLookupMetrics = {
recordLookup: () => undefined,
};
export function recordOpenIDUserLookup(
result: OpenIDUserLookupResult,
durationSeconds: number,
): void {
openIDUserLookupMetrics.recordLookup(result, durationSeconds);
}
type MongooseQueryMetrics = {
recordQuery: (model: string, operation: string, status: string, durationSeconds: number) => void;
};
let mongooseQueryMetrics: MongooseQueryMetrics = {
recordQuery: () => undefined,
};
type GenerationJobMetrics = {
recordJob: (store: GenerationJobStore, result: GenerationJobResult) => void;
setJobsInFlight: (store: GenerationJobStore, count: number) => void;
recordSubscription: (
store: GenerationJobStore,
type: GenerationStreamSubscriptionType,
result: GenerationStreamSubscriptionResult,
) => void;
recordResumePendingEvents: (store: GenerationJobStore, count: number) => void;
};
let generationJobMetrics: GenerationJobMetrics = {
recordJob: () => undefined,
setJobsInFlight: () => undefined,
recordSubscription: () => undefined,
recordResumePendingEvents: () => undefined,
};
const resetMetricRecorders = (): void => {
openIDUserLookupMetrics = {
recordLookup: () => undefined,
};
mongooseQueryMetrics = {
recordQuery: () => undefined,
};
generationJobMetrics = {
recordJob: () => undefined,
setJobsInFlight: () => undefined,
recordSubscription: () => undefined,
recordResumePendingEvents: () => undefined,
};
};
export function recordGenerationJob(store: GenerationJobStore, result: GenerationJobResult): void {
generationJobMetrics.recordJob(store, result);
}
export function setGenerationJobsInFlight(store: GenerationJobStore, count: number): void {
generationJobMetrics.setJobsInFlight(store, count);
}
export function recordGenerationStreamSubscription(
store: GenerationJobStore,
type: GenerationStreamSubscriptionType,
result: GenerationStreamSubscriptionResult,
): void {
generationJobMetrics.recordSubscription(store, type, result);
}
export function recordGenerationStreamResumePendingEvents(
store: GenerationJobStore,
count: number,
): void {
generationJobMetrics.recordResumePendingEvents(store, count);
}
const getElapsedSeconds = (startedAt: bigint): number =>
Number(process.hrtime.bigint() - startedAt) / 1_000_000_000;
export const isMetricsConfigured = (): boolean => Boolean(process.env.METRICS_SECRET);
const createUnauthorizedMetricsRouter = (): Router => {
const metricsRouter = Router();
metricsRouter.get('/', (_req, res) => {
res.status(401).end();
});
return metricsRouter;
};
const normalizeMongooseLabel = (value: unknown): string => {
if (typeof value !== 'string' || !value) return 'unknown';
return value.replace(/[^a-zA-Z0-9_:-]/g, '_').slice(0, 64) || 'unknown';
};
const getHeader = (headers: Record<string, unknown>, name: string): unknown => {
const lowerName = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === lowerName) return value;
}
return undefined;
};
const headerIncludes = (value: unknown, expected: string): boolean => {
if (Array.isArray(value)) {
return value.some((entry) => headerIncludes(entry, expected));
}
return typeof value === 'string' && value.toLowerCase().includes(expected);
};
const isEventStreamContentType = (value: unknown): boolean =>
headerIncludes(value, 'text/event-stream');
const isMultipartContentType = (value: unknown): boolean =>
headerIncludes(value, 'multipart/form-data');
const getRequestContentLength = (req: Request): number | null => {
const contentLength = req.headers['content-length'];
const rawContentLength = Array.isArray(contentLength) ? contentLength[0] : contentLength;
const bodyBytes = rawContentLength == null ? NaN : Number(rawContentLength);
return Number.isFinite(bodyBytes) && bodyBytes >= 0 ? bodyBytes : null;
};
const isUploadRequest = (req: Request, normalizedPath: string): boolean => {
if (!UPLOAD_METHODS.has(req.method)) return false;
if (isMultipartContentType(req.headers['content-type'])) return true;
return UPLOAD_PATHS.has(normalizedPath);
};
export function recordMongooseQuery(
model: string,
operation: string,
status: string,
durationSeconds: number,
): void {
mongooseQueryMetrics.recordQuery(
normalizeMongooseLabel(model),
normalizeMongooseLabel(operation),
normalizeMongooseLabel(status),
durationSeconds,
);
}
export function instrumentMongooseQueryMetrics(mongoose: Mongoose): void {
if (!isMetricsConfigured()) return;
const instrumented = Symbol.for('librechat.mongooseQueryMetrics.instrumented');
const queryPrototype = mongoose.Query?.prototype as
| (typeof mongoose.Query.prototype & { [instrumented]?: boolean })
| undefined;
if (!queryPrototype || queryPrototype[instrumented]) return;
const originalExec = queryPrototype.exec;
queryPrototype.exec = function instrumentedExec(
this: typeof queryPrototype & {
model?: { modelName?: string };
op?: string;
},
...args: Parameters<typeof originalExec>
) {
const startedAt = process.hrtime.bigint();
const model = normalizeMongooseLabel(this.model?.modelName);
const operation = normalizeMongooseLabel(this.op);
let result: ReturnType<typeof originalExec>;
try {
result = originalExec.apply(this, args);
} catch (error) {
recordMongooseQuery(model, operation, 'error', getElapsedSeconds(startedAt));
throw error;
}
return Promise.resolve(result).then(
(result) => {
recordMongooseQuery(model, operation, 'success', getElapsedSeconds(startedAt));
return result;
},
(error) => {
recordMongooseQuery(model, operation, 'error', getElapsedSeconds(startedAt));
throw error;
},
);
} as typeof originalExec;
queryPrototype[instrumented] = true;
}
export function createMetrics(): PrometheusMetrics {
if (!isMetricsConfigured()) {
resetMetricRecorders();
return {
metricsMiddleware: (_req: Request, _res: Response, next: NextFunction) => next(),
metricsRouter: createUnauthorizedMetricsRouter(),
};
}
const registry = new Registry();
collectDefaultMetrics({ register: registry });
@ -108,13 +354,238 @@ export function createMetrics(): PrometheusMetrics {
registers: [registry],
});
const httpRequestsInFlight = new Gauge({
name: 'http_requests_in_flight',
help: 'HTTP requests currently being handled',
labelNames: ['method', 'path'] as const,
registers: [registry],
});
const httpRequestBodyBytes = new Histogram({
name: 'http_request_body_bytes',
help: 'HTTP request body size in bytes from the Content-Length header',
labelNames: ['method', 'path'] as const,
buckets: [1_000, 10_000, 100_000, 1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000],
registers: [registry],
});
const sseStreams = new Counter({
name: 'sse_streams_total',
help: 'Total SSE streams opened',
labelNames: ['method', 'path', 'status'] as const,
registers: [registry],
});
const sseStreamsInFlight = new Gauge({
name: 'sse_streams_in_flight',
help: 'SSE streams currently open',
labelNames: ['method', 'path'] as const,
registers: [registry],
});
const sseStreamDuration = new Histogram({
name: 'sse_stream_duration_seconds',
help: 'SSE stream open duration in seconds',
labelNames: ['method', 'path', 'status'] as const,
buckets: [1, 5, 10, 30, 60, 120, 300, 600, 1_200, 1_800],
registers: [registry],
});
const uploadRequests = new Counter({
name: 'upload_requests_total',
help: 'Total upload requests',
labelNames: ['method', 'path', 'status'] as const,
registers: [registry],
});
const uploadRequestsInFlight = new Gauge({
name: 'upload_requests_in_flight',
help: 'Upload requests currently being handled',
labelNames: ['method', 'path'] as const,
registers: [registry],
});
const uploadRequestDuration = new Histogram({
name: 'upload_request_duration_seconds',
help: 'Upload request duration in seconds',
labelNames: ['method', 'path', 'status'] as const,
buckets: [0.1, 0.3, 0.5, 1, 2, 5, 10, 30, 60, 120, 300],
registers: [registry],
});
const uploadBytes = new Counter({
name: 'upload_bytes_total',
help: 'Upload request bytes from the Content-Length header',
labelNames: ['method', 'path'] as const,
registers: [registry],
});
const openIDUserLookupTotal = new Counter({
name: 'openid_user_lookup_total',
help: 'OpenID user lookup attempts',
labelNames: ['result'] as const,
registers: [registry],
});
const openIDUserLookupDuration = new Histogram({
name: 'openid_user_lookup_duration_seconds',
help: 'OpenID user lookup latency in seconds',
labelNames: ['result'] as const,
buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60],
registers: [registry],
});
openIDUserLookupMetrics = {
recordLookup: (result, durationSeconds) => {
openIDUserLookupTotal.inc({ result });
openIDUserLookupDuration.observe({ result }, durationSeconds);
},
};
const mongooseQueries = new Counter({
name: 'mongoose_queries_total',
help: 'Mongoose queries by model, operation, and status',
labelNames: ['model', 'operation', 'status'] as const,
registers: [registry],
});
const mongooseQueryDuration = new Histogram({
name: 'mongoose_query_duration_seconds',
help: 'Mongoose query duration in seconds by model, operation, and status',
labelNames: ['model', 'operation', 'status'] as const,
buckets: [0.001, 0.003, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60],
registers: [registry],
});
mongooseQueryMetrics = {
recordQuery: (model, operation, status, durationSeconds) => {
const labels = { model, operation, status };
mongooseQueries.inc(labels);
mongooseQueryDuration.observe(labels, durationSeconds);
},
};
const generationJobs = new Counter({
name: 'generation_jobs_total',
help: 'Generation jobs by backing store and result',
labelNames: ['store', 'result'] as const,
registers: [registry],
});
const generationJobsInFlight = new Gauge({
name: 'generation_jobs_in_flight',
help: 'Generation jobs currently running in this process',
labelNames: ['store'] as const,
registers: [registry],
});
const generationStreamSubscriptions = new Counter({
name: 'generation_stream_subscriptions_total',
help: 'Generation stream subscription attempts by backing store, type, and result',
labelNames: ['store', 'type', 'result'] as const,
registers: [registry],
});
const generationStreamResumePendingEvents = new Counter({
name: 'generation_stream_resume_pending_events_total',
help: 'Pending events delivered while resuming generation streams',
labelNames: ['store'] as const,
registers: [registry],
});
generationJobMetrics = {
recordJob: (store, result) => generationJobs.inc({ store, result }),
setJobsInFlight: (store, count) => generationJobsInFlight.set({ store }, count),
recordSubscription: (store, type, result) =>
generationStreamSubscriptions.inc({ store, type, result }),
recordResumePendingEvents: (store, count) =>
generationStreamResumePendingEvents.inc({ store }, count),
};
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);
});
const labels = { method: req.method, path: normalizePath(req.path) };
const uploadTracked = isUploadRequest(req, labels.path);
const uploadStartedAt = uploadTracked ? process.hrtime.bigint() : null;
let sseTracked = false;
let sseStartedAt: bigint | null = null;
let completed = false;
const markSSEStream = () => {
if (completed || res.writableEnded || res.destroyed) return;
if (sseTracked) return;
sseTracked = true;
sseStartedAt = process.hrtime.bigint();
sseStreamsInFlight.inc(labels);
};
const originalSetHeader = res.setHeader;
res.setHeader = function setHeader(this: Response, ...args: Parameters<Response['setHeader']>) {
const [name, value] = args;
const result = originalSetHeader.apply(this, args);
if (String(name).toLowerCase() === 'content-type' && isEventStreamContentType(value)) {
markSSEStream();
}
return result;
} as Response['setHeader'];
const originalWriteHead = res.writeHead;
res.writeHead = function writeHead(this: Response, ...args: [number, unknown?, unknown?]) {
const [, reasonPhrase, headers] = args;
const responseHeaders =
typeof reasonPhrase === 'object' && reasonPhrase != null ? reasonPhrase : headers;
if (
isEventStreamContentType(
getHeader((responseHeaders ?? {}) as Record<string, unknown>, 'content-type'),
) ||
isEventStreamContentType(res.getHeader('content-type'))
) {
markSSEStream();
}
return originalWriteHead.apply(this, args as Parameters<typeof originalWriteHead>);
} as Response['writeHead'];
httpRequestsInFlight.inc(labels);
if (uploadTracked) {
uploadRequestsInFlight.inc(labels);
}
const complete = (completedBy: 'finish' | 'close') => {
if (completed) return;
completed = true;
const requestLabels = { ...labels, status: completedBy === 'close' ? 499 : res.statusCode };
httpRequests.inc(requestLabels);
end(requestLabels);
httpRequestsInFlight.dec(labels);
const bodyBytes = getRequestContentLength(req);
if (bodyBytes != null) {
httpRequestBodyBytes.observe(labels, bodyBytes);
}
if (sseTracked) {
sseStreams.inc(requestLabels);
sseStreamsInFlight.dec(labels);
if (sseStartedAt) {
sseStreamDuration.observe(requestLabels, getElapsedSeconds(sseStartedAt));
}
}
if (uploadTracked) {
uploadRequests.inc(requestLabels);
uploadRequestsInFlight.dec(labels);
if (uploadStartedAt) {
uploadRequestDuration.observe(requestLabels, getElapsedSeconds(uploadStartedAt));
}
if (bodyBytes != null) {
uploadBytes.inc(labels, bodyBytes);
}
}
};
res.once('finish', () => complete('finish'));
res.once('close', () => complete('close'));
next();
};

View file

@ -5,6 +5,7 @@ import { ErrorTypes } from 'librechat-data-provider';
import type { IUser, UserMethods } from '@librechat/data-schemas';
import type { CommandStartedEvent } from 'mongodb';
import type { FilterQuery } from 'mongoose';
import { recordOpenIDUserLookup } from '~/app/metrics';
import { findOpenIDUser, getOpenIdEmail, getOpenIdIssuer, normalizeOpenIdIssuer } from './openid';
function newId() {
@ -19,6 +20,11 @@ jest.mock('@librechat/data-schemas', () => ({
},
}));
jest.mock('~/app/metrics', () => ({
isMetricsConfigured: jest.fn(() => true),
recordOpenIDUserLookup: jest.fn(),
}));
describe('normalizeOpenIdIssuer', () => {
it('normalizes blank, trailing-slash, and discovery-document issuers', () => {
expect(normalizeOpenIdIssuer('')).toBeUndefined();
@ -111,6 +117,7 @@ describe('findOpenIDUser', () => {
error: null,
migration: false,
});
expect(recordOpenIDUserLookup).toHaveBeenCalledWith('found', expect.any(Number));
});
it('should find user by idOnTheSource', async () => {
@ -734,6 +741,7 @@ describe('findOpenIDUser', () => {
findUser: mockFindUser,
}),
).rejects.toThrow('Database error');
expect(recordOpenIDUserLookup).toHaveBeenCalledWith('error', expect.any(Number));
});
it('should reject email fallback when openidId is empty and user has a stored openidId', async () => {

View file

@ -2,6 +2,8 @@ import { logger } from '@librechat/data-schemas';
import { ErrorTypes } from 'librechat-data-provider';
import type { IUser, UserMethods } from '@librechat/data-schemas';
import type { FilterQuery } from 'mongoose';
import { isMetricsConfigured, recordOpenIDUserLookup } from '~/app/metrics';
import type { OpenIDUserLookupResult } from '~/app/metrics';
export type OpenIdEmailClaims = {
email?: unknown;
@ -65,6 +67,17 @@ function hasOpenIdLookupValue(value: string | undefined): value is string {
return typeof value === 'string' && value.length > 0;
}
function getElapsedSeconds(startedAt: bigint): number {
return Number(process.hrtime.bigint() - startedAt) / 1_000_000_000;
}
function getOpenIDUserLookupResult(resolution: OpenIdUserResolution): OpenIDUserLookupResult {
if (resolution.error) return 'auth_failed';
if (resolution.migration) return 'migration';
if (resolution.user) return 'found';
return 'not_found';
}
function getIssuerExactCondition(
field: OpenIdLookupField,
value: string | undefined,
@ -208,61 +221,79 @@ export async function findOpenIDUser({
idOnTheSource?: string;
strategyName?: string;
}): Promise<OpenIdUserResolution> {
const normalizedIssuer = normalizeOpenIdIssuer(openidIssuer);
const primaryConditions = getPrimaryLookupConditions(openidId, idOnTheSource, normalizedIssuer);
let user: IUser | null = null;
if (primaryConditions.length > 0) {
user = await findFirstOpenIdUser(findUser, primaryConditions);
}
const primaryIssuerResolution = resolveIssuerBoundUser(
user,
normalizedIssuer,
strategyName,
'OpenID lookup',
);
if (primaryIssuerResolution) return primaryIssuerResolution;
if (!user && email) {
user = await findUser({ email });
logger.warn(
`[${strategyName}] user ${user ? 'found' : 'not found'} with email: ${email} for openidId: ${openidId}`,
);
// If user found by email, check if they're allowed to use OpenID provider
if (user && user.provider && user.provider !== 'openid') {
logger.warn(
`[${strategyName}] Attempted OpenID login by user ${user.email}, was registered with "${user.provider}" provider`,
const lookupStartedAt = isMetricsConfigured() ? process.hrtime.bigint() : null;
const finish = (resolution: OpenIdUserResolution): OpenIdUserResolution => {
if (lookupStartedAt != null) {
recordOpenIDUserLookup(
getOpenIDUserLookupResult(resolution),
getElapsedSeconds(lookupStartedAt),
);
return { user: null, error: ErrorTypes.AUTH_FAILED, migration: false };
}
return resolution;
};
try {
const normalizedIssuer = normalizeOpenIdIssuer(openidIssuer);
const primaryConditions = getPrimaryLookupConditions(openidId, idOnTheSource, normalizedIssuer);
let user: IUser | null = null;
if (primaryConditions.length > 0) {
user = await findFirstOpenIdUser(findUser, primaryConditions);
}
if (user?.openidId && user.openidId !== openidId) {
logger.warn(
`[${strategyName}] Rejected email fallback for ${user.email}: stored openidId does not match token sub`,
);
return { user: null, error: ErrorTypes.AUTH_FAILED, migration: false };
}
const emailIssuerResolution = resolveIssuerBoundUser(
const primaryIssuerResolution = resolveIssuerBoundUser(
user,
normalizedIssuer,
strategyName,
'email fallback',
'OpenID lookup',
);
if (emailIssuerResolution) return emailIssuerResolution;
if (primaryIssuerResolution) return finish(primaryIssuerResolution);
if (user && !user.openidId) {
logger.info(
`[${strategyName}] Preparing user ${user.email} for migration to OpenID with sub: ${openidId}`,
if (!user && email) {
user = await findUser({ email });
logger.warn(
`[${strategyName}] user ${user ? 'found' : 'not found'} with email: ${email} for openidId: ${openidId}`,
);
user.provider = 'openid';
user.openidId = openidId;
if (normalizedIssuer) user.openidIssuer = normalizedIssuer;
return { user, error: null, migration: true };
}
}
return { user, error: null, migration: false };
// If user found by email, check if they're allowed to use OpenID provider
if (user && user.provider && user.provider !== 'openid') {
logger.warn(
`[${strategyName}] Attempted OpenID login by user ${user.email}, was registered with "${user.provider}" provider`,
);
return finish({ user: null, error: ErrorTypes.AUTH_FAILED, migration: false });
}
if (user?.openidId && user.openidId !== openidId) {
logger.warn(
`[${strategyName}] Rejected email fallback for ${user.email}: stored openidId does not match token sub`,
);
return finish({ user: null, error: ErrorTypes.AUTH_FAILED, migration: false });
}
const emailIssuerResolution = resolveIssuerBoundUser(
user,
normalizedIssuer,
strategyName,
'email fallback',
);
if (emailIssuerResolution) return finish(emailIssuerResolution);
if (user && !user.openidId) {
logger.info(
`[${strategyName}] Preparing user ${user.email} for migration to OpenID with sub: ${openidId}`,
);
user.provider = 'openid';
user.openidId = openidId;
if (normalizedIssuer) user.openidIssuer = normalizedIssuer;
return finish({ user, error: null, migration: true });
}
}
return finish({ user, error: null, migration: false });
} catch (error) {
if (lookupStartedAt != null) {
recordOpenIDUserLookup('error', getElapsedSeconds(lookupStartedAt));
}
throw error;
}
}

View file

@ -10,6 +10,13 @@ import type {
IJobStore,
} from './interfaces/IJobStore';
import type * as t from '~/types';
import {
recordGenerationJob,
recordGenerationStreamResumePendingEvents,
recordGenerationStreamSubscription,
setGenerationJobsInFlight,
} from '~/app/metrics';
import type { GenerationJobStore } from '~/app/metrics';
import { InMemoryEventTransport } from './implementations/InMemoryEventTransport';
import { InMemoryJobStore } from './implementations/InMemoryJobStore';
@ -87,6 +94,9 @@ class GenerationJobManagerClass {
/** Runtime state - always in-memory, not serializable */
private runtimeState = new Map<string, RuntimeJobState>();
/** Jobs actively generating in this process. */
private runningJobs = new Set<string>();
private cleanupInterval: NodeJS.Timeout | null = null;
/** Whether we're using Redis stores */
@ -144,6 +154,7 @@ class GenerationJobManagerClass {
isRedis?: boolean;
cleanupOnComplete?: boolean;
}): void {
const previousStore = this.storeLabel;
if (this.cleanupInterval) {
logger.warn(
'[GenerationJobManager] Reconfiguring after initialization - destroying existing services',
@ -151,10 +162,14 @@ class GenerationJobManagerClass {
this.destroy();
}
this.runningJobs.clear();
setGenerationJobsInFlight(previousStore, 0);
this.jobStore = services.jobStore;
this.eventTransport = services.eventTransport;
this._isRedis = services.isRedis ?? false;
this._cleanupOnComplete = services.cleanupOnComplete ?? true;
this.syncRunningJobMetrics();
logger.info(
`[GenerationJobManager] Configured with ${this._isRedis ? 'Redis' : 'in-memory'} stores`,
@ -168,6 +183,14 @@ class GenerationJobManagerClass {
return this._isRedis;
}
private get storeLabel(): GenerationJobStore {
return this._isRedis ? 'redis' : 'memory';
}
private syncRunningJobMetrics(store: GenerationJobStore = this.storeLabel): void {
setGenerationJobsInFlight(store, this.runningJobs.size);
}
/**
* Get the job store instance (for advanced use cases).
*/
@ -226,6 +249,9 @@ class GenerationJobManagerClass {
hasSubscriber: false,
};
this.runtimeState.set(streamId, runtime);
this.runningJobs.add(streamId);
this.syncRunningJobMetrics();
recordGenerationJob(this.storeLabel, 'created');
// Resolve immediately - early event buffer handles late subscribers
resolveReady!();
@ -554,6 +580,9 @@ class GenerationJobManagerClass {
completedAt: Date.now(),
error,
});
this.runningJobs.delete(streamId);
this.syncRunningJobMetrics();
recordGenerationJob(this.storeLabel, 'error');
// Keep runtime state so subscribe() can access errorEvent
logger.debug(
`[GenerationJobManager] Job completed with error (keeping for late subscribers): ${streamId}`,
@ -575,6 +604,9 @@ class GenerationJobManagerClass {
});
}
this.runningJobs.delete(streamId);
this.syncRunningJobMetrics();
recordGenerationJob(this.storeLabel, 'completed');
logger.debug(`[GenerationJobManager] Job completed: ${streamId}`);
}
@ -592,6 +624,7 @@ class GenerationJobManagerClass {
if (!jobData) {
logger.warn(`[GenerationJobManager] Cannot abort - job not found: ${streamId}`);
recordGenerationJob(this.storeLabel, 'abort_failed');
return {
text: '',
content: [],
@ -682,6 +715,9 @@ class GenerationJobManagerClass {
});
}
this.runningJobs.delete(streamId);
this.syncRunningJobMetrics();
recordGenerationJob(this.storeLabel, 'aborted');
logger.debug(`[GenerationJobManager] Job aborted: ${streamId}`);
return {
@ -723,9 +759,11 @@ class GenerationJobManagerClass {
onError?: t.ErrorHandler,
options?: t.SubscribeOptions,
): Promise<{ unsubscribe: t.UnsubscribeFn } | null> {
const subscriptionType = options?.skipBufferReplay ? 'resume' : 'initial';
// Use lazy initialization to support cross-replica subscriptions
const runtime = await this.getOrCreateRuntimeState(streamId);
if (!runtime) {
recordGenerationStreamSubscription(this.storeLabel, subscriptionType, 'not_found');
return null;
}
@ -761,8 +799,14 @@ class GenerationJobManagerClass {
onError,
});
if (subscription.ready) {
await subscription.ready;
try {
if (subscription.ready) {
await subscription.ready;
}
recordGenerationStreamSubscription(this.storeLabel, subscriptionType, 'success');
} catch (err) {
recordGenerationStreamSubscription(this.storeLabel, subscriptionType, 'error');
throw err;
}
const isFirst = this.eventTransport.isFirstSubscriber(streamId);
@ -864,6 +908,11 @@ class GenerationJobManagerClass {
: 0;
const resumeState = await this.getResumeState(streamId);
recordGenerationStreamSubscription(
this.storeLabel,
'resume_state',
resumeState ? 'found' : 'missing',
);
let pendingEvents: t.ServerSentEvent[] = [];
if (!this._isRedis) {
@ -872,6 +921,7 @@ class GenerationJobManagerClass {
pendingEvents = runtime.earlyEventBuffer.slice(bufferLengthAtSnapshot);
runtime.earlyEventBuffer = [];
if (pendingEvents.length > 0) {
recordGenerationStreamResumePendingEvents(this.storeLabel, pendingEvents.length);
logger.debug(
`[GenerationJobManager] Captured ${pendingEvents.length} gap events for ${streamId}`,
);
@ -1179,11 +1229,13 @@ class GenerationJobManagerClass {
*/
private async cleanup(): Promise<void> {
const count = await this.jobStore.cleanup();
let runningJobsChanged = false;
// Cleanup runtime state for deleted jobs
for (const streamId of this.runtimeState.keys()) {
if (!(await this.jobStore.hasJob(streamId))) {
this.runtimeState.delete(streamId);
runningJobsChanged = this.runningJobs.delete(streamId) || runningJobsChanged;
this.runStepBuffers?.delete(streamId);
this.jobStore.clearContentState(streamId);
this.eventTransport.cleanup(streamId);
@ -1207,6 +1259,10 @@ class GenerationJobManagerClass {
}
}
if (runningJobsChanged) {
this.syncRunningJobMetrics();
}
if (count > 0) {
logger.debug(`[GenerationJobManager] Cleaned up ${count} expired jobs`);
}
@ -1295,6 +1351,8 @@ class GenerationJobManagerClass {
await this.jobStore.destroy();
this.eventTransport.destroy();
this.runtimeState.clear();
this.runningJobs.clear();
this.syncRunningJobMetrics();
this.runStepBuffers?.clear();
logger.debug('[GenerationJobManager] Destroyed');