📡 feat: Add Configurable HyperDX Browser Real User Monitoring (#13287)

This commit is contained in:
Ravi Kumar L 2026-05-29 20:04:26 +02:00 committed by GitHub
parent cfee8c72cb
commit 71a7c9ce7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 2676 additions and 8 deletions

View file

@ -0,0 +1,173 @@
jest.mock('~/cache/getLogStores');
const mockGetAppConfig = jest.fn();
jest.mock('~/server/services/Config/app', () => ({
getAppConfig: (...args) => mockGetAppConfig(...args),
}));
jest.mock('~/server/services/Config/ldap', () => ({
getLdapConfig: jest.fn(() => null),
}));
jest.mock('~/server/middleware/roles/capabilities', () => ({
hasCapability: jest.fn(),
}));
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
getTenantId: jest.fn(() => undefined),
}));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
getCloudFrontConfig: jest.fn(() => null),
}));
const request = require('supertest');
const express = require('express');
const configRoute = require('../config');
function createApp(user) {
const app = express();
app.disable('x-powered-by');
if (user) {
app.use((req, _res, next) => {
req.user = user;
next();
});
}
app.use('/api/config', configRoute);
return app;
}
const baseAppConfig = {
registration: { socialLogins: ['google', 'github'] },
interfaceConfig: { modelSelect: true },
turnstileConfig: { siteKey: 'test-key' },
modelSpecs: { list: [{ name: 'test-spec' }] },
};
const mockUser = {
id: 'user123',
role: 'USER',
tenantId: undefined,
};
afterEach(() => {
jest.resetAllMocks();
delete process.env.RUM_ENABLED;
delete process.env.RUM_PROVIDER;
delete process.env.RUM_URL;
delete process.env.RUM_SERVICE_NAME;
delete process.env.RUM_AUTH_MODE;
delete process.env.RUM_AUTH_HEADER_SCHEME;
delete process.env.RUM_PUBLIC_TOKEN;
delete process.env.RUM_TRACE_PROPAGATION_TARGETS;
delete process.env.RUM_CONSOLE_CAPTURE;
delete process.env.RUM_DISABLE_REPLAY;
delete process.env.RUM_ADVANCED_NETWORK_CAPTURE;
delete process.env.RUM_SAMPLE_RATE;
delete process.env.RUM_ENVIRONMENT;
});
describe('GET /api/config RUM config', () => {
it('includes public-token RUM config when enabled with valid env', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
process.env.RUM_ENABLED = 'true';
process.env.RUM_URL = 'https://rum.example.com';
process.env.RUM_PUBLIC_TOKEN = 'public-token';
process.env.RUM_TRACE_PROPAGATION_TARGETS =
'https://app.example.com,https://api.openai.com,*,http://api.example.com';
process.env.RUM_SAMPLE_RATE = '0.25';
process.env.RUM_ENVIRONMENT = 'test';
const app = createApp(null);
const response = await request(app).get('/api/config');
expect(response.body.rum).toEqual({
provider: 'hyperdx',
enabled: true,
url: 'https://rum.example.com',
serviceName: 'librechat-web',
authMode: 'publicToken',
publicToken: 'public-token',
tracePropagationTargets: ['https://app.example.com', 'https://api.openai.com'],
consoleCapture: false,
disableReplay: true,
advancedNetworkCapture: false,
sampleRate: 0.25,
environment: 'test',
});
});
it('omits malformed RUM config', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
process.env.RUM_ENABLED = 'true';
process.env.RUM_URL = 'not a url';
process.env.RUM_PUBLIC_TOKEN = 'public-token';
const app = createApp(null);
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';
process.env.RUM_URL = 'https://user:password@rum.example.com';
process.env.RUM_PUBLIC_TOKEN = 'public-token';
const app = createApp(null);
const response = await request(app).get('/api/config');
expect(response.body).not.toHaveProperty('rum');
});
it('allows IPv6 localhost HTTP RUM URLs in public-token mode', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
process.env.RUM_ENABLED = 'true';
process.env.RUM_URL = 'http://[::1]:4318';
process.env.RUM_PUBLIC_TOKEN = 'public-token';
const app = createApp(null);
const response = await request(app).get('/api/config');
expect(response.body.rum?.url).toBe('http://[::1]:4318');
});
it('includes userJwt RUM config for authenticated users', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
process.env.RUM_ENABLED = 'true';
process.env.RUM_URL = 'https://rum.example.com';
process.env.RUM_AUTH_MODE = 'userJwt';
process.env.RUM_AUTH_HEADER_SCHEME = 'Basic';
const app = createApp(mockUser);
const response = await request(app).get('/api/config');
expect(response.body.rum).toEqual({
provider: 'hyperdx',
enabled: true,
url: 'https://rum.example.com',
serviceName: 'librechat-web',
authMode: 'userJwt',
authHeaderScheme: 'Basic',
consoleCapture: false,
disableReplay: true,
advancedNetworkCapture: false,
});
});
it('omits userJwt RUM config for unauthenticated users', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
process.env.RUM_ENABLED = 'true';
process.env.RUM_URL = 'https://rum.example.com';
process.env.RUM_AUTH_MODE = 'userJwt';
const app = createApp(null);
const response = await request(app).get('/api/config');
expect(response.body).not.toHaveProperty('rum');
});
});

View file

@ -10,6 +10,7 @@ const { defaultSocialLogins } = require('librechat-data-provider');
const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
const { getLdapConfig } = require('~/server/services/Config/ldap');
const { getRumConfig } = require('~/server/services/Config/rum');
const { getAppConfig } = require('~/server/services/Config/app');
const router = express.Router();
@ -169,6 +170,7 @@ router.get('/', async function (req, res) {
try {
const sharedPayload = buildSharedPayload();
const cloudFront = buildCloudFrontStartupConfig();
const rum = getRumConfig(req.user);
if (!req.user) {
const tenantId = getTenantId();
@ -180,6 +182,7 @@ router.get('/', async function (req, res) {
socialLogins: baseConfig?.registration?.socialLogins ?? defaultSocialLogins,
turnstile: baseConfig?.turnstileConfig,
...(cloudFront ? { cloudFront } : {}),
...(rum ? { rum } : {}),
};
const interfaceConfig = baseConfig?.interfaceConfig;
@ -231,6 +234,7 @@ router.get('/', async function (req, res) {
? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10)
: 0,
...(cloudFront ? { cloudFront } : {}),
...(rum ? { rum } : {}),
};
const webSearch = buildWebSearchConfig(appConfig);

View file

@ -0,0 +1,156 @@
const { isEnabled } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const DEFAULT_RUM_SERVICE_NAME = 'librechat-web';
let hasWarnedUserJwtAuth = false;
function parseBooleanEnv(value, defaultValue = false) {
if (value == null || value === '') {
return defaultValue;
}
return isEnabled(value);
}
function parseNumberEnv(value) {
if (value == null || value === '') {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function parseCsvEnv(value) {
if (!value) {
return [];
}
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function parseUrl(value) {
try {
return new URL(value);
} catch {
return undefined;
}
}
function isLocalhost(url) {
return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]';
}
function isSafeRumUrl(url, authMode) {
if (url.username || url.password) {
return false;
}
if (url.protocol === 'https:') {
return true;
}
return authMode === 'publicToken' && url.protocol === 'http:' && isLocalhost(url);
}
function isSafeTraceTarget(target) {
if (target.includes('*')) {
return false;
}
const url = parseUrl(target);
if (!url || url.protocol !== 'https:') {
return false;
}
return true;
}
function warnOnceForUserJwtAuth() {
if (hasWarnedUserJwtAuth) {
return;
}
hasWarnedUserJwtAuth = true;
logger.warn(
'[config] RUM userJwt mode sends the active LibreChat user JWT to RUM_URL; use only with a trusted HTTPS collector that will not log authorization headers',
);
}
function getRumConfig(user) {
if (!parseBooleanEnv(process.env.RUM_ENABLED)) {
return undefined;
}
const provider = process.env.RUM_PROVIDER || 'hyperdx';
if (provider !== 'hyperdx') {
logger.warn(`[config] Unsupported RUM provider "${provider}", disabling RUM`);
return undefined;
}
const authMode = process.env.RUM_AUTH_MODE === 'userJwt' ? 'userJwt' : 'publicToken';
const rumUrl = process.env.RUM_URL;
const parsedUrl = rumUrl ? parseUrl(rumUrl) : undefined;
if (!parsedUrl || !isSafeRumUrl(parsedUrl, authMode)) {
logger.warn('[config] Invalid RUM_URL, disabling RUM');
return undefined;
}
if (authMode === 'userJwt' && !user) {
return undefined;
}
if (authMode === 'userJwt') {
warnOnceForUserJwtAuth();
}
if (authMode === 'publicToken' && !process.env.RUM_PUBLIC_TOKEN) {
logger.warn('[config] RUM publicToken mode requires RUM_PUBLIC_TOKEN, disabling RUM');
return undefined;
}
const rawTracePropagationTargets = parseCsvEnv(process.env.RUM_TRACE_PROPAGATION_TARGETS);
const tracePropagationTargets = rawTracePropagationTargets.filter(isSafeTraceTarget);
if (rawTracePropagationTargets.length !== tracePropagationTargets.length) {
logger.info('[config] Ignored unsafe RUM trace propagation targets');
}
const configuredSampleRate = parseNumberEnv(process.env.RUM_SAMPLE_RATE);
const sampleRate =
configuredSampleRate != null && configuredSampleRate >= 0 && configuredSampleRate <= 1
? configuredSampleRate
: undefined;
const authHeaderScheme = process.env.RUM_AUTH_HEADER_SCHEME === 'Basic' ? 'Basic' : 'Bearer';
const consoleCapture = parseBooleanEnv(process.env.RUM_CONSOLE_CAPTURE);
const advancedNetworkCapture = parseBooleanEnv(process.env.RUM_ADVANCED_NETWORK_CAPTURE);
if (consoleCapture) {
logger.warn('[config] RUM console capture is enabled and may collect sensitive browser logs');
}
if (advancedNetworkCapture) {
logger.warn('[config] RUM advanced network capture is enabled and may collect payload data');
}
return {
provider: 'hyperdx',
enabled: true,
url: parsedUrl.href.replace(/\/$/, ''),
serviceName: process.env.RUM_SERVICE_NAME || DEFAULT_RUM_SERVICE_NAME,
authMode,
...(authMode === 'userJwt' ? { authHeaderScheme } : {}),
...(authMode === 'publicToken' ? { publicToken: process.env.RUM_PUBLIC_TOKEN } : {}),
...(tracePropagationTargets.length > 0 ? { tracePropagationTargets } : {}),
consoleCapture,
disableReplay: parseBooleanEnv(process.env.RUM_DISABLE_REPLAY, true),
advancedNetworkCapture,
...(sampleRate != null ? { sampleRate } : {}),
...(process.env.RUM_ENVIRONMENT ? { environment: process.env.RUM_ENVIRONMENT } : {}),
};
}
module.exports = { getRumConfig };