📡 feat: Add Authenticated Proxy Mode for Browser RUM Telemetry (#13464)

This commit is contained in:
Ravi Kumar L 2026-06-02 03:11:35 +02:00 committed by GitHub
parent 88e5a2f23b
commit a86e504a57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 511 additions and 22 deletions

View file

@ -59,6 +59,8 @@ export * from './cache';
export * from './stream';
/* Diagnostics */
export { memoryDiagnostics } from './utils/memory';
/* RUM */
export * from './rum/proxy';
/* types */
export type * from './mcp/types';
export type * from './flow/types';

View file

@ -0,0 +1,138 @@
import {
getRumProxyBodyLimit,
getRumProxyClientUrl,
getRumProxyTimeoutMs,
getRumProxyTargetBaseUrl,
isRumProxyEnabled,
proxyRumRequest,
resolveRumProxyTarget,
} from './proxy';
const makeResponse = () => {
const res = {
set: jest.fn(),
send: jest.fn(),
status: jest.fn(),
json: jest.fn(),
};
res.status.mockReturnValue(res);
return res;
};
describe('RUM proxy configuration', () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('uses the fixed LibreChat RUM proxy URL and default body limit', () => {
delete process.env.RUM_PROXY_BODY_LIMIT;
delete process.env.RUM_PROXY_TIMEOUT_MS;
expect(getRumProxyClientUrl()).toBe('/api/rum');
expect(getRumProxyBodyLimit()).toBe('3mb');
expect(getRumProxyTimeoutMs()).toBe(10000);
});
it('uses a positive custom collector timeout', () => {
process.env.RUM_PROXY_TIMEOUT_MS = '2500';
expect(getRumProxyTimeoutMs()).toBe(2500);
process.env.RUM_PROXY_TIMEOUT_MS = '-1';
expect(getRumProxyTimeoutMs()).toBe(10000);
});
it('resolves OTLP paths against the configured collector base URL', () => {
process.env.RUM_AUTH_MODE = 'proxy';
process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318';
expect(isRumProxyEnabled()).toBe(true);
expect(resolveRumProxyTarget('/v1/traces')).toBe('http://otel-collector:4318/v1/traces');
expect(resolveRumProxyTarget('/v1/logs')).toBe('http://otel-collector:4318/v1/logs');
expect(resolveRumProxyTarget('/v1/metrics')).toBeUndefined();
});
it('rejects unsafe collector target URLs', () => {
process.env.RUM_PROXY_TARGET_URL = 'https://user:pass@collector.example.com';
expect(getRumProxyTargetBaseUrl()).toBeUndefined();
process.env.RUM_PROXY_TARGET_URL = 'file:///tmp/collector';
expect(getRumProxyTargetBaseUrl()).toBeUndefined();
});
it('forwards OTLP requests without forwarding app authorization', async () => {
process.env.RUM_AUTH_MODE = 'proxy';
process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318';
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue(
new Response('ok', {
status: 202,
headers: { 'content-type': 'application/json' },
}),
);
const res = makeResponse();
await proxyRumRequest(
{
path: '/v1/traces',
body: { resourceSpans: [] },
headers: {
accept: 'application/json',
authorization: 'Bearer app-token',
'content-type': 'application/json',
},
} as never,
res as never,
);
expect(fetchMock).toHaveBeenCalledWith('http://otel-collector:4318/v1/traces', {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify({ resourceSpans: [] }),
signal: expect.any(AbortSignal),
});
expect(res.set).toHaveBeenCalledWith('content-type', 'application/json');
expect(res.status).toHaveBeenCalledWith(202);
expect(res.send).toHaveBeenCalledWith(Buffer.from('ok'));
fetchMock.mockRestore();
});
it('returns 400 for missing payloads and 404 for unsupported OTLP paths', async () => {
process.env.RUM_AUTH_MODE = 'proxy';
process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318';
const missingBodyRes = makeResponse();
const unsupportedPathRes = makeResponse();
await proxyRumRequest({ path: '/v1/traces', headers: {} } as never, missingBodyRes as never);
await proxyRumRequest(
{ path: '/v1/metrics', body: Buffer.from('payload'), headers: {} } as never,
unsupportedPathRes as never,
);
expect(missingBodyRes.status).toHaveBeenCalledWith(400);
expect(unsupportedPathRes.status).toHaveBeenCalledWith(404);
});
it('returns 502 when the collector request fails', async () => {
process.env.RUM_AUTH_MODE = 'proxy';
process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318';
const fetchMock = jest.spyOn(global, 'fetch').mockRejectedValue(new Error('collector down'));
const res = makeResponse();
await proxyRumRequest(
{ path: '/v1/traces', body: Buffer.from('payload'), headers: {} } as never,
res as never,
);
expect(res.status).toHaveBeenCalledWith(502);
fetchMock.mockRestore();
});
});

