mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
22
api/server/routes/rum.js
Normal 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;
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1079,7 +1079,7 @@ export type TRumConfig = {
|
|||
enabled: boolean;
|
||||
url: string;
|
||||
serviceName: string;
|
||||
authMode: 'publicToken';
|
||||
authMode: 'publicToken' | 'proxy';
|
||||
publicToken?: string;
|
||||
tracePropagationTargets?: string[];
|
||||
consoleCapture?: boolean;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue