📡 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

@ -152,6 +152,12 @@ NODE_MAX_OLD_SPACE_SIZE=6144
# RUM_AUTH_MODE=publicToken
# RUM_PUBLIC_TOKEN=
# Authenticated proxy mode sends browser telemetry to this LibreChat backend first.
# The backend validates the LibreChat session, strips app auth, and forwards to the collector.
# RUM_AUTH_MODE=proxy
# RUM_PROXY_TARGET_URL=http://otel-collector:4318
# RUM_PROXY_TIMEOUT_MS=10000
# Optional comma-separated first-party HTTPS origins/URLs that should receive traceparent headers.
# Wildcards and non-HTTPS targets are ignored.
# RUM_TRACE_PROPAGATION_TARGETS=https://api.example.com

View file

@ -220,6 +220,7 @@ const startServer = async () => {
app.use('/api/tags', routes.tags);
app.use('/api/mcp', routes.mcp);
app.use('/api/rum', routes.rum);
app.use('/metrics', metricsRouter);

View file

@ -58,6 +58,7 @@ afterEach(() => {
delete process.env.RUM_ENABLED;
delete process.env.RUM_PROVIDER;
delete process.env.RUM_URL;
delete process.env.RUM_PROXY_TARGET_URL;
delete process.env.RUM_SERVICE_NAME;
delete process.env.RUM_AUTH_MODE;
delete process.env.RUM_PUBLIC_TOKEN;
@ -111,6 +112,38 @@ describe('GET /api/config RUM config', () => {
expect(response.body).not.toHaveProperty('rum');
});
it('includes proxy RUM config when enabled with valid env', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
process.env.RUM_ENABLED = 'true';
process.env.RUM_AUTH_MODE = 'proxy';
process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318';
const app = createApp(mockUser);
const response = await request(app).get('/api/config');
expect(response.body.rum).toEqual({
provider: 'hyperdx',
enabled: true,
url: '/api/rum',
serviceName: 'librechat-web',
authMode: 'proxy',
consoleCapture: false,
disableReplay: true,
advancedNetworkCapture: false,
});
});
it('omits proxy RUM config without a target collector URL', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
process.env.RUM_ENABLED = 'true';
process.env.RUM_AUTH_MODE = 'proxy';
const app = createApp(mockUser);
const response = await request(app).get('/api/config');
expect(response.body).not.toHaveProperty('rum');
});
it('omits RUM config when the URL contains credentials', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
process.env.RUM_ENABLED = 'true';

View file

@ -32,8 +32,10 @@ const auth = require('./auth');
const keys = require('./keys');
const user = require('./user');
const mcp = require('./mcp');
const rum = require('./rum');
module.exports = {
rum,
mcp,
auth,
adminAuth,

22
api/server/routes/rum.js Normal file
View file

@ -0,0 +1,22 @@
const express = require('express');
const { getRumProxyBodyLimit, isRumProxyEnabled, proxyRumRequest } = require('@librechat/api');
const { requireJwtAuth } = require('~/server/middleware');
const router = express.Router();
const rawOtlpBody = express.raw({
limit: getRumProxyBodyLimit(),
type: ['application/x-protobuf', 'application/octet-stream'],
});
function requireRumProxyEnabled(_req, res, next) {
if (!isRumProxyEnabled()) {
return res.status(404).json({ message: 'RUM proxy is not configured' });
}
return next();
}
router.post('/v1/traces', requireRumProxyEnabled, requireJwtAuth, rawOtlpBody, proxyRumRequest);
router.post('/v1/logs', requireRumProxyEnabled, requireJwtAuth, rawOtlpBody, proxyRumRequest);
module.exports = router;

View file

@ -1,4 +1,4 @@
const { isEnabled } = require('@librechat/api');
const { getRumProxyClientUrl, isEnabled, isRumProxyEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const DEFAULT_RUM_SERVICE_NAME = 'librechat-web';
@ -80,22 +80,34 @@ function getRumConfig() {
}
const authMode = process.env.RUM_AUTH_MODE || 'publicToken';
if (authMode !== 'publicToken') {
if (authMode !== 'publicToken' && authMode !== 'proxy') {
logger.warn(`[config] Unsupported RUM auth mode "${authMode}", disabling RUM`);
return undefined;
}
const rumUrl = process.env.RUM_URL;
const parsedUrl = rumUrl ? parseUrl(rumUrl) : undefined;
let rumUrl;
if (authMode === 'proxy') {
rumUrl = getRumProxyClientUrl();
if (!parsedUrl || !isSafeRumUrl(parsedUrl)) {
logger.warn('[config] Invalid RUM_URL, disabling RUM');
return undefined;
}
if (!isRumProxyEnabled()) {
logger.warn('[config] RUM proxy mode requires RUM_PROXY_TARGET_URL, disabling RUM');
return undefined;
}
} else {
rumUrl = process.env.RUM_URL;
const parsedUrl = rumUrl ? parseUrl(rumUrl) : undefined;
if (!process.env.RUM_PUBLIC_TOKEN) {
logger.warn('[config] RUM publicToken mode requires RUM_PUBLIC_TOKEN, disabling RUM');
return undefined;
if (!parsedUrl || !isSafeRumUrl(parsedUrl)) {
logger.warn('[config] Invalid RUM_URL, disabling RUM');
return undefined;
}
if (!process.env.RUM_PUBLIC_TOKEN) {
logger.warn('[config] RUM publicToken mode requires RUM_PUBLIC_TOKEN, disabling RUM');
return undefined;
}
rumUrl = parsedUrl.href.replace(/\/$/, '');
}
const rawTracePropagationTargets = parseCsvEnv(process.env.RUM_TRACE_PROPAGATION_TARGETS);
@ -123,10 +135,10 @@ function getRumConfig() {
return {
provider: 'hyperdx',
enabled: true,
url: parsedUrl.href.replace(/\/$/, ''),
url: rumUrl,
serviceName: process.env.RUM_SERVICE_NAME || DEFAULT_RUM_SERVICE_NAME,
authMode,
publicToken: process.env.RUM_PUBLIC_TOKEN,
...(authMode === 'publicToken' ? { publicToken: process.env.RUM_PUBLIC_TOKEN } : {}),
...(tracePropagationTargets.length > 0 ? { tracePropagationTargets } : {}),
consoleCapture,
disableReplay: parseBooleanEnv(process.env.RUM_DISABLE_REPLAY, true),

View file

@ -103,4 +103,63 @@ describe('useRum', () => {
expect(mockInit).not.toHaveBeenCalled();
});
it('initializes proxy RUM with the LibreChat bearer token for same-origin ingest', async () => {
const fetchMock = jest.fn(() => Promise.resolve({}));
window.fetch = fetchMock;
mockUseGetStartupConfig.mockReturnValue({
data: {
rum: {
provider: 'hyperdx',
enabled: true,
url: '/api/rum',
serviceName: 'librechat-web',
authMode: 'proxy',
},
},
});
renderHook(() => useRum());
await waitFor(() => {
expect(mockInit).toHaveBeenCalledWith({
advancedNetworkCapture: false,
apiKey: 'librechat-rum-proxy',
consoleCapture: false,
disableReplay: true,
service: 'librechat-web',
tracePropagationTargets: undefined,
url: '/api/rum',
});
});
await window.fetch('/api/rum/v1/traces', { method: 'POST' });
const headers = fetchMock.mock.calls[0]?.[1]?.headers;
expect(headers).toBeInstanceOf(Headers);
expect((headers as Headers).get('authorization')).toBe('Bearer jwt-token');
});
it('does not initialize proxy RUM without an authenticated token', async () => {
mockUseAuthContext.mockReturnValue({
isAuthenticated: false,
token: undefined,
user: undefined,
});
mockUseGetStartupConfig.mockReturnValue({
data: {
rum: {
provider: 'hyperdx',
enabled: true,
url: '/api/rum',
serviceName: 'librechat-web',
authMode: 'proxy',
},
},
});
renderHook(() => useRum());
expect(mockInit).not.toHaveBeenCalled();
});
});

View file

@ -5,6 +5,11 @@ import { useGetStartupConfig } from '~/data-provider';
import { useAuthContext } from '~/hooks/AuthContext';
import { normalizeRumPath } from './routes';
const PROXY_API_KEY = 'librechat-rum-proxy';
let rumProxyToken: string | undefined;
let rumProxyFetchPatched = false;
type HyperDXBrowser = {
init: (config: {
advancedNetworkCapture: boolean;
@ -18,18 +23,70 @@ type HyperDXBrowser = {
setGlobalAttributes: (attributes: Record<string, string>) => void;
};
function shouldInitializeRum(config: TRumConfig | undefined): boolean {
function shouldInitializeRum(config: TRumConfig | undefined, token: string | undefined): boolean {
if (!config?.enabled || config.provider !== 'hyperdx' || !config.url || !config.serviceName) {
return false;
}
return config.authMode === 'publicToken' && !!config.publicToken;
if (config.authMode === 'publicToken') {
return !!config.publicToken;
}
return config.authMode === 'proxy' && !!token && !config.publicToken;
}
function getApiKey(config: TRumConfig): string {
function getApiKey(config: TRumConfig, token: string | undefined): string {
if (config.authMode === 'proxy') {
return token ? PROXY_API_KEY : '';
}
return config.publicToken ?? '';
}
function isRumProxyRequest(input: RequestInfo | URL, proxyUrl: string): boolean {
const rawUrl =
typeof Request !== 'undefined' && input instanceof Request ? input.url : input.toString();
const url = new URL(rawUrl, window.location.origin);
const proxy = new URL(proxyUrl, window.location.origin);
return url.origin === window.location.origin && url.pathname.startsWith(`${proxy.pathname}/`);
}
function withAuthorization(
input: RequestInfo | URL,
init: RequestInit | undefined,
token: string,
): [RequestInfo | URL, RequestInit | undefined] {
const headers = new Headers(
init?.headers ??
(typeof Request !== 'undefined' && input instanceof Request ? input.headers : undefined),
);
headers.set('authorization', `Bearer ${token}`);
if (typeof Request !== 'undefined' && input instanceof Request) {
return [new Request(input, { ...init, headers }), undefined];
}
return [input, { ...init, headers }];
}
function ensureRumProxyAuth(proxyUrl: string): void {
if (rumProxyFetchPatched || typeof window === 'undefined' || typeof window.fetch !== 'function') {
return;
}
const originalFetch = window.fetch.bind(window);
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
if (rumProxyToken && isRumProxyRequest(input, proxyUrl)) {
const [authorizedInput, authorizedInit] = withAuthorization(input, init, rumProxyToken);
return originalFetch(authorizedInput, authorizedInit);
}
return originalFetch(input, init);
};
rumProxyFetchPatched = true;
}
function buildGlobalAttributes(
user: TUser | undefined,
config: TRumConfig,
@ -56,7 +113,7 @@ async function loadHyperDX(): Promise<HyperDXBrowser> {
export default function useRum(): void {
const { data: startupConfig } = useGetStartupConfig();
const { user } = useAuthContext();
const { token, user } = useAuthContext();
const location = useLocation();
const initializedKeyRef = useRef<string | undefined>(undefined);
const sampledInitKeyRef = useRef<string | undefined>(undefined);
@ -75,13 +132,21 @@ export default function useRum(): void {
return;
}
if (!shouldInitializeRum(rumConfig)) {
if (!shouldInitializeRum(rumConfig, token)) {
if (rumConfig?.authMode === 'proxy') {
rumProxyToken = undefined;
}
return;
}
const config = rumConfig;
const apiKey = getApiKey(config, token);
if (config.authMode === 'proxy') {
rumProxyToken = token;
ensureRumProxyAuth(config.url);
}
const initKey = [config.url, config.serviceName, config.authMode, config.publicToken].join(':');
const initKey = [config.url, config.serviceName, config.authMode, apiKey].join(':');
if (initializedKeyRef.current === initKey) {
return;
@ -107,7 +172,7 @@ export default function useRum(): void {
HyperDX.init({
advancedNetworkCapture: config.advancedNetworkCapture ?? false,
apiKey: getApiKey(config),
apiKey,
consoleCapture: config.consoleCapture ?? false,
disableReplay: config.disableReplay ?? true,
service: config.serviceName,
@ -124,7 +189,7 @@ export default function useRum(): void {
return () => {
cancelled = true;
};
}, [rumConfig, user]);
}, [rumConfig, token, user]);
useEffect(() => {
hyperDxRef.current?.setGlobalAttributes(

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

View file

@ -1079,7 +1079,7 @@ export type TRumConfig = {
enabled: boolean;
url: string;
serviceName: string;
authMode: 'publicToken';
authMode: 'publicToken' | 'proxy';
publicToken?: string;
tracePropagationTargets?: string[];
consoleCapture?: boolean;