View file

@ -0,0 +1,149 @@
import type { Request, Response } from 'express';
import { logger } from '@librechat/data-schemas';
const DEFAULT_PROXY_PATH = '/api/rum';
const DEFAULT_BODY_LIMIT = '3mb';
const DEFAULT_TIMEOUT_MS = 10_000;
const OTLP_PATHS = new Set(['/v1/traces', '/v1/logs']);
function normalizeBasePath(pathname: string): string {
if (pathname === '/') {
return '';
}
return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
}
export function getRumProxyClientUrl(): string {
return DEFAULT_PROXY_PATH;
}
export function getRumProxyBodyLimit(): string {
return process.env.RUM_PROXY_BODY_LIMIT?.trim() || DEFAULT_BODY_LIMIT;
}
export function getRumProxyTimeoutMs(): number {
const parsed = Number(process.env.RUM_PROXY_TIMEOUT_MS);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
}
export function getRumProxyTargetBaseUrl(): URL | undefined {
const value = process.env.RUM_PROXY_TARGET_URL?.trim();
if (!value) {
return undefined;
}
let url: URL;
try {
url = new URL(value);
} catch {
return undefined;
}
if (url.username || url.password || url.search || url.hash) {
return undefined;
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return undefined;
}
return url;
}
export function isRumProxyEnabled(): boolean {
return process.env.RUM_AUTH_MODE === 'proxy' && getRumProxyTargetBaseUrl() != null;
}
export function resolveRumProxyTarget(path: string): string | undefined {
if (!OTLP_PATHS.has(path)) {
return undefined;
}
const baseUrl = getRumProxyTargetBaseUrl();
if (!baseUrl) {
return undefined;
}
const targetUrl = new URL(baseUrl.href);
targetUrl.pathname = `${normalizeBasePath(targetUrl.pathname)}${path}`;
return targetUrl.href;
}
function getRequestBody(req: Request): Buffer | string | undefined {
const body = req.body as unknown;
if (Buffer.isBuffer(body) || typeof body === 'string') {
return body;
}
if (body && typeof body === 'object') {
return JSON.stringify(body);
}
return undefined;
}
function getHeader(req: Request, name: string): string | undefined {
const value = req.headers[name.toLowerCase()];
if (Array.isArray(value)) {
return value[0];
}
return typeof value === 'string' ? value : undefined;
}
function getProxyHeaders(req: Request, body: Buffer | string): Record<string, string> {
const contentType =
getHeader(req, 'content-type') || (typeof body === 'string' ? 'application/json' : undefined);
const accept = getHeader(req, 'accept');
return {
...(contentType ? { 'content-type': contentType } : {}),
...(accept ? { accept } : {}),
};
}
export async function proxyRumRequest(req: Request, res: Response): Promise<void> {
const target = resolveRumProxyTarget(req.path);
if (!target) {
res.status(404).json({ message: 'RUM proxy is not configured' });
return;
}
const body = getRequestBody(req);
if (!body) {
res.status(400).json({ message: 'RUM payload is required' });
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), getRumProxyTimeoutMs());
try {
const response = await fetch(target, {
method: 'POST',
headers: getProxyHeaders(req, body),
body,
signal: controller.signal,
});
const contentType = response.headers.get('content-type');
if (contentType) {
res.set('content-type', contentType);
}
const responseBody = Buffer.from(await response.arrayBuffer());
res.status(response.status).send(responseBody);
} catch (error) {
logger.warn('[rumProxy] Failed to proxy RUM telemetry', {
error: error instanceof Error ? error.message : String(error),
target,
});
res.status(controller.signal.aborted ? 504 : 502).json({
message: controller.signal.aborted
? 'RUM telemetry proxy timed out'
: 'Failed to proxy RUM telemetry',
});
} finally {
clearTimeout(timeout);
}
}