mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 04:37:37 +00:00
📡 feat: Add Authenticated Proxy Mode for Browser RUM Telemetry (#13464)
This commit is contained in:
parent
88e5a2f23b
commit
a86e504a57
12 changed files with 511 additions and 22 deletions
|
|
@ -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';
|
||||
|
|
|
|||
138
packages/api/src/rum/proxy.spec.ts
Normal file
138
packages/api/src/rum/proxy.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
149
packages/api/src/rum/proxy.ts
Normal file
149
packages/api/src/rum/proxy.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